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