diff --git a/Cargo.lock b/Cargo.lock index 0205a3a..64dae6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1237,7 +1237,7 @@ checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" name = "wip-client" version = "0.1.0" dependencies = [ - "wip-https", + "wip-http", "wip-protocol", ] @@ -1249,7 +1249,7 @@ dependencies = [ "rustix 1.1.5", "tempfile", "wip-host", - "wip-https", + "wip-http", "wip-interface", "wip-protocol", ] @@ -1264,7 +1264,7 @@ dependencies = [ ] [[package]] -name = "wip-https" +name = "wip-http" version = "0.1.0" dependencies = [ "base64 0.22.1", @@ -1318,7 +1318,7 @@ dependencies = [ "ureq", "wip-client", "wip-fs-server", - "wip-https", + "wip-http", "wip-protocol", ] diff --git a/README.md b/README.md index d3585d8..0d47d63 100644 --- a/README.md +++ b/README.md @@ -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 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 request-scoped host runtime, a bounded Linux filesystem projection server, an interactive terminal client, and a private public-API conformance consumer. The @@ -15,20 +15,20 @@ repository root is not itself a package. ```text wip-tui (interactive Worldspace explorer and operation editor) ├── 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-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-interface macro-generated filesystem capabilities wip-public-api-example (workspace-only executable, integration tests, doctest) ├── 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-client stateful Known Space, lazy tree, observation, and call runtime -│ ├── wip-https +│ ├── wip-http │ └── wip-protocol ├── wip-interface application DTO codecs and interface authoring facade │ ├── wip-protocol @@ -41,18 +41,18 @@ wip-public-api-example (workspace-only executable, integration tests, doctest) | 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-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-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-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-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. | The boundary is intentional: protocol types describe what can cross a 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 `CallOperationResponse` without placing transport-specific types in descriptors 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 a field editor, and requires a separate confirmation action before every call. It uses the stateful `wip-client` runtime for request correlation, observed -validators, stale transitions, and unknown outcomes. HTTPS is the default; -loopback plain HTTP requires `--allow-http`. Full startup, TLS, key binding, +validators, stale transitions, and unknown outcomes. One canonical `http` or +`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 [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 requested depth or a resource-limit failure, and publishes validator-protected replacement only in explicit read-write mode. -The built-in cleartext listener is loopback-only: production HTTPS is explicitly -delegated to a reverse proxy, while local plain HTTP requires an opt-in flag. +The built-in HTTP listener is loopback-only. Externally visible URL schemes, +TLS termination, certificates, authentication, and proxy routing are deployment +concerns rather than separate WIP profiles. ## Validation diff --git a/crates/wip-client/Cargo.toml b/crates/wip-client/Cargo.toml index a6cdd55..8dd1a57 100644 --- a/crates/wip-client/Cargo.toml +++ b/crates/wip-client/Cargo.toml @@ -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" } diff --git a/crates/wip-client/src/lib.rs b/crates/wip-client/src/lib.rs index ca2dfc6..f8dc133 100644 --- a/crates/wip-client/src/lib.rs +++ b/crates/wip-client/src/lib.rs @@ -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>, } @@ -1399,7 +1399,7 @@ impl Client { &mut self, session: &SessionId, id: RequestId, - mismatch: Option, + mismatch: Option, ) -> Result<(), ClientError> { if let Some(mismatch) = mismatch { let maximum = self.limits.history; diff --git a/crates/wip-client/tests/adapter.rs b/crates/wip-client/tests/adapter.rs index feb0c5e..3875bd1 100644 --- a/crates/wip-client/tests/adapter.rs +++ b/crates/wip-client/tests/adapter.rs @@ -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, }; diff --git a/crates/wip-client/tests/runtime.rs b/crates/wip-client/tests/runtime.rs index ac16beb..7dfacc6 100644 --- a/crates/wip-client/tests/runtime.rs +++ b/crates/wip-client/tests/runtime.rs @@ -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, }; diff --git a/crates/wip-fs-server/Cargo.toml b/crates/wip-fs-server/Cargo.toml index 1aa9210..b43deb7 100644 --- a/crates/wip-fs-server/Cargo.toml +++ b/crates/wip-fs-server/Cargo.toml @@ -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" } diff --git a/crates/wip-fs-server/README.md b/crates/wip-fs-server/README.md index 894dad8..403061f 100644 --- a/crates/wip-fs-server/README.md +++ b/crates/wip-fs-server/README.md @@ -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: diff --git a/crates/wip-fs-server/src/lib.rs b/crates/wip-fs-server/src/lib.rs index 8f0244d..2dba665 100644 --- a/crates/wip-fs-server/src/lib.rs +++ b/crates/wip-fs-server/src/lib.rs @@ -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) -> 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 { - let endpoint = format!("https://localhost{}", self.endpoint_base_path); - wip_https::Endpoint::parse(&endpoint).map_err(|_| ConfigError::InvalidBasePath) + pub(crate) fn endpoint(&self) -> Result { + let endpoint = format!("http://localhost{}", self.endpoint_base_path); + wip_http::Endpoint::parse(&endpoint).map_err(|_| ConfigError::InvalidBasePath) } } diff --git a/crates/wip-fs-server/src/main.rs b/crates/wip-fs-server/src/main.rs index 0061cff..786e755 100644 --- a/crates/wip-fs-server/src/main.rs +++ b/crates/wip-fs-server/src/main.rs @@ -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> { let mut root: Option = 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> { "--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> { } 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, - value: TransportSecurity, -) -> Result<(), Box> { - 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." ); } diff --git a/crates/wip-fs-server/src/server.rs b/crates/wip-fs-server/src/server.rs index 3e16188..f725785 100644 --- a/crates/wip-fs-server/src/server.rs +++ b/crates/wip-fs-server/src/server.rs @@ -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] diff --git a/crates/wip-fs-server/tests/end_to_end.rs b/crates/wip-fs-server/tests/end_to_end.rs index e4f2433..d210f2f 100644 --- a/crates/wip-fs-server/tests/end_to_end.rs +++ b/crates/wip-fs-server/tests/end_to_end.rs @@ -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(), diff --git a/crates/wip-https/Cargo.toml b/crates/wip-http/Cargo.toml similarity index 68% rename from crates/wip-https/Cargo.toml rename to crates/wip-http/Cargo.toml index 64d37a7..116f2da 100644 --- a/crates/wip-https/Cargo.toml +++ b/crates/wip-http/Cargo.toml @@ -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] diff --git a/crates/wip-https/src/batch.rs b/crates/wip-http/src/batch.rs similarity index 100% rename from crates/wip-https/src/batch.rs rename to crates/wip-http/src/batch.rs diff --git a/crates/wip-https/src/codec.rs b/crates/wip-http/src/codec.rs similarity index 100% rename from crates/wip-https/src/codec.rs rename to crates/wip-http/src/codec.rs diff --git a/crates/wip-https/src/endpoint.rs b/crates/wip-http/src/endpoint.rs similarity index 85% rename from crates/wip-https/src/endpoint.rs rename to crates/wip-http/src/endpoint.rs index c7f70c6..9de5db2 100644 --- a/crates/wip-https/src/endpoint.rs +++ b/crates/wip-http/src/endpoint.rs @@ -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 { 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") } diff --git a/crates/wip-https/src/error.rs b/crates/wip-http/src/error.rs similarity index 100% rename from crates/wip-https/src/error.rs rename to crates/wip-http/src/error.rs diff --git a/crates/wip-https/src/json.rs b/crates/wip-http/src/json.rs similarity index 100% rename from crates/wip-https/src/json.rs rename to crates/wip-http/src/json.rs diff --git a/crates/wip-https/src/lib.rs b/crates/wip-http/src/lib.rs similarity index 83% rename from crates/wip-https/src/lib.rs rename to crates/wip-http/src/lib.rs index 8a71abf..7d8827f 100644 --- a/crates/wip-https/src/lib.rs +++ b/crates/wip-http/src/lib.rs @@ -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>(()) //! ``` diff --git a/crates/wip-https/src/response.rs b/crates/wip-http/src/response.rs similarity index 100% rename from crates/wip-https/src/response.rs rename to crates/wip-http/src/response.rs diff --git a/crates/wip-https/tests/batch.rs b/crates/wip-http/tests/batch.rs similarity index 97% rename from crates/wip-https/tests/batch.rs rename to crates/wip-http/tests/batch.rs index 102ef51..609d24c 100644 --- a/crates/wip-https/tests/batch.rs +++ b/crates/wip-http/tests/batch.rs @@ -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( diff --git a/crates/wip-https/tests/codecs.rs b/crates/wip-http/tests/codecs.rs similarity index 98% rename from crates/wip-https/tests/codecs.rs rename to crates/wip-http/tests/codecs.rs index d9c1f44..084a0d2 100644 --- a/crates/wip-https/tests/codecs.rs +++ b/crates/wip-http/tests/codecs.rs @@ -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()) diff --git a/crates/wip-https/tests/responses.rs b/crates/wip-http/tests/responses.rs similarity index 98% rename from crates/wip-https/tests/responses.rs rename to crates/wip-http/tests/responses.rs index e26bbbb..14cd29e 100644 --- a/crates/wip-https/tests/responses.rs +++ b/crates/wip-http/tests/responses.rs @@ -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, }), diff --git a/crates/wip-https/tests/routes.rs b/crates/wip-http/tests/routes.rs similarity index 62% rename from crates/wip-https/tests/routes.rs rename to crates/wip-http/tests/routes.rs index d594ade..2e0d949 100644 --- a/crates/wip-https/tests/routes.rs +++ b/crates/wip-http/tests/routes.rs @@ -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 + ); + } } diff --git a/crates/wip-tui/Cargo.toml b/crates/wip-tui/Cargo.toml index a0796e3..72d010c 100644 --- a/crates/wip-tui/Cargo.toml +++ b/crates/wip-tui/Cargo.toml @@ -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] diff --git a/crates/wip-tui/README.md b/crates/wip-tui/README.md index ef085eb..b60e4b5 100644 --- a/crates/wip-tui/README.md +++ b/crates/wip-tui/README.md @@ -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 diff --git a/crates/wip-tui/src/app.rs b/crates/wip-tui/src/app.rs index e0259a6..122c70f 100644 --- a/crates/wip-tui/src/app.rs +++ b/crates/wip-tui/src/app.rs @@ -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( diff --git a/crates/wip-tui/src/config.rs b/crates/wip-tui/src/config.rs index cba53f6..a7f7c9e 100644 --- a/crates/wip-tui/src/config.rs +++ b/crates/wip-tui/src/config.rs @@ -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, - /// 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, { 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); + } } } diff --git a/crates/wip-tui/src/runtime.rs b/crates/wip-tui/src/runtime.rs index ac1cbc7..34ee793 100644 --- a/crates/wip-tui/src/runtime.rs +++ b/crates/wip-tui/src/runtime.rs @@ -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>, 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, + endpoint: wip_http::Endpoint, + authorization: Option, 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 { 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 { - 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 Runtime { ); 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 Runtime { 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")); } } diff --git a/crates/wip-tui/tests/filesystem_e2e.rs b/crates/wip-tui/tests/filesystem_e2e.rs index 8773dab..f1e4930 100644 --- a/crates/wip-tui/tests/filesystem_e2e.rs +++ b/crates/wip-tui/tests/filesystem_e2e.rs @@ -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"); diff --git a/crates/wip-tui/tests/runtime_async.rs b/crates/wip-tui/tests/runtime_async.rs index e0ebfba..8726738 100644 --- a/crates/wip-tui/tests/runtime_async.rs +++ b/crates/wip-tui/tests/runtime_async.rs @@ -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;