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
Generated
+4 -4
View File
@@ -1237,7 +1237,7 @@ checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
name = "wip-client" name = "wip-client"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"wip-https", "wip-http",
"wip-protocol", "wip-protocol",
] ]
@@ -1249,7 +1249,7 @@ dependencies = [
"rustix 1.1.5", "rustix 1.1.5",
"tempfile", "tempfile",
"wip-host", "wip-host",
"wip-https", "wip-http",
"wip-interface", "wip-interface",
"wip-protocol", "wip-protocol",
] ]
@@ -1264,7 +1264,7 @@ dependencies = [
] ]
[[package]] [[package]]
name = "wip-https" name = "wip-http"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
@@ -1318,7 +1318,7 @@ dependencies = [
"ureq", "ureq",
"wip-client", "wip-client",
"wip-fs-server", "wip-fs-server",
"wip-https", "wip-http",
"wip-protocol", "wip-protocol",
] ]
+16 -13
View File
@@ -4,7 +4,7 @@ Rust building blocks for the Web Interface Protocol (WIP).
The repository is a resolver-3 virtual Cargo workspace whose packages all live The repository is a resolver-3 virtual Cargo workspace whose packages all live
under `crates/*`. It provides a transport-independent protocol model, the under `crates/*`. It provides a transport-independent protocol model, the
standard framework-independent WIP over HTTPS v1 binding, a stateful standard framework-independent WIP over HTTP v1 binding, a stateful
presentation-independent client runtime, a Rust interface-authoring facade, a presentation-independent client runtime, a Rust interface-authoring facade, a
request-scoped host runtime, a bounded Linux filesystem projection server, an request-scoped host runtime, a bounded Linux filesystem projection server, an
interactive terminal client, and a private public-API conformance consumer. The interactive terminal client, and a private public-API conformance consumer. The
@@ -15,20 +15,20 @@ repository root is not itself a package.
```text ```text
wip-tui (interactive Worldspace explorer and operation editor) wip-tui (interactive Worldspace explorer and operation editor)
├── wip-client Known Space, request identity, validators, and calls ├── wip-client Known Space, request identity, validators, and calls
├── wip-https framework-neutral HTTP request/response messages ├── wip-http framework-neutral HTTP request/response messages
└── wip-protocol descriptors and logical values └── wip-protocol descriptors and logical values
wip-fs-server (request-time filesystem projection and loopback listener) wip-fs-server (request-time filesystem projection and loopback listener)
├── wip-https standard routes/codecs/envelopes/batching ├── wip-http standard routes/codecs/envelopes/batching
├── wip-host request-scoped publication and dispatch ├── wip-host request-scoped publication and dispatch
└── wip-interface macro-generated filesystem capabilities └── wip-interface macro-generated filesystem capabilities
wip-public-api-example (workspace-only executable, integration tests, doctest) wip-public-api-example (workspace-only executable, integration tests, doctest)
├── wip-protocol logical declarations, values, calls, and validation ├── wip-protocol logical declarations, values, calls, and validation
├── wip-https standard HTTPS v1 routes, JSON codecs, envelopes, and batching ├── wip-http standard HTTP v1 routes, JSON codecs, envelopes, and batching
│ └── wip-protocol │ └── wip-protocol
├── wip-client stateful Known Space, lazy tree, observation, and call runtime ├── wip-client stateful Known Space, lazy tree, observation, and call runtime
│ ├── wip-https │ ├── wip-http
│ └── wip-protocol │ └── wip-protocol
├── wip-interface application DTO codecs and interface authoring facade ├── wip-interface application DTO codecs and interface authoring facade
│ ├── wip-protocol │ ├── wip-protocol
@@ -41,18 +41,18 @@ wip-public-api-example (workspace-only executable, integration tests, doctest)
| Crate | Responsibility | Does not own | | Crate | Responsibility | Does not own |
| --- | --- | --- | | --- | --- | --- |
| `wip-protocol` | Transport-independent descriptors, object/call/result types, logical `Value`s, validation, and stable protocol error categories. | Application DTOs, implementations, persistence, or wire encoding. | | `wip-protocol` | Transport-independent descriptors, object/call/result types, logical `Value`s, validation, and stable protocol error categories. | Application DTOs, implementations, persistence, or wire encoding. |
| `wip-https` | Standard HTTPS v1 endpoint/route derivation, UTF-8 JSON codecs, direct success bodies, error/status classification, and retrieval batches. | HTTP I/O, TLS termination, authentication schemes, server frameworks, filesystem projection, or client presentation/cache policy. | | `wip-http` | Standard WIP over HTTP v1 endpoint/route derivation, UTF-8 JSON codecs, direct success bodies, error/status classification, and retrieval batches. | HTTP I/O, URL scheme or TLS policy, authentication schemes, server frameworks, filesystem projection, or client presentation/cache policy. |
| `wip-client` | Endpoint/security-context-isolated Known Space, normalized object and interface observations, intent-driven object and descriptor coverage with bounded request deduplication, immutable operation call contexts, stale transitions, and bounded histories. | HTTP I/O, credentials, persistent cache, automatic operation retries, or presentation widgets/events. | | `wip-client` | Endpoint/security-context-isolated Known Space, normalized object and interface observations, intent-driven object and descriptor coverage with bounded request deduplication, immutable operation call contexts, stale transitions, and bounded histories. | HTTP I/O, credentials, persistent cache, automatic operation retries, or presentation widgets/events. |
| `wip-tui` | Keyboard-only interactive Worldspace tree, selected Entry and qualified operation panes, descriptor-driven argument editing, bounded result/diagnostic rendering, terminal lifecycle, and HTTPS I/O workers over `wip-client`. | Independent cache/validator semantics, automatic operation retries, Host internals, backend paths, or persistent state. | | `wip-tui` | Keyboard-only interactive Worldspace tree, selected Entry and qualified operation panes, descriptor-driven argument editing, bounded result/diagnostic rendering, terminal lifecycle, and HTTP I/O workers over `wip-client`. | Independent cache/validator semantics, automatic operation retries, Host internals, backend paths, or persistent state. |
| `wip-interface` | The public `WipType`/`interface` authoring API, Rust-to-protocol codecs, generated descriptors, and typed implementation dispatch. | Object publication, backend lifetime, or transport policy. | | `wip-interface` | The public `WipType`/`interface` authoring API, Rust-to-protocol codecs, generated descriptors, and typed implementation dispatch. | Object publication, backend lifetime, or transport policy. |
| `wip-interface-macros` | Procedural-macro expansion used by `wip-interface`. It is not an application dependency. | A supported direct user API. | | `wip-interface-macros` | Procedural-macro expansion used by `wip-interface`. It is not an application dependency. | A supported direct user API. |
| `wip-host` | Opaque interface registration, explicit object membership, validator checks, interface-qualified dispatch against one host-owned instance, and descriptor-guided argument decoding inside that same resolution boundary. | Backend atomicity, global object lookup, or network serving. | | `wip-host` | Opaque interface registration, explicit object membership, validator checks, interface-qualified dispatch against one host-owned instance, and descriptor-guided argument decoding inside that same resolution boundary. | Backend atomicity, global object lookup, or network serving. |
| `wip-fs-server` | Linux directory-handle-confined request-time projection, complete depth-bounded observations and bounded file operations, standard `wip-https` dispatch/batch handling, and a loopback HTTP binary for TLS-proxy or explicit development use. | A new wire profile, implicit symlink following, non-loopback cleartext, authentication policy, or filesystem-wide preregistration. | | `wip-fs-server` | Linux directory-handle-confined request-time projection, complete depth-bounded observations and bounded file operations, standard `wip-http` dispatch/batch handling, and a loopback HTTP binary. | A new wire profile, implicit symlink following, non-loopback cleartext, authentication policy, TLS termination, or filesystem-wide preregistration. |
| `wip-public-api-example` | An independently compiled consumer of the protocol, authoring, and host public crates. Its DTOs and traits are application-owned; its implementation and backend handle remain host-private. | Reusable library API or a production server. | | `wip-public-api-example` | An independently compiled consumer of the protocol, authoring, and host public crates. Its DTOs and traits are application-owned; its implementation and backend handle remain host-private. | Reusable library API or a production server. |
The boundary is intentional: protocol types describe what can cross a The boundary is intentional: protocol types describe what can cross a
transport; application DTOs opt into that model with `WipType`; a host-private transport; application DTOs opt into that model with `WipType`; a host-private
implementation owns credentials, handles, and backend state; and `wip-https` implementation owns credentials, handles, and backend state; and `wip-http`
maps standard routes/envelopes to `CallOperationRequest` and maps standard routes/envelopes to `CallOperationRequest` and
`CallOperationResponse` without placing transport-specific types in descriptors `CallOperationResponse` without placing transport-specific types in descriptors
or application traits. or application traits.
@@ -263,8 +263,10 @@ from Worldspace `/`, lazily expands materialized tree nodes, displays current
object/interface/operation metadata, validates all canonical descriptor types in object/interface/operation metadata, validates all canonical descriptor types in
a field editor, and requires a separate confirmation action before every call. a field editor, and requires a separate confirmation action before every call.
It uses the stateful `wip-client` runtime for request correlation, observed It uses the stateful `wip-client` runtime for request correlation, observed
validators, stale transitions, and unknown outcomes. HTTPS is the default; validators, stale transitions, and unknown outcomes. One canonical `http` or
loopback plain HTTP requires `--allow-http`. Full startup, TLS, key binding, `https` endpoint is used unchanged for request construction, transport, and
cache partitioning. Scheme, TLS, certificates, authentication, and proxy policy
belong to the HTTP implementation and deployment. Full startup, key binding,
layout, validator, and filesystem-operation examples are in the layout, validator, and filesystem-operation examples are in the
[package README](crates/wip-tui/README.md). [package README](crates/wip-tui/README.md).
@@ -277,8 +279,9 @@ state, uses pinned directory-relative handles without following symlinks, omits
non-UTF-8 backend names, returns complete recursive observations through the non-UTF-8 backend names, returns complete recursive observations through the
requested depth or a resource-limit failure, and publishes validator-protected requested depth or a resource-limit failure, and publishes validator-protected
replacement only in explicit read-write mode. replacement only in explicit read-write mode.
The built-in cleartext listener is loopback-only: production HTTPS is explicitly The built-in HTTP listener is loopback-only. Externally visible URL schemes,
delegated to a reverse proxy, while local plain HTTP requires an opt-in flag. TLS termination, certificates, authentication, and proxy routing are deployment
concerns rather than separate WIP profiles.
## Validation ## Validation
+1 -1
View File
@@ -6,5 +6,5 @@ description = "Presentation-independent stateful WIP client runtime"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
[dependencies] [dependencies]
wip-https = { path = "../wip-https" } wip-http = { path = "../wip-http" }
wip-protocol = { path = "../wip-protocol" } wip-protocol = { path = "../wip-protocol" }
+6 -6
View File
@@ -1,9 +1,9 @@
//! Presentation-independent stateful runtime for WIP clients. //! 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 //! supplied security context. It reconciles requested observation coverage without
//! duplicating fresh or loading work, creates requests with the standard //! 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 //! bounded object, tree, interface, diagnostic, and operation state. The crate
//! contains no terminal, widget, or UI event types. //! contains no terminal, widget, or UI event types.
@@ -13,8 +13,8 @@ use std::collections::{BTreeMap, VecDeque};
use std::error::Error; use std::error::Error;
use std::fmt::{self, Display, Formatter}; use std::fmt::{self, Display, Formatter};
use wip_https::http::{Request, Response}; use wip_http::http::{Request, Response};
use wip_https::{ use wip_http::{
ClientResponseError, DecodedResponse, Endpoint, Limits, decode_call_operation_response, ClientResponseError, DecodedResponse, Endpoint, Limits, decode_call_operation_response,
decode_fetch_interface_response, decode_observe_response, encode_call_operation_request, decode_fetch_interface_response, decode_observe_response, encode_call_operation_request,
encode_fetch_interface_request, encode_observe_request, encode_fetch_interface_request, encode_observe_request,
@@ -364,7 +364,7 @@ pub struct Diagnostic {
pub struct PreparedRequest { pub struct PreparedRequest {
/// Identity required when completing or failing this request. /// Identity required when completing or failing this request.
pub id: RequestId, pub id: RequestId,
/// Standard WIP over HTTPS request. /// Standard WIP over HTTP request.
pub request: Request<Vec<u8>>, pub request: Request<Vec<u8>>,
} }
@@ -1399,7 +1399,7 @@ impl Client {
&mut self, &mut self,
session: &SessionId, session: &SessionId,
id: RequestId, id: RequestId,
mismatch: Option<wip_https::StatusMismatch>, mismatch: Option<wip_http::StatusMismatch>,
) -> Result<(), ClientError> { ) -> Result<(), ClientError> {
if let Some(mismatch) = mismatch { if let Some(mismatch) = mismatch {
let maximum = self.limits.history; let maximum = self.limits.history;
+2 -2
View File
@@ -4,8 +4,8 @@ use wip_client::{
CallOutcome, Client, ClientErrorKind, ClientLimits, Completion, DiagnosticKind, CallOutcome, Client, ClientErrorKind, ClientLimits, Completion, DiagnosticKind,
ObservationState, OutcomeUnknownReason, SecurityContext, ObservationState, OutcomeUnknownReason, SecurityContext,
}; };
use wip_https::http::{Response, StatusCode}; use wip_http::http::{Response, StatusCode};
use wip_https::{ use wip_http::{
Limits, encode_fetch_interface_response, encode_observe_response, Limits, encode_fetch_interface_response, encode_observe_response,
encode_protocol_error_response, encode_protocol_error_response,
}; };
+2 -2
View File
@@ -4,8 +4,8 @@ use wip_client::{
CallOutcome, Client, ClientErrorKind, ClientLimits, Completion, DispatchedTransportFailure, CallOutcome, Client, ClientErrorKind, ClientLimits, Completion, DispatchedTransportFailure,
ObservationState, OutcomeUnknownReason, SecurityContext, ObservationState, OutcomeUnknownReason, SecurityContext,
}; };
use wip_https::http::Response; use wip_http::http::Response;
use wip_https::{ use wip_http::{
EncodeHttpError, Limits, encode_call_operation_response, encode_fetch_interface_response, EncodeHttpError, Limits, encode_call_operation_response, encode_fetch_interface_response,
encode_observe_response, encode_protocol_error_response, encode_observe_response, encode_protocol_error_response,
}; };
+2 -2
View File
@@ -2,7 +2,7 @@
name = "wip-fs-server" name = "wip-fs-server"
version = "0.1.0" version = "0.1.0"
edition = "2024" 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" license = "MIT OR Apache-2.0"
readme = "README.md" readme = "README.md"
publish = false publish = false
@@ -11,7 +11,7 @@ publish = false
http = "1" http = "1"
rustix = { version = "1.1", features = ["fs"] } rustix = { version = "1.1", features = ["fs"] }
wip-host = { path = "../wip-host" } wip-host = { path = "../wip-host" }
wip-https = { path = "../wip-https" } wip-http = { path = "../wip-http" }
wip-interface = { path = "../wip-interface" } wip-interface = { path = "../wip-interface" }
wip-protocol = { path = "../wip-protocol" } wip-protocol = { path = "../wip-protocol" }
+12 -14
View File
@@ -3,7 +3,7 @@
`wip-fs-server` projects one configured Linux filesystem directory onto WIP `wip-fs-server` projects one configured Linux filesystem directory onto WIP
Worldspace `/` at request time. It does not register the filesystem contents or 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 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. envelopes, batching, and HTTP status mapping.
## Security model ## Security model
@@ -28,9 +28,9 @@ envelopes, batching, and HTTP status mapping.
- Protocol metadata and errors never contain the backend absolute path. Startup - Protocol metadata and errors never contain the backend absolute path. Startup
prints only the explicitly configured effective root and security-sensitive prints only the explicitly configured effective root and security-sensitive
modes. modes.
- The built-in listener is cleartext and loopback-only. Production deployments - The built-in HTTP listener is loopback-only. TLS termination, certificates,
must pass `--behind-tls-proxy` and terminate HTTPS at a trusted reverse proxy. authentication, an externally visible URL scheme, and proxy routing are
Plain development HTTP requires the explicit `--development-http` flag. deployment concerns rather than separate WIP transport profiles.
The implementation currently targets Linux because it uses `O_PATH`, The implementation currently targets Linux because it uses `O_PATH`,
directory-relative descriptor traversal, and `/proc/self/fd` to reopen the same 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 ## Run
Read-only behind a TLS-terminating reverse proxy: Run the loopback listener in read-only mode:
```console ```console
cargo run -p wip-fs-server -- \ cargo run -p wip-fs-server -- \
--root /srv/public-files \ --root /srv/public-files \
--listen 127.0.0.1:8443 \ --listen 127.0.0.1:8080 \
--base-path /wip \ --base-path /wip
--behind-tls-proxy
``` ```
Local development with the write capability deliberately enabled: Run with the write capability deliberately enabled:
```console ```console
cargo run -p wip-fs-server -- \ cargo run -p wip-fs-server -- \
--root ./example-files \ --root ./example-files \
--listen 127.0.0.1:8080 \ --listen 127.0.0.1:8080 \
--base-path /wip \ --base-path /wip \
--development-http \
--read-write --read-write
``` ```
The server reports the effective root, `ReadOnly`/`ReadWrite` mode, transport The server reports the effective root, `ReadOnly`/`ReadWrite` mode, endpoint,
mode, endpoint, and actual listen address. It does not enumerate the root. and actual listen address. It does not enumerate the root.
## Standard interactions ## Standard interactions
The following examples use development HTTP for readability. The JSON is the The following examples use the built-in HTTP listener. The JSON follows the
standard `wip-https` profile; validators and operation `Bytes` are base64 body standard WIP over HTTP binding; validators and operation `Bytes` are base64 body
fields, never `ETag` or `If-Match` headers. fields, never `ETag` or `If-Match` headers.
Observe only the root Object: 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: //! This crate deliberately keeps the boundaries visible:
//! //!
//! - [`wip_protocol`] owns logical requests and responses; //! - [`wip_protocol`] owns logical requests and responses;
//! - [`wip_host`] owns object publication, validators, and typed dispatch; //! - [`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. //! - this crate owns the filesystem capability boundary and the real HTTP listener.
//! //!
//! TLS is terminated by an explicitly configured reverse proxy. The built-in //! The built-in HTTP listener is intentionally loopback-only. TLS termination,
//! listener accepts cleartext only on loopback, either behind that proxy or when //! certificates, authentication, and externally visible schemes are independent
//! [`TransportSecurity::DevelopmentHttp`] is deliberately selected. //! deployment concerns rather than WIP transport profiles.
#![deny(missing_docs)] #![deny(missing_docs)]
@@ -33,20 +33,11 @@ pub enum AccessMode {
ReadWrite, 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. /// Bounded resource policy applied by the HTTP adapter and filesystem provider.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct ResourceLimits { pub struct ResourceLimits {
/// Standard JSON, body-size, nesting, and batch limits. /// 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. /// Maximum bytes returned by one file read.
pub max_file_read_bytes: usize, pub max_file_read_bytes: usize,
/// Maximum bytes accepted by one file replacement. /// Maximum bytes accepted by one file replacement.
@@ -68,7 +59,7 @@ pub struct ResourceLimits {
impl Default for ResourceLimits { impl Default for ResourceLimits {
fn default() -> Self { fn default() -> Self {
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"), .expect("default wire limits are nonzero"),
max_file_read_bytes: 1024 * 1024, max_file_read_bytes: 1024 * 1024,
max_file_write_bytes: 1024 * 1024, max_file_write_bytes: 1024 * 1024,
@@ -93,8 +84,6 @@ pub struct ServerConfig {
pub endpoint_base_path: String, pub endpoint_base_path: String,
/// Read-only or explicit read-write publication mode. /// Read-only or explicit read-write publication mode.
pub access: AccessMode, pub access: AccessMode,
/// TLS termination or explicit development HTTP policy.
pub transport_security: TransportSecurity,
/// Request, operation, traversal, and concurrency bounds. /// Request, operation, traversal, and concurrency bounds.
pub limits: ResourceLimits, pub limits: ResourceLimits,
} }
@@ -102,8 +91,7 @@ pub struct ServerConfig {
impl ServerConfig { impl ServerConfig {
/// Builds a secure-by-default configuration for `root`. /// Builds a secure-by-default configuration for `root`.
/// ///
/// The listener defaults to an ephemeral loopback port, read-only access, /// The listener defaults to an ephemeral loopback port and read-only access.
/// and TLS termination delegated to a reverse proxy.
#[must_use] #[must_use]
pub fn for_root(root: impl Into<PathBuf>) -> Self { pub fn for_root(root: impl Into<PathBuf>) -> Self {
Self { Self {
@@ -111,7 +99,6 @@ impl ServerConfig {
listen: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0), listen: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
endpoint_base_path: "/wip".into(), endpoint_base_path: "/wip".into(),
access: AccessMode::ReadOnly, access: AccessMode::ReadOnly,
transport_security: TransportSecurity::ReverseProxyTls,
limits: ResourceLimits::default(), limits: ResourceLimits::default(),
} }
} }
@@ -142,9 +129,9 @@ impl ServerConfig {
Ok(()) Ok(())
} }
pub(crate) fn endpoint(&self) -> Result<wip_https::Endpoint, ConfigError> { pub(crate) fn endpoint(&self) -> Result<wip_http::Endpoint, ConfigError> {
let endpoint = format!("https://localhost{}", self.endpoint_base_path); let endpoint = format!("http://localhost{}", self.endpoint_base_path);
wip_https::Endpoint::parse(&endpoint).map_err(|_| ConfigError::InvalidBasePath) 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::net::SocketAddr;
use std::path::PathBuf; 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>> { fn main() -> Result<(), Box<dyn Error>> {
let mut root: Option<PathBuf> = None; 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 base_path = "/wip".to_owned();
let mut access = AccessMode::ReadOnly; let mut access = AccessMode::ReadOnly;
let mut security = None;
let mut arguments = std::env::args().skip(1); let mut arguments = std::env::args().skip(1);
while let Some(argument) = arguments.next() { while let Some(argument) = arguments.next() {
match argument.as_str() { match argument.as_str() {
@@ -17,12 +16,6 @@ fn main() -> Result<(), Box<dyn Error>> {
"--listen" => listen = required_value(&mut arguments, "--listen")?.parse()?, "--listen" => listen = required_value(&mut arguments, "--listen")?.parse()?,
"--base-path" => base_path = required_value(&mut arguments, "--base-path")?, "--base-path" => base_path = required_value(&mut arguments, "--base-path")?,
"--read-write" => access = AccessMode::ReadWrite, "--read-write" => access = AccessMode::ReadWrite,
"--development-http" => {
set_security(&mut security, TransportSecurity::DevelopmentHttp)?
}
"--behind-tls-proxy" => {
set_security(&mut security, TransportSecurity::ReverseProxyTls)?
}
"--help" | "-h" => { "--help" | "-h" => {
print_help(); print_help();
return Ok(()); return Ok(());
@@ -32,20 +25,17 @@ fn main() -> Result<(), Box<dyn Error>> {
} }
let root = root.ok_or("--root is required")?; 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); let mut config = ServerConfig::for_root(root);
config.listen = listen; config.listen = listen;
config.endpoint_base_path = base_path; config.endpoint_base_path = base_path;
config.access = access; config.access = access;
config.transport_security = transport_security;
let server = WipFsServer::new(config)?; let server = WipFsServer::new(config)?;
let effective_root = server.effective_root().to_owned(); let effective_root = server.effective_root().to_owned();
let endpoint = server.endpoint().clone(); let endpoint = server.endpoint().clone();
let bound = server.bind()?; let bound = server.bind()?;
eprintln!( eprintln!(
"wip-fs-server root={} access={access:?} transport={transport_security:?} endpoint={} listen={}", "wip-fs-server root={} access={access:?} endpoint={} listen={}",
effective_root.display(), effective_root.display(),
endpoint, endpoint,
bound.local_addr() bound.local_addr()
@@ -63,21 +53,10 @@ fn required_value(
.ok_or_else(|| format!("{option} requires a value").into()) .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() { fn print_help() {
println!( 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\ "Usage: wip-fs-server --root PATH [--listen 127.0.0.1:8080] \\\n [--base-path /wip] [--read-write]\n\n\
The built-in listener is cleartext and loopback-only. In production, \ The built-in HTTP listener is loopback-only. TLS termination, authentication, \
select --behind-tls-proxy and terminate HTTPS at a trusted reverse proxy." 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 http::{Request, Response, StatusCode};
use wip_host::{ExecutionContext, ObjectProvider}; use wip_host::{ExecutionContext, ObjectProvider};
use wip_https::{ use wip_http::{
BatchRequestKind, BatchResponse, BatchResponseItem, BatchSuccess, CodecError, EncodeHttpError, BatchRequestKind, BatchResponse, BatchResponseItem, BatchSuccess, CodecError, EncodeHttpError,
Endpoint, RequestMetadataError, ResourceLimitKind, Route, decode_batch_request, Endpoint, RequestMetadataError, ResourceLimitKind, Route, decode_batch_request,
decode_call_operation_metadata, decode_fetch_interface_request, decode_observe_request, decode_call_operation_metadata, decode_fetch_interface_request, decode_observe_request,
@@ -55,7 +55,7 @@ impl WipFsServer {
&self.endpoint &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. /// Listener integrations must enforce the same body limit while collecting bytes.
#[must_use] #[must_use]
+8 -10
View File
@@ -7,8 +7,8 @@ use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tempfile::tempdir; use tempfile::tempdir;
use wip_fs_server::{AccessMode, ServerConfig, TransportSecurity, WipFsServer}; use wip_fs_server::{AccessMode, ServerConfig, WipFsServer};
use wip_https::{ use wip_http::{
BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse, BatchSuccess, DecodedResponse, BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse, BatchSuccess, DecodedResponse,
Endpoint, JSON_CONTENT_TYPE, Limits, Route, decode_batch_request, decode_batch_response, Endpoint, JSON_CONTENT_TYPE, Limits, Route, decode_batch_request, decode_batch_response,
decode_call_operation_response, decode_fetch_interface_response, decode_observe_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()); let mut config = ServerConfig::for_root(temporary.path());
config.access = AccessMode::ReadWrite; config.access = AccessMode::ReadWrite;
config.transport_security = TransportSecurity::DevelopmentHttp;
let limits = config.limits.wire; let limits = config.limits.wire;
let bound = Arc::new( let bound = Arc::new(
WipFsServer::new(config) WipFsServer::new(config)
@@ -43,7 +42,7 @@ fn real_listener_serves_three_core_interactions_batch_and_write() {
let shutdown = Arc::clone(&shutdown); let shutdown = Arc::clone(&shutdown);
thread::spawn(move || bound.serve_until(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"); .expect("client endpoint");
let file_request = ObserveRequest { 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() { fn stalled_request_body_times_out_and_releases_the_only_worker() {
let temporary = tempdir().expect("temporary root"); let temporary = tempdir().expect("temporary root");
let mut config = ServerConfig::for_root(temporary.path()); let mut config = ServerConfig::for_root(temporary.path());
config.transport_security = TransportSecurity::DevelopmentHttp;
config.limits.max_concurrent_requests = 1; config.limits.max_concurrent_requests = 1;
config.limits.request_timeout = Duration::from_millis(200); config.limits.request_timeout = Duration::from_millis(200);
let limits = config.limits.wire; 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"); stalled.flush().expect("flush partial request");
thread::sleep(Duration::from_millis(25)); 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"); .expect("client endpoint");
let observe = ObserveRequest { let observe = ObserveRequest {
path: "/".into(), path: "/".into(),
@@ -289,7 +287,7 @@ fn batch_keeps_known_malformed_arguments_item_local() {
let config = ServerConfig::for_root(temporary.path()); let config = ServerConfig::for_root(temporary.path());
let limits = config.limits.wire; let limits = config.limits.wire;
let server = WipFsServer::new(config).expect("build server"); 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 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"); let logical = decode_batch_request(body, limits).expect("decode item-local invalid request");
assert!(matches!( assert!(matches!(
@@ -319,7 +317,7 @@ fn call_argument_decoding_does_not_mask_missing_target() {
let temporary = tempdir().expect("temporary root"); let temporary = tempdir().expect("temporary root");
let config = ServerConfig::for_root(temporary.path()); let config = ServerConfig::for_root(temporary.path());
let server = WipFsServer::new(config).expect("build server"); 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 body = br#"{"target":{"path":"/missing"},"interface":{"reference":"unknown"},"operation":"missing","arguments":{"bytes":"not-base64"}}"#;
let request = http::Request::builder() let request = http::Request::builder()
.method(http::Method::POST) .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 config = ServerConfig::for_root(temporary.path());
let limits = config.limits.wire; let limits = config.limits.wire;
let server = WipFsServer::new(config).expect("build server"); 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 { let request = ObserveRequest {
path: "/bad\0name".into(), path: "/bad\0name".into(),
depth: 0, 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"); config.limits.wire = Limits::new(4_096, 1_024, 64, 64).expect("wire limits");
let limits = config.limits.wire; let limits = config.limits.wire;
let server = WipFsServer::new(config).expect("build server"); 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 { let observe_request = ObserveRequest {
path: "/large".into(), path: "/large".into(),
@@ -1,8 +1,8 @@
[package] [package]
name = "wip-https" name = "wip-http"
version = "0.1.0" version = "0.1.0"
edition = "2024" 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" license = "MIT OR Apache-2.0"
[dependencies] [dependencies]
@@ -6,7 +6,7 @@ use url::Url;
/// The media type emitted for every WIP request and every response with a body. /// 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"; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Route { pub enum Route {
/// Observe one object and its indexable descendants. /// 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Endpoint(Url); pub struct Endpoint(Url);
impl Endpoint { impl Endpoint {
/// Parses and canonicalizes an endpoint. /// Parses and canonicalizes an endpoint.
/// ///
/// The input must be an absolute HTTPS URL without a query or fragment. Its /// The input must be an absolute hierarchical URL with a host and without a
/// path is an endpoint prefix, not a Worldspace path. A trailing slash is /// query or fragment. Its path is an endpoint prefix, not a Worldspace path.
/// appended when absent so RFC 3986 relative-reference resolution preserves /// A trailing slash is appended when absent so RFC 3986 relative-reference
/// the final prefix segment. /// 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> { pub fn parse(input: &str) -> Result<Self, EndpointError> {
let mut url = Url::parse(input).map_err(EndpointError::InvalidUrl)?; 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() { if url.cannot_be_a_base() || url.host_str().is_none() {
return Err(EndpointError::NotAbsoluteBase); 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)] #[derive(Debug)]
pub enum EndpointError { pub enum EndpointError {
/// URL parsing failed. /// URL parsing failed.
InvalidUrl(url::ParseError), InvalidUrl(url::ParseError),
/// The URL scheme is not `https`.
NotHttps,
/// The URL is not a hierarchical absolute URL with an authority. /// The URL is not a hierarchical absolute URL with an authority.
NotAbsoluteBase, NotAbsoluteBase,
/// A query component was supplied. /// A query component was supplied.
@@ -132,7 +128,6 @@ impl Display for EndpointError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
match self { match self {
Self::InvalidUrl(error) => write!(formatter, "invalid endpoint URL: {error}"), Self::InvalidUrl(error) => write!(formatter, "invalid endpoint URL: {error}"),
Self::NotHttps => formatter.write_str("WIP endpoint must use HTTPS"),
Self::NotAbsoluteBase => { Self::NotAbsoluteBase => {
formatter.write_str("WIP endpoint must be an absolute base URL") 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 //! This crate owns endpoint and route derivation, UTF-8 JSON encoding, canonical
//! success and error response handling, HTTP status mapping, descriptor-guided //! success and error response handling, HTTP status mapping, descriptor-guided
//! operation-value conversion, and retrieval batching. It deliberately performs //! operation-value conversion, and retrieval batching. It deliberately performs
//! no I/O and depends on no server framework, TLS implementation, filesystem //! 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 //! Logical values remain owned by [`wip_protocol`]. Validators and `Bytes` are
//! encoded in JSON bodies using canonical RFC 4648 Section 4 base64; this crate //! encoded in JSON bodies using canonical RFC 4648 Section 4 base64; this crate
//! does not infer WIP validator semantics from `ETag` or `If-Match`. //! 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; //! 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 limits = Limits::new(64 * 1024, 1024 * 1024, 64, 100)?;
//! let request = encode_observe_request( //! let request = encode_observe_request(
//! &endpoint, //! &endpoint,
//! &ObserveRequest { path: "/items/123".into(), depth: 0 }, //! &ObserveRequest { path: "/items/123".into(), depth: 0 },
//! limits, //! 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); //! assert_eq!(request.method(), http::Method::POST);
//! # Ok::<(), Box<dyn std::error::Error>>(()) //! # Ok::<(), Box<dyn std::error::Error>>(())
//! ``` //! ```
@@ -1,9 +1,9 @@
use wip_https::http::StatusCode; use wip_http::http::StatusCode;
use wip_https::wip_protocol::{ use wip_http::wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor, FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor,
Object, ObjectObservation, ObserveRequest, ProtocolError, ProtocolErrorCode, Object, ObjectObservation, ObserveRequest, ProtocolError, ProtocolErrorCode,
}; };
use wip_https::{ use wip_http::{
BatchMethod, BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse, BatchMethod, BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse,
BatchResponseItem, BatchSuccess, ClientResponseError, DecodedResponse, Endpoint, BatchResponseItem, BatchSuccess, ClientResponseError, DecodedResponse, Endpoint,
InvalidResponseKind, Limits, ResourceLimitKind, decode_batch_request, decode_batch_response, 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) .status(StatusCode::NOT_FOUND)
.header("content-type", "application/json; charset=utf-8") .header("content-type", "application/json; charset=utf-8")
.body(br#"{"error":{"code":"not_found","message":"not a batch failure"}}"#.to_vec()) .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] #[test]
fn unsupported_batch_descriptor_waits_for_envelope_correlation() { 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) .status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8") .header("content-type", "application/json; charset=utf-8")
.body( .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) .status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8") .header("content-type", "application/json; charset=utf-8")
.body( .body(
@@ -1,14 +1,14 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use wip_https::http::StatusCode; use wip_http::http::StatusCode;
use wip_https::wip_protocol::{ use wip_http::wip_protocol::{
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest, CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
InterfaceTarget, MAX_SAFE_INTEGER, Object, ObjectObservation, ObserveRequest, InterfaceTarget, MAX_SAFE_INTEGER, Object, ObjectObservation, ObserveRequest,
OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target, TypeDeclaration, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target, TypeDeclaration,
TypeExpr, UnionCase, Value, TypeExpr, UnionCase, Value,
}; };
use wip_https::{ use wip_http::{
ClientResponseError, CodecError, DecodedResponse, Endpoint, InvalidResponseKind, Limits, ClientResponseError, CodecError, DecodedResponse, Endpoint, InvalidResponseKind, Limits,
ResourceLimitKind, decode_call_operation_metadata, decode_call_operation_request, ResourceLimitKind, decode_call_operation_metadata, decode_call_operation_request,
decode_call_operation_response, decode_fetch_interface_request, decode_call_operation_response, decode_fetch_interface_request,
@@ -466,7 +466,7 @@ fn unsupported_descriptor_format_is_client_local() {
ClientResponseError::UnsupportedDescriptorFormat { .. } ClientResponseError::UnsupportedDescriptorFormat { .. }
)); ));
let future_shape = wip_https::http::Response::builder() let future_shape = wip_http::http::Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8") .header("content-type", "application/json; charset=utf-8")
.body( .body(
@@ -485,7 +485,7 @@ fn unsupported_descriptor_format_is_client_local() {
br#"{"interface":"interface-v1","descriptor":{"format":"wip-interface/2"},"validator":null}"# br#"{"interface":"interface-v1","descriptor":{"format":"wip-interface/2"},"validator":null}"#
.as_slice(), .as_slice(),
] { ] {
let response = wip_https::http::Response::builder() let response = wip_http::http::Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8") .header("content-type", "application/json; charset=utf-8")
.body(malformed.to_vec()) .body(malformed.to_vec())
@@ -502,7 +502,7 @@ fn unsupported_descriptor_format_is_client_local() {
#[test] #[test]
fn invalid_success_is_classified_as_invalid_response() { 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) .status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8") .header("content-type", "application/json; charset=utf-8")
.body(br#"{"object":{"name":"wrong","interfaces":[]}}"#.to_vec()) .body(br#"{"object":{"name":"wrong","interfaces":[]}}"#.to_vec())
@@ -1,8 +1,8 @@
use wip_https::http::{Response, StatusCode}; use wip_http::http::{Response, StatusCode};
use wip_https::wip_protocol::{ use wip_http::wip_protocol::{
ObserveRequest, ProtocolError, ProtocolErrorCode, ProtocolInteraction, ObserveRequest, ProtocolError, ProtocolErrorCode, ProtocolInteraction,
}; };
use wip_https::{ use wip_http::{
ClientResponseError, DecodedResponse, InvalidResponseKind, Limits, ResourceLimitKind, ClientResponseError, DecodedResponse, InvalidResponseKind, Limits, ResourceLimitKind,
TransportFailureKind, decode_observe_response, encode_protocol_error_response, TransportFailureKind, decode_observe_response, encode_protocol_error_response,
encode_resource_limit_error_response, error_code_name, is_json_content_type, status_for_error, 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(), decode_observe_response(&request(), &encoded, limits()).unwrap(),
DecodedResponse::ProtocolFailure { DecodedResponse::ProtocolFailure {
error, error,
status_mismatch: Some(wip_https::StatusMismatch { status_mismatch: Some(wip_http::StatusMismatch {
actual: StatusCode::IM_A_TEAPOT, actual: StatusCode::IM_A_TEAPOT,
expected: StatusCode::NOT_FOUND, expected: StatusCode::NOT_FOUND,
}), }),
@@ -1,41 +1,40 @@
use wip_https::http::{Method, Request, StatusCode}; use wip_http::http::{Method, Request, StatusCode};
use wip_https::{Endpoint, RequestMetadataError, Route, validate_http_request}; use wip_http::{Endpoint, RequestMetadataError, Route, validate_http_request};
#[test] #[test]
fn endpoint_is_https_absolute_queryless_and_canonical() { fn endpoint_preserves_http_and_https_when_resolving_canonical_routes() {
let endpoint = Endpoint::parse("https://example.test/root/prefix").unwrap(); for scheme in ["http", "https"] {
assert_eq!(endpoint.to_string(), "https://example.test/root/prefix/"); let endpoint = Endpoint::parse(&format!("{scheme}://example.test/root/prefix")).unwrap();
assert_eq!(
endpoint.to_string(),
format!("{scheme}://example.test/root/prefix/")
);
let expected = [ let expected = [
( (Route::Observe, "v1/observe"),
Route::Observe, (Route::FetchInterface, "v1/fetch_interface"),
"https://example.test/root/prefix/v1/observe", (Route::CallOperation, "v1/call_operation"),
), (Route::Batch, "v1/batch"),
(
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 { 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.route(route).as_str(), url);
assert_eq!( assert_eq!(
endpoint.recognize(&wip_https::url::Url::parse(url).unwrap()), endpoint.recognize(&wip_http::url::Url::parse(&url).unwrap()),
Some(route) Some(route)
); );
} }
assert_eq!( assert_eq!(
endpoint.recognize( endpoint.recognize(
&wip_https::url::Url::parse("https://example.test/root/prefix/v1/observe?path=/items") &wip_http::url::Url::parse(&format!(
"{scheme}://example.test/root/prefix/v1/observe?path=/items"
))
.unwrap() .unwrap()
), ),
None None
); );
} }
}
#[test] #[test]
fn routes_never_mix_worldspace_paths_into_endpoint_prefixes() { fn routes_never_mix_worldspace_paths_into_endpoint_prefixes() {
@@ -118,9 +117,26 @@ fn inbound_http_metadata_enforces_post_route_and_json() {
} }
#[test] #[test]
fn invalid_endpoint_components_are_rejected() { fn invalid_endpoint_components_and_non_routes_are_rejected() {
assert!(Endpoint::parse("http://example.test/wip").is_err());
assert!(Endpoint::parse("/relative").is_err()); 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?q=1").is_err());
assert!(Endpoint::parse("https://example.test/wip#fragment").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" serde_json = "1"
ureq = "3.1.2" ureq = "3.1.2"
wip-client = { path = "../wip-client" } wip-client = { path = "../wip-client" }
wip-https = { path = "../wip-https" } wip-http = { path = "../wip-http" }
wip-protocol = { path = "../wip-protocol" } wip-protocol = { path = "../wip-protocol" }
[dev-dependencies] [dev-dependencies]
+7 -13
View File
@@ -8,12 +8,17 @@ owned by the client runtime.
## Start ## Start
HTTPS is the normal mode: Supply one `http` or `https` endpoint:
```console ```console
cargo run -p wip-tui -- --endpoint https://wip.example.test/api/wip 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 The endpoint may instead be supplied as `WIP_ENDPOINT`. A non-secret cache
partition identity may be supplied with `--security-context NAME` or partition identity may be supplied with `--security-context NAME` or
`WIP_SECURITY_CONTEXT`. The default request timeout is 30 seconds and can be `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 redacted from `Debug` and is never rendered. Shell and process environment security
remain the operator's responsibility. 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 ### Filesystem server example
In one terminal: In one terminal:
@@ -52,14 +47,13 @@ cargo run -p wip-fs-server -- \
--root /tmp/wip-example \ --root /tmp/wip-example \
--listen 127.0.0.1:8080 \ --listen 127.0.0.1:8080 \
--base-path /wip \ --base-path /wip \
--development-http \
--read-write --read-write
``` ```
In another: In another:
```console ```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 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. // A request identity has no public constructor; derive one through the runtime.
let mut client = wip_client::Client::new( let mut client = wip_client::Client::new(
wip_client::ClientLimits::new(1, 4, 4, 4, 4).unwrap(), 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 let session = client
.open_session( .open_session(
+32 -108
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::error::Error;
use std::fmt::{self, Debug, Display, Formatter}; use std::fmt::{self, Debug, Display, Formatter};
use std::time::Duration; use std::time::Duration;
use wip_https::url::Url; use wip_http::Endpoint;
/// Credential wrapper whose debug output is always redacted. /// Credential wrapper whose debug output is always redacted.
#[derive(Clone)] #[derive(Clone)]
@@ -25,45 +25,33 @@ impl Debug for Secret {
/// Validated runtime configuration. /// Validated runtime configuration.
#[derive(Clone)] #[derive(Clone)]
pub struct Config { pub struct Config {
/// Canonical logical HTTPS endpoint used by `wip-client`. /// Canonical endpoint used for request construction, transport, and cache partitioning.
logical_endpoint: String, endpoint: String,
/// Actual request origin; differs only for explicit loopback development HTTP.
transport_endpoint: String,
/// Non-secret cache partition identity. /// Non-secret cache partition identity.
security_context: String, security_context: String,
/// Bounded network timeout. /// Bounded network timeout.
timeout: Duration, timeout: Duration,
/// Optional complete Authorization header value, never rendered or logged. /// Optional complete Authorization header value, never rendered or logged.
authorization: Option<Secret>, authorization: Option<Secret>,
/// Whether loopback request URIs are deliberately downgraded to HTTP.
development_http: bool,
} }
impl Debug for Config { impl Debug for Config {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
formatter formatter
.debug_struct("Config") .debug_struct("Config")
.field("logical_endpoint", &self.logical_endpoint) .field("endpoint", &self.endpoint)
.field("transport_endpoint", &self.transport_endpoint)
.field("security_context", &self.security_context) .field("security_context", &self.security_context)
.field("timeout", &self.timeout) .field("timeout", &self.timeout)
.field("authorization", &self.authorization) .field("authorization", &self.authorization)
.field("development_http", &self.development_http)
.finish() .finish()
} }
} }
impl Config { 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] #[must_use]
pub fn logical_endpoint(&self) -> &str { pub fn endpoint(&self) -> &str {
&self.logical_endpoint &self.endpoint
}
/// Returns the validated effective transport endpoint shown in the UI.
#[must_use]
pub fn transport_endpoint(&self) -> &str {
&self.transport_endpoint
} }
/// Returns the non-secret session cache partition identity. /// Returns the non-secret session cache partition identity.
@@ -78,12 +66,6 @@ impl Config {
self.timeout 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> { pub(crate) fn authorization(&self) -> Option<&Secret> {
self.authorization.as_ref() self.authorization.as_ref()
} }
@@ -115,7 +97,6 @@ impl Config {
F: FnMut(&str) -> Option<String>, F: FnMut(&str) -> Option<String>,
{ {
let mut endpoint = None; let mut endpoint = None;
let mut allow_http = false;
let mut timeout = Duration::from_secs(30); let mut timeout = Duration::from_secs(30);
let mut security_context = security_context_env.unwrap_or_else(|| "anonymous".into()); let mut security_context = security_context_env.unwrap_or_else(|| "anonymous".into());
let mut authorization_env = "WIP_AUTHORIZATION".to_owned(); let mut authorization_env = "WIP_AUTHORIZATION".to_owned();
@@ -123,7 +104,6 @@ impl Config {
while let Some(argument) = iter.next() { while let Some(argument) = iter.next() {
match argument.as_str() { match argument.as_str() {
"--endpoint" => endpoint = Some(next_value(&mut iter, "--endpoint")?), "--endpoint" => endpoint = Some(next_value(&mut iter, "--endpoint")?),
"--allow-http" => allow_http = true,
"--security-context" => { "--security-context" => {
security_context = next_value(&mut iter, "--security-context")?; security_context = next_value(&mut iter, "--security-context")?;
} }
@@ -149,52 +129,22 @@ impl Config {
_ => return Err(ConfigError::Invalid("multiple endpoints supplied".into())), _ => return Err(ConfigError::Invalid("multiple endpoints supplied".into())),
} }
} }
let endpoint = endpoint let supplied = endpoint
.or(endpoint_env) .or(endpoint_env)
.ok_or_else(|| ConfigError::Invalid("endpoint is required".into()))?; .ok_or_else(|| ConfigError::Invalid("endpoint is required".into()))?;
let mut actual = Url::parse(&endpoint) let endpoint =
.map_err(|_| ConfigError::Invalid("endpoint is not a valid absolute URL".into()))?; Endpoint::parse(&supplied).map_err(|error| ConfigError::Invalid(error.to_string()))?;
if actual.query().is_some() if !endpoint.as_url().username().is_empty() || endpoint.as_url().password().is_some() {
|| actual.fragment().is_some()
|| actual.host_str().is_none()
|| !actual.username().is_empty()
|| actual.password().is_some()
{
return Err(ConfigError::Invalid( 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('/') { if !matches!(endpoint.as_url().scheme(), "http" | "https") {
let path = format!("{}/", actual.path());
actual.set_path(&path);
}
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( return Err(ConfigError::Invalid(
"plain HTTP requires the explicit --allow-http development option".into(), "this HTTP client supports http and https endpoint schemes".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() { if security_context.is_empty() {
return Err(ConfigError::Invalid( return Err(ConfigError::Invalid(
"security context must not be empty".into(), "security context must not be empty".into(),
@@ -204,20 +154,18 @@ impl Config {
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.map(Secret); .map(Secret);
Ok(Self { Ok(Self {
logical_endpoint, endpoint: endpoint.to_string(),
transport_endpoint,
security_context, security_context,
timeout, timeout,
authorization, authorization,
development_http,
}) })
} }
/// Returns concise command usage without mentioning credential values. /// Returns concise command usage without mentioning credential values.
#[must_use] #[must_use]
pub const fn usage() -> &'static str { pub const fn usage() -> &'static str {
"usage: wip-tui [--endpoint] URL [--allow-http] [--security-context NAME] [--authorization-env VAR] [--timeout SECONDS]\n\ "usage: wip-tui [--endpoint] URL [--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." 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"))) .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. /// Startup configuration failure.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigError { pub enum ConfigError {
@@ -263,7 +202,7 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn https_is_default_and_secrets_are_redacted() { fn endpoint_is_canonical_and_secrets_are_redacted() {
let config = Config::parse( let config = Config::parse(
["https://example.test/wip", "--authorization-env", "TOKEN"], ["https://example.test/wip", "--authorization-env", "TOKEN"],
None, None,
@@ -271,7 +210,7 @@ mod tests {
|name| (name == "TOKEN").then(|| "Bearer secret-value".into()), |name| (name == "TOKEN").then(|| "Bearer secret-value".into()),
) )
.unwrap(); .unwrap();
assert_eq!(config.logical_endpoint(), "https://example.test/wip/"); assert_eq!(config.endpoint(), "https://example.test/wip/");
let debug = format!("{config:?}"); let debug = format!("{config:?}");
assert!(debug.contains("[REDACTED]")); assert!(debug.contains("[REDACTED]"));
assert!(!debug.contains("secret-value")); assert!(!debug.contains("secret-value"));
@@ -279,37 +218,22 @@ mod tests {
#[test] #[test]
fn endpoint_userinfo_is_rejected_before_it_can_reach_debug_or_render_state() { fn endpoint_userinfo_is_rejected_before_it_can_reach_debug_or_render_state() {
for arguments in [ for endpoint in [
vec!["https://user:password@example.test/wip"], "https://user:password@example.test/wip",
vec!["http://user:password@localhost:3000/wip", "--allow-http"], "http://user:password@localhost:3000/wip",
] { ] {
let error = Config::parse(arguments, None, None, |_| None).unwrap_err(); let error = Config::parse([endpoint], None, None, |_| None).unwrap_err();
assert!(error.to_string().contains("without userinfo")); assert!(error.to_string().contains("userinfo"));
assert!(!error.to_string().contains("password")); assert!(!error.to_string().contains("password"));
} }
} }
#[test] #[test]
fn http_requires_explicit_loopback_development_mode() { fn http_and_https_are_preserved_as_the_single_endpoint() {
assert!(Config::parse(["http://localhost:3000/wip"], None, None, |_| None).is_err()); for expected in ["http://example.test:3000/wip/", "https://example.test/wip/"] {
let config = Config::parse( let supplied = expected.trim_end_matches('/');
["http://127.0.0.1:3000/wip", "--allow-http"], let config = Config::parse([supplied], None, None, |_| None).unwrap();
None, assert_eq!(config.endpoint(), expected);
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()
);
} }
} }
+45 -81
View File
@@ -13,8 +13,8 @@ use wip_client::{
DispatchedTransportFailure, OutcomeUnknownReason, PreparedRequest, RequestId, SecurityContext, DispatchedTransportFailure, OutcomeUnknownReason, PreparedRequest, RequestId, SecurityContext,
SessionId, SessionId,
}; };
use wip_https::Limits; use wip_http::Limits;
use wip_https::http::{Request, Response}; use wip_http::http::{Request, Response};
use wip_protocol::{InterfaceDescriptor, TypeExpr, Value}; use wip_protocol::{InterfaceDescriptor, TypeExpr, Value};
use crate::app::{ use crate::app::{
@@ -68,18 +68,17 @@ pub trait Transport: Send + Sync + 'static {
fn execute(&self, request: Self::Prepared) -> Result<Response<Vec<u8>>, TransportError>; 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)] #[derive(Clone)]
pub struct HttpTransport { pub struct HttpTransport {
agent: ureq::Agent, agent: ureq::Agent,
logical_endpoint: wip_https::Endpoint, endpoint: wip_http::Endpoint,
transport_endpoint: wip_https::url::Url, authorization: Option<wip_http::http::HeaderValue>,
authorization: Option<wip_https::http::HeaderValue>,
max_response_bytes: usize, max_response_bytes: usize,
} }
impl HttpTransport { 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> { pub fn new(config: &Config, max_response_bytes: usize) -> Result<Self, TransportError> {
if max_response_bytes == 0 { if max_response_bytes == 0 {
return Err(TransportError { return Err(TransportError {
@@ -87,29 +86,18 @@ impl HttpTransport {
detail: "transport response limit must be nonzero".into(), detail: "transport response limit must be nonzero".into(),
}); });
} }
let logical_endpoint = let endpoint =
wip_https::Endpoint::parse(config.logical_endpoint()).map_err(|_| TransportError { wip_http::Endpoint::parse(config.endpoint()).map_err(|_| TransportError {
kind: TransportErrorKind::Disconnect, 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 let authorization = config
.authorization() .authorization()
.map(|secret| { .map(|secret| {
wip_https::http::HeaderValue::from_str(secret.expose()).map_err(|_| { wip_http::http::HeaderValue::from_str(secret.expose()).map_err(|_| TransportError {
TransportError {
kind: TransportErrorKind::Disconnect, kind: TransportErrorKind::Disconnect,
detail: detail: "authorization environment variable is not a valid HTTP header value"
"authorization environment variable is not a valid HTTP header value"
.into(), .into(),
}
}) })
}) })
.transpose()?; .transpose()?;
@@ -121,67 +109,24 @@ impl HttpTransport {
.into(); .into();
Ok(Self { Ok(Self {
agent, agent,
logical_endpoint, endpoint,
transport_endpoint,
authorization, authorization,
max_response_bytes, max_response_bytes,
}) })
} }
fn effective_uri(&self, uri: &str) -> Result<String, TransportError> { 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, kind: TransportErrorKind::Disconnect,
detail: "refused a request with an invalid absolute URI".into(), detail: "refused a request with an invalid absolute URI".into(),
})?; })?;
let route = self self.endpoint
.logical_endpoint
.recognize(&candidate) .recognize(&candidate)
.ok_or_else(|| TransportError { .ok_or_else(|| TransportError {
kind: TransportErrorKind::Disconnect, kind: TransportErrorKind::Disconnect,
detail: "refused a request outside the configured WIP origin and routes".into(), detail: "refused a request outside the configured WIP origin and routes".into(),
})?; })?;
Ok(self Ok(candidate.to_string())
.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(),
})
} }
} }
@@ -196,10 +141,9 @@ impl Transport for HttpTransport {
detail: "transport could not construct the configured request URI".into(), detail: "transport could not construct the configured request URI".into(),
})?; })?;
if let Some(authorization) = &self.authorization { if let Some(authorization) = &self.authorization {
parts.headers.insert( parts
wip_https::http::header::AUTHORIZATION, .headers
authorization.clone(), .insert(wip_http::http::header::AUTHORIZATION, authorization.clone());
);
} }
Ok(Request::from_parts(parts, body)) Ok(Request::from_parts(parts, body))
} }
@@ -242,7 +186,7 @@ fn classify_ureq_error(error: ureq::Error) -> TransportError {
}, },
_ => TransportError { _ => TransportError {
kind: TransportErrorKind::Disconnect, 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 let session = client
.open_session( .open_session(
config.logical_endpoint(), config.endpoint(),
SecurityContext::new(config.security_context()), SecurityContext::new(config.security_context()),
) )
.map_err(|error| RuntimeError(error.to_string()))?; .map_err(|error| RuntimeError(error.to_string()))?;
@@ -282,7 +226,7 @@ impl<T: Transport> Runtime<T> {
Ok(Self { Ok(Self {
client, client,
session, session,
app: App::new(config.transport_endpoint()), app: App::new(config.endpoint()),
transport: Arc::new(transport), transport: Arc::new(transport),
sender, sender,
receiver, 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] #[test]
fn authorization_is_attached_only_after_exact_origin_and_route_validation() { fn authorization_is_attached_only_after_exact_origin_and_route_validation() {
let config = Config::parse( let config = Config::parse(
@@ -1065,15 +1029,15 @@ mod tests {
assert!(error.detail.contains("outside")); assert!(error.detail.contains("outside"));
assert!(!error.detail.contains("sensitive")); 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() let valid = Request::builder()
.method("POST") .method("POST")
.uri(endpoint.route(wip_https::Route::Observe).as_str()) .uri(endpoint.route(wip_http::Route::Observe).as_str())
.body(Vec::new()) .body(Vec::new())
.unwrap(); .unwrap();
let prepared = transport.prepare(valid).unwrap(); let prepared = transport.prepare(valid).unwrap();
assert_eq!( assert_eq!(
prepared.headers()[wip_https::http::header::AUTHORIZATION], prepared.headers()[wip_http::http::header::AUTHORIZATION],
"Bearer sensitive" "Bearer sensitive"
); );
} }
@@ -1081,7 +1045,7 @@ mod tests {
#[test] #[test]
fn transport_error_messages_do_not_echo_underlying_data() { fn transport_error_messages_do_not_echo_underlying_data() {
let error = classify_ureq_error(ureq::Error::BadUri("secret-token".into())); 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")); assert!(!error.detail.contains("secret-token"));
} }
} }
+2 -8
View File
@@ -4,7 +4,7 @@ use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tempfile::tempdir; 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::app::{Focus, HistoryKind, InputKey, Modal, ViewState};
use wip_tui::config::Config; use wip_tui::config::Config;
use wip_tui::runtime::{HttpTransport, Runtime, default_wire_limits}; 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()); let mut server_config = ServerConfig::for_root(temporary.path());
server_config.access = AccessMode::ReadWrite; server_config.access = AccessMode::ReadWrite;
server_config.transport_security = TransportSecurity::DevelopmentHttp;
let bound = Arc::new( let bound = Arc::new(
WipFsServer::new(server_config) WipFsServer::new(server_config)
.expect("build filesystem server") .expect("build filesystem server")
@@ -36,12 +35,7 @@ fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() {
thread::spawn(move || bound.serve_until(shutdown)) thread::spawn(move || bound.serve_until(shutdown))
}; };
let endpoint = format!("http://127.0.0.1:{}/wip", bound.local_addr().port()); let endpoint = format!("http://127.0.0.1:{}/wip", bound.local_addr().port());
let config = Config::parse( let config = Config::parse([endpoint], None, Some("filesystem-test".into()), |_| None)
[endpoint, "--allow-http".into()],
None,
Some("filesystem-test".into()),
|_| None,
)
.expect("development config"); .expect("development config");
let transport = HttpTransport::new(&config, default_wire_limits().max_response_bytes()) let transport = HttpTransport::new(&config, default_wire_limits().max_response_bytes())
.expect("HTTP transport"); .expect("HTTP transport");
+2 -2
View File
@@ -5,8 +5,8 @@ use std::time::{Duration, Instant};
use tempfile::tempdir; use tempfile::tempdir;
use wip_fs_server::{ServerConfig, WipFsServer}; use wip_fs_server::{ServerConfig, WipFsServer};
use wip_https::encode_protocol_error_response; use wip_http::encode_protocol_error_response;
use wip_https::http::{Request, Response}; use wip_http::http::{Request, Response};
use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction}; use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction};
use wip_tui::app::{ConnectionState, Focus, HistoryKind, InputKey, Modal, ViewState}; use wip_tui::app::{ConnectionState, Focus, HistoryKind, InputKey, Modal, ViewState};
use wip_tui::config::Config; use wip_tui::config::Config;