Rename transport binding to WIP over HTTP

This commit is contained in:
2026-10-02 02:22:17 +09:00
parent 511e9fef9f
commit 55c784c22d
31 changed files with 250 additions and 396 deletions
+1 -1
View File
@@ -6,5 +6,5 @@ description = "Presentation-independent stateful WIP client runtime"
license = "MIT OR Apache-2.0"
[dependencies]
wip-https = { path = "../wip-https" }
wip-http = { path = "../wip-http" }
wip-protocol = { path = "../wip-protocol" }
+6 -6
View File
@@ -1,9 +1,9 @@
//! Presentation-independent stateful runtime for WIP clients.
//!
//! [`Client`] separates observations by canonical HTTPS endpoint and caller-
//! [`Client`] separates observations by canonical WIP over HTTP endpoint and caller-
//! supplied security context. It reconciles requested observation coverage without
//! duplicating fresh or loading work, creates requests with the standard
//! [`wip_https`] adapter, correlates responses by [`RequestId`], and retains
//! [`wip_http`] adapter, correlates responses by [`RequestId`], and retains
//! bounded object, tree, interface, diagnostic, and operation state. The crate
//! contains no terminal, widget, or UI event types.
@@ -13,8 +13,8 @@ use std::collections::{BTreeMap, VecDeque};
use std::error::Error;
use std::fmt::{self, Display, Formatter};
use wip_https::http::{Request, Response};
use wip_https::{
use wip_http::http::{Request, Response};
use wip_http::{
ClientResponseError, DecodedResponse, Endpoint, Limits, decode_call_operation_response,
decode_fetch_interface_response, decode_observe_response, encode_call_operation_request,
encode_fetch_interface_request, encode_observe_request,
@@ -364,7 +364,7 @@ pub struct Diagnostic {
pub struct PreparedRequest {
/// Identity required when completing or failing this request.
pub id: RequestId,
/// Standard WIP over HTTPS request.
/// Standard WIP over HTTP request.
pub request: Request<Vec<u8>>,
}
@@ -1399,7 +1399,7 @@ impl Client {
&mut self,
session: &SessionId,
id: RequestId,
mismatch: Option<wip_https::StatusMismatch>,
mismatch: Option<wip_http::StatusMismatch>,
) -> Result<(), ClientError> {
if let Some(mismatch) = mismatch {
let maximum = self.limits.history;
+2 -2
View File
@@ -4,8 +4,8 @@ use wip_client::{
CallOutcome, Client, ClientErrorKind, ClientLimits, Completion, DiagnosticKind,
ObservationState, OutcomeUnknownReason, SecurityContext,
};
use wip_https::http::{Response, StatusCode};
use wip_https::{
use wip_http::http::{Response, StatusCode};
use wip_http::{
Limits, encode_fetch_interface_response, encode_observe_response,
encode_protocol_error_response,
};
+2 -2
View File
@@ -4,8 +4,8 @@ use wip_client::{
CallOutcome, Client, ClientErrorKind, ClientLimits, Completion, DispatchedTransportFailure,
ObservationState, OutcomeUnknownReason, SecurityContext,
};
use wip_https::http::Response;
use wip_https::{
use wip_http::http::Response;
use wip_http::{
EncodeHttpError, Limits, encode_call_operation_response, encode_fetch_interface_response,
encode_observe_response, encode_protocol_error_response,
};
+2 -2
View File
@@ -2,7 +2,7 @@
name = "wip-fs-server"
version = "0.1.0"
edition = "2024"
description = "Request-time filesystem projection server for WIP over HTTPS"
description = "Request-time filesystem projection server for WIP over HTTP"
license = "MIT OR Apache-2.0"
readme = "README.md"
publish = false
@@ -11,7 +11,7 @@ publish = false
http = "1"
rustix = { version = "1.1", features = ["fs"] }
wip-host = { path = "../wip-host" }
wip-https = { path = "../wip-https" }
wip-http = { path = "../wip-http" }
wip-interface = { path = "../wip-interface" }
wip-protocol = { path = "../wip-protocol" }
+12 -14
View File
@@ -3,7 +3,7 @@
`wip-fs-server` projects one configured Linux filesystem directory onto WIP
Worldspace `/` at request time. It does not register the filesystem contents or
keep one object instance per entry. It uses `wip-host` for publication and typed
operation dispatch and uses the standard `wip-https` routes, JSON codecs,
operation dispatch and uses the standard `wip-http` routes, JSON codecs,
envelopes, batching, and HTTP status mapping.
## Security model
@@ -28,9 +28,9 @@ envelopes, batching, and HTTP status mapping.
- Protocol metadata and errors never contain the backend absolute path. Startup
prints only the explicitly configured effective root and security-sensitive
modes.
- The built-in listener is cleartext and loopback-only. Production deployments
must pass `--behind-tls-proxy` and terminate HTTPS at a trusted reverse proxy.
Plain development HTTP requires the explicit `--development-http` flag.
- The built-in HTTP listener is loopback-only. TLS termination, certificates,
authentication, an externally visible URL scheme, and proxy routing are
deployment concerns rather than separate WIP transport profiles.
The implementation currently targets Linux because it uses `O_PATH`,
directory-relative descriptor traversal, and `/proc/self/fd` to reopen the same
@@ -38,34 +38,32 @@ pinned backend object without resolving the untrusted Worldspace path twice.
## Run
Read-only behind a TLS-terminating reverse proxy:
Run the loopback listener in read-only mode:
```console
cargo run -p wip-fs-server -- \
--root /srv/public-files \
--listen 127.0.0.1:8443 \
--base-path /wip \
--behind-tls-proxy
--listen 127.0.0.1:8080 \
--base-path /wip
```
Local development with the write capability deliberately enabled:
Run with the write capability deliberately enabled:
```console
cargo run -p wip-fs-server -- \
--root ./example-files \
--listen 127.0.0.1:8080 \
--base-path /wip \
--development-http \
--read-write
```
The server reports the effective root, `ReadOnly`/`ReadWrite` mode, transport
mode, endpoint, and actual listen address. It does not enumerate the root.
The server reports the effective root, `ReadOnly`/`ReadWrite` mode, endpoint,
and actual listen address. It does not enumerate the root.
## Standard interactions
The following examples use development HTTP for readability. The JSON is the
standard `wip-https` profile; validators and operation `Bytes` are base64 body
The following examples use the built-in HTTP listener. The JSON follows the
standard WIP over HTTP binding; validators and operation `Bytes` are base64 body
fields, never `ETag` or `If-Match` headers.
Observe only the root Object:
+11 -24
View File
@@ -1,15 +1,15 @@
//! Request-time filesystem projection served through the standard WIP HTTPS binding.
//! Request-time filesystem projection served through the standard WIP over HTTP binding.
//!
//! This crate deliberately keeps the boundaries visible:
//!
//! - [`wip_protocol`] owns logical requests and responses;
//! - [`wip_host`] owns object publication, validators, and typed dispatch;
//! - [`wip_https`] owns routes, JSON, envelopes, batching, and status mapping;
//! - [`wip_http`] owns routes, JSON, envelopes, batching, and status mapping;
//! - this crate owns the filesystem capability boundary and the real HTTP listener.
//!
//! TLS is terminated by an explicitly configured reverse proxy. The built-in
//! listener accepts cleartext only on loopback, either behind that proxy or when
//! [`TransportSecurity::DevelopmentHttp`] is deliberately selected.
//! The built-in HTTP listener is intentionally loopback-only. TLS termination,
//! certificates, authentication, and externally visible schemes are independent
//! deployment concerns rather than WIP transport profiles.
#![deny(missing_docs)]
@@ -33,20 +33,11 @@ pub enum AccessMode {
ReadWrite,
}
/// How HTTPS is provided for the endpoint.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransportSecurity {
/// A trusted reverse proxy terminates TLS and forwards to the loopback listener.
ReverseProxyTls,
/// Cleartext HTTP is deliberately enabled for local development only.
DevelopmentHttp,
}
/// Bounded resource policy applied by the HTTP adapter and filesystem provider.
#[derive(Debug, Clone, Copy)]
pub struct ResourceLimits {
/// Standard JSON, body-size, nesting, and batch limits.
pub wire: wip_https::Limits,
pub wire: wip_http::Limits,
/// Maximum bytes returned by one file read.
pub max_file_read_bytes: usize,
/// Maximum bytes accepted by one file replacement.
@@ -68,7 +59,7 @@ pub struct ResourceLimits {
impl Default for ResourceLimits {
fn default() -> Self {
Self {
wire: wip_https::Limits::new(256 * 1024, 2 * 1024 * 1024, 64, 64)
wire: wip_http::Limits::new(256 * 1024, 2 * 1024 * 1024, 64, 64)
.expect("default wire limits are nonzero"),
max_file_read_bytes: 1024 * 1024,
max_file_write_bytes: 1024 * 1024,
@@ -93,8 +84,6 @@ pub struct ServerConfig {
pub endpoint_base_path: String,
/// Read-only or explicit read-write publication mode.
pub access: AccessMode,
/// TLS termination or explicit development HTTP policy.
pub transport_security: TransportSecurity,
/// Request, operation, traversal, and concurrency bounds.
pub limits: ResourceLimits,
}
@@ -102,8 +91,7 @@ pub struct ServerConfig {
impl ServerConfig {
/// Builds a secure-by-default configuration for `root`.
///
/// The listener defaults to an ephemeral loopback port, read-only access,
/// and TLS termination delegated to a reverse proxy.
/// The listener defaults to an ephemeral loopback port and read-only access.
#[must_use]
pub fn for_root(root: impl Into<PathBuf>) -> Self {
Self {
@@ -111,7 +99,6 @@ impl ServerConfig {
listen: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
endpoint_base_path: "/wip".into(),
access: AccessMode::ReadOnly,
transport_security: TransportSecurity::ReverseProxyTls,
limits: ResourceLimits::default(),
}
}
@@ -142,9 +129,9 @@ impl ServerConfig {
Ok(())
}
pub(crate) fn endpoint(&self) -> Result<wip_https::Endpoint, ConfigError> {
let endpoint = format!("https://localhost{}", self.endpoint_base_path);
wip_https::Endpoint::parse(&endpoint).map_err(|_| ConfigError::InvalidBasePath)
pub(crate) fn endpoint(&self) -> Result<wip_http::Endpoint, ConfigError> {
let endpoint = format!("http://localhost{}", self.endpoint_base_path);
wip_http::Endpoint::parse(&endpoint).map_err(|_| ConfigError::InvalidBasePath)
}
}
+6 -27
View File
@@ -2,14 +2,13 @@ use std::error::Error;
use std::net::SocketAddr;
use std::path::PathBuf;
use wip_fs_server::{AccessMode, ServerConfig, TransportSecurity, WipFsServer};
use wip_fs_server::{AccessMode, ServerConfig, WipFsServer};
fn main() -> Result<(), Box<dyn Error>> {
let mut root: Option<PathBuf> = None;
let mut listen: SocketAddr = "127.0.0.1:8443".parse()?;
let mut listen: SocketAddr = "127.0.0.1:8080".parse()?;
let mut base_path = "/wip".to_owned();
let mut access = AccessMode::ReadOnly;
let mut security = None;
let mut arguments = std::env::args().skip(1);
while let Some(argument) = arguments.next() {
match argument.as_str() {
@@ -17,12 +16,6 @@ fn main() -> Result<(), Box<dyn Error>> {
"--listen" => listen = required_value(&mut arguments, "--listen")?.parse()?,
"--base-path" => base_path = required_value(&mut arguments, "--base-path")?,
"--read-write" => access = AccessMode::ReadWrite,
"--development-http" => {
set_security(&mut security, TransportSecurity::DevelopmentHttp)?
}
"--behind-tls-proxy" => {
set_security(&mut security, TransportSecurity::ReverseProxyTls)?
}
"--help" | "-h" => {
print_help();
return Ok(());
@@ -32,20 +25,17 @@ fn main() -> Result<(), Box<dyn Error>> {
}
let root = root.ok_or("--root is required")?;
let transport_security =
security.ok_or("select exactly one of --behind-tls-proxy or --development-http")?;
let mut config = ServerConfig::for_root(root);
config.listen = listen;
config.endpoint_base_path = base_path;
config.access = access;
config.transport_security = transport_security;
let server = WipFsServer::new(config)?;
let effective_root = server.effective_root().to_owned();
let endpoint = server.endpoint().clone();
let bound = server.bind()?;
eprintln!(
"wip-fs-server root={} access={access:?} transport={transport_security:?} endpoint={} listen={}",
"wip-fs-server root={} access={access:?} endpoint={} listen={}",
effective_root.display(),
endpoint,
bound.local_addr()
@@ -63,21 +53,10 @@ fn required_value(
.ok_or_else(|| format!("{option} requires a value").into())
}
fn set_security(
security: &mut Option<TransportSecurity>,
value: TransportSecurity,
) -> Result<(), Box<dyn Error>> {
if security.replace(value).is_some() {
Err("select only one transport-security mode".into())
} else {
Ok(())
}
}
fn print_help() {
println!(
"Usage: wip-fs-server --root PATH [--listen 127.0.0.1:8443] \\\n [--base-path /wip] [--read-write] \\\n (--behind-tls-proxy | --development-http)\n\n\
The built-in listener is cleartext and loopback-only. In production, \
select --behind-tls-proxy and terminate HTTPS at a trusted reverse proxy."
"Usage: wip-fs-server --root PATH [--listen 127.0.0.1:8080] \\\n [--base-path /wip] [--read-write]\n\n\
The built-in HTTP listener is loopback-only. TLS termination, authentication, \
and external routing are deployment concerns."
);
}
+2 -2
View File
@@ -7,7 +7,7 @@ use std::time::{Duration, Instant};
use http::{Request, Response, StatusCode};
use wip_host::{ExecutionContext, ObjectProvider};
use wip_https::{
use wip_http::{
BatchRequestKind, BatchResponse, BatchResponseItem, BatchSuccess, CodecError, EncodeHttpError,
Endpoint, RequestMetadataError, ResourceLimitKind, Route, decode_batch_request,
decode_call_operation_metadata, decode_fetch_interface_request, decode_observe_request,
@@ -55,7 +55,7 @@ impl WipFsServer {
&self.endpoint
}
/// Handles one already-buffered HTTP request with the standard `wip-https` codecs.
/// Handles one already-buffered HTTP request with the standard `wip-http` codecs.
///
/// Listener integrations must enforce the same body limit while collecting bytes.
#[must_use]
+8 -10
View File
@@ -7,8 +7,8 @@ use std::thread;
use std::time::{Duration, Instant};
use tempfile::tempdir;
use wip_fs_server::{AccessMode, ServerConfig, TransportSecurity, WipFsServer};
use wip_https::{
use wip_fs_server::{AccessMode, ServerConfig, WipFsServer};
use wip_http::{
BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse, BatchSuccess, DecodedResponse,
Endpoint, JSON_CONTENT_TYPE, Limits, Route, decode_batch_request, decode_batch_response,
decode_call_operation_response, decode_fetch_interface_response, decode_observe_response,
@@ -28,7 +28,6 @@ fn real_listener_serves_three_core_interactions_batch_and_write() {
let mut config = ServerConfig::for_root(temporary.path());
config.access = AccessMode::ReadWrite;
config.transport_security = TransportSecurity::DevelopmentHttp;
let limits = config.limits.wire;
let bound = Arc::new(
WipFsServer::new(config)
@@ -43,7 +42,7 @@ fn real_listener_serves_three_core_interactions_batch_and_write() {
let shutdown = Arc::clone(&shutdown);
thread::spawn(move || bound.serve_until(shutdown))
};
let endpoint = Endpoint::parse(&format!("https://localhost:{}/wip", address.port()))
let endpoint = Endpoint::parse(&format!("http://localhost:{}/wip", address.port()))
.expect("client endpoint");
let file_request = ObserveRequest {
@@ -215,7 +214,6 @@ fn real_listener_serves_three_core_interactions_batch_and_write() {
fn stalled_request_body_times_out_and_releases_the_only_worker() {
let temporary = tempdir().expect("temporary root");
let mut config = ServerConfig::for_root(temporary.path());
config.transport_security = TransportSecurity::DevelopmentHttp;
config.limits.max_concurrent_requests = 1;
config.limits.request_timeout = Duration::from_millis(200);
let limits = config.limits.wire;
@@ -245,7 +243,7 @@ fn stalled_request_body_times_out_and_releases_the_only_worker() {
stalled.flush().expect("flush partial request");
thread::sleep(Duration::from_millis(25));
let endpoint = Endpoint::parse(&format!("https://localhost:{}/wip", address.port()))
let endpoint = Endpoint::parse(&format!("http://localhost:{}/wip", address.port()))
.expect("client endpoint");
let observe = ObserveRequest {
path: "/".into(),
@@ -289,7 +287,7 @@ fn batch_keeps_known_malformed_arguments_item_local() {
let config = ServerConfig::for_root(temporary.path());
let limits = config.limits.wire;
let server = WipFsServer::new(config).expect("build server");
let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint");
let endpoint = Endpoint::parse("http://localhost/wip").expect("endpoint");
let body = br#"{"requests":[{"id":"ok","method":"observe","arguments":{"path":"/","depth":0}},{"id":"bad","method":"observe","arguments":{"path":"/","depth":"bad"}}]}"#;
let logical = decode_batch_request(body, limits).expect("decode item-local invalid request");
assert!(matches!(
@@ -319,7 +317,7 @@ fn call_argument_decoding_does_not_mask_missing_target() {
let temporary = tempdir().expect("temporary root");
let config = ServerConfig::for_root(temporary.path());
let server = WipFsServer::new(config).expect("build server");
let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint");
let endpoint = Endpoint::parse("http://localhost/wip").expect("endpoint");
let body = br#"{"target":{"path":"/missing"},"interface":{"reference":"unknown"},"operation":"missing","arguments":{"bytes":"not-base64"}}"#;
let request = http::Request::builder()
.method(http::Method::POST)
@@ -343,7 +341,7 @@ fn backend_invalid_unicode_path_is_not_found_not_internal() {
let config = ServerConfig::for_root(temporary.path());
let limits = config.limits.wire;
let server = WipFsServer::new(config).expect("build server");
let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint");
let endpoint = Endpoint::parse("http://localhost/wip").expect("endpoint");
let request = ObserveRequest {
path: "/bad\0name".into(),
depth: 0,
@@ -368,7 +366,7 @@ fn read_response_size_failure_preserves_non_commit_guarantee() {
config.limits.wire = Limits::new(4_096, 1_024, 64, 64).expect("wire limits");
let limits = config.limits.wire;
let server = WipFsServer::new(config).expect("build server");
let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint");
let endpoint = Endpoint::parse("http://localhost/wip").expect("endpoint");
let observe_request = ObserveRequest {
path: "/large".into(),
@@ -1,8 +1,8 @@
[package]
name = "wip-https"
name = "wip-http"
version = "0.1.0"
edition = "2024"
description = "Framework-independent standard WIP over HTTPS v1 binding"
description = "Framework-independent standard WIP over HTTP v1 binding"
license = "MIT OR Apache-2.0"
[dependencies]
@@ -6,7 +6,7 @@ use url::Url;
/// The media type emitted for every WIP request and every response with a body.
pub const JSON_CONTENT_TYPE: &str = "application/json; charset=utf-8";
/// A fixed standard WIP over HTTPS v1 route.
/// A fixed standard WIP over HTTP v1 route.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Route {
/// Observe one object and its indexable descendants.
@@ -32,22 +32,20 @@ impl Route {
}
}
/// A canonical absolute HTTPS base URL whose path always ends in `/`.
/// A canonical absolute hierarchical base URL whose path always ends in `/`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Endpoint(Url);
impl Endpoint {
/// Parses and canonicalizes an endpoint.
///
/// The input must be an absolute HTTPS URL without a query or fragment. Its
/// path is an endpoint prefix, not a Worldspace path. A trailing slash is
/// appended when absent so RFC 3986 relative-reference resolution preserves
/// the final prefix segment.
/// The input must be an absolute hierarchical URL with a host and without a
/// query or fragment. Its path is an endpoint prefix, not a Worldspace path.
/// A trailing slash is appended when absent so RFC 3986 relative-reference
/// resolution preserves the final prefix segment. The supplied scheme is
/// preserved; scheme and transport security are deployment concerns.
pub fn parse(input: &str) -> Result<Self, EndpointError> {
let mut url = Url::parse(input).map_err(EndpointError::InvalidUrl)?;
if url.scheme() != "https" {
return Err(EndpointError::NotHttps);
}
if url.cannot_be_a_base() || url.host_str().is_none() {
return Err(EndpointError::NotAbsoluteBase);
}
@@ -113,13 +111,11 @@ impl TryFrom<&str> for Endpoint {
}
}
/// Why an endpoint is not a valid standard WIP HTTPS base.
/// Why an endpoint is not a valid standard WIP over HTTP base.
#[derive(Debug)]
pub enum EndpointError {
/// URL parsing failed.
InvalidUrl(url::ParseError),
/// The URL scheme is not `https`.
NotHttps,
/// The URL is not a hierarchical absolute URL with an authority.
NotAbsoluteBase,
/// A query component was supplied.
@@ -132,7 +128,6 @@ impl Display for EndpointError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidUrl(error) => write!(formatter, "invalid endpoint URL: {error}"),
Self::NotHttps => formatter.write_str("WIP endpoint must use HTTPS"),
Self::NotAbsoluteBase => {
formatter.write_str("WIP endpoint must be an absolute base URL")
}
@@ -1,27 +1,29 @@
//! Framework-independent standard WIP over HTTPS v1 binding.
//! Framework-independent standard WIP over HTTP v1 binding.
//!
//! This crate owns endpoint and route derivation, UTF-8 JSON encoding, canonical
//! success and error response handling, HTTP status mapping, descriptor-guided
//! operation-value conversion, and retrieval batching. It deliberately performs
//! no I/O and depends on no server framework, TLS implementation, filesystem
//! projection, client cache, or presentation layer.
//! projection, client cache, or presentation layer. URL schemes, TLS,
//! certificates, authentication mechanisms, proxies, and caches are selected by
//! the HTTP implementation and deployment rather than by WIP conformance.
//!
//! Logical values remain owned by [`wip_protocol`]. Validators and `Bytes` are
//! encoded in JSON bodies using canonical RFC 4648 Section 4 base64; this crate
//! does not infer WIP validator semantics from `ETag` or `If-Match`.
//!
//! ```
//! use wip_https::{Endpoint, Limits, encode_observe_request};
//! use wip_http::{Endpoint, Limits, encode_observe_request};
//! use wip_protocol::ObserveRequest;
//!
//! let endpoint = Endpoint::parse("https://example.test/api/wip")?;
//! let endpoint = Endpoint::parse("http://example.test/api/wip")?;
//! let limits = Limits::new(64 * 1024, 1024 * 1024, 64, 100)?;
//! let request = encode_observe_request(
//! &endpoint,
//! &ObserveRequest { path: "/items/123".into(), depth: 0 },
//! limits,
//! )?;
//! assert_eq!(request.uri(), "https://example.test/api/wip/v1/observe");
//! assert_eq!(request.uri(), "http://example.test/api/wip/v1/observe");
//! assert_eq!(request.method(), http::Method::POST);
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```
@@ -1,9 +1,9 @@
use wip_https::http::StatusCode;
use wip_https::wip_protocol::{
use wip_http::http::StatusCode;
use wip_http::wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor,
Object, ObjectObservation, ObserveRequest, ProtocolError, ProtocolErrorCode,
};
use wip_https::{
use wip_http::{
BatchMethod, BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse,
BatchResponseItem, BatchSuccess, ClientResponseError, DecodedResponse, Endpoint,
InvalidResponseKind, Limits, ResourceLimitKind, decode_batch_request, decode_batch_response,
@@ -269,7 +269,7 @@ fn top_level_batch_errors_use_the_batch_allowed_code_set() {
}
);
let not_found = wip_https::http::Response::builder()
let not_found = wip_http::http::Response::builder()
.status(StatusCode::NOT_FOUND)
.header("content-type", "application/json; charset=utf-8")
.body(br#"{"error":{"code":"not_found","message":"not a batch failure"}}"#.to_vec())
@@ -285,7 +285,7 @@ fn top_level_batch_errors_use_the_batch_allowed_code_set() {
#[test]
fn unsupported_batch_descriptor_waits_for_envelope_correlation() {
let duplicate = wip_https::http::Response::builder()
let duplicate = wip_http::http::Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8")
.body(
@@ -301,7 +301,7 @@ fn unsupported_batch_descriptor_waits_for_envelope_correlation() {
}
));
let missing = wip_https::http::Response::builder()
let missing = wip_http::http::Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8")
.body(
@@ -1,14 +1,14 @@
use std::collections::BTreeMap;
use wip_https::http::StatusCode;
use wip_https::wip_protocol::{
use wip_http::http::StatusCode;
use wip_http::wip_protocol::{
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
InterfaceTarget, MAX_SAFE_INTEGER, Object, ObjectObservation, ObserveRequest,
OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target, TypeDeclaration,
TypeExpr, UnionCase, Value,
};
use wip_https::{
use wip_http::{
ClientResponseError, CodecError, DecodedResponse, Endpoint, InvalidResponseKind, Limits,
ResourceLimitKind, decode_call_operation_metadata, decode_call_operation_request,
decode_call_operation_response, decode_fetch_interface_request,
@@ -466,7 +466,7 @@ fn unsupported_descriptor_format_is_client_local() {
ClientResponseError::UnsupportedDescriptorFormat { .. }
));
let future_shape = wip_https::http::Response::builder()
let future_shape = wip_http::http::Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8")
.body(
@@ -485,7 +485,7 @@ fn unsupported_descriptor_format_is_client_local() {
br#"{"interface":"interface-v1","descriptor":{"format":"wip-interface/2"},"validator":null}"#
.as_slice(),
] {
let response = wip_https::http::Response::builder()
let response = wip_http::http::Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8")
.body(malformed.to_vec())
@@ -502,7 +502,7 @@ fn unsupported_descriptor_format_is_client_local() {
#[test]
fn invalid_success_is_classified_as_invalid_response() {
let response = wip_https::http::Response::builder()
let response = wip_http::http::Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8")
.body(br#"{"object":{"name":"wrong","interfaces":[]}}"#.to_vec())
@@ -1,8 +1,8 @@
use wip_https::http::{Response, StatusCode};
use wip_https::wip_protocol::{
use wip_http::http::{Response, StatusCode};
use wip_http::wip_protocol::{
ObserveRequest, ProtocolError, ProtocolErrorCode, ProtocolInteraction,
};
use wip_https::{
use wip_http::{
ClientResponseError, DecodedResponse, InvalidResponseKind, Limits, ResourceLimitKind,
TransportFailureKind, decode_observe_response, encode_protocol_error_response,
encode_resource_limit_error_response, error_code_name, is_json_content_type, status_for_error,
@@ -211,7 +211,7 @@ fn valid_envelope_code_is_authoritative_when_status_mismatches() {
decode_observe_response(&request(), &encoded, limits()).unwrap(),
DecodedResponse::ProtocolFailure {
error,
status_mismatch: Some(wip_https::StatusMismatch {
status_mismatch: Some(wip_http::StatusMismatch {
actual: StatusCode::IM_A_TEAPOT,
expected: StatusCode::NOT_FOUND,
}),
@@ -1,40 +1,39 @@
use wip_https::http::{Method, Request, StatusCode};
use wip_https::{Endpoint, RequestMetadataError, Route, validate_http_request};
use wip_http::http::{Method, Request, StatusCode};
use wip_http::{Endpoint, RequestMetadataError, Route, validate_http_request};
#[test]
fn endpoint_is_https_absolute_queryless_and_canonical() {
let endpoint = Endpoint::parse("https://example.test/root/prefix").unwrap();
assert_eq!(endpoint.to_string(), "https://example.test/root/prefix/");
let expected = [
(
Route::Observe,
"https://example.test/root/prefix/v1/observe",
),
(
Route::FetchInterface,
"https://example.test/root/prefix/v1/fetch_interface",
),
(
Route::CallOperation,
"https://example.test/root/prefix/v1/call_operation",
),
(Route::Batch, "https://example.test/root/prefix/v1/batch"),
];
for (route, url) in expected {
assert_eq!(endpoint.route(route).as_str(), url);
fn endpoint_preserves_http_and_https_when_resolving_canonical_routes() {
for scheme in ["http", "https"] {
let endpoint = Endpoint::parse(&format!("{scheme}://example.test/root/prefix")).unwrap();
assert_eq!(
endpoint.recognize(&wip_https::url::Url::parse(url).unwrap()),
Some(route)
endpoint.to_string(),
format!("{scheme}://example.test/root/prefix/")
);
let expected = [
(Route::Observe, "v1/observe"),
(Route::FetchInterface, "v1/fetch_interface"),
(Route::CallOperation, "v1/call_operation"),
(Route::Batch, "v1/batch"),
];
for (route, suffix) in expected {
let url = format!("{scheme}://example.test/root/prefix/{suffix}");
assert_eq!(endpoint.route(route).as_str(), url);
assert_eq!(
endpoint.recognize(&wip_http::url::Url::parse(&url).unwrap()),
Some(route)
);
}
assert_eq!(
endpoint.recognize(
&wip_http::url::Url::parse(&format!(
"{scheme}://example.test/root/prefix/v1/observe?path=/items"
))
.unwrap()
),
None
);
}
assert_eq!(
endpoint.recognize(
&wip_https::url::Url::parse("https://example.test/root/prefix/v1/observe?path=/items")
.unwrap()
),
None
);
}
#[test]
@@ -118,9 +117,26 @@ fn inbound_http_metadata_enforces_post_route_and_json() {
}
#[test]
fn invalid_endpoint_components_are_rejected() {
assert!(Endpoint::parse("http://example.test/wip").is_err());
fn invalid_endpoint_components_and_non_routes_are_rejected() {
assert!(Endpoint::parse("/relative").is_err());
assert!(Endpoint::parse("file:///wip").is_err());
assert!(Endpoint::parse("https://example.test/wip?q=1").is_err());
assert!(Endpoint::parse("https://example.test/wip#fragment").is_err());
let deployment_endpoint = Endpoint::parse("http://user:secret@example.test/wip").unwrap();
assert_eq!(deployment_endpoint.as_url().scheme(), "http");
assert_eq!(deployment_endpoint.as_url().username(), "user");
let endpoint = Endpoint::parse("http://example.test/wip").unwrap();
for outside in [
"http://example.test/wip/v1/unknown",
"http://example.test/wip/v1/observe/extra",
"https://example.test/wip/v1/observe",
"http://other.test/wip/v1/observe",
] {
assert_eq!(
endpoint.recognize(&wip_http::url::Url::parse(outside).unwrap()),
None
);
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ ratatui = "0.29.0"
serde_json = "1"
ureq = "3.1.2"
wip-client = { path = "../wip-client" }
wip-https = { path = "../wip-https" }
wip-http = { path = "../wip-http" }
wip-protocol = { path = "../wip-protocol" }
[dev-dependencies]
+7 -13
View File
@@ -8,12 +8,17 @@ owned by the client runtime.
## Start
HTTPS is the normal mode:
Supply one `http` or `https` endpoint:
```console
cargo run -p wip-tui -- --endpoint https://wip.example.test/api/wip
```
The same canonical endpoint is used unchanged to construct requests, perform
HTTP I/O, and partition the client cache. WIP over HTTP does not define separate
HTTPS and development-HTTP profiles; URL scheme, TLS, certificate, authentication,
and proxy policy belong to the HTTP implementation and deployment.
The endpoint may instead be supplied as `WIP_ENDPOINT`. A non-secret cache
partition identity may be supplied with `--security-context NAME` or
`WIP_SECURITY_CONTEXT`. The default request timeout is 30 seconds and can be
@@ -31,16 +36,6 @@ Use `--authorization-env OTHER_VARIABLE` to select another variable. The value i
redacted from `Debug` and is never rendered. Shell and process environment security
remain the operator's responsibility.
Plain HTTP is rejected by default. It is available only as an explicit loopback
development mode:
```console
cargo run -p wip-tui -- http://127.0.0.1:8080/wip --allow-http
```
Non-loopback HTTP remains rejected even with `--allow-http`. For production, use a
real HTTPS endpoint and a trusted TLS deployment.
### Filesystem server example
In one terminal:
@@ -52,14 +47,13 @@ cargo run -p wip-fs-server -- \
--root /tmp/wip-example \
--listen 127.0.0.1:8080 \
--base-path /wip \
--development-http \
--read-write
```
In another:
```console
cargo run -p wip-tui -- http://127.0.0.1:8080/wip --allow-http
cargo run -p wip-tui -- http://127.0.0.1:8080/wip
```
Expand `/`, select `notes.txt`, focus the Interface/Operation pane, and choose
+1 -1
View File
@@ -939,7 +939,7 @@ mod tests {
// A request identity has no public constructor; derive one through the runtime.
let mut client = wip_client::Client::new(
wip_client::ClientLimits::new(1, 4, 4, 4, 4).unwrap(),
wip_https::Limits::new(1024, 1024, 8, 8).unwrap(),
wip_http::Limits::new(1024, 1024, 8, 8).unwrap(),
);
let session = client
.open_session(
+34 -110
View File
@@ -1,10 +1,10 @@
//! Secure-by-default command-line and environment configuration.
//! Command-line and environment configuration for the HTTP client.
use std::error::Error;
use std::fmt::{self, Debug, Display, Formatter};
use std::time::Duration;
use wip_https::url::Url;
use wip_http::Endpoint;
/// Credential wrapper whose debug output is always redacted.
#[derive(Clone)]
@@ -25,45 +25,33 @@ impl Debug for Secret {
/// Validated runtime configuration.
#[derive(Clone)]
pub struct Config {
/// Canonical logical HTTPS endpoint used by `wip-client`.
logical_endpoint: String,
/// Actual request origin; differs only for explicit loopback development HTTP.
transport_endpoint: String,
/// Canonical endpoint used for request construction, transport, and cache partitioning.
endpoint: String,
/// Non-secret cache partition identity.
security_context: String,
/// Bounded network timeout.
timeout: Duration,
/// Optional complete Authorization header value, never rendered or logged.
authorization: Option<Secret>,
/// Whether loopback request URIs are deliberately downgraded to HTTP.
development_http: bool,
}
impl Debug for Config {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("Config")
.field("logical_endpoint", &self.logical_endpoint)
.field("transport_endpoint", &self.transport_endpoint)
.field("endpoint", &self.endpoint)
.field("security_context", &self.security_context)
.field("timeout", &self.timeout)
.field("authorization", &self.authorization)
.field("development_http", &self.development_http)
.finish()
}
}
impl Config {
/// Returns the canonical HTTPS endpoint used for protocol request construction.
/// Returns the canonical endpoint used by the client and HTTP transport.
#[must_use]
pub fn logical_endpoint(&self) -> &str {
&self.logical_endpoint
}
/// Returns the validated effective transport endpoint shown in the UI.
#[must_use]
pub fn transport_endpoint(&self) -> &str {
&self.transport_endpoint
pub fn endpoint(&self) -> &str {
&self.endpoint
}
/// Returns the non-secret session cache partition identity.
@@ -78,12 +66,6 @@ impl Config {
self.timeout
}
/// Whether explicit loopback development HTTP is enabled.
#[must_use]
pub const fn development_http(&self) -> bool {
self.development_http
}
pub(crate) fn authorization(&self) -> Option<&Secret> {
self.authorization.as_ref()
}
@@ -115,7 +97,6 @@ impl Config {
F: FnMut(&str) -> Option<String>,
{
let mut endpoint = None;
let mut allow_http = false;
let mut timeout = Duration::from_secs(30);
let mut security_context = security_context_env.unwrap_or_else(|| "anonymous".into());
let mut authorization_env = "WIP_AUTHORIZATION".to_owned();
@@ -123,7 +104,6 @@ impl Config {
while let Some(argument) = iter.next() {
match argument.as_str() {
"--endpoint" => endpoint = Some(next_value(&mut iter, "--endpoint")?),
"--allow-http" => allow_http = true,
"--security-context" => {
security_context = next_value(&mut iter, "--security-context")?;
}
@@ -149,52 +129,22 @@ impl Config {
_ => return Err(ConfigError::Invalid("multiple endpoints supplied".into())),
}
}
let endpoint = endpoint
let supplied = endpoint
.or(endpoint_env)
.ok_or_else(|| ConfigError::Invalid("endpoint is required".into()))?;
let mut actual = Url::parse(&endpoint)
.map_err(|_| ConfigError::Invalid("endpoint is not a valid absolute URL".into()))?;
if actual.query().is_some()
|| actual.fragment().is_some()
|| actual.host_str().is_none()
|| !actual.username().is_empty()
|| actual.password().is_some()
{
let endpoint =
Endpoint::parse(&supplied).map_err(|error| ConfigError::Invalid(error.to_string()))?;
if !endpoint.as_url().username().is_empty() || endpoint.as_url().password().is_some() {
return Err(ConfigError::Invalid(
"endpoint must be an absolute URL without userinfo, query, or fragment".into(),
"endpoint userinfo is not accepted; supply credentials through the configured environment variable"
.into(),
));
}
if !actual.path().ends_with('/') {
let path = format!("{}/", actual.path());
actual.set_path(&path);
if !matches!(endpoint.as_url().scheme(), "http" | "https") {
return Err(ConfigError::Invalid(
"this HTTP client supports http and https endpoint schemes".into(),
));
}
let development_http = actual.scheme() == "http";
match actual.scheme() {
"https" => {}
"http" if allow_http && loopback_host(&actual) => {}
"http" if !allow_http => {
return Err(ConfigError::Invalid(
"plain HTTP requires the explicit --allow-http development option".into(),
));
}
"http" => {
return Err(ConfigError::Invalid(
"development HTTP is restricted to loopback endpoints".into(),
));
}
_ => return Err(ConfigError::Invalid("endpoint must use HTTPS".into())),
}
let transport_endpoint = actual.to_string();
let mut logical = actual;
if development_http {
logical
.set_scheme("https")
.expect("http and https are interchangeable hierarchical schemes");
}
// Let the binding enforce all remaining canonical endpoint rules.
let logical_endpoint = wip_https::Endpoint::parse(logical.as_str())
.map_err(|error| ConfigError::Invalid(error.to_string()))?
.to_string();
if security_context.is_empty() {
return Err(ConfigError::Invalid(
"security context must not be empty".into(),
@@ -204,20 +154,18 @@ impl Config {
.filter(|value| !value.is_empty())
.map(Secret);
Ok(Self {
logical_endpoint,
transport_endpoint,
endpoint: endpoint.to_string(),
security_context,
timeout,
authorization,
development_http,
})
}
/// Returns concise command usage without mentioning credential values.
#[must_use]
pub const fn usage() -> &'static str {
"usage: wip-tui [--endpoint] URL [--allow-http] [--security-context NAME] [--authorization-env VAR] [--timeout SECONDS]\n\
URL may also be supplied as WIP_ENDPOINT; HTTPS is required unless loopback HTTP is explicitly enabled."
"usage: wip-tui [--endpoint] URL [--security-context NAME] [--authorization-env VAR] [--timeout SECONDS]\n\
URL may also be supplied as WIP_ENDPOINT; the HTTP adapter supports http and https."
}
}
@@ -229,15 +177,6 @@ fn next_value(
.ok_or_else(|| ConfigError::Invalid(format!("{option} requires a value")))
}
fn loopback_host(url: &Url) -> bool {
match url.host() {
Some(wip_https::url::Host::Ipv4(address)) => address.is_loopback(),
Some(wip_https::url::Host::Ipv6(address)) => address.is_loopback(),
Some(wip_https::url::Host::Domain(name)) => name.eq_ignore_ascii_case("localhost"),
None => false,
}
}
/// Startup configuration failure.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigError {
@@ -263,7 +202,7 @@ mod tests {
use super::*;
#[test]
fn https_is_default_and_secrets_are_redacted() {
fn endpoint_is_canonical_and_secrets_are_redacted() {
let config = Config::parse(
["https://example.test/wip", "--authorization-env", "TOKEN"],
None,
@@ -271,7 +210,7 @@ mod tests {
|name| (name == "TOKEN").then(|| "Bearer secret-value".into()),
)
.unwrap();
assert_eq!(config.logical_endpoint(), "https://example.test/wip/");
assert_eq!(config.endpoint(), "https://example.test/wip/");
let debug = format!("{config:?}");
assert!(debug.contains("[REDACTED]"));
assert!(!debug.contains("secret-value"));
@@ -279,37 +218,22 @@ mod tests {
#[test]
fn endpoint_userinfo_is_rejected_before_it_can_reach_debug_or_render_state() {
for arguments in [
vec!["https://user:password@example.test/wip"],
vec!["http://user:password@localhost:3000/wip", "--allow-http"],
for endpoint in [
"https://user:password@example.test/wip",
"http://user:password@localhost:3000/wip",
] {
let error = Config::parse(arguments, None, None, |_| None).unwrap_err();
assert!(error.to_string().contains("without userinfo"));
let error = Config::parse([endpoint], None, None, |_| None).unwrap_err();
assert!(error.to_string().contains("userinfo"));
assert!(!error.to_string().contains("password"));
}
}
#[test]
fn http_requires_explicit_loopback_development_mode() {
assert!(Config::parse(["http://localhost:3000/wip"], None, None, |_| None).is_err());
let config = Config::parse(
["http://127.0.0.1:3000/wip", "--allow-http"],
None,
None,
|_| None,
)
.unwrap();
assert_eq!(config.logical_endpoint(), "https://127.0.0.1:3000/wip/");
assert_eq!(config.transport_endpoint(), "http://127.0.0.1:3000/wip/");
assert!(config.development_http());
assert!(
Config::parse(
["http://example.test/wip", "--allow-http"],
None,
None,
|_| None,
)
.is_err()
);
fn http_and_https_are_preserved_as_the_single_endpoint() {
for expected in ["http://example.test:3000/wip/", "https://example.test/wip/"] {
let supplied = expected.trim_end_matches('/');
let config = Config::parse([supplied], None, None, |_| None).unwrap();
assert_eq!(config.endpoint(), expected);
}
}
}
+47 -83
View File
@@ -13,8 +13,8 @@ use wip_client::{
DispatchedTransportFailure, OutcomeUnknownReason, PreparedRequest, RequestId, SecurityContext,
SessionId,
};
use wip_https::Limits;
use wip_https::http::{Request, Response};
use wip_http::Limits;
use wip_http::http::{Request, Response};
use wip_protocol::{InterfaceDescriptor, TypeExpr, Value};
use crate::app::{
@@ -68,18 +68,17 @@ pub trait Transport: Send + Sync + 'static {
fn execute(&self, request: Self::Prepared) -> Result<Response<Vec<u8>>, TransportError>;
}
/// HTTPS transport with redirects disabled and bounded response bodies.
/// HTTP transport with redirects disabled and bounded response bodies.
#[derive(Clone)]
pub struct HttpTransport {
agent: ureq::Agent,
logical_endpoint: wip_https::Endpoint,
transport_endpoint: wip_https::url::Url,
authorization: Option<wip_https::http::HeaderValue>,
endpoint: wip_http::Endpoint,
authorization: Option<wip_http::http::HeaderValue>,
max_response_bytes: usize,
}
impl HttpTransport {
/// Builds the default TLS transport and revalidates its immutable origins.
/// Builds the HTTP transport and revalidates its immutable endpoint.
pub fn new(config: &Config, max_response_bytes: usize) -> Result<Self, TransportError> {
if max_response_bytes == 0 {
return Err(TransportError {
@@ -87,29 +86,18 @@ impl HttpTransport {
detail: "transport response limit must be nonzero".into(),
});
}
let logical_endpoint =
wip_https::Endpoint::parse(config.logical_endpoint()).map_err(|_| TransportError {
let endpoint =
wip_http::Endpoint::parse(config.endpoint()).map_err(|_| TransportError {
kind: TransportErrorKind::Disconnect,
detail: "logical endpoint failed transport validation".into(),
detail: "endpoint failed transport validation".into(),
})?;
let transport_endpoint =
wip_https::url::Url::parse(config.transport_endpoint()).map_err(|_| {
TransportError {
kind: TransportErrorKind::Disconnect,
detail: "transport endpoint failed validation".into(),
}
})?;
validate_transport_origin(config, &logical_endpoint, &transport_endpoint)?;
let authorization = config
.authorization()
.map(|secret| {
wip_https::http::HeaderValue::from_str(secret.expose()).map_err(|_| {
TransportError {
kind: TransportErrorKind::Disconnect,
detail:
"authorization environment variable is not a valid HTTP header value"
.into(),
}
wip_http::http::HeaderValue::from_str(secret.expose()).map_err(|_| TransportError {
kind: TransportErrorKind::Disconnect,
detail: "authorization environment variable is not a valid HTTP header value"
.into(),
})
})
.transpose()?;
@@ -121,67 +109,24 @@ impl HttpTransport {
.into();
Ok(Self {
agent,
logical_endpoint,
transport_endpoint,
endpoint,
authorization,
max_response_bytes,
})
}
fn effective_uri(&self, uri: &str) -> Result<String, TransportError> {
let candidate = wip_https::url::Url::parse(uri).map_err(|_| TransportError {
let candidate = wip_http::url::Url::parse(uri).map_err(|_| TransportError {
kind: TransportErrorKind::Disconnect,
detail: "refused a request with an invalid absolute URI".into(),
})?;
let route = self
.logical_endpoint
self.endpoint
.recognize(&candidate)
.ok_or_else(|| TransportError {
kind: TransportErrorKind::Disconnect,
detail: "refused a request outside the configured WIP origin and routes".into(),
})?;
Ok(self
.transport_endpoint
.join(route.relative_reference())
.expect("validated endpoint joins a fixed route")
.to_string())
}
}
fn validate_transport_origin(
config: &Config,
logical: &wip_https::Endpoint,
transport: &wip_https::url::Url,
) -> Result<(), TransportError> {
let common_invalid = transport.query().is_some()
|| transport.fragment().is_some()
|| transport.host_str().is_none()
|| !transport.username().is_empty()
|| transport.password().is_some();
let valid = if config.development_http() {
let loopback = match transport.host() {
Some(wip_https::url::Host::Ipv4(address)) => address.is_loopback(),
Some(wip_https::url::Host::Ipv6(address)) => address.is_loopback(),
Some(wip_https::url::Host::Domain(name)) => name.eq_ignore_ascii_case("localhost"),
None => false,
};
let mut upgraded = transport.clone();
let changed = upgraded.set_scheme("https").is_ok();
!common_invalid
&& transport.scheme() == "http"
&& loopback
&& changed
&& &upgraded == logical.as_url()
} else {
!common_invalid && transport == logical.as_url()
};
if valid {
Ok(())
} else {
Err(TransportError {
kind: TransportErrorKind::Disconnect,
detail: "transport endpoint does not match the validated logical origin".into(),
})
Ok(candidate.to_string())
}
}
@@ -196,10 +141,9 @@ impl Transport for HttpTransport {
detail: "transport could not construct the configured request URI".into(),
})?;
if let Some(authorization) = &self.authorization {
parts.headers.insert(
wip_https::http::header::AUTHORIZATION,
authorization.clone(),
);
parts
.headers
.insert(wip_http::http::header::AUTHORIZATION, authorization.clone());
}
Ok(Request::from_parts(parts, body))
}
@@ -242,7 +186,7 @@ fn classify_ureq_error(error: ureq::Error) -> TransportError {
},
_ => TransportError {
kind: TransportErrorKind::Disconnect,
detail: "HTTP/TLS transport failed".into(),
detail: "HTTP transport failed".into(),
},
}
}
@@ -274,7 +218,7 @@ impl<T: Transport> Runtime<T> {
);
let session = client
.open_session(
config.logical_endpoint(),
config.endpoint(),
SecurityContext::new(config.security_context()),
)
.map_err(|error| RuntimeError(error.to_string()))?;
@@ -282,7 +226,7 @@ impl<T: Transport> Runtime<T> {
Ok(Self {
client,
session,
app: App::new(config.transport_endpoint()),
app: App::new(config.endpoint()),
transport: Arc::new(transport),
sender,
receiver,
@@ -1046,6 +990,26 @@ mod tests {
);
}
#[test]
fn transport_preserves_the_configured_endpoint_scheme_for_requests() {
for scheme in ["http", "https"] {
let configured = format!("{scheme}://example.test/wip");
let config = Config::parse([configured], None, None, |_| None).unwrap();
let transport = HttpTransport::new(&config, 1024).unwrap();
let endpoint = wip_http::Endpoint::parse(config.endpoint()).unwrap();
let expected = endpoint.route(wip_http::Route::Observe).to_string();
let request = Request::builder()
.method("POST")
.uri(&expected)
.body(Vec::new())
.unwrap();
let prepared = transport.prepare(request).unwrap();
assert_eq!(prepared.uri().to_string(), expected);
}
}
#[test]
fn authorization_is_attached_only_after_exact_origin_and_route_validation() {
let config = Config::parse(
@@ -1065,15 +1029,15 @@ mod tests {
assert!(error.detail.contains("outside"));
assert!(!error.detail.contains("sensitive"));
let endpoint = wip_https::Endpoint::parse(config.logical_endpoint()).unwrap();
let endpoint = wip_http::Endpoint::parse(config.endpoint()).unwrap();
let valid = Request::builder()
.method("POST")
.uri(endpoint.route(wip_https::Route::Observe).as_str())
.uri(endpoint.route(wip_http::Route::Observe).as_str())
.body(Vec::new())
.unwrap();
let prepared = transport.prepare(valid).unwrap();
assert_eq!(
prepared.headers()[wip_https::http::header::AUTHORIZATION],
prepared.headers()[wip_http::http::header::AUTHORIZATION],
"Bearer sensitive"
);
}
@@ -1081,7 +1045,7 @@ mod tests {
#[test]
fn transport_error_messages_do_not_echo_underlying_data() {
let error = classify_ureq_error(ureq::Error::BadUri("secret-token".into()));
assert_eq!(error.detail, "HTTP/TLS transport failed");
assert_eq!(error.detail, "HTTP transport failed");
assert!(!error.detail.contains("secret-token"));
}
}
+3 -9
View File
@@ -4,7 +4,7 @@ use std::thread;
use std::time::{Duration, Instant};
use tempfile::tempdir;
use wip_fs_server::{AccessMode, ServerConfig, TransportSecurity, WipFsServer};
use wip_fs_server::{AccessMode, ServerConfig, WipFsServer};
use wip_tui::app::{Focus, HistoryKind, InputKey, Modal, ViewState};
use wip_tui::config::Config;
use wip_tui::runtime::{HttpTransport, Runtime, default_wire_limits};
@@ -22,7 +22,6 @@ fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() {
let mut server_config = ServerConfig::for_root(temporary.path());
server_config.access = AccessMode::ReadWrite;
server_config.transport_security = TransportSecurity::DevelopmentHttp;
let bound = Arc::new(
WipFsServer::new(server_config)
.expect("build filesystem server")
@@ -36,13 +35,8 @@ fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() {
thread::spawn(move || bound.serve_until(shutdown))
};
let endpoint = format!("http://127.0.0.1:{}/wip", bound.local_addr().port());
let config = Config::parse(
[endpoint, "--allow-http".into()],
None,
Some("filesystem-test".into()),
|_| None,
)
.expect("development config");
let config = Config::parse([endpoint], None, Some("filesystem-test".into()), |_| None)
.expect("development config");
let transport = HttpTransport::new(&config, default_wire_limits().max_response_bytes())
.expect("HTTP transport");
let mut runtime = Runtime::new(&config, transport).expect("runtime");
+2 -2
View File
@@ -5,8 +5,8 @@ use std::time::{Duration, Instant};
use tempfile::tempdir;
use wip_fs_server::{ServerConfig, WipFsServer};
use wip_https::encode_protocol_error_response;
use wip_https::http::{Request, Response};
use wip_http::encode_protocol_error_response;
use wip_http::http::{Request, Response};
use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction};
use wip_tui::app::{ConnectionState, Focus, HistoryKind, InputKey, Modal, ViewState};
use wip_tui::config::Config;