Compare commits

...
44 changed files with 2684 additions and 3132 deletions
Generated
+4 -4
View File
@@ -1237,7 +1237,7 @@ checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
name = "wip-client" name = "wip-client"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"wip-https", "wip-http",
"wip-protocol", "wip-protocol",
] ]
@@ -1249,7 +1249,7 @@ dependencies = [
"rustix 1.1.5", "rustix 1.1.5",
"tempfile", "tempfile",
"wip-host", "wip-host",
"wip-https", "wip-http",
"wip-interface", "wip-interface",
"wip-protocol", "wip-protocol",
] ]
@@ -1264,7 +1264,7 @@ dependencies = [
] ]
[[package]] [[package]]
name = "wip-https" name = "wip-http"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
@@ -1318,7 +1318,7 @@ dependencies = [
"ureq", "ureq",
"wip-client", "wip-client",
"wip-fs-server", "wip-fs-server",
"wip-https", "wip-http",
"wip-protocol", "wip-protocol",
] ]
+20 -16
View File
@@ -4,7 +4,7 @@ Rust building blocks for the Web Interface Protocol (WIP).
The repository is a resolver-3 virtual Cargo workspace whose packages all live The repository is a resolver-3 virtual Cargo workspace whose packages all live
under `crates/*`. It provides a transport-independent protocol model, the under `crates/*`. It provides a transport-independent protocol model, the
standard framework-independent WIP over HTTPS v1 binding, a stateful standard framework-independent WIP over HTTP v1 binding, a stateful
presentation-independent client runtime, a Rust interface-authoring facade, a presentation-independent client runtime, a Rust interface-authoring facade, a
request-scoped host runtime, a bounded Linux filesystem projection server, an request-scoped host runtime, a bounded Linux filesystem projection server, an
interactive terminal client, and a private public-API conformance consumer. The interactive terminal client, and a private public-API conformance consumer. The
@@ -15,20 +15,20 @@ repository root is not itself a package.
```text ```text
wip-tui (interactive Worldspace explorer and operation editor) wip-tui (interactive Worldspace explorer and operation editor)
├── wip-client Known Space, request identity, validators, and calls ├── wip-client Known Space, request identity, validators, and calls
├── wip-https framework-neutral HTTP request/response messages ├── wip-http framework-neutral HTTP request/response messages
└── wip-protocol descriptors and logical values └── wip-protocol descriptors and logical values
wip-fs-server (request-time filesystem projection and loopback listener) wip-fs-server (request-time filesystem projection and loopback listener)
├── wip-https standard routes/codecs/envelopes/batching ├── wip-http standard routes/codecs/envelopes
├── wip-host request-scoped publication and dispatch ├── wip-host request-scoped publication and dispatch
└── wip-interface macro-generated filesystem capabilities └── wip-interface macro-generated filesystem capabilities
wip-public-api-example (workspace-only executable, integration tests, doctest) wip-public-api-example (workspace-only executable, integration tests, doctest)
├── wip-protocol logical declarations, values, calls, and validation ├── wip-protocol logical declarations, values, calls, and validation
├── wip-https standard HTTPS v1 routes, JSON codecs, envelopes, and batching ├── wip-http standard HTTP v1 routes, JSON codecs, and envelopes
│ └── wip-protocol │ └── wip-protocol
├── wip-client stateful Known Space, lazy tree, observation, and call runtime ├── wip-client stateful Known Space, lazy tree, observation, and call runtime
│ ├── wip-https │ ├── wip-http
│ └── wip-protocol │ └── wip-protocol
├── wip-interface application DTO codecs and interface authoring facade ├── wip-interface application DTO codecs and interface authoring facade
│ ├── wip-protocol │ ├── wip-protocol
@@ -41,18 +41,18 @@ wip-public-api-example (workspace-only executable, integration tests, doctest)
| Crate | Responsibility | Does not own | | Crate | Responsibility | Does not own |
| --- | --- | --- | | --- | --- | --- |
| `wip-protocol` | Transport-independent descriptors, object/call/result types, logical `Value`s, validation, and stable protocol error categories. | Application DTOs, implementations, persistence, or wire encoding. | | `wip-protocol` | Transport-independent descriptors, object/call/result types, logical `Value`s, validation, and stable protocol error categories. | Application DTOs, implementations, persistence, or wire encoding. |
| `wip-https` | Standard HTTPS v1 endpoint/route derivation, UTF-8 JSON codecs, direct success bodies, error/status classification, and retrieval batches. | HTTP I/O, TLS termination, authentication schemes, server frameworks, filesystem projection, or client presentation/cache policy. | | `wip-http` | Standard WIP over HTTP v1 endpoint/route derivation, UTF-8 JSON codecs, direct success bodies, and error/status classification. | 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, lazy entry trees, immutable operation call contexts, stale transitions, and bounded histories. | HTTP I/O, credentials, persistent cache, automatic operation retries, or presentation widgets/events. | | `wip-client` | Endpoint/security-context-isolated Known Space, normalized object and interface observations, intent-driven object and descriptor coverage with bounded request deduplication, immutable operation call contexts, stale transitions, and bounded histories. | HTTP I/O, credentials, persistent cache, automatic operation retries, or presentation widgets/events. |
| `wip-tui` | Keyboard-only interactive Worldspace tree, selected Entry and qualified operation panes, descriptor-driven argument editing, bounded result/diagnostic rendering, terminal lifecycle, and HTTPS I/O workers over `wip-client`. | Independent cache/validator semantics, automatic operation retries, Host internals, backend paths, or persistent state. | | `wip-tui` | Keyboard-only interactive Worldspace tree, selected Entry and qualified operation panes, descriptor-driven argument editing, bounded result/diagnostic rendering, terminal lifecycle, and HTTP I/O workers over `wip-client`. | Independent cache/validator semantics, automatic operation retries, Host internals, backend paths, or persistent state. |
| `wip-interface` | The public `WipType`/`interface` authoring API, Rust-to-protocol codecs, generated descriptors, and typed implementation dispatch. | Object publication, backend lifetime, or transport policy. | | `wip-interface` | The public `WipType`/`interface` authoring API, Rust-to-protocol codecs, generated descriptors, and typed implementation dispatch. | Object publication, backend lifetime, or transport policy. |
| `wip-interface-macros` | Procedural-macro expansion used by `wip-interface`. It is not an application dependency. | A supported direct user API. | | `wip-interface-macros` | Procedural-macro expansion used by `wip-interface`. It is not an application dependency. | A supported direct user API. |
| `wip-host` | Opaque interface registration, explicit object membership, validator checks, interface-qualified dispatch against one host-owned instance, and descriptor-guided argument decoding inside that same resolution boundary. | Backend atomicity, global object lookup, or network serving. | | `wip-host` | Opaque interface registration, explicit object membership, validator checks, interface-qualified dispatch against one host-owned instance, and descriptor-guided argument decoding inside that same resolution boundary. | Backend atomicity, global object lookup, or network serving. |
| `wip-fs-server` | Linux directory-handle-confined request-time projection, bounded BFS trees and 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, and a loopback HTTP binary. | A new wire profile, implicit symlink following, non-loopback cleartext, authentication policy, TLS termination, or filesystem-wide preregistration. |
| `wip-public-api-example` | An independently compiled consumer of the protocol, authoring, and host public crates. Its DTOs and traits are application-owned; its implementation and backend handle remain host-private. | Reusable library API or a production server. | | `wip-public-api-example` | An independently compiled consumer of the protocol, authoring, and host public crates. Its DTOs and traits are application-owned; its implementation and backend handle remain host-private. | Reusable library API or a production server. |
The boundary is intentional: protocol types describe what can cross a The boundary is intentional: protocol types describe what can cross a
transport; application DTOs opt into that model with `WipType`; a host-private transport; application DTOs opt into that model with `WipType`; a host-private
implementation owns credentials, handles, and backend state; and `wip-https` implementation owns credentials, handles, and backend state; and `wip-http`
maps standard routes/envelopes to `CallOperationRequest` and maps standard routes/envelopes to `CallOperationRequest` and
`CallOperationResponse` without placing transport-specific types in descriptors `CallOperationResponse` without placing transport-specific types in descriptors
or application traits. or application traits.
@@ -263,8 +263,10 @@ from Worldspace `/`, lazily expands materialized tree nodes, displays current
object/interface/operation metadata, validates all canonical descriptor types in object/interface/operation metadata, validates all canonical descriptor types in
a field editor, and requires a separate confirmation action before every call. a field editor, and requires a separate confirmation action before every call.
It uses the stateful `wip-client` runtime for request correlation, observed It uses the stateful `wip-client` runtime for request correlation, observed
validators, stale transitions, and unknown outcomes. HTTPS is the default; validators, stale transitions, and unknown outcomes. One canonical `http` or
loopback plain HTTP requires `--allow-http`. Full startup, TLS, key binding, `https` endpoint is used unchanged for request construction, transport, and
cache partitioning. Scheme, TLS, certificates, authentication, and proxy policy
belong to the HTTP implementation and deployment. Full startup, key binding,
layout, validator, and filesystem-operation examples are in the layout, validator, and filesystem-operation examples are in the
[package README](crates/wip-tui/README.md). [package README](crates/wip-tui/README.md).
@@ -274,10 +276,12 @@ layout, validator, and filesystem-operation examples are in the
binary and its full security model, configuration, curl walkthrough, and binary and its full security model, configuration, curl walkthrough, and
resource/failure semantics. It resolves every object from current filesystem resource/failure semantics. It resolves every object from current filesystem
state, uses pinned directory-relative handles without following symlinks, omits state, uses pinned directory-relative handles without following symlinks, omits
non-UTF-8 backend names, performs bounded breadth-first tree discovery, and non-UTF-8 backend names, returns complete recursive observations through the
publishes validator-protected replacement only in explicit read-write mode. requested depth or a resource-limit failure, and publishes validator-protected
The built-in cleartext listener is loopback-only: production HTTPS is explicitly replacement only in explicit read-write mode.
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 ## Validation
+1 -1
View File
@@ -6,5 +6,5 @@ description = "Presentation-independent stateful WIP client runtime"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
[dependencies] [dependencies]
wip-https = { path = "../wip-https" } wip-http = { path = "../wip-http" }
wip-protocol = { path = "../wip-protocol" } wip-protocol = { path = "../wip-protocol" }
+249 -213
View File
@@ -1,8 +1,9 @@
//! Presentation-independent stateful runtime for WIP clients. //! Presentation-independent stateful runtime for WIP clients.
//! //!
//! [`Client`] separates observations by canonical HTTPS endpoint and caller- //! [`Client`] separates observations by canonical WIP over HTTP endpoint and caller-
//! supplied security context. It creates requests with the standard //! supplied security context. It reconciles requested observation coverage without
//! [`wip_https`] adapter, correlates responses by [`RequestId`], and retains //! duplicating fresh or loading work, creates requests with the standard
//! [`wip_http`] adapter, correlates responses by [`RequestId`], and retains
//! bounded object, tree, interface, diagnostic, and operation state. The crate //! bounded object, tree, interface, diagnostic, and operation state. The crate
//! contains no terminal, widget, or UI event types. //! contains no terminal, widget, or UI event types.
@@ -12,17 +13,16 @@ use std::collections::{BTreeMap, VecDeque};
use std::error::Error; use std::error::Error;
use std::fmt::{self, Display, Formatter}; use std::fmt::{self, Display, Formatter};
use wip_https::http::{Request, Response}; use wip_http::http::{Request, Response};
use wip_https::{ use wip_http::{
ClientResponseError, DecodedResponse, Endpoint, Limits, decode_call_operation_response, ClientResponseError, DecodedResponse, Endpoint, Limits, decode_call_operation_response,
decode_fetch_interface_response, decode_fetch_response, decode_fetch_tree_response, decode_fetch_interface_response, decode_observe_response, encode_call_operation_request,
encode_call_operation_request, encode_fetch_interface_request, encode_fetch_request, encode_fetch_interface_request, encode_observe_request,
encode_fetch_tree_request,
}; };
use wip_protocol::{ use wip_protocol::{
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, FetchRequest, CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, InterfaceDescriptor,
FetchTreeRequest, FetchTreeResponse, InterfaceDescriptor, InterfaceTarget, Object, InterfaceTarget, Object, ObjectObservation as ProtocolObjectObservation, ObserveRequest,
ObjectTreeNode, OperationDeclaration, ProtocolError, ProtocolErrorCode, Target, Value, OperationDeclaration, ProtocolError, ProtocolErrorCode, Target, Value,
}; };
/// Monotonic identity assigned to one logical request. /// Monotonic identity assigned to one logical request.
@@ -84,7 +84,7 @@ impl SessionId {
pub struct ClientLimits { pub struct ClientLimits {
/// Maximum simultaneously retained endpoint/security-context sessions. /// Maximum simultaneously retained endpoint/security-context sessions.
pub sessions: usize, pub sessions: usize,
/// Maximum object, path, and lazy-tree observations retained by each session. /// Maximum object, path, and observed-children state retained by each session.
pub objects_per_session: usize, pub objects_per_session: usize,
/// Maximum interface observations retained by each session. /// Maximum interface observations retained by each session.
pub interfaces_per_session: usize, pub interfaces_per_session: usize,
@@ -217,8 +217,6 @@ pub struct TreeObservation {
pub path: String, pub path: String,
/// Materialized direct child paths in Host order. /// Materialized direct child paths in Host order.
pub children: Vec<String>, pub children: Vec<String>,
/// Whether the latest expansion rooted here omitted indexable entries.
pub truncated: bool,
/// Request revision that most recently owned this tree subject. /// Request revision that most recently owned this tree subject.
pub revision: RequestId, pub revision: RequestId,
/// Current expansion lifecycle. /// Current expansion lifecycle.
@@ -366,7 +364,7 @@ pub struct Diagnostic {
pub struct PreparedRequest { pub struct PreparedRequest {
/// Identity required when completing or failing this request. /// Identity required when completing or failing this request.
pub id: RequestId, pub id: RequestId,
/// Standard WIP over HTTPS request. /// Standard WIP over HTTP request.
pub request: Request<Vec<u8>>, pub request: Request<Vec<u8>>,
} }
@@ -375,8 +373,6 @@ pub struct PreparedRequest {
pub enum Completion { pub enum Completion {
/// An object observation was updated. /// An object observation was updated.
Object(ObjectObservation), Object(ObjectObservation),
/// A lazy tree expansion was updated.
Tree(TreeObservation),
/// An interface observation was updated. /// An interface observation was updated.
Interface(InterfaceObservation), Interface(InterfaceObservation),
/// An operation reached a terminal outcome. /// An operation reached a terminal outcome.
@@ -501,13 +497,9 @@ impl Session {
#[derive(Debug)] #[derive(Debug)]
enum Pending { enum Pending {
Fetch { Observe {
session: SessionId, session: SessionId,
request: FetchRequest, request: ObserveRequest,
},
Tree {
session: SessionId,
request: FetchTreeRequest,
}, },
Interface { Interface {
session: SessionId, session: SessionId,
@@ -522,9 +514,7 @@ enum Pending {
impl Pending { impl Pending {
fn session(&self) -> &SessionId { fn session(&self) -> &SessionId {
match self { match self {
Self::Fetch { session, .. } Self::Observe { session, .. } | Self::Interface { session, .. } => session,
| Self::Tree { session, .. }
| Self::Interface { session, .. } => session,
Self::Call { context, .. } => context.session(), Self::Call { context, .. } => context.session(),
} }
} }
@@ -641,70 +631,86 @@ impl Client {
} }
} }
/// Starts or refreshes an object observation. /// Ensures that an observation is available through `depth` without issuing
pub fn prepare_fetch( /// duplicate work.
///
/// Returns `None` when the requested coverage is already fresh, is currently
/// loading, is blocked by a retained failure or stale object state that requires
/// an explicit refresh, or when the bounded in-flight capacity is currently
/// full. Callers may invoke this method again after applying another completion.
pub fn ensure_observed(
&mut self, &mut self,
session: &SessionId, session: &SessionId,
path: impl Into<String>, path: impl Into<String>,
) -> Result<PreparedRequest, ClientError> { depth: u32,
self.ensure_capacity()?; ) -> Result<Option<PreparedRequest>, ClientError> {
let request = FetchRequest { path: path.into() }; let path = path.into();
let endpoint = session_endpoint(session)?; wip_protocol::validate_path(&path).map_err(|error| {
let encoded =
encode_fetch_request(&endpoint, &request, self.wire_limits).map_err(|error| {
ClientError::new(ClientErrorKind::InvalidRequest, error.to_string()) ClientError::new(ClientErrorKind::InvalidRequest, error.to_string())
})?; })?;
let id = self.allocate_request_id(); let coverage = observation_coverage(self.session(session)?, &path, depth);
let maximum = self.limits.objects_per_session; if coverage != ObservationCoverage::Missing || self.pending.len() >= self.limits.in_flight {
let state = self.session_mut(session)?; return Ok(None);
ensure_entry_slot(state, &request.path, maximum)?; }
let entry = state self.refresh_observed(session, path, depth).map(Some)
.entries }
.entry(request.path.clone())
.or_insert(EntryRecord { /// Ensures that an object's direct children have been observed without
object_key: None, /// duplicating fresh or loading work.
revision: id, pub fn ensure_children_observed(
state: ObservationState::Loading(id), &mut self,
}); session: &SessionId,
entry.revision = id; path: impl Into<String>,
entry.state = ObservationState::Loading(id); ) -> Result<Option<PreparedRequest>, ClientError> {
self.pending.insert( self.ensure_observed(session, path, 1)
id, }
Pending::Fetch {
session: session.clone(), /// Explicitly refreshes only one object while preserving previously observed
request, /// children in Known Space.
}, pub fn refresh_object(
); &mut self,
Ok(PreparedRequest { session: &SessionId,
id, path: impl Into<String>,
request: encoded, ) -> Result<PreparedRequest, ClientError> {
}) self.refresh_observed(session, path, 0)
}
/// Explicitly refreshes one object and its direct children.
pub fn refresh_children(
&mut self,
session: &SessionId,
path: impl Into<String>,
) -> Result<PreparedRequest, ClientError> {
self.refresh_observed(session, path, 1)
} }
/// Starts or refreshes a bounded lazy tree expansion. /// Explicitly starts or refreshes an object observation through `depth`.
pub fn prepare_tree( ///
/// Unlike [`Client::ensure_observed`], this always creates a new request and
/// supersedes older requests for the same root path.
pub fn refresh_observed(
&mut self, &mut self,
session: &SessionId, session: &SessionId,
path: impl Into<String>, path: impl Into<String>,
depth: u32, depth: u32,
limit: u32,
) -> Result<PreparedRequest, ClientError> { ) -> Result<PreparedRequest, ClientError> {
self.ensure_capacity()?; self.ensure_capacity()?;
let request = FetchTreeRequest { let request = ObserveRequest {
path: path.into(), path: path.into(),
depth, depth,
limit,
}; };
let endpoint = session_endpoint(session)?; let endpoint = session_endpoint(session)?;
let encoded = let encoded =
encode_fetch_tree_request(&endpoint, &request, self.wire_limits).map_err(|error| { encode_observe_request(&endpoint, &request, self.wire_limits).map_err(|error| {
ClientError::new(ClientErrorKind::InvalidRequest, error.to_string()) ClientError::new(ClientErrorKind::InvalidRequest, error.to_string())
})?; })?;
let id = self.allocate_request_id(); let id = self.allocate_request_id();
let maximum = self.limits.objects_per_session; let maximum = self.limits.objects_per_session;
let state = self.session_mut(session)?; let state = self.session_mut(session)?;
ensure_entry_slot(state, &request.path, maximum)?; ensure_entry_slot(state, &request.path, maximum)?;
if request.depth > 0 {
ensure_tree_slot(state, &request.path, maximum)?; ensure_tree_slot(state, &request.path, maximum)?;
}
let entry = state let entry = state
.entries .entries
.entry(request.path.clone()) .entry(request.path.clone())
@@ -715,21 +721,26 @@ impl Client {
}); });
entry.revision = id; entry.revision = id;
entry.state = ObservationState::Loading(id); entry.state = ObservationState::Loading(id);
if request.depth > 0 {
let tree = state let tree = state
.trees .trees
.entry(request.path.clone()) .entry(request.path.clone())
.or_insert(TreeObservation { .or_insert(TreeObservation {
path: request.path.clone(), path: request.path.clone(),
children: Vec::new(), children: Vec::new(),
truncated: false,
revision: id, revision: id,
state: ObservationState::Loading(id), state: ObservationState::Loading(id),
}); });
tree.revision = id; tree.revision = id;
tree.state = ObservationState::Loading(id); tree.state = ObservationState::Loading(id);
} else if let Some(tree) = state.trees.get_mut(&request.path)
&& matches!(tree.state, ObservationState::Loading(_))
{
tree.state = ObservationState::Stale;
}
self.pending.insert( self.pending.insert(
id, id,
Pending::Tree { Pending::Observe {
session: session.clone(), session: session.clone(),
request, request,
}, },
@@ -740,7 +751,34 @@ impl Client {
}) })
} }
/// Starts or refreshes an interface descriptor observation. /// Ensures that an interface descriptor has been observed without issuing
/// duplicate work or evicting another cached descriptor.
///
/// Returns `None` when the descriptor is already fresh, is currently loading,
/// is blocked by retained stale or failure state that requires an explicit
/// refresh, or when either the interface cache or bounded in-flight capacity is
/// currently full. Callers may invoke this method again after applying another
/// completion.
pub fn ensure_interface(
&mut self,
session: &SessionId,
reference: impl Into<String>,
) -> Result<Option<PreparedRequest>, ClientError> {
let reference = reference.into();
let state = self.session(session)?;
if state.interfaces.contains_key(&reference)
|| state.interfaces.len() >= self.limits.interfaces_per_session
|| self.pending.len() >= self.limits.in_flight
{
return Ok(None);
}
self.prepare_interface(session, reference).map(Some)
}
/// Explicitly starts or refreshes an interface descriptor observation.
///
/// Unlike [`Client::ensure_interface`], this always creates a new request and
/// may evict an older non-loading descriptor when the interface cache is full.
pub fn prepare_interface( pub fn prepare_interface(
&mut self, &mut self,
session: &SessionId, session: &SessionId,
@@ -928,11 +966,8 @@ impl Client {
) -> Result<Completion, ClientError> { ) -> Result<Completion, ClientError> {
let pending = self.pending.remove(&id).ok_or_else(unknown_request)?; let pending = self.pending.remove(&id).ok_or_else(unknown_request)?;
match pending { match pending {
Pending::Fetch { session, request } => { Pending::Observe { session, request } => {
self.complete_fetch(id, &session, request, response) self.complete_observe(id, &session, request, response)
}
Pending::Tree { session, request } => {
self.complete_tree(id, &session, request, response)
} }
Pending::Interface { session, request } => { Pending::Interface { session, request } => {
self.complete_interface(id, &session, request, response) self.complete_interface(id, &session, request, response)
@@ -994,12 +1029,9 @@ impl Client {
other => { other => {
let session = other.session().clone(); let session = other.session().clone();
let is_current = match &other { let is_current = match &other {
Pending::Fetch { request, .. } => { Pending::Observe { request, .. } => {
self.entry_request_is_current(&session, &request.path, id) self.entry_request_is_current(&session, &request.path, id)
} }
Pending::Tree { request, .. } => {
self.tree_request_is_current(&session, &request.path, id)
}
Pending::Interface { request, .. } => { Pending::Interface { request, .. } => {
self.interface_request_is_current(&session, &request.interface, id) self.interface_request_is_current(&session, &request.interface, id)
} }
@@ -1054,28 +1086,29 @@ impl Client {
result result
} }
fn complete_fetch( fn complete_observe(
&mut self, &mut self,
id: RequestId, id: RequestId,
session: &SessionId, session: &SessionId,
request: FetchRequest, request: ObserveRequest,
response: Response<Vec<u8>>, response: Response<Vec<u8>>,
) -> Result<Completion, ClientError> { ) -> Result<Completion, ClientError> {
if !self.entry_request_is_current(session, &request.path, id) { if !self.entry_request_is_current(session, &request.path, id) {
return self.reject_stale(session, id); return self.reject_stale(session, id);
} }
match decode_fetch_response(&request, &response, self.wire_limits) { match decode_observe_response(&request, &response, self.wire_limits) {
Ok(DecodedResponse::Success(object)) => { Ok(DecodedResponse::Success(value)) => {
let maximum = self.limits.objects_per_session; let maximum = self.limits.objects_per_session;
let mut candidate = self.session(session)?.clone(); let mut candidate = self.session(session)?.clone();
if let Err(error) = if let Err(error) =
insert_object(&mut candidate, &request.path, object, maximum, id) apply_observation(&mut candidate, &request.path, value, maximum, id)
{ {
self.session_mut(session)? set_observe_subject_state(
.entries self.session_mut(session)?,
.get_mut(&request.path) &request,
.expect("loading entry") id,
.state = ObservationState::Error(error.clone()); ObservationState::Error(error.clone()),
);
self.push_failure_diagnostic(session, id, &error)?; self.push_failure_diagnostic(session, id, &error)?;
return Err(error); return Err(error);
} }
@@ -1085,62 +1118,6 @@ impl Client {
self.sessions.insert(session.clone(), candidate); self.sessions.insert(session.clone(), candidate);
Ok(Completion::Object(observation)) Ok(Completion::Object(observation))
} }
Ok(DecodedResponse::ProtocolFailure {
error,
status_mismatch,
}) => {
self.record_status_mismatch(session, id, status_mismatch)?;
self.session_mut(session)?
.entries
.get_mut(&request.path)
.expect("loading entry")
.state = if error.code == ProtocolErrorCode::NotFound {
ObservationState::Stale
} else {
ObservationState::Error(ClientError::new(
ClientErrorKind::ProtocolFailure,
error.to_string(),
))
};
Ok(Completion::ProtocolFailure(error))
}
Err(error) => self.retrieval_decode_error(
id,
session,
RetrievalSubject::Object(request.path),
error,
),
}
}
fn complete_tree(
&mut self,
id: RequestId,
session: &SessionId,
request: FetchTreeRequest,
response: Response<Vec<u8>>,
) -> Result<Completion, ClientError> {
if !self.tree_request_is_current(session, &request.path, id) {
return self.reject_stale(session, id);
}
match decode_fetch_tree_response(&request, &response, self.wire_limits) {
Ok(DecodedResponse::Success(tree)) => {
let maximum = self.limits.objects_per_session;
let mut candidate = self.session(session)?.clone();
if let Err(error) = apply_tree(&mut candidate, &request.path, tree, maximum, id) {
set_tree_subject_state(
self.session_mut(session)?,
&request.path,
id,
ObservationState::Error(error.clone()),
);
self.push_failure_diagnostic(session, id, &error)?;
return Err(error);
}
let observation = candidate.trees[&request.path].clone();
self.sessions.insert(session.clone(), candidate);
Ok(Completion::Tree(observation))
}
Ok(DecodedResponse::ProtocolFailure { Ok(DecodedResponse::ProtocolFailure {
error, error,
status_mismatch, status_mismatch,
@@ -1154,20 +1131,17 @@ impl Client {
error.to_string(), error.to_string(),
)) ))
}; };
set_tree_subject_state( set_observe_subject_state(
self.session_mut(session)?, self.session_mut(session)?,
&request.path, &request,
id, id,
observation_state, observation_state,
); );
Ok(Completion::ProtocolFailure(error)) Ok(Completion::ProtocolFailure(error))
} }
Err(error) => self.retrieval_decode_error( Err(error) => {
id, self.retrieval_decode_error(id, session, RetrievalSubject::Observe(request), error)
session, }
RetrievalSubject::Tree(request.path),
error,
),
} }
} }
@@ -1261,11 +1235,14 @@ impl Client {
let session_id = context.session.clone(); let session_id = context.session.clone();
let maximum = self.limits.history; let maximum = self.limits.history;
let state = self.session_mut(&session_id)?; let state = self.session_mut(&session_id)?;
if let Some(object) = state.objects.get_mut(&context.object_key) { if let Some(object) = state.objects.get_mut(&context.object_key)
object.object.validator = value.validator.clone(); && object.revision <= id
object.revision = object.revision.max(id); {
if let Some(validator) = value.validator.clone() {
object.object.validator = Some(validator);
}
object.revision = id;
} }
state.mark_object_stale(context.object_key, id);
let record = CallRecord { let record = CallRecord {
request_id: id, request_id: id,
context, context,
@@ -1369,17 +1346,10 @@ impl Client {
) -> Result<Completion, ClientError> { ) -> Result<Completion, ClientError> {
let error = classify_response_error(error); let error = classify_response_error(error);
match subject { match subject {
RetrievalSubject::Object(path) => { RetrievalSubject::Observe(request) => {
self.session_mut(session)? set_observe_subject_state(
.entries
.get_mut(&path)
.expect("entry exists")
.state = ObservationState::Error(error.clone());
}
RetrievalSubject::Tree(path) => {
set_tree_subject_state(
self.session_mut(session)?, self.session_mut(session)?,
&path, &request,
id, id,
ObservationState::Error(error.clone()), ObservationState::Error(error.clone()),
); );
@@ -1404,17 +1374,10 @@ impl Client {
error: ClientError, error: ClientError,
) -> Result<(), ClientError> { ) -> Result<(), ClientError> {
match pending { match pending {
Pending::Fetch { session, request } => { Pending::Observe { session, request } => {
self.session_mut(&session)? set_observe_subject_state(
.entries
.get_mut(&request.path)
.expect("entry exists")
.state = ObservationState::Error(error);
}
Pending::Tree { session, request } => {
set_tree_subject_state(
self.session_mut(&session)?, self.session_mut(&session)?,
&request.path, &request,
id, id,
ObservationState::Error(error), ObservationState::Error(error),
); );
@@ -1436,7 +1399,7 @@ impl Client {
&mut self, &mut self,
session: &SessionId, session: &SessionId,
id: RequestId, id: RequestId,
mismatch: Option<wip_https::StatusMismatch>, mismatch: Option<wip_http::StatusMismatch>,
) -> Result<(), ClientError> { ) -> Result<(), ClientError> {
if let Some(mismatch) = mismatch { if let Some(mismatch) = mismatch {
let maximum = self.limits.history; let maximum = self.limits.history;
@@ -1500,16 +1463,6 @@ impl Client {
) )
} }
fn tree_request_is_current(&self, session: &SessionId, path: &str, id: RequestId) -> bool {
matches!(
self.sessions
.get(session)
.and_then(|state| state.trees.get(path))
.map(|tree| &tree.state),
Some(ObservationState::Loading(current)) if *current == id
)
}
fn interface_request_is_current( fn interface_request_is_current(
&self, &self,
session: &SessionId, session: &SessionId,
@@ -1557,11 +1510,68 @@ impl Client {
#[derive(Debug)] #[derive(Debug)]
enum RetrievalSubject { enum RetrievalSubject {
Object(String), Observe(ObserveRequest),
Tree(String),
Interface(String), Interface(String),
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ObservationCoverage {
Missing,
Loading,
Covered,
Blocked,
}
fn observation_coverage(state: &Session, path: &str, depth: u32) -> ObservationCoverage {
let Some(entry) = state.entries.get(path) else {
return ObservationCoverage::Missing;
};
match &entry.state {
ObservationState::Loading(_) => return ObservationCoverage::Loading,
ObservationState::Stale | ObservationState::Error(_) => {
return ObservationCoverage::Blocked;
}
ObservationState::Fresh => {}
}
if entry
.object_key
.is_none_or(|key| !state.objects.contains_key(&key))
{
return ObservationCoverage::Missing;
}
if depth == 0 {
return ObservationCoverage::Covered;
}
let Some(tree) = state.trees.get(path) else {
return ObservationCoverage::Missing;
};
match &tree.state {
ObservationState::Loading(_) => return ObservationCoverage::Loading,
ObservationState::Stale => return ObservationCoverage::Missing,
ObservationState::Error(_) => return ObservationCoverage::Blocked,
ObservationState::Fresh => {}
}
let mut saw_loading = false;
let mut saw_missing = false;
for child in &tree.children {
match observation_coverage(state, child, depth - 1) {
ObservationCoverage::Blocked => return ObservationCoverage::Blocked,
ObservationCoverage::Loading => saw_loading = true,
ObservationCoverage::Missing => saw_missing = true,
ObservationCoverage::Covered => {}
}
}
if saw_loading {
ObservationCoverage::Loading
} else if saw_missing {
ObservationCoverage::Missing
} else {
ObservationCoverage::Covered
}
}
fn session_endpoint(session: &SessionId) -> Result<Endpoint, ClientError> { fn session_endpoint(session: &SessionId) -> Result<Endpoint, ClientError> {
Endpoint::parse(&session.endpoint) Endpoint::parse(&session.endpoint)
.map_err(|error| ClientError::new(ClientErrorKind::InvalidRequest, error.to_string())) .map_err(|error| ClientError::new(ClientErrorKind::InvalidRequest, error.to_string()))
@@ -1586,18 +1596,19 @@ fn classify_response_error(error: ClientResponseError) -> ClientError {
} }
} }
fn set_tree_subject_state( fn set_observe_subject_state(
state: &mut Session, state: &mut Session,
path: &str, request: &ObserveRequest,
revision: RequestId, revision: RequestId,
observation_state: ObservationState, observation_state: ObservationState,
) { ) {
if let Some(tree) = state.trees.get_mut(path) if request.depth > 0
&& let Some(tree) = state.trees.get_mut(&request.path)
&& tree.revision == revision && tree.revision == revision
{ {
tree.state = observation_state.clone(); tree.state = observation_state.clone();
} }
if let Some(entry) = state.entries.get_mut(path) if let Some(entry) = state.entries.get_mut(&request.path)
&& entry.revision == revision && entry.revision == revision
{ {
entry.state = observation_state; entry.state = observation_state;
@@ -1913,9 +1924,10 @@ fn remove_object(state: &mut Session, key: u64) {
} }
} }
fn reserve_tree_metadata( fn reserve_observation_metadata(
state: &mut Session, state: &mut Session,
paths: &[String], paths: &[String],
observed_children_paths: &[String],
maximum: usize, maximum: usize,
) -> Result<(), ClientError> { ) -> Result<(), ClientError> {
while state.entries.len() while state.entries.len()
@@ -1952,7 +1964,7 @@ fn reserve_tree_metadata(
remove_entry(state, &candidate); remove_entry(state, &candidate);
} }
while state.trees.len() while state.trees.len()
+ paths + observed_children_paths
.iter() .iter()
.filter(|path| !state.trees.contains_key(*path)) .filter(|path| !state.trees.contains_key(*path))
.count() .count()
@@ -1962,7 +1974,8 @@ fn reserve_tree_metadata(
.trees .trees
.iter() .iter()
.find(|(path, tree)| { .find(|(path, tree)| {
!paths.contains(path) && !matches!(tree.state, ObservationState::Loading(_)) !observed_children_paths.contains(path)
&& !matches!(tree.state, ObservationState::Loading(_))
}) })
.map(|(path, _)| path.clone()) .map(|(path, _)| path.clone())
.ok_or_else(|| { .ok_or_else(|| {
@@ -1990,27 +2003,43 @@ fn mark_subtree_stale(state: &mut Session, root: &str, request_id: RequestId) {
} }
} }
fn apply_tree( fn apply_observation(
state: &mut Session, state: &mut Session,
root_path: &str, root_path: &str,
response: FetchTreeResponse, response: ProtocolObjectObservation,
maximum: usize, maximum: usize,
request_id: RequestId, request_id: RequestId,
) -> Result<(), ClientError> { ) -> Result<(), ClientError> {
let mut nodes = Vec::new(); let mut nodes = Vec::new();
flatten_tree(root_path, &response.root, &mut nodes); flatten_observation(root_path, &response, &mut nodes);
if nodes.len() > maximum { if nodes.len() > maximum {
return Err(ClientError::new( return Err(ClientError::new(
ClientErrorKind::Capacity, ClientErrorKind::Capacity,
"tree response exceeds the local observation bound", "observation response exceeds the local observation bound",
)); ));
} }
let paths: Vec<String> = nodes.iter().map(|(path, _, _)| path.clone()).collect(); let paths: Vec<String> = nodes.iter().map(|(path, _, _)| path.clone()).collect();
reserve_tree_metadata(state, &paths, maximum)?; let observed_children_paths: Vec<String> = nodes
.iter()
.filter_map(|(path, _, children)| children.as_ref().map(|_| path.clone()))
.collect();
reserve_observation_metadata(state, &paths, &observed_children_paths, maximum)?;
for (path, _, children) in &nodes {
if children.is_none()
&& let Some(tree) = state.trees.get_mut(path)
&& tree.revision < request_id
&& matches!(tree.state, ObservationState::Loading(_))
{
tree.state = ObservationState::Stale;
}
}
if !response.truncated {
let mut omitted = Vec::new(); let mut omitted = Vec::new();
for (path, _, children) in &nodes { for (path, _, children) in &nodes {
let Some(children) = children else {
continue;
};
if let Some(previous) = state if let Some(previous) = state
.trees .trees
.get(path) .get(path)
@@ -2028,7 +2057,6 @@ fn apply_tree(
for path in omitted { for path in omitted {
mark_subtree_stale(state, &path, request_id); mark_subtree_stale(state, &path, request_id);
} }
}
for (path, object, _) in &nodes { for (path, object, _) in &nodes {
validate_object_consistency(state, object)?; validate_object_consistency(state, object)?;
@@ -2041,6 +2069,9 @@ fn apply_tree(
} }
} }
for (path, _, children) in nodes { for (path, _, children) in nodes {
let Some(children) = children else {
continue;
};
let newer_owner = state let newer_owner = state
.trees .trees
.get(&path) .get(&path)
@@ -2048,13 +2079,11 @@ fn apply_tree(
if newer_owner { if newer_owner {
continue; continue;
} }
let is_root = path == root_path;
state.trees.insert( state.trees.insert(
path.clone(), path.clone(),
TreeObservation { TreeObservation {
path, path,
children, children,
truncated: is_root && response.truncated,
revision: request_id, revision: request_id,
state: ObservationState::Fresh, state: ObservationState::Fresh,
}, },
@@ -2063,19 +2092,26 @@ fn apply_tree(
Ok(()) Ok(())
} }
fn flatten_tree( fn flatten_observation(
path: &str, path: &str,
node: &ObjectTreeNode, observation: &ProtocolObjectObservation,
output: &mut Vec<(String, Object, Vec<String>)>, output: &mut Vec<(String, Object, Option<Vec<String>>)>,
) { ) {
let children: Vec<String> = node let children = observation.children.as_ref().map(|children| {
.children children
.iter() .iter()
.map(|child| child_path(path, &child.object.name)) .map(|child| child_path(path, &child.object.name))
.collect(); .collect::<Vec<_>>()
output.push((path.to_owned(), node.object.clone(), children.clone())); });
for (child, child_path) in node.children.iter().zip(children) { output.push((
flatten_tree(&child_path, child, output); path.to_owned(),
observation.object.clone(),
children.clone(),
));
if let (Some(observations), Some(paths)) = (&observation.children, children) {
for (child, child_path) in observations.iter().zip(paths) {
flatten_observation(&child_path, child, output);
}
} }
} }
+125 -22
View File
@@ -4,18 +4,19 @@ use wip_client::{
CallOutcome, Client, ClientErrorKind, ClientLimits, Completion, DiagnosticKind, CallOutcome, Client, ClientErrorKind, ClientLimits, Completion, DiagnosticKind,
ObservationState, OutcomeUnknownReason, SecurityContext, ObservationState, OutcomeUnknownReason, SecurityContext,
}; };
use wip_https::http::{Response, StatusCode}; use wip_http::http::{Response, StatusCode};
use wip_https::{ use wip_http::{
Limits, encode_fetch_interface_response, encode_fetch_response, encode_protocol_error_response, Limits, encode_fetch_interface_response, encode_observe_response,
encode_protocol_error_response,
}; };
use wip_protocol::{ use wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, FetchRequest, INTERFACE_FORMAT_V1, FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor,
InterfaceDescriptor, Object, OperationDeclaration, ParameterDeclaration, ProtocolError, Object, ObjectObservation, ObserveRequest, OperationDeclaration, ParameterDeclaration,
ProtocolErrorCode, ProtocolInteraction, ReturnDeclaration, TypeExpr, Value, ProtocolError, ProtocolErrorCode, ProtocolInteraction, ReturnDeclaration, TypeExpr, Value,
}; };
fn limits() -> Limits { fn limits() -> Limits {
Limits::new(64 * 1024, 64 * 1024, 64, 64).unwrap() Limits::new(64 * 1024, 64 * 1024, 64).unwrap()
} }
fn setup() -> (Client, wip_client::SessionId) { fn setup() -> (Client, wip_client::SessionId) {
@@ -76,7 +77,7 @@ fn malformed_json_unknown_fields_duplicates_and_invalid_success_are_invalid_resp
]; ];
for body in cases { for body in cases {
let (mut client, session) = setup(); let (mut client, session) = setup();
let request = client.prepare_fetch(&session, "/item").unwrap(); let request = client.refresh_observed(&session, "/item", 0).unwrap();
let error = client let error = client
.complete(request.id, response(StatusCode::OK, body)) .complete(request.id, response(StatusCode::OK, body))
.unwrap_err(); .unwrap_err();
@@ -106,7 +107,7 @@ fn disallowed_code_and_status_success_contradictions_are_invalid_response() {
]; ];
for (status, body) in cases { for (status, body) in cases {
let (mut client, session) = setup(); let (mut client, session) = setup();
let request = client.prepare_fetch(&session, "/item").unwrap(); let request = client.refresh_observed(&session, "/item", 0).unwrap();
assert_eq!( assert_eq!(
client client
.complete(request.id, response(status, body)) .complete(request.id, response(status, body))
@@ -120,13 +121,13 @@ fn disallowed_code_and_status_success_contradictions_are_invalid_response() {
#[test] #[test]
fn valid_error_envelope_is_authoritative_and_status_mismatch_is_diagnostic() { fn valid_error_envelope_is_authoritative_and_status_mismatch_is_diagnostic() {
let (mut client, session) = setup(); let (mut client, session) = setup();
let request = client.prepare_fetch(&session, "/item").unwrap(); let request = client.refresh_observed(&session, "/item", 0).unwrap();
let error = ProtocolError { let error = ProtocolError {
code: ProtocolErrorCode::NotFound, code: ProtocolErrorCode::NotFound,
message: "missing".into(), message: "missing".into(),
}; };
let mut encoded = let mut encoded =
encode_protocol_error_response(ProtocolInteraction::Fetch, &error, limits()).unwrap(); encode_protocol_error_response(ProtocolInteraction::Observe, &error, limits()).unwrap();
*encoded.status_mut() = StatusCode::IM_A_TEAPOT; *encoded.status_mut() = StatusCode::IM_A_TEAPOT;
assert_eq!( assert_eq!(
@@ -144,16 +145,37 @@ fn valid_error_envelope_is_authoritative_and_status_mismatch_is_diagnostic() {
} }
#[test] #[test]
fn non_success_without_valid_envelope_remains_transport_failure() { fn resource_envelope_on_413_uses_code_and_records_canonical_422_mismatch() {
let (mut client, session) = setup(); let (mut client, session) = setup();
let request = client.prepare_fetch(&session, "/item").unwrap(); let request = client.refresh_observed(&session, "/item", 0).unwrap();
let error = ProtocolError {
code: ProtocolErrorCode::ResourceLimitExceeded,
message: "logical observation limit exceeded".into(),
};
let mut encoded =
encode_protocol_error_response(ProtocolInteraction::Observe, &error, limits()).unwrap();
*encoded.status_mut() = StatusCode::PAYLOAD_TOO_LARGE;
assert_eq!(
client.complete(request.id, encoded).unwrap(),
Completion::ProtocolFailure(error)
);
assert_eq!(
client.diagnostics(&session).unwrap().back().unwrap().kind,
DiagnosticKind::HttpStatusMismatch
);
}
#[test]
fn non_success_without_valid_envelope_remains_transport_failure() {
for status in [StatusCode::BAD_GATEWAY, StatusCode::PAYLOAD_TOO_LARGE] {
let (mut client, session) = setup();
let request = client.refresh_observed(&session, "/item", 0).unwrap();
let error = client let error = client
.complete( .complete(request.id, response(status, b"infrastructure failure"))
request.id,
response(StatusCode::BAD_GATEWAY, b"gateway html"),
)
.unwrap_err(); .unwrap_err();
assert_eq!(error.kind, ClientErrorKind::Transport); assert_eq!(error.kind, ClientErrorKind::Transport);
}
} }
#[test] #[test]
@@ -176,15 +198,19 @@ fn unsupported_descriptor_format_is_distinct_from_invalid_response() {
#[test] #[test]
fn malformed_call_success_after_dispatch_is_outcome_unknown_and_stales_object() { fn malformed_call_success_after_dispatch_is_outcome_unknown_and_stales_object() {
let (mut client, session) = setup(); let (mut client, session) = setup();
let fetch = client.prepare_fetch(&session, "/item").unwrap(); let observation = client.refresh_observed(&session, "/item", 0).unwrap();
client client
.complete( .complete(
fetch.id, observation.id,
encode_fetch_response( encode_observe_response(
&FetchRequest { &ObserveRequest {
path: "/item".into(), path: "/item".into(),
depth: 0,
},
&ObjectObservation {
object: object(),
children: None,
}, },
&object(),
limits(), limits(),
) )
.unwrap(), .unwrap(),
@@ -245,3 +271,80 @@ fn malformed_call_success_after_dispatch_is_outcome_unknown_and_stales_object()
); );
assert_eq!(client.in_flight_len(), 0); assert_eq!(client.in_flight_len(), 0);
} }
#[test]
fn plain_413_after_call_dispatch_is_unknown_transport_outcome() {
let (mut client, session) = setup();
let observation = client.refresh_observed(&session, "/item", 0).unwrap();
client
.complete(
observation.id,
encode_observe_response(
&ObserveRequest {
path: "/item".into(),
depth: 0,
},
&ObjectObservation {
object: object(),
children: None,
},
limits(),
)
.unwrap(),
)
.unwrap();
let interface = client.prepare_interface(&session, "math").unwrap();
client
.complete(
interface.id,
encode_fetch_interface_response(
&FetchInterfaceRequest {
interface: "math".into(),
},
&FetchInterfaceResponse {
interface: "math".into(),
descriptor: descriptor(INTERFACE_FORMAT_V1),
validator: None,
},
limits(),
)
.unwrap(),
)
.unwrap();
let call = client
.prepare_call(
&session,
"/item",
"math",
"calculate",
BTreeMap::from([("value".into(), Value::Integer(2))]),
)
.unwrap();
client.mark_dispatched(call.id).unwrap();
let completion = client
.complete(
call.id,
Response::builder()
.status(StatusCode::PAYLOAD_TOO_LARGE)
.body(Vec::<u8>::new())
.unwrap(),
)
.unwrap();
assert!(matches!(
completion,
Completion::Call(record)
if matches!(
&record.outcome,
CallOutcome::Unknown {
reason: OutcomeUnknownReason::ResponseDecode,
failure: Some(failure),
} if failure.kind == ClientErrorKind::Transport
)
));
assert_eq!(
client.object(&session, "/item").unwrap().state,
ObservationState::Stale
);
assert_eq!(client.in_flight_len(), 0);
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -2,7 +2,7 @@
name = "wip-fs-server" name = "wip-fs-server"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
description = "Request-time filesystem projection server for WIP over HTTPS" description = "Request-time filesystem projection server for WIP over HTTP"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
readme = "README.md" readme = "README.md"
publish = false publish = false
@@ -11,7 +11,7 @@ publish = false
http = "1" http = "1"
rustix = { version = "1.1", features = ["fs"] } rustix = { version = "1.1", features = ["fs"] }
wip-host = { path = "../wip-host" } wip-host = { path = "../wip-host" }
wip-https = { path = "../wip-https" } wip-http = { path = "../wip-http" }
wip-interface = { path = "../wip-interface" } wip-interface = { path = "../wip-interface" }
wip-protocol = { path = "../wip-protocol" } wip-protocol = { path = "../wip-protocol" }
+28 -34
View File
@@ -3,8 +3,8 @@
`wip-fs-server` projects one configured Linux filesystem directory onto WIP `wip-fs-server` projects one configured Linux filesystem directory onto WIP
Worldspace `/` at request time. It does not register the filesystem contents or Worldspace `/` at request time. It does not register the filesystem contents or
keep one object instance per entry. It uses `wip-host` for publication and typed keep one object instance per entry. It uses `wip-host` for publication and typed
operation dispatch and uses the standard `wip-https` routes, JSON codecs, operation dispatch and uses the standard `wip-http` routes, JSON codecs,
envelopes, batching, and HTTP status mapping. envelopes, and HTTP status mapping.
## Security model ## Security model
@@ -28,9 +28,9 @@ envelopes, batching, and HTTP status mapping.
- Protocol metadata and errors never contain the backend absolute path. Startup - Protocol metadata and errors never contain the backend absolute path. Startup
prints only the explicitly configured effective root and security-sensitive prints only the explicitly configured effective root and security-sensitive
modes. modes.
- The built-in listener is cleartext and loopback-only. Production deployments - The built-in HTTP listener is loopback-only. TLS termination, certificates,
must pass `--behind-tls-proxy` and terminate HTTPS at a trusted reverse proxy. authentication, an externally visible URL scheme, and proxy routing are
Plain development HTTP requires the explicit `--development-http` flag. deployment concerns rather than separate WIP transport profiles.
The implementation currently targets Linux because it uses `O_PATH`, The implementation currently targets Linux because it uses `O_PATH`,
directory-relative descriptor traversal, and `/proc/self/fd` to reopen the same directory-relative descriptor traversal, and `/proc/self/fd` to reopen the same
@@ -38,59 +38,57 @@ pinned backend object without resolving the untrusted Worldspace path twice.
## Run ## Run
Read-only behind a TLS-terminating reverse proxy: Run the loopback listener in read-only mode:
```console ```console
cargo run -p wip-fs-server -- \ cargo run -p wip-fs-server -- \
--root /srv/public-files \ --root /srv/public-files \
--listen 127.0.0.1:8443 \ --listen 127.0.0.1:8080 \
--base-path /wip \ --base-path /wip
--behind-tls-proxy
``` ```
Local development with the write capability deliberately enabled: Run with the write capability deliberately enabled:
```console ```console
cargo run -p wip-fs-server -- \ cargo run -p wip-fs-server -- \
--root ./example-files \ --root ./example-files \
--listen 127.0.0.1:8080 \ --listen 127.0.0.1:8080 \
--base-path /wip \ --base-path /wip \
--development-http \
--read-write --read-write
``` ```
The server reports the effective root, `ReadOnly`/`ReadWrite` mode, transport The server reports the effective root, `ReadOnly`/`ReadWrite` mode, endpoint,
mode, endpoint, and actual listen address. It does not enumerate the root. and actual listen address. It does not enumerate the root.
## Standard interactions ## Standard interactions
The following examples use development HTTP for readability. The JSON is the The following examples use the built-in HTTP listener. The JSON follows the
standard `wip-https` profile; validators and operation `Bytes` are base64 body standard WIP over HTTP binding; validators and operation `Bytes` are base64 body
fields, never `ETag` or `If-Match` headers. fields, never `ETag` or `If-Match` headers.
Fetch root: Observe only the root Object:
```console ```console
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch \ curl -sS -X POST http://127.0.0.1:8080/wip/v1/observe \
-H 'Content-Type: application/json' -H 'Accept: application/json' \ -H 'Content-Type: application/json' -H 'Accept: application/json' \
--data '{"path":"/"}' --data '{"path":"/","depth":0}'
``` ```
Fetch a bounded tree (the root counts toward `limit`): Observe the root and two levels of indexable children:
```console ```console
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch_tree \ curl -sS -X POST http://127.0.0.1:8080/wip/v1/observe \
-H 'Content-Type: application/json' -H 'Accept: application/json' \ -H 'Content-Type: application/json' -H 'Accept: application/json' \
--data '{"path":"/","depth":2,"limit":100}' --data '{"path":"/","depth":2}'
``` ```
A regular-file fetch returns the opaque read interface reference and an object A regular-file observation returns the opaque read interface reference and an object
validator. Retrieve that descriptor with: validator. Retrieve that descriptor with:
```console ```console
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch_interface \ curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch_interface \
-H 'Content-Type: application/json' -H 'Accept: application/json' \ -H 'Content-Type: application/json' -H 'Accept: application/json' \
--data '{"interface":"<read-interface-from-fetch>"}' --data '{"interface":"<read-interface-from-observation>"}'
``` ```
Then call a bounded range read: Then call a bounded range read:
@@ -120,25 +118,21 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/call_operation \
}' }'
``` ```
`fetch`, `fetch_tree`, and `fetch_interface` can also be combined through the
standard `POST /wip/v1/batch` route. `call_operation` is never batchable.
## Resource and failure behavior ## Resource and failure behavior
`ResourceLimits` bounds request/response bytes, HTTP request-head bytes, JSON `ResourceLimits` bounds request/response bytes, HTTP request-head bytes, JSON
nesting, batch items, file read/write bytes, tree depth/node count, pinned handles, nesting, file read/write bytes, tree depth/node count, pinned handles, and active
and active connections/requests. One absolute request deadline covers header and connections/requests. One absolute request deadline covers header and
body intake, dispatch, cooperative filesystem work, and response transmission; body intake, dispatch, cooperative filesystem work, and response transmission;
stalled clients are closed and cannot occupy a worker beyond that bound. Workers stalled clients are closed and cannot occupy a worker beyond that bound. Workers
accept connections directly, so the listener never moves more than the configured accept connections directly, so the listener never moves more than the configured
concurrency into active userspace service. Advisory file-lock acquisition uses a concurrency into active userspace service. Advisory file-lock acquisition uses a
nonblocking loop and checks the same deadline/cancellation rather than allowing a nonblocking loop and checks the same deadline/cancellation rather than allowing a
contended lock to stall a worker. Tree traversal is breadth-first and opens only contended lock to stall a worker. Object observation recursively opens only the
requested entries. Once a limit or depth boundary is reached it reads only far requested depth. A successful response is complete through that depth; if the
enough to configured projected-node budget would be exceeded, the interaction fails with
prove whether an additional visible child exists, so `truncated` is true only `ResourceLimitExceeded` instead of returning a partial observation. I/O failure
when something was actually omitted. I/O failure and cancellation remain errors, and cancellation remain errors.
not clean exhaustion.
Whole-file replacement compares the request validator against the identity and Whole-file replacement compares the request validator against the identity and
state of the same pinned file handle used for the write. Failures before mutation state of the same pinned file handle used for the write. Failures before mutation
+21 -34
View File
@@ -1,15 +1,15 @@
//! Request-time filesystem projection served through the standard WIP HTTPS binding. //! Request-time filesystem projection served through the standard WIP over HTTP binding.
//! //!
//! This crate deliberately keeps the boundaries visible: //! This crate deliberately keeps the boundaries visible:
//! //!
//! - [`wip_protocol`] owns logical requests and responses; //! - [`wip_protocol`] owns logical requests and responses;
//! - [`wip_host`] owns object publication, validators, and typed dispatch; //! - [`wip_host`] owns object publication, validators, and typed dispatch;
//! - [`wip_https`] owns routes, JSON, envelopes, batching, and status mapping; //! - [`wip_http`] owns routes, JSON, envelopes, and status mapping;
//! - this crate owns the filesystem capability boundary and the real HTTP listener. //! - this crate owns the filesystem capability boundary and the real HTTP listener.
//! //!
//! TLS is terminated by an explicitly configured reverse proxy. The built-in //! The built-in HTTP listener is intentionally loopback-only. TLS termination,
//! listener accepts cleartext only on loopback, either behind that proxy or when //! certificates, authentication, and externally visible schemes are independent
//! [`TransportSecurity::DevelopmentHttp`] is deliberately selected. //! deployment concerns rather than WIP transport profiles.
#![deny(missing_docs)] #![deny(missing_docs)]
@@ -33,28 +33,19 @@ pub enum AccessMode {
ReadWrite, ReadWrite,
} }
/// How HTTPS is provided for the endpoint.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransportSecurity {
/// A trusted reverse proxy terminates TLS and forwards to the loopback listener.
ReverseProxyTls,
/// Cleartext HTTP is deliberately enabled for local development only.
DevelopmentHttp,
}
/// Bounded resource policy applied by the HTTP adapter and filesystem provider. /// Bounded resource policy applied by the HTTP adapter and filesystem provider.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct ResourceLimits { pub struct ResourceLimits {
/// Standard JSON, body-size, nesting, and batch limits. /// Standard JSON, body-size, and nesting limits.
pub wire: wip_https::Limits, pub wire: wip_http::Limits,
/// Maximum bytes returned by one file read. /// Maximum bytes returned by one file read.
pub max_file_read_bytes: usize, pub max_file_read_bytes: usize,
/// Maximum bytes accepted by one file replacement. /// Maximum bytes accepted by one file replacement.
pub max_file_write_bytes: usize, pub max_file_write_bytes: usize,
/// Maximum accepted `fetch_tree` depth. /// Maximum accepted `observe` depth.
pub max_tree_depth: u32, pub max_observation_depth: u32,
/// Maximum accepted `fetch_tree` node count, including its root. /// Maximum accepted `observe` node count, including its root.
pub max_tree_nodes: u32, pub max_observation_nodes: u32,
/// Maximum simultaneously pinned projected filesystem handles. /// Maximum simultaneously pinned projected filesystem handles.
pub max_open_handles: usize, pub max_open_handles: usize,
/// Maximum bytes accepted for one HTTP request head, including its terminator. /// Maximum bytes accepted for one HTTP request head, including its terminator.
@@ -68,12 +59,12 @@ pub struct ResourceLimits {
impl Default for ResourceLimits { impl Default for ResourceLimits {
fn default() -> Self { fn default() -> Self {
Self { Self {
wire: wip_https::Limits::new(256 * 1024, 2 * 1024 * 1024, 64, 64) wire: wip_http::Limits::new(256 * 1024, 2 * 1024 * 1024, 64)
.expect("default wire limits are nonzero"), .expect("default wire limits are nonzero"),
max_file_read_bytes: 1024 * 1024, max_file_read_bytes: 1024 * 1024,
max_file_write_bytes: 1024 * 1024, max_file_write_bytes: 1024 * 1024,
max_tree_depth: 32, max_observation_depth: 32,
max_tree_nodes: 1024, max_observation_nodes: 1024,
max_open_handles: 2048, max_open_handles: 2048,
max_http_header_bytes: 16 * 1024, max_http_header_bytes: 16 * 1024,
max_concurrent_requests: 32, max_concurrent_requests: 32,
@@ -93,8 +84,6 @@ pub struct ServerConfig {
pub endpoint_base_path: String, pub endpoint_base_path: String,
/// Read-only or explicit read-write publication mode. /// Read-only or explicit read-write publication mode.
pub access: AccessMode, pub access: AccessMode,
/// TLS termination or explicit development HTTP policy.
pub transport_security: TransportSecurity,
/// Request, operation, traversal, and concurrency bounds. /// Request, operation, traversal, and concurrency bounds.
pub limits: ResourceLimits, pub limits: ResourceLimits,
} }
@@ -102,8 +91,7 @@ pub struct ServerConfig {
impl ServerConfig { impl ServerConfig {
/// Builds a secure-by-default configuration for `root`. /// Builds a secure-by-default configuration for `root`.
/// ///
/// The listener defaults to an ephemeral loopback port, read-only access, /// The listener defaults to an ephemeral loopback port and read-only access.
/// and TLS termination delegated to a reverse proxy.
#[must_use] #[must_use]
pub fn for_root(root: impl Into<PathBuf>) -> Self { pub fn for_root(root: impl Into<PathBuf>) -> Self {
Self { Self {
@@ -111,7 +99,6 @@ impl ServerConfig {
listen: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0), listen: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
endpoint_base_path: "/wip".into(), endpoint_base_path: "/wip".into(),
access: AccessMode::ReadOnly, access: AccessMode::ReadOnly,
transport_security: TransportSecurity::ReverseProxyTls,
limits: ResourceLimits::default(), limits: ResourceLimits::default(),
} }
} }
@@ -125,7 +112,7 @@ impl ServerConfig {
} }
if self.limits.max_file_read_bytes == 0 if self.limits.max_file_read_bytes == 0
|| self.limits.max_file_write_bytes == 0 || self.limits.max_file_write_bytes == 0
|| self.limits.max_tree_nodes == 0 || self.limits.max_observation_nodes == 0
|| self.limits.max_open_handles == 0 || self.limits.max_open_handles == 0
|| self.limits.max_http_header_bytes == 0 || self.limits.max_http_header_bytes == 0
|| self.limits.max_concurrent_requests == 0 || self.limits.max_concurrent_requests == 0
@@ -133,7 +120,7 @@ impl ServerConfig {
{ {
return Err(ConfigError::ZeroLimit); return Err(ConfigError::ZeroLimit);
} }
if self.limits.max_open_handles < self.limits.max_tree_nodes as usize { if self.limits.max_open_handles < self.limits.max_observation_nodes as usize {
return Err(ConfigError::InsufficientHandles); return Err(ConfigError::InsufficientHandles);
} }
if self.limits.wire.max_response_bytes() < 512 { if self.limits.wire.max_response_bytes() < 512 {
@@ -142,9 +129,9 @@ impl ServerConfig {
Ok(()) Ok(())
} }
pub(crate) fn endpoint(&self) -> Result<wip_https::Endpoint, ConfigError> { pub(crate) fn endpoint(&self) -> Result<wip_http::Endpoint, ConfigError> {
let endpoint = format!("https://localhost{}", self.endpoint_base_path); let endpoint = format!("http://localhost{}", self.endpoint_base_path);
wip_https::Endpoint::parse(&endpoint).map_err(|_| ConfigError::InvalidBasePath) wip_http::Endpoint::parse(&endpoint).map_err(|_| ConfigError::InvalidBasePath)
} }
} }
@@ -171,7 +158,7 @@ impl std::fmt::Display for ConfigError {
"endpoint base path must be absolute, canonical, and query-free" "endpoint base path must be absolute, canonical, and query-free"
} }
Self::ZeroLimit => "all resource limits and the request timeout must be nonzero", Self::ZeroLimit => "all resource limits and the request timeout must be nonzero",
Self::InsufficientHandles => "max_open_handles must cover max_tree_nodes", Self::InsufficientHandles => "max_open_handles must cover max_observation_nodes",
Self::ResponseLimitTooSmall => "max_response_bytes must be at least 512", Self::ResponseLimitTooSmall => "max_response_bytes must be at least 512",
}) })
} }
+6 -27
View File
@@ -2,14 +2,13 @@ use std::error::Error;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::PathBuf; use std::path::PathBuf;
use wip_fs_server::{AccessMode, ServerConfig, TransportSecurity, WipFsServer}; use wip_fs_server::{AccessMode, ServerConfig, WipFsServer};
fn main() -> Result<(), Box<dyn Error>> { fn main() -> Result<(), Box<dyn Error>> {
let mut root: Option<PathBuf> = None; let mut root: Option<PathBuf> = None;
let mut listen: SocketAddr = "127.0.0.1:8443".parse()?; let mut listen: SocketAddr = "127.0.0.1:8080".parse()?;
let mut base_path = "/wip".to_owned(); let mut base_path = "/wip".to_owned();
let mut access = AccessMode::ReadOnly; let mut access = AccessMode::ReadOnly;
let mut security = None;
let mut arguments = std::env::args().skip(1); let mut arguments = std::env::args().skip(1);
while let Some(argument) = arguments.next() { while let Some(argument) = arguments.next() {
match argument.as_str() { match argument.as_str() {
@@ -17,12 +16,6 @@ fn main() -> Result<(), Box<dyn Error>> {
"--listen" => listen = required_value(&mut arguments, "--listen")?.parse()?, "--listen" => listen = required_value(&mut arguments, "--listen")?.parse()?,
"--base-path" => base_path = required_value(&mut arguments, "--base-path")?, "--base-path" => base_path = required_value(&mut arguments, "--base-path")?,
"--read-write" => access = AccessMode::ReadWrite, "--read-write" => access = AccessMode::ReadWrite,
"--development-http" => {
set_security(&mut security, TransportSecurity::DevelopmentHttp)?
}
"--behind-tls-proxy" => {
set_security(&mut security, TransportSecurity::ReverseProxyTls)?
}
"--help" | "-h" => { "--help" | "-h" => {
print_help(); print_help();
return Ok(()); return Ok(());
@@ -32,20 +25,17 @@ fn main() -> Result<(), Box<dyn Error>> {
} }
let root = root.ok_or("--root is required")?; let root = root.ok_or("--root is required")?;
let transport_security =
security.ok_or("select exactly one of --behind-tls-proxy or --development-http")?;
let mut config = ServerConfig::for_root(root); let mut config = ServerConfig::for_root(root);
config.listen = listen; config.listen = listen;
config.endpoint_base_path = base_path; config.endpoint_base_path = base_path;
config.access = access; config.access = access;
config.transport_security = transport_security;
let server = WipFsServer::new(config)?; let server = WipFsServer::new(config)?;
let effective_root = server.effective_root().to_owned(); let effective_root = server.effective_root().to_owned();
let endpoint = server.endpoint().clone(); let endpoint = server.endpoint().clone();
let bound = server.bind()?; let bound = server.bind()?;
eprintln!( eprintln!(
"wip-fs-server root={} access={access:?} transport={transport_security:?} endpoint={} listen={}", "wip-fs-server root={} access={access:?} endpoint={} listen={}",
effective_root.display(), effective_root.display(),
endpoint, endpoint,
bound.local_addr() bound.local_addr()
@@ -63,21 +53,10 @@ fn required_value(
.ok_or_else(|| format!("{option} requires a value").into()) .ok_or_else(|| format!("{option} requires a value").into())
} }
fn set_security(
security: &mut Option<TransportSecurity>,
value: TransportSecurity,
) -> Result<(), Box<dyn Error>> {
if security.replace(value).is_some() {
Err("select only one transport-security mode".into())
} else {
Ok(())
}
}
fn print_help() { fn print_help() {
println!( println!(
"Usage: wip-fs-server --root PATH [--listen 127.0.0.1:8443] \\\n [--base-path /wip] [--read-write] \\\n (--behind-tls-proxy | --development-http)\n\n\ "Usage: wip-fs-server --root PATH [--listen 127.0.0.1:8080] \\\n [--base-path /wip] [--read-write]\n\n\
The built-in listener is cleartext and loopback-only. In production, \ The built-in HTTP listener is loopback-only. TLS termination, authentication, \
select --behind-tls-proxy and terminate HTTPS at a trusted reverse proxy." and external routing are deployment concerns."
); );
} }
+102 -110
View File
@@ -1,4 +1,3 @@
use std::collections::VecDeque;
use std::ffi::CStr; use std::ffi::CStr;
use std::mem::MaybeUninit; use std::mem::MaybeUninit;
use std::os::fd::{AsRawFd, OwnedFd}; use std::os::fd::{AsRawFd, OwnedFd};
@@ -17,8 +16,8 @@ use wip_host::{
}; };
use wip_interface as wip; use wip_interface as wip;
use wip_protocol::{ use wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, FetchTreeRequest, FetchTreeResponse, Object, FetchInterfaceRequest, FetchInterfaceResponse, ObjectObservation, ObserveRequest,
ObjectTreeNode, ProtocolError, ProtocolErrorCode, ObserveResponse, ProtocolError, ProtocolErrorCode,
}; };
use crate::interfaces::{ use crate::interfaces::{
@@ -223,37 +222,60 @@ impl Projection {
.protocol_response() .protocol_response()
} }
pub(crate) fn fetch_tree( pub(crate) fn observe(
&self, &self,
context: &ExecutionContext, context: &ExecutionContext,
request: &FetchTreeRequest, request: &ObserveRequest,
) -> Result<FetchTreeResponse, ProtocolError> { ) -> Result<ObserveResponse, ProtocolError> {
request.validate().map_err(|_| ProtocolError { request.validate().map_err(|_| ProtocolError {
code: ProtocolErrorCode::InvalidRequest, code: ProtocolErrorCode::InvalidRequest,
message: "invalid fetch_tree request".into(), message: "invalid observe request".into(),
})?; })?;
if request.depth > self.limits.max_tree_depth || request.limit > self.limits.max_tree_nodes if request.depth > self.limits.max_observation_depth {
{
return Err(ProjectionError::Resource.protocol()); return Err(ProjectionError::Resource.protocol());
} }
let root = self let root = self
.resolve(context, &request.path) .resolve(context, &request.path)
.map_err(ProjectionError::protocol)?; .map_err(ProjectionError::protocol)?;
let root_object = self let mut node_count = 0_u32;
.publication(root.clone(), context.clone()) let response = self.observe_entry(context, root, 0, request.depth, &mut node_count)?;
.protocol_object(); request
let mut objects = vec![root_object]; .validate_response(&response)
let mut children: Vec<Vec<usize>> = vec![Vec::new()]; .map_err(|_| ProtocolError {
let mut queue = VecDeque::from([(0_usize, root, 0_u32)]); code: ProtocolErrorCode::Internal,
let mut truncated = false; message: "Host produced an invalid observation response".into(),
})?;
while let Some((parent_index, parent, depth)) = queue.pop_front() { Ok(response)
check_context(context).map_err(ProjectionError::protocol)?;
if parent.kind != EntryKind::Directory {
continue;
} }
let directory_fd = reopen(parent.fd(), OFlags::RDONLY | OFlags::DIRECTORY)
fn observe_entry(
&self,
context: &ExecutionContext,
entry: Entry,
depth: u32,
maximum_depth: u32,
node_count: &mut u32,
) -> Result<ObjectObservation, ProtocolError> {
check_context(context).map_err(ProjectionError::protocol)?;
*node_count = node_count.saturating_add(1);
if *node_count > self.limits.max_observation_nodes {
return Err(ProjectionError::Resource.protocol());
}
let object = self
.publication(entry.clone(), context.clone())
.protocol_object();
if depth == maximum_depth {
return Ok(ObjectObservation {
object,
children: None,
});
}
let mut children = Vec::new();
if entry.kind == EntryKind::Directory {
let directory_fd = reopen(entry.fd(), OFlags::RDONLY | OFlags::DIRECTORY)
.map_err(ProjectionError::protocol)?; .map_err(ProjectionError::protocol)?;
let mut buffer = [MaybeUninit::<u8>::uninit(); 8192]; let mut buffer = [MaybeUninit::<u8>::uninit(); 8192];
let mut directory = RawDir::new(directory_fd, &mut buffer); let mut directory = RawDir::new(directory_fd, &mut buffer);
@@ -271,50 +293,25 @@ impl Projection {
if name.contains('/') || name.is_empty() || name == "." || name == ".." { if name.contains('/') || name.is_empty() || name == "." || name == ".." {
continue; continue;
} }
let child = match self.open_child(context, parent.fd(), name) { let child = match self.open_child(context, entry.fd(), name) {
Ok(child) => child, Ok(child) => child,
Err(ProjectionError::NotFound) => continue, Err(ProjectionError::NotFound) => continue,
Err(error) => return Err(error.protocol()), Err(error) => return Err(error.protocol()),
}; };
if depth == request.depth || objects.len() == request.limit as usize { children.push(self.observe_entry(
truncated = true; context,
break; child,
} depth + 1,
let child_index = objects.len(); maximum_depth,
objects.push( node_count,
self.publication(child.clone(), context.clone()) )?);
.protocol_object(),
);
children.push(Vec::new());
children[parent_index].push(child_index);
if child.kind == EntryKind::Directory {
queue.push_back((child_index, child, depth + 1));
}
}
if truncated && objects.len() == request.limit as usize {
break;
} }
} }
fn build(index: usize, objects: &[Object], children: &[Vec<usize>]) -> ObjectTreeNode { Ok(ObjectObservation {
ObjectTreeNode { object,
object: objects[index].clone(), children: Some(children),
children: children[index] })
.iter()
.map(|child| build(*child, objects, children))
.collect(),
}
}
let response = FetchTreeResponse {
root: build(0, &objects, &children),
truncated,
};
response.validate_for(request).map_err(|_| ProtocolError {
code: ProtocolErrorCode::Internal,
message: "Host produced an invalid tree response".into(),
})?;
Ok(response)
} }
fn publication( fn publication(
@@ -809,7 +806,7 @@ mod tests {
use wip_host::{ use wip_host::{
CallErrorKind, CallRequest, ExecutionContext, InterfaceTarget, ObjectProvider, Target, CallErrorKind, CallRequest, ExecutionContext, InterfaceTarget, ObjectProvider, Target,
}; };
use wip_protocol::{FetchTreeRequest, ProtocolErrorCode, Value}; use wip_protocol::{ObserveRequest, ProtocolErrorCode, Value};
use super::{FailureInjection, FlockOperation, Projection}; use super::{FailureInjection, FlockOperation, Projection};
use crate::{AccessMode, ResourceLimits}; use crate::{AccessMode, ResourceLimits};
@@ -850,23 +847,29 @@ mod tests {
.expect("create symlink"); .expect("create symlink");
let projection = projection(temporary.path(), AccessMode::ReadOnly); let projection = projection(temporary.path(), AccessMode::ReadOnly);
let root = projection.fetch("/").expect("fetch root"); let root = projection.observe_object("/").expect("observe root");
assert_eq!(root.name, ""); assert_eq!(root.name, "");
assert_eq!(root.description.as_deref(), Some("Filesystem directory")); assert_eq!(root.description.as_deref(), Some("Filesystem directory"));
assert!(root.interfaces.is_empty()); assert!(root.interfaces.is_empty());
assert!(root.validator.is_none()); assert!(root.validator.is_none());
let file = projection.fetch("/file.txt").expect("fetch file"); let file = projection
.observe_object("/file.txt")
.expect("observe file");
assert_eq!(file.interfaces.len(), 1); assert_eq!(file.interfaces.len(), 1);
assert!(file.validator.is_some()); assert!(file.validator.is_some());
assert!(file.r#ref.is_none()); assert!(file.r#ref.is_none());
let directory = projection.fetch("/dir").expect("fetch directory"); let directory = projection
.observe_object("/dir")
.expect("observe directory");
assert!(directory.interfaces.is_empty()); assert!(directory.interfaces.is_empty());
#[cfg(unix)] #[cfg(unix)]
{ {
let link = projection.fetch("/link").expect("fetch link itself"); let link = projection
.observe_object("/link")
.expect("observe link itself");
assert_eq!( assert_eq!(
link.description.as_deref(), link.description.as_deref(),
Some("Symbolic link (not followed)") Some("Symbolic link (not followed)")
@@ -874,7 +877,7 @@ mod tests {
assert_eq!(link.interfaces.len(), 1); assert_eq!(link.interfaces.len(), 1);
assert_eq!( assert_eq!(
projection projection
.fetch("/link/shadow") .observe_object("/link/shadow")
.expect_err("must not follow") .expect_err("must not follow")
.kind(), .kind(),
CallErrorKind::NotFound CallErrorKind::NotFound
@@ -892,18 +895,21 @@ mod tests {
} }
assert_eq!( assert_eq!(
projection.fetch("/missing").expect_err("missing").kind(), projection
.observe_object("/missing")
.expect_err("missing")
.kind(),
CallErrorKind::NotFound CallErrorKind::NotFound
); );
} }
#[test] #[test]
fn reads_are_bounded_and_tree_truncation_is_exact() { fn reads_are_bounded_and_observation_is_complete() {
let temporary = tempdir().expect("temporary root"); let temporary = tempdir().expect("temporary root");
fs::write(temporary.path().join("a"), b"abcdef").expect("write file"); fs::write(temporary.path().join("a"), b"abcdef").expect("write file");
fs::write(temporary.path().join("b"), b"b").expect("write file"); fs::write(temporary.path().join("b"), b"b").expect("write file");
let projection = projection(temporary.path(), AccessMode::ReadOnly); let projection = projection(temporary.path(), AccessMode::ReadOnly);
let file = projection.fetch("/a").expect("fetch file"); let file = projection.observe_object("/a").expect("observe file");
let result = call( let result = call(
&projection, &projection,
"/a", "/a",
@@ -919,30 +925,17 @@ mod tests {
assert_eq!(result.result, Value::Bytes(b"cde".to_vec())); assert_eq!(result.result, Value::Bytes(b"cde".to_vec()));
let complete = projection let complete = projection
.fetch_tree( .observe(
&ExecutionContext::new(), &ExecutionContext::new(),
&FetchTreeRequest { &ObserveRequest {
path: "/".into(), path: "/".into(),
depth: 1, depth: 1,
limit: 3,
}, },
) )
.expect("complete tree"); .expect("complete observation");
assert!(!complete.truncated); let children = complete.children.as_ref().expect("root children observed");
assert_eq!(complete.root.children.len(), 2); assert_eq!(children.len(), 2);
assert!(children.iter().all(|child| child.children.is_none()));
let limited = projection
.fetch_tree(
&ExecutionContext::new(),
&FetchTreeRequest {
path: "/".into(),
depth: 1,
limit: 2,
},
)
.expect("limited tree");
assert!(limited.truncated);
assert_eq!(limited.root.children.len(), 1);
} }
#[test] #[test]
@@ -951,7 +944,7 @@ mod tests {
let path = temporary.path().join("file"); let path = temporary.path().join("file");
fs::write(&path, b"old").expect("write file"); fs::write(&path, b"old").expect("write file");
let projection = projection(temporary.path(), AccessMode::ReadWrite); let projection = projection(temporary.path(), AccessMode::ReadWrite);
let observed = projection.fetch("/file").expect("fetch file"); let observed = projection.observe_object("/file").expect("observe file");
assert_eq!(observed.interfaces.len(), 2); assert_eq!(observed.interfaces.len(), 2);
let write_interface = observed.interfaces[1].clone(); let write_interface = observed.interfaces[1].clone();
@@ -981,7 +974,9 @@ mod tests {
assert_eq!(stale.kind(), CallErrorKind::ValidatorMismatch); assert_eq!(stale.kind(), CallErrorKind::ValidatorMismatch);
assert_eq!(fs::read(&path).expect("read replacement"), b"replacement"); assert_eq!(fs::read(&path).expect("read replacement"), b"replacement");
let current = projection.fetch("/file").expect("fetch replacement"); let current = projection
.observe_object("/file")
.expect("observe replacement");
let result = call( let result = call(
&projection, &projection,
"/file", "/file",
@@ -1001,7 +996,7 @@ mod tests {
let path = temporary.path().join("file"); let path = temporary.path().join("file");
fs::write(&path, b"old").expect("write file"); fs::write(&path, b"old").expect("write file");
let projection = projection(temporary.path(), AccessMode::ReadWrite); let projection = projection(temporary.path(), AccessMode::ReadWrite);
let observed = projection.fetch("/file").expect("fetch file"); let observed = projection.observe_object("/file").expect("observe file");
let request = || { let request = || {
call( call(
&projection, &projection,
@@ -1029,25 +1024,23 @@ mod tests {
} }
#[test] #[test]
fn large_directory_stops_after_proving_truncation() { fn large_directory_fails_without_partial_observation() {
let temporary = tempdir().expect("temporary root"); let temporary = tempdir().expect("temporary root");
for index in 0..2_000 { for index in 0..2_000 {
fs::write(temporary.path().join(format!("entry-{index}")), b"x") fs::write(temporary.path().join(format!("entry-{index}")), b"x")
.expect("write directory entry"); .expect("write directory entry");
} }
let projection = projection(temporary.path(), AccessMode::ReadOnly); let projection = projection(temporary.path(), AccessMode::ReadOnly);
let tree = projection let error = projection
.fetch_tree( .observe(
&ExecutionContext::new(), &ExecutionContext::new(),
&FetchTreeRequest { &ObserveRequest {
path: "/".into(), path: "/".into(),
depth: 1, depth: 1,
limit: 2,
}, },
) )
.expect("bounded tree"); .expect_err("complete observation exceeds node budget");
assert!(tree.truncated); assert_eq!(error.code, ProtocolErrorCode::ResourceLimitExceeded);
assert_eq!(tree.root.children.len(), 1);
} }
#[cfg(unix)] #[cfg(unix)]
@@ -1065,22 +1058,22 @@ mod tests {
let projection = projection(temporary.path(), AccessMode::ReadOnly); let projection = projection(temporary.path(), AccessMode::ReadOnly);
let tree = projection let tree = projection
.fetch_tree( .observe(
&ExecutionContext::new(), &ExecutionContext::new(),
&FetchTreeRequest { &ObserveRequest {
path: "/".into(), path: "/".into(),
depth: 1, depth: 1,
limit: 10,
}, },
) )
.expect("list root"); .expect("list root");
assert_eq!(tree.root.children.len(), 1); let children = tree.children.as_ref().expect("root children observed");
assert_eq!(tree.root.children[0].object.name, "socket"); assert_eq!(children.len(), 1);
assert_eq!(children[0].object.name, "socket");
assert_eq!( assert_eq!(
tree.root.children[0].object.description.as_deref(), children[0].object.description.as_deref(),
Some("Unsupported filesystem entry") Some("Unsupported filesystem entry")
); );
assert!(tree.root.children[0].object.interfaces.is_empty()); assert!(children[0].object.interfaces.is_empty());
} }
#[test] #[test]
@@ -1091,12 +1084,11 @@ mod tests {
let cancellation = wip_host::CancellationToken::new(); let cancellation = wip_host::CancellationToken::new();
cancellation.cancel(); cancellation.cancel();
let error = projection let error = projection
.fetch_tree( .observe(
&ExecutionContext::new().with_cancellation(cancellation), &ExecutionContext::new().with_cancellation(cancellation),
&FetchTreeRequest { &ObserveRequest {
path: "/".into(), path: "/".into(),
depth: 1, depth: 1,
limit: 2,
}, },
) )
.expect_err("cancelled traversal"); .expect_err("cancelled traversal");
@@ -1108,7 +1100,7 @@ mod tests {
let temporary = tempdir().expect("temporary root"); let temporary = tempdir().expect("temporary root");
let projection = projection(temporary.path(), AccessMode::ReadOnly); let projection = projection(temporary.path(), AccessMode::ReadOnly);
let error = projection let error = projection
.fetch("/bad\0name") .observe_object("/bad\0name")
.expect_err("NUL cannot name a backend entry"); .expect_err("NUL cannot name a backend entry");
assert_eq!(error.kind(), CallErrorKind::NotFound); assert_eq!(error.kind(), CallErrorKind::NotFound);
} }
@@ -1144,7 +1136,7 @@ mod tests {
}; };
let projection = Projection::open(temporary.path(), AccessMode::ReadOnly, limits) let projection = Projection::open(temporary.path(), AccessMode::ReadOnly, limits)
.expect("open projection"); .expect("open projection");
let observed = projection.fetch("/file").expect("fetch file"); let observed = projection.observe_object("/file").expect("observe file");
let started = std::time::Instant::now(); let started = std::time::Instant::now();
let error = call( let error = call(
&projection, &projection,
+70 -192
View File
@@ -5,17 +5,14 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::thread; use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use http::header::ALLOW;
use http::{Request, Response, StatusCode}; use http::{Request, Response, StatusCode};
use wip_host::{ExecutionContext, ObjectProvider}; use wip_host::{ExecutionContext, ObjectProvider};
use wip_https::{ use wip_http::{
BatchRequestKind, BatchResponse, BatchResponseItem, BatchSuccess, CodecError, EncodeHttpError, CodecError, EncodeHttpError, Endpoint, RequestMetadataError, Route,
Endpoint, RequestMetadataError, ResourceLimitKind, Route, decode_batch_request, decode_call_operation_metadata, decode_fetch_interface_request, decode_observe_request,
decode_call_operation_metadata, decode_fetch_interface_request, decode_fetch_request, encode_call_operation_response, encode_fetch_interface_response, encode_observe_response,
decode_fetch_tree_request, encode_batch_error_response, encode_protocol_error_response, validate_http_request,
encode_batch_resource_limit_error_response, encode_batch_response,
encode_call_operation_response, encode_fetch_interface_response, encode_fetch_response,
encode_fetch_tree_response, encode_protocol_error_response,
encode_resource_limit_error_response, validate_http_request,
}; };
use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction}; use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction};
@@ -57,7 +54,7 @@ impl WipFsServer {
&self.endpoint &self.endpoint
} }
/// Handles one already-buffered HTTP request with the standard `wip-https` codecs. /// Handles one already-buffered HTTP request with the standard `wip-http` codecs.
/// ///
/// Listener integrations must enforce the same body limit while collecting bytes. /// Listener integrations must enforce the same body limit while collecting bytes.
#[must_use] #[must_use]
@@ -70,28 +67,32 @@ impl WipFsServer {
request: Request<Vec<u8>>, request: Request<Vec<u8>>,
deadline: Instant, deadline: Instant,
) -> Response<Vec<u8>> { ) -> Response<Vec<u8>> {
let Some(route) = self.route(request.uri().path()) else { let Some(route) = self.route(request.uri()) else {
return empty_response(StatusCode::NOT_FOUND); return empty_response(StatusCode::NOT_FOUND);
}; };
if let Err(error) = validate_http_request(&self.endpoint, route, &request) { if let Err(error) = validate_http_request(&self.endpoint, route, &request) {
return self.metadata_error(route, error); return self.metadata_error(error);
} }
let context = ExecutionContext::new() let context = ExecutionContext::new()
.with_deadline(deadline) .with_deadline(deadline)
.with_resource_budget(u64::from(self.config.limits.max_tree_nodes)); .with_resource_budget(u64::from(self.config.limits.max_observation_nodes));
match route { let response = match route {
Route::Fetch => self.fetch(request.body(), &context), Route::Observe => self.observe(request.body(), &context),
Route::FetchTree => self.fetch_tree(request.body(), &context),
Route::FetchInterface => self.fetch_interface(request.body()), Route::FetchInterface => self.fetch_interface(request.body()),
Route::CallOperation => self.call_operation(request.body(), &context), Route::CallOperation => self.call_operation(request.body(), &context),
Route::Batch => self.batch(request.body(), &context), };
if Instant::now() >= deadline {
empty_response(StatusCode::GATEWAY_TIMEOUT)
} else {
response
} }
} }
/// Binds the built-in bounded loopback HTTP listener. /// Binds the built-in bounded loopback HTTP listener.
/// ///
/// The listener is cleartext by design. Configuration must explicitly state /// The built-in listener is cleartext and loopback-only. Externally visible
/// whether TLS is terminated by a reverse proxy or development HTTP is in use. /// schemes, TLS termination, authentication, and proxy routing are deployment
/// concerns rather than WIP transport profiles.
pub fn bind(self) -> Result<BoundServer, ServerBuildError> { pub fn bind(self) -> Result<BoundServer, ServerBuildError> {
let listener = TcpListener::bind(self.config.listen) let listener = TcpListener::bind(self.config.listen)
.map_err(|error| ServerBuildError::Listener(error.to_string()))?; .map_err(|error| ServerBuildError::Listener(error.to_string()))?;
@@ -108,45 +109,26 @@ impl WipFsServer {
}) })
} }
fn route(&self, path: &str) -> Option<Route> { fn route(&self, uri: &http::Uri) -> Option<Route> {
[ if uri.query().is_some() {
Route::Fetch, return None;
Route::FetchTree, }
Route::FetchInterface, [Route::Observe, Route::FetchInterface, Route::CallOperation]
Route::CallOperation,
Route::Batch,
]
.into_iter() .into_iter()
.find(|route| self.endpoint.route(*route).path() == path) .find(|route| self.endpoint.route(*route).path() == uri.path())
} }
fn fetch(&self, body: &[u8], context: &ExecutionContext) -> Response<Vec<u8>> { fn observe(&self, body: &[u8], context: &ExecutionContext) -> Response<Vec<u8>> {
let request = match decode_fetch_request(body, self.config.limits.wire) { let request = match decode_observe_request(body, self.config.limits.wire) {
Ok(request) => request, Ok(request) => request,
Err(error) => return self.codec_error(ProtocolInteraction::Fetch, error), Err(error) => return self.codec_error(ProtocolInteraction::Observe, error),
}; };
match self match self.projection.observe(context, &request) {
.projection Ok(tree) => encode_observe_response(&request, &tree, self.config.limits.wire)
.fetch_with_context(context, &request.path)
.map_err(|error| error.protocol_error())
{
Ok(object) => encode_fetch_response(&request, &object, self.config.limits.wire)
.unwrap_or_else(|error| self.read_encode_error(ProtocolInteraction::Fetch, error)),
Err(error) => self.protocol_error(ProtocolInteraction::Fetch, &error, None),
}
}
fn fetch_tree(&self, body: &[u8], context: &ExecutionContext) -> Response<Vec<u8>> {
let request = match decode_fetch_tree_request(body, self.config.limits.wire) {
Ok(request) => request,
Err(error) => return self.codec_error(ProtocolInteraction::FetchTree, error),
};
match self.projection.fetch_tree(context, &request) {
Ok(tree) => encode_fetch_tree_response(&request, &tree, self.config.limits.wire)
.unwrap_or_else(|error| { .unwrap_or_else(|error| {
self.read_encode_error(ProtocolInteraction::FetchTree, error) self.read_encode_error(ProtocolInteraction::Observe, error)
}), }),
Err(error) => self.protocol_error(ProtocolInteraction::FetchTree, &error, None), Err(error) => self.protocol_error(ProtocolInteraction::Observe, &error),
} }
} }
@@ -162,7 +144,7 @@ impl WipFsServer {
self.read_encode_error(ProtocolInteraction::FetchInterface, error) self.read_encode_error(ProtocolInteraction::FetchInterface, error)
}) })
} }
Err(error) => self.protocol_error(ProtocolInteraction::FetchInterface, &error, None), Err(error) => self.protocol_error(ProtocolInteraction::FetchInterface, &error),
} }
} }
@@ -200,7 +182,7 @@ impl WipFsServer {
}, },
message: "operation completed without response metadata".into(), message: "operation completed without response metadata".into(),
}; };
return self.protocol_error(ProtocolInteraction::CallOperation, &error, None); return self.protocol_error(ProtocolInteraction::CallOperation, &error);
}; };
encode_call_operation_response( encode_call_operation_response(
&request, &request,
@@ -209,6 +191,12 @@ impl WipFsServer {
self.config.limits.wire, self.config.limits.wire,
) )
.unwrap_or_else(|encode_error| { .unwrap_or_else(|encode_error| {
if matches!(
&encode_error,
EncodeHttpError::Codec(codec) if is_transport_limit(codec)
) {
return empty_response(StatusCode::INTERNAL_SERVER_ERROR);
}
if !may_commit { if !may_commit {
return self return self
.read_encode_error(ProtocolInteraction::CallOperation, encode_error); .read_encode_error(ProtocolInteraction::CallOperation, encode_error);
@@ -219,63 +207,26 @@ impl WipFsServer {
code: ProtocolErrorCode::OperationOutcomeUnknown, code: ProtocolErrorCode::OperationOutcomeUnknown,
message: "operation completed but its response could not be encoded".into(), message: "operation completed but its response could not be encoded".into(),
}; };
self.protocol_error(ProtocolInteraction::CallOperation, &error, None) self.protocol_error(ProtocolInteraction::CallOperation, &error)
}) })
} }
Err(error) => self.protocol_error(ProtocolInteraction::CallOperation, &error, None), Err(error) => self.protocol_error(ProtocolInteraction::CallOperation, &error),
} }
} }
fn batch(&self, body: &[u8], context: &ExecutionContext) -> Response<Vec<u8>> { fn metadata_error(&self, error: RequestMetadataError) -> Response<Vec<u8>> {
let request = match decode_batch_request(body, self.config.limits.wire) {
Ok(request) => request,
Err(error) => return self.batch_codec_error(error),
};
let responses = request
.requests
.iter()
.map(|item| {
let outcome = match &item.request {
BatchRequestKind::Fetch(request) => self
.projection
.fetch_with_context(context, &request.path)
.map(BatchSuccess::Fetch)
.map_err(|error| error.protocol_error()),
BatchRequestKind::FetchTree(request) => self
.projection
.fetch_tree(context, request)
.map(BatchSuccess::FetchTree),
BatchRequestKind::FetchInterface(request) => self
.projection
.fetch_interface(request)
.map(BatchSuccess::FetchInterface),
BatchRequestKind::Invalid { error, .. } => Err(error.clone()),
};
BatchResponseItem {
id: item.id.clone(),
outcome,
}
})
.collect();
let response = BatchResponse { responses };
encode_batch_response(&request, &response, self.config.limits.wire).unwrap_or_else(
|error| match error {
EncodeHttpError::Codec(codec) => self.batch_codec_error(codec),
EncodeHttpError::Http(_) => self.batch_top_error(&internal_error(), None),
},
)
}
fn metadata_error(&self, route: Route, error: RequestMetadataError) -> Response<Vec<u8>> {
match error { match error {
RequestMetadataError::InvalidRequest(_) => self.protocol_error( RequestMetadataError::NotFound => empty_response(StatusCode::NOT_FOUND),
interaction(route), error @ RequestMetadataError::MethodNotAllowed => Response::builder()
&ProtocolError { .status(error.status())
code: ProtocolErrorCode::InvalidRequest, .header(
message: "invalid HTTP request metadata".into(), ALLOW,
}, error
None, .allow()
), .expect("method-not-allowed metadata has an Allow value"),
)
.body(Vec::new())
.expect("static method response is valid"),
RequestMetadataError::UnsupportedMediaType => { RequestMetadataError::UnsupportedMediaType => {
empty_response(StatusCode::UNSUPPORTED_MEDIA_TYPE) empty_response(StatusCode::UNSUPPORTED_MEDIA_TYPE)
} }
@@ -288,8 +239,8 @@ impl WipFsServer {
interaction: ProtocolInteraction, interaction: ProtocolInteraction,
error: CodecError, error: CodecError,
) -> Response<Vec<u8>> { ) -> Response<Vec<u8>> {
if let Some(kind) = error.resource_limit_kind() { if is_transport_limit(&error) {
self.protocol_error(interaction, &resource_error(), Some(kind)) empty_response(StatusCode::PAYLOAD_TOO_LARGE)
} else { } else {
self.protocol_error( self.protocol_error(
interaction, interaction,
@@ -297,21 +248,6 @@ impl WipFsServer {
code: ProtocolErrorCode::InvalidRequest, code: ProtocolErrorCode::InvalidRequest,
message: "malformed WIP request".into(), message: "malformed WIP request".into(),
}, },
None,
)
}
}
fn batch_codec_error(&self, error: CodecError) -> Response<Vec<u8>> {
if let Some(kind) = error.resource_limit_kind() {
self.batch_top_error(&resource_error(), Some(kind))
} else {
self.batch_top_error(
&ProtocolError {
code: ProtocolErrorCode::InvalidRequest,
message: "malformed WIP batch request".into(),
},
None,
) )
} }
} }
@@ -322,10 +258,10 @@ impl WipFsServer {
error: EncodeHttpError, error: EncodeHttpError,
) -> Response<Vec<u8>> { ) -> Response<Vec<u8>> {
match error { match error {
EncodeHttpError::Codec(codec) if codec.resource_limit_kind().is_some() => { EncodeHttpError::Codec(codec) if is_transport_limit(&codec) => {
self.protocol_error(interaction, &resource_error(), codec.resource_limit_kind()) empty_response(StatusCode::INTERNAL_SERVER_ERROR)
} }
_ => self.protocol_error(interaction, &internal_error(), None), _ => self.protocol_error(interaction, &internal_error()),
} }
} }
@@ -333,32 +269,9 @@ impl WipFsServer {
&self, &self,
interaction: ProtocolInteraction, interaction: ProtocolInteraction,
error: &ProtocolError, error: &ProtocolError,
resource_kind: Option<ResourceLimitKind>,
) -> Response<Vec<u8>> { ) -> Response<Vec<u8>> {
let encoded = match resource_kind { encode_protocol_error_response(interaction, error, self.config.limits.wire)
Some(kind) => encode_resource_limit_error_response( .unwrap_or_else(|_| empty_response(StatusCode::INTERNAL_SERVER_ERROR))
interaction,
error,
kind,
self.config.limits.wire,
),
None => encode_protocol_error_response(interaction, error, self.config.limits.wire),
};
encoded.unwrap_or_else(|_| empty_response(StatusCode::INTERNAL_SERVER_ERROR))
}
fn batch_top_error(
&self,
error: &ProtocolError,
resource_kind: Option<ResourceLimitKind>,
) -> Response<Vec<u8>> {
let encoded = match resource_kind {
Some(kind) => {
encode_batch_resource_limit_error_response(error, kind, self.config.limits.wire)
}
None => encode_batch_error_response(error, self.config.limits.wire),
};
encoded.unwrap_or_else(|_| empty_response(StatusCode::INTERNAL_SERVER_ERROR))
} }
} }
@@ -428,7 +341,7 @@ struct ParsedRequestHead {
enum IntakeError { enum IntakeError {
Timeout, Timeout,
HeaderTooLarge, HeaderTooLarge,
PayloadTooLarge { target: String }, PayloadTooLarge,
BadRequest, BadRequest,
Socket, Socket,
} }
@@ -449,9 +362,7 @@ fn serve_connection(service: &WipFsServer, mut stream: TcpStream) {
Err(IntakeError::HeaderTooLarge) => { Err(IntakeError::HeaderTooLarge) => {
Some(empty_response(StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE)) Some(empty_response(StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE))
} }
Err(IntakeError::PayloadTooLarge { target }) => { Err(IntakeError::PayloadTooLarge) => Some(empty_response(StatusCode::PAYLOAD_TOO_LARGE)),
Some(payload_too_large_response(service, &target))
}
Err(IntakeError::BadRequest) => Some(empty_response(StatusCode::BAD_REQUEST)), Err(IntakeError::BadRequest) => Some(empty_response(StatusCode::BAD_REQUEST)),
Err(IntakeError::Timeout | IntakeError::Socket) => None, Err(IntakeError::Timeout | IntakeError::Socket) => None,
}; };
@@ -490,9 +401,7 @@ fn read_http_request(
let parsed = parse_request_head(&received[..header_end])?; let parsed = parse_request_head(&received[..header_end])?;
if parsed.content_length > maximum_body { if parsed.content_length > maximum_body {
return Err(IntakeError::PayloadTooLarge { return Err(IntakeError::PayloadTooLarge);
target: parsed.target,
});
} }
let mut body = Vec::with_capacity(parsed.content_length); let mut body = Vec::with_capacity(parsed.content_length);
let buffered = &received[header_end..]; let buffered = &received[header_end..];
@@ -591,27 +500,6 @@ fn read_with_deadline(
}) })
} }
fn payload_too_large_response(service: &WipFsServer, target: &str) -> Response<Vec<u8>> {
let route = service.route(target.split('?').next().unwrap_or_default());
route.map_or_else(
|| empty_response(StatusCode::PAYLOAD_TOO_LARGE),
|route| {
if route == Route::Batch {
service.batch_top_error(
&resource_error(),
Some(ResourceLimitKind::RepresentationBytes),
)
} else {
service.protocol_error(
interaction(route),
&resource_error(),
Some(ResourceLimitKind::RepresentationBytes),
)
}
},
)
}
fn write_http_response( fn write_http_response(
stream: &mut TcpStream, stream: &mut TcpStream,
response: &Response<Vec<u8>>, response: &Response<Vec<u8>>,
@@ -672,21 +560,11 @@ fn write_all_with_deadline(
stream.flush() stream.flush()
} }
fn interaction(route: Route) -> ProtocolInteraction { fn is_transport_limit(error: &CodecError) -> bool {
match route { matches!(
Route::Fetch => ProtocolInteraction::Fetch, error,
Route::FetchTree => ProtocolInteraction::FetchTree, CodecError::BodyTooLarge { .. } | CodecError::NestingTooDeep { .. }
Route::FetchInterface => ProtocolInteraction::FetchInterface, )
Route::CallOperation => ProtocolInteraction::CallOperation,
Route::Batch => ProtocolInteraction::Fetch,
}
}
fn resource_error() -> ProtocolError {
ProtocolError {
code: ProtocolErrorCode::ResourceLimitExceeded,
message: "configured resource limit exceeded".into(),
}
} }
fn internal_error() -> ProtocolError { fn internal_error() -> ProtocolError {
+227 -122
View File
@@ -7,28 +7,26 @@ use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tempfile::tempdir; use tempfile::tempdir;
use wip_fs_server::{AccessMode, ServerConfig, TransportSecurity, WipFsServer}; use wip_fs_server::{AccessMode, ServerConfig, WipFsServer};
use wip_https::{ use wip_http::{
BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse, BatchSuccess, DecodedResponse, ClientResponseError, DecodedResponse, Endpoint, JSON_CONTENT_TYPE, Limits, Route,
Endpoint, JSON_CONTENT_TYPE, Limits, Route, decode_batch_request, decode_batch_response, TransportFailureKind, decode_call_operation_response, decode_fetch_interface_response,
decode_call_operation_response, decode_fetch_interface_response, decode_fetch_response, decode_observe_response, encode_call_operation_request, encode_fetch_interface_request,
decode_fetch_tree_response, encode_batch_request, encode_call_operation_request, encode_observe_request,
encode_fetch_interface_request, encode_fetch_request, encode_fetch_tree_request,
}; };
use wip_protocol::{ use wip_protocol::{
CallOperationRequest, FetchInterfaceRequest, FetchRequest, FetchTreeRequest, InterfaceTarget, CallOperationRequest, FetchInterfaceRequest, InterfaceTarget, ObserveRequest,
ProtocolErrorCode, Target, Value, ProtocolErrorCode, Target, Value,
}; };
#[test] #[test]
fn real_listener_serves_four_core_interactions_batch_and_write() { fn real_listener_serves_three_core_interactions_and_write() {
let temporary = tempdir().expect("temporary root"); let temporary = tempdir().expect("temporary root");
std::fs::write(temporary.path().join("hello.txt"), b"hello world").expect("write file"); std::fs::write(temporary.path().join("hello.txt"), b"hello world").expect("write file");
std::fs::create_dir(temporary.path().join("nested")).expect("create directory"); std::fs::create_dir(temporary.path().join("nested")).expect("create directory");
let mut config = ServerConfig::for_root(temporary.path()); let mut config = ServerConfig::for_root(temporary.path());
config.access = AccessMode::ReadWrite; config.access = AccessMode::ReadWrite;
config.transport_security = TransportSecurity::DevelopmentHttp;
let limits = config.limits.wire; let limits = config.limits.wire;
let bound = Arc::new( let bound = Arc::new(
WipFsServer::new(config) WipFsServer::new(config)
@@ -43,35 +41,42 @@ fn real_listener_serves_four_core_interactions_batch_and_write() {
let shutdown = Arc::clone(&shutdown); let shutdown = Arc::clone(&shutdown);
thread::spawn(move || bound.serve_until(shutdown)) thread::spawn(move || bound.serve_until(shutdown))
}; };
let endpoint = Endpoint::parse(&format!("https://localhost:{}/wip", address.port())) let endpoint = Endpoint::parse(&format!("http://localhost:{}/wip", address.port()))
.expect("client endpoint"); .expect("client endpoint");
let file_request = FetchRequest { let file_request = ObserveRequest {
path: "/hello.txt".into(), path: "/hello.txt".into(),
depth: 0,
}; };
let response = exchange( let response = exchange(
address, address,
encode_fetch_request(&endpoint, &file_request, limits).expect("encode fetch"), encode_observe_request(&endpoint, &file_request, limits).expect("encode observation"),
); );
let file = let file = success(
success(decode_fetch_response(&file_request, &response, limits).expect("decode fetch")); decode_observe_response(&file_request, &response, limits).expect("decode observation"),
)
.object;
assert_eq!(file.name, "hello.txt"); assert_eq!(file.name, "hello.txt");
assert_eq!(file.interfaces.len(), 2); assert_eq!(file.interfaces.len(), 2);
assert!(file.validator.is_some()); assert!(file.validator.is_some());
let tree_request = FetchTreeRequest { let tree_request = ObserveRequest {
path: "/".into(), path: "/".into(),
depth: 1, depth: 1,
limit: 10,
}; };
let response = exchange( let response = exchange(
address, address,
encode_fetch_tree_request(&endpoint, &tree_request, limits).expect("encode tree"), encode_observe_request(&endpoint, &tree_request, limits).expect("encode tree"),
); );
let tree = let tree =
success(decode_fetch_tree_response(&tree_request, &response, limits).expect("decode tree")); success(decode_observe_response(&tree_request, &response, limits).expect("decode tree"));
assert!(!tree.truncated); assert_eq!(
assert_eq!(tree.root.children.len(), 2); tree.children
.as_ref()
.expect("root children observed")
.len(),
2
);
let read_interface_request = FetchInterfaceRequest { let read_interface_request = FetchInterfaceRequest {
interface: file.interfaces[0].clone(), interface: file.interfaces[0].clone(),
@@ -118,34 +123,6 @@ fn real_listener_serves_four_core_interactions_batch_and_write() {
); );
assert_eq!(read.result, Value::Bytes(b"world".to_vec())); assert_eq!(read.result, Value::Bytes(b"world".to_vec()));
let batch_request = BatchRequest {
requests: vec![
BatchRequestItem {
id: "root".into(),
request: BatchRequestKind::Fetch(FetchRequest { path: "/".into() }),
},
BatchRequestItem {
id: "tree".into(),
request: BatchRequestKind::FetchTree(FetchTreeRequest {
path: "/nested".into(),
depth: 1,
limit: 4,
}),
},
BatchRequestItem {
id: "interface".into(),
request: BatchRequestKind::FetchInterface(read_interface_request.clone()),
},
],
};
let response = exchange(
address,
encode_batch_request(&endpoint, &batch_request, limits).expect("encode batch"),
);
let batch =
success(decode_batch_response(&batch_request, &response, limits).expect("decode batch"));
assert_batch_successes(&batch);
let write_interface_request = FetchInterfaceRequest { let write_interface_request = FetchInterfaceRequest {
interface: file.interfaces[1].clone(), interface: file.interfaces[1].clone(),
}; };
@@ -202,11 +179,166 @@ fn real_listener_serves_four_core_interactions_batch_and_write() {
.expect("listener exits cleanly"); .expect("listener exits cleanly");
} }
#[test]
fn real_listener_keeps_route_method_and_malformed_body_boundaries_distinct() {
let temporary = tempdir().expect("temporary root");
let mut config = ServerConfig::for_root(temporary.path());
config.limits.wire = Limits::new(64, 4_096, 64).expect("wire limits");
let bound = Arc::new(
WipFsServer::new(config)
.expect("build server")
.bind()
.expect("bind"),
);
let address = bound.local_addr();
let shutdown = Arc::new(AtomicBool::new(false));
let worker = {
let bound = Arc::clone(&bound);
let shutdown = Arc::clone(&shutdown);
thread::spawn(move || bound.serve_until(shutdown))
};
for uri in [
"/wip/v1/unknown",
"/wip/v1/batch",
"/wip/v1/observe?unexpected=true",
"/wip/v1/observe/extra",
] {
let response = exchange(address, json_request(http::Method::POST, uri, b"{}"));
assert_eq!(response.status(), http::StatusCode::NOT_FOUND);
assert!(response.body().is_empty());
assert!(!response.headers().contains_key(http::header::CONTENT_TYPE));
assert!(!response.headers().contains_key(http::header::ALLOW));
}
let wrong_method = exchange(
address,
json_request(http::Method::GET, "/wip/v1/observe", b""),
);
assert_eq!(wrong_method.status(), http::StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(wrong_method.headers()[http::header::ALLOW], "POST");
assert!(wrong_method.body().is_empty());
assert!(
!wrong_method
.headers()
.contains_key(http::header::CONTENT_TYPE)
);
let mut unsupported_coding = json_request(
http::Method::POST,
"/wip/v1/observe",
br#"{"path":"/","depth":0}"#,
);
unsupported_coding
.headers_mut()
.insert(http::header::CONTENT_ENCODING, "gzip".parse().unwrap());
let unsupported_coding = exchange(address, unsupported_coding);
assert_eq!(
unsupported_coding.status(),
http::StatusCode::UNSUPPORTED_MEDIA_TYPE
);
assert!(unsupported_coding.body().is_empty());
assert!(
!unsupported_coding
.headers()
.contains_key(http::header::CONTENT_TYPE)
);
let malformed = exchange(
address,
json_request(http::Method::POST, "/wip/v1/observe", b"{"),
);
assert_eq!(malformed.status(), http::StatusCode::BAD_REQUEST);
assert_eq!(
malformed.headers()[http::header::CONTENT_TYPE],
JSON_CONTENT_TYPE
);
assert!(
std::str::from_utf8(malformed.body())
.expect("UTF-8 error envelope")
.contains("\"invalid_request\"")
);
let oversized = exchange(
address,
json_request(http::Method::POST, "/wip/v1/observe", &[b'x'; 65]),
);
assert_eq!(oversized.status(), http::StatusCode::PAYLOAD_TOO_LARGE);
assert!(oversized.body().is_empty());
assert!(!oversized.headers().contains_key(http::header::CONTENT_TYPE));
let mut header_only = TcpStream::connect(address).expect("connect header-only client");
header_only
.set_read_timeout(Some(Duration::from_secs(1)))
.expect("set header-only timeout");
write!(
header_only,
"POST /wip/v1/observe HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: 65\r\n\r\n"
)
.expect("write oversized request head");
header_only.flush().expect("flush oversized request head");
let mut raw = Vec::new();
header_only
.read_to_end(&mut raw)
.expect("read immediate limit response");
assert!(raw.starts_with(b"HTTP/1.1 413 "));
assert!(raw.ends_with(b"\r\n\r\n"));
assert!(
!raw.windows(b"resource_limit_exceeded".len())
.any(|window| { window == b"resource_limit_exceeded" })
);
shutdown.store(true, Ordering::Release);
worker
.join()
.expect("join listener")
.expect("listener exits cleanly");
}
#[test]
fn raw_decoder_limits_are_transport_failures() {
let temporary = tempdir().expect("temporary root");
let mut config = ServerConfig::for_root(temporary.path());
config.limits.wire = Limits::new(4_096, 4_096, 8).expect("wire limits");
let server = WipFsServer::new(config).expect("build server");
let nested = br#"{"path":[[[[[[[[["/"]]]]]]]]],"depth":0}"#;
let response = server.handle_http(json_request(http::Method::POST, "/wip/v1/observe", nested));
assert_eq!(response.status(), http::StatusCode::PAYLOAD_TOO_LARGE);
assert!(response.body().is_empty());
assert!(!response.headers().contains_key(http::header::CONTENT_TYPE));
}
#[test]
fn logical_observation_limit_remains_protocol_resource_failure() {
let temporary = tempdir().expect("temporary root");
let mut config = ServerConfig::for_root(temporary.path());
config.limits.max_observation_depth = 0;
let limits = config.limits.wire;
let server = WipFsServer::new(config).expect("build server");
let endpoint = Endpoint::parse("http://localhost/wip").expect("endpoint");
let request = ObserveRequest {
path: "/".into(),
depth: 1,
};
let response = server.handle_http(
encode_observe_request(&endpoint, &request, limits).expect("encode observation"),
);
assert_eq!(response.status(), http::StatusCode::UNPROCESSABLE_ENTITY);
assert!(matches!(
decode_observe_response(&request, &response, limits).expect("decode logical limit"),
DecodedResponse::ProtocolFailure {
error,
status_mismatch: None,
} if error.code == ProtocolErrorCode::ResourceLimitExceeded
));
}
#[test] #[test]
fn stalled_request_body_times_out_and_releases_the_only_worker() { fn stalled_request_body_times_out_and_releases_the_only_worker() {
let temporary = tempdir().expect("temporary root"); let temporary = tempdir().expect("temporary root");
let mut config = ServerConfig::for_root(temporary.path()); let mut config = ServerConfig::for_root(temporary.path());
config.transport_security = TransportSecurity::DevelopmentHttp;
config.limits.max_concurrent_requests = 1; config.limits.max_concurrent_requests = 1;
config.limits.request_timeout = Duration::from_millis(200); config.limits.request_timeout = Duration::from_millis(200);
let limits = config.limits.wire; let limits = config.limits.wire;
@@ -230,21 +362,25 @@ fn stalled_request_body_times_out_and_releases_the_only_worker() {
.expect("set stalled read timeout"); .expect("set stalled read timeout");
write!( write!(
stalled, stalled,
"POST /wip/v1/fetch HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nAccept: application/json\r\nContent-Length: 100\r\n\r\n{{" "POST /wip/v1/observe HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nAccept: application/json\r\nContent-Length: 100\r\n\r\n{{"
) )
.expect("write partial request"); .expect("write partial request");
stalled.flush().expect("flush partial request"); stalled.flush().expect("flush partial request");
thread::sleep(Duration::from_millis(25)); thread::sleep(Duration::from_millis(25));
let endpoint = Endpoint::parse(&format!("https://localhost:{}/wip", address.port())) let endpoint = Endpoint::parse(&format!("http://localhost:{}/wip", address.port()))
.expect("client endpoint"); .expect("client endpoint");
let fetch = FetchRequest { path: "/".into() }; let observe = ObserveRequest {
path: "/".into(),
depth: 0,
};
let started = Instant::now(); let started = Instant::now();
let response = exchange( let response = exchange(
address, address,
encode_fetch_request(&endpoint, &fetch, limits).expect("encode fetch"), encode_observe_request(&endpoint, &observe, limits).expect("encode observation"),
); );
let _root = success(decode_fetch_response(&fetch, &response, limits).expect("decode fetch")); let _root =
success(decode_observe_response(&observe, &response, limits).expect("decode observation"));
assert!( assert!(
started.elapsed() < Duration::from_secs(1), started.elapsed() < Duration::from_secs(1),
"the valid request remained starved after the intake deadline" "the valid request remained starved after the intake deadline"
@@ -270,43 +406,12 @@ fn stalled_request_body_times_out_and_releases_the_only_worker() {
.expect("listener exits cleanly"); .expect("listener exits cleanly");
} }
#[test]
fn batch_keeps_known_malformed_arguments_item_local() {
let temporary = tempdir().expect("temporary root");
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 body = br#"{"requests":[{"id":"ok","method":"fetch","arguments":{"path":"/"}},{"id":"bad","method":"fetch_tree","arguments":{"path":"/","depth":"bad","limit":1}}]}"#;
let logical = decode_batch_request(body, limits).expect("decode item-local invalid request");
assert!(matches!(
logical.requests[1].request,
BatchRequestKind::Invalid { .. }
));
let request = http::Request::builder()
.method(http::Method::POST)
.uri(endpoint.route(Route::Batch).as_str())
.header(http::header::CONTENT_TYPE, JSON_CONTENT_TYPE)
.header(http::header::ACCEPT, "application/json")
.body(body.to_vec())
.expect("build request");
let response = server.handle_http(request);
let batch = success(
decode_batch_response(&logical, &response, limits).expect("decode mixed batch response"),
);
assert!(matches!(
batch.responses[0].outcome,
Ok(BatchSuccess::Fetch(_))
));
assert!(batch.responses[1].outcome.is_err());
}
#[test] #[test]
fn call_argument_decoding_does_not_mask_missing_target() { fn call_argument_decoding_does_not_mask_missing_target() {
let temporary = tempdir().expect("temporary root"); let temporary = tempdir().expect("temporary root");
let config = ServerConfig::for_root(temporary.path()); let config = ServerConfig::for_root(temporary.path());
let server = WipFsServer::new(config).expect("build server"); let server = WipFsServer::new(config).expect("build server");
let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint"); let endpoint = Endpoint::parse("http://localhost/wip").expect("endpoint");
let body = br#"{"target":{"path":"/missing"},"interface":{"reference":"unknown"},"operation":"missing","arguments":{"bytes":"not-base64"}}"#; let body = br#"{"target":{"path":"/missing"},"interface":{"reference":"unknown"},"operation":"missing","arguments":{"bytes":"not-base64"}}"#;
let request = http::Request::builder() let request = http::Request::builder()
.method(http::Method::POST) .method(http::Method::POST)
@@ -330,14 +435,16 @@ fn backend_invalid_unicode_path_is_not_found_not_internal() {
let config = ServerConfig::for_root(temporary.path()); let config = ServerConfig::for_root(temporary.path());
let limits = config.limits.wire; let limits = config.limits.wire;
let server = WipFsServer::new(config).expect("build server"); let server = WipFsServer::new(config).expect("build server");
let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint"); let endpoint = Endpoint::parse("http://localhost/wip").expect("endpoint");
let request = FetchRequest { let request = ObserveRequest {
path: "/bad\0name".into(), path: "/bad\0name".into(),
depth: 0,
}; };
let response = server let response = server.handle_http(
.handle_http(encode_fetch_request(&endpoint, &request, limits).expect("encode fetch")); encode_observe_request(&endpoint, &request, limits).expect("encode observation"),
);
assert_eq!(response.status(), http::StatusCode::NOT_FOUND); assert_eq!(response.status(), http::StatusCode::NOT_FOUND);
match decode_fetch_response(&request, &response, limits).expect("decode missing response") { match decode_observe_response(&request, &response, limits).expect("decode missing response") {
DecodedResponse::ProtocolFailure { error, .. } => { DecodedResponse::ProtocolFailure { error, .. } => {
assert_eq!(error.code, ProtocolErrorCode::NotFound); assert_eq!(error.code, ProtocolErrorCode::NotFound);
} }
@@ -346,23 +453,26 @@ fn backend_invalid_unicode_path_is_not_found_not_internal() {
} }
#[test] #[test]
fn read_response_size_failure_preserves_non_commit_guarantee() { fn read_response_size_failure_is_transport_local() {
let temporary = tempdir().expect("temporary root"); let temporary = tempdir().expect("temporary root");
std::fs::write(temporary.path().join("large"), vec![b'x'; 2_000]).expect("write file"); std::fs::write(temporary.path().join("large"), vec![b'x'; 2_000]).expect("write file");
let mut config = ServerConfig::for_root(temporary.path()); let mut config = ServerConfig::for_root(temporary.path());
config.limits.wire = Limits::new(4_096, 1_024, 64, 64).expect("wire limits"); config.limits.wire = Limits::new(4_096, 1_024, 64).expect("wire limits");
let limits = config.limits.wire; let limits = config.limits.wire;
let server = WipFsServer::new(config).expect("build server"); let server = WipFsServer::new(config).expect("build server");
let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint"); let endpoint = Endpoint::parse("http://localhost/wip").expect("endpoint");
let fetch_request = FetchRequest { let observe_request = ObserveRequest {
path: "/large".into(), path: "/large".into(),
depth: 0,
}; };
let response = server.handle_http( let response = server.handle_http(
encode_fetch_request(&endpoint, &fetch_request, limits).expect("encode fetch"), encode_observe_request(&endpoint, &observe_request, limits).expect("encode observation"),
); );
let file = let file = success(
success(decode_fetch_response(&fetch_request, &response, limits).expect("decode fetch")); decode_observe_response(&observe_request, &response, limits).expect("decode observation"),
)
.object;
let interface_request = FetchInterfaceRequest { let interface_request = FetchInterfaceRequest {
interface: file.interfaces[0].clone(), interface: file.interfaces[0].clone(),
}; };
@@ -390,30 +500,15 @@ fn read_response_size_failure_preserves_non_commit_guarantee() {
encode_call_operation_request(&endpoint, &call, &interface.descriptor, limits) encode_call_operation_request(&endpoint, &call, &interface.descriptor, limits)
.expect("encode read call"), .expect("encode read call"),
); );
assert_eq!(response.status(), http::StatusCode::PAYLOAD_TOO_LARGE); assert_eq!(response.status(), http::StatusCode::INTERNAL_SERVER_ERROR);
match decode_call_operation_response(&call, &interface.descriptor, &response, limits) assert!(response.body().is_empty());
.expect("decode resource failure") assert!(!response.headers().contains_key(http::header::CONTENT_TYPE));
{
DecodedResponse::ProtocolFailure { error, .. } => {
assert_eq!(error.code, ProtocolErrorCode::ResourceLimitExceeded);
}
DecodedResponse::Success(_) => panic!("oversized read response must fail"),
}
}
fn assert_batch_successes(batch: &BatchResponse) {
assert_eq!(batch.responses.len(), 3);
assert!(matches!( assert!(matches!(
batch.responses[0].outcome, decode_call_operation_response(&call, &interface.descriptor, &response, limits)
Ok(BatchSuccess::Fetch(_)) .expect_err("oversized representation is not a protocol failure"),
)); ClientResponseError::TransportBinding(failure)
assert!(matches!( if failure.status == http::StatusCode::INTERNAL_SERVER_ERROR
batch.responses[1].outcome, && failure.kind == TransportFailureKind::HttpStatus
Ok(BatchSuccess::FetchTree(_))
));
assert!(matches!(
batch.responses[2].outcome,
Ok(BatchSuccess::FetchInterface(_))
)); ));
} }
@@ -424,6 +519,16 @@ fn success<T>(response: DecodedResponse<T>) -> T {
} }
} }
fn json_request(method: http::Method, uri: &str, body: &[u8]) -> http::Request<Vec<u8>> {
http::Request::builder()
.method(method)
.uri(uri)
.header(http::header::CONTENT_TYPE, JSON_CONTENT_TYPE)
.header(http::header::ACCEPT, "application/json")
.body(body.to_vec())
.expect("build JSON request")
}
fn exchange(address: SocketAddr, request: http::Request<Vec<u8>>) -> http::Response<Vec<u8>> { fn exchange(address: SocketAddr, request: http::Request<Vec<u8>>) -> http::Response<Vec<u8>> {
let mut stream = TcpStream::connect(address).expect("connect listener"); let mut stream = TcpStream::connect(address).expect("connect listener");
stream stream
+7 -7
View File
@@ -352,7 +352,7 @@ impl InterfaceRegistry {
/// Observes a registered interface by its host-side reference. /// Observes a registered interface by its host-side reference.
#[must_use] #[must_use]
pub fn observe(&self, reference: &InterfaceRef) -> Option<InterfaceObservation> { pub fn observe_object(&self, reference: &InterfaceRef) -> Option<InterfaceObservation> {
self.artifacts.get(reference).map(Self::observation) self.artifacts.get(reference).map(Self::observation)
} }
@@ -863,13 +863,13 @@ pub trait ObjectProvider {
true true
} }
/// Fetches the canonical object projection currently published at `path`. /// Observes the canonical object projection currently published at `path`.
fn fetch(&self, path: &str) -> Result<Object, CallError> { fn observe_object(&self, path: &str) -> Result<Object, CallError> {
self.fetch_with_context(&ExecutionContext::default(), path) self.observe_object_with_context(&ExecutionContext::default(), path)
} }
/// Fetches an object while propagating runtime execution controls. /// Observes an object while propagating runtime execution controls.
fn fetch_with_context( fn observe_object_with_context(
&self, &self,
context: &ExecutionContext, context: &ExecutionContext,
path: &str, path: &str,
@@ -882,7 +882,7 @@ pub trait ObjectProvider {
.map_err(|_| { .map_err(|_| {
CallError::new( CallError::new(
CallErrorKind::Internal, CallErrorKind::Internal,
"provider panicked while fetching an object", "provider panicked while observing an object",
) )
})?; })?;
let object = match resolved { let object = match resolved {
+8 -8
View File
@@ -467,7 +467,7 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
#[test] #[test]
fn canonical_object_projection_supports_optional_metadata_and_ordered_sets() { fn canonical_object_projection_supports_optional_metadata_and_ordered_sets() {
let (_, provider, _) = registry_and_provider(); let (_, provider, _) = registry_and_provider();
let object = provider.fetch("/document").unwrap(); let object = provider.observe_object("/document").unwrap();
assert_eq!(object.name, "document"); assert_eq!(object.name, "document");
assert_eq!(object.description.as_deref(), Some("public document")); assert_eq!(object.description.as_deref(), Some("public document"));
@@ -483,7 +483,7 @@ fn canonical_object_projection_supports_optional_metadata_and_ordered_sets() {
assert!(!format!("{object:?}").contains("secret-handle")); assert!(!format!("{object:?}").contains("secret-handle"));
assert!(!format!("{object:?}").contains("/document")); assert!(!format!("{object:?}").contains("/document"));
let empty = provider.fetch("/empty").unwrap(); let empty = provider.observe_object("/empty").unwrap();
assert!(empty.interfaces.is_empty()); assert!(empty.interfaces.is_empty());
assert_eq!(empty.r#ref, None); assert_eq!(empty.r#ref, None);
assert_eq!(empty.validator, None); assert_eq!(empty.validator, None);
@@ -518,13 +518,13 @@ fn duplicate_exposure_is_rejected_and_private_state_is_not_projected() {
fn paths_are_resolution_context_not_object_identity() { fn paths_are_resolution_context_not_object_identity() {
let (_, provider, _) = registry_and_provider(); let (_, provider, _) = registry_and_provider();
let left = provider.fetch("/left/document").unwrap(); let left = provider.observe_object("/left/document").unwrap();
let right = provider.fetch("/right/document").unwrap(); let right = provider.observe_object("/right/document").unwrap();
assert_eq!(left, right); assert_eq!(left, right);
assert_eq!(left.r#ref.as_deref(), Some("shared-ref")); assert_eq!(left.r#ref.as_deref(), Some("shared-ref"));
let first = provider.fetch("/switch").unwrap(); let first = provider.observe_object("/switch").unwrap();
let second = provider.fetch("/switch").unwrap(); let second = provider.observe_object("/switch").unwrap();
assert_ne!(first.r#ref, second.r#ref); assert_ne!(first.r#ref, second.r#ref);
assert_ne!(first.validator, second.validator); assert_ne!(first.validator, second.validator);
} }
@@ -603,7 +603,7 @@ fn call_failures_follow_canonical_precedence_without_execution() {
} }
assert_eq!(provider.resolutions.get(), resolutions); assert_eq!(provider.resolutions.get(), resolutions);
assert_failure( assert_failure(
provider.fetch("/invalid/").unwrap_err(), provider.observe_object("/invalid/").unwrap_err(),
CallErrorKind::InvalidRequest, CallErrorKind::InvalidRequest,
ProtocolErrorCode::InvalidRequest, ProtocolErrorCode::InvalidRequest,
); );
@@ -849,7 +849,7 @@ fn invalid_host_responses_and_uncertain_operation_failures_use_canonical_codes()
let (_, provider, _) = registry_and_provider(); let (_, provider, _) = registry_and_provider();
assert_failure( assert_failure(
provider.fetch("/invalid-object").unwrap_err(), provider.observe_object("/invalid-object").unwrap_err(),
CallErrorKind::Internal, CallErrorKind::Internal,
ProtocolErrorCode::Internal, ProtocolErrorCode::Internal,
); );
@@ -1,8 +1,8 @@
[package] [package]
name = "wip-https" name = "wip-http"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
description = "Framework-independent standard WIP over HTTPS v1 binding" description = "Framework-independent standard WIP over HTTP v1 binding"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
[dependencies] [dependencies]
@@ -1,18 +1,18 @@
use std::error::Error; use std::error::Error;
use std::fmt::{self, Display, Formatter}; use std::fmt::{self, Display, Formatter};
use http::header::{ACCEPT, CONTENT_TYPE}; use http::header::{ACCEPT, CONTENT_ENCODING, CONTENT_TYPE};
use http::{HeaderMap, Method, Request, Response, StatusCode}; use http::{HeaderMap, Method, Request, Response, StatusCode};
use wip_protocol::{ use wip_protocol::{
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, FetchInterfaceResponse, CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, FetchInterfaceResponse,
FetchRequest, FetchResponse, FetchTreeRequest, FetchTreeResponse, INTERFACE_FORMAT_V1, INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceTarget, ObserveRequest, ObserveResponse,
InterfaceDescriptor, InterfaceTarget, ProtocolError, ProtocolInteraction, Target, ProtocolError, ProtocolInteraction, Target,
}; };
use crate::response::{self, DecodedResponse}; use crate::response::{self, DecodedResponse};
use crate::{ use crate::{
BodyKind, ClientResponseError, CodecError, Endpoint, InvalidResponseKind, JSON_CONTENT_TYPE, BodyKind, ClientResponseError, CodecError, Endpoint, InvalidResponseKind, JSON_CONTENT_TYPE,
Limits, ResourceLimitKind, Route, json, Limits, Route, json,
}; };
fn request( fn request(
@@ -36,6 +36,9 @@ fn response(status: StatusCode, body: Vec<u8>) -> Result<Response<Vec<u8>>, http
} }
fn require_response_content_type<B>(response: &Response<B>) -> Result<(), ClientResponseError> { fn require_response_content_type<B>(response: &Response<B>) -> Result<(), ClientResponseError> {
if !has_supported_content_coding(response.headers()) {
return Err(response::unsupported_content_coding(response.status()));
}
let valid = response let valid = response
.headers() .headers()
.get(CONTENT_TYPE) .get(CONTENT_TYPE)
@@ -181,9 +184,11 @@ fn accepts_json(headers: &HeaderMap) -> bool {
/// A failure while validating inbound HTTP request metadata. /// A failure while validating inbound HTTP request metadata.
#[derive(Debug)] #[derive(Debug)]
pub enum RequestMetadataError { pub enum RequestMetadataError {
/// The method or route does not match the selected WIP interaction. /// The request URI does not exactly identify the selected standard route.
InvalidRequest(CodecError), NotFound,
/// The request body does not use UTF-8 `application/json`. /// A known standard route was requested with a method other than `POST`.
MethodNotAllowed,
/// The request body does not use UTF-8 `application/json` or identity coding.
UnsupportedMediaType, UnsupportedMediaType,
/// The request's `Accept` metadata excludes `application/json`. /// The request's `Accept` metadata excludes `application/json`.
NotAcceptable, NotAcceptable,
@@ -194,32 +199,47 @@ impl RequestMetadataError {
#[must_use] #[must_use]
pub const fn status(&self) -> StatusCode { pub const fn status(&self) -> StatusCode {
match self { match self {
Self::InvalidRequest(_) => StatusCode::BAD_REQUEST, Self::NotFound => StatusCode::NOT_FOUND,
Self::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED,
Self::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE, Self::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE,
Self::NotAcceptable => StatusCode::NOT_ACCEPTABLE, Self::NotAcceptable => StatusCode::NOT_ACCEPTABLE,
} }
} }
/// Returns the value for an `Allow` response header, when required.
#[must_use]
pub const fn allow(&self) -> Option<&'static str> {
match self {
Self::MethodNotAllowed => Some("POST"),
Self::NotFound | Self::UnsupportedMediaType | Self::NotAcceptable => None,
}
}
} }
impl Display for RequestMetadataError { impl Display for RequestMetadataError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
match self { match self {
Self::InvalidRequest(error) => error.fmt(formatter), Self::NotFound => formatter.write_str("request URI is not a standard WIP route"),
Self::MethodNotAllowed => formatter.write_str("standard WIP routes require POST"),
Self::UnsupportedMediaType => { Self::UnsupportedMediaType => {
formatter.write_str("expected UTF-8 application/json Content-Type") formatter.write_str("expected identity-coded UTF-8 application/json Content-Type")
} }
Self::NotAcceptable => formatter.write_str("request does not accept application/json"), Self::NotAcceptable => formatter.write_str("request does not accept application/json"),
} }
} }
} }
impl Error for RequestMetadataError { impl Error for RequestMetadataError {}
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self { pub(crate) fn has_supported_content_coding(headers: &HeaderMap) -> bool {
Self::InvalidRequest(error) => Some(error), headers.get_all(CONTENT_ENCODING).iter().all(|value| {
Self::UnsupportedMediaType | Self::NotAcceptable => None, value.to_str().is_ok_and(|value| {
} let mut codings = value.split(',').map(str::trim);
} let first = codings.next();
first.is_some_and(|coding| coding.eq_ignore_ascii_case("identity"))
&& codings.all(|coding| coding.eq_ignore_ascii_case("identity"))
})
})
} }
/// Validates mandatory HTTP metadata for an inbound WIP request. /// Validates mandatory HTTP metadata for an inbound WIP request.
@@ -233,29 +253,19 @@ pub fn validate_http_request<B>(
route: Route, route: Route,
value: &Request<B>, value: &Request<B>,
) -> Result<(), RequestMetadataError> { ) -> Result<(), RequestMetadataError> {
if value.method() != Method::POST {
return Err(RequestMetadataError::InvalidRequest(
CodecError::InvalidField {
field: "method".into(),
reason: "standard WIP routes require POST".into(),
},
));
}
let expected = endpoint.route(route); let expected = endpoint.route(route);
if value.uri().path() != expected.path() || value.uri().query().is_some() { if value.uri().path() != expected.path() || value.uri().query().is_some() {
return Err(RequestMetadataError::InvalidRequest( return Err(RequestMetadataError::NotFound);
CodecError::InvalidField { }
field: "route".into(), if value.method() != Method::POST {
reason: "request URI does not match the selected standard route".into(), return Err(RequestMetadataError::MethodNotAllowed);
},
));
} }
let valid_content_type = value let valid_content_type = value
.headers() .headers()
.get(CONTENT_TYPE) .get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
.is_some_and(is_json_content_type); .is_some_and(is_json_content_type);
if !valid_content_type { if !valid_content_type || !has_supported_content_coding(value.headers()) {
return Err(RequestMetadataError::UnsupportedMediaType); return Err(RequestMetadataError::UnsupportedMediaType);
} }
if !accepts_json(value.headers()) { if !accepts_json(value.headers()) {
@@ -264,49 +274,25 @@ pub fn validate_http_request<B>(
Ok(()) Ok(())
} }
/// Encodes a `fetch` HTTP request. /// Encodes an `observe` HTTP request.
pub fn encode_fetch_request( pub fn encode_observe_request(
endpoint: &Endpoint, endpoint: &Endpoint,
value: &FetchRequest, value: &ObserveRequest,
limits: Limits, limits: Limits,
) -> Result<Request<Vec<u8>>, EncodeHttpError> { ) -> Result<Request<Vec<u8>>, EncodeHttpError> {
value.validate().map_err(CodecError::from)?; value.validate().map_err(CodecError::from)?;
let body = json::serialize( let body = json::serialize(
&json::fetch_request_to_json(value), &json::observe_request_to_json(value),
BodyKind::Request, BodyKind::Request,
limits, limits,
)?; )?;
Ok(request(endpoint, Route::Fetch, body)?) Ok(request(endpoint, Route::Observe, body)?)
} }
/// Decodes a `fetch` request body. /// Decodes an `observe` request body.
pub fn decode_fetch_request(body: &[u8], limits: Limits) -> Result<FetchRequest, CodecError> { pub fn decode_observe_request(body: &[u8], limits: Limits) -> Result<ObserveRequest, CodecError> {
let value = json::parse(body, BodyKind::Request, limits)?; let value = json::parse(body, BodyKind::Request, limits)?;
json::fetch_request_from_json(&value) json::observe_request_from_json(&value)
}
/// Encodes a `fetch_tree` HTTP request.
pub fn encode_fetch_tree_request(
endpoint: &Endpoint,
value: &FetchTreeRequest,
limits: Limits,
) -> Result<Request<Vec<u8>>, EncodeHttpError> {
value.validate().map_err(CodecError::from)?;
let body = json::serialize(
&json::fetch_tree_request_to_json(value),
BodyKind::Request,
limits,
)?;
Ok(request(endpoint, Route::FetchTree, body)?)
}
/// Decodes a `fetch_tree` request body.
pub fn decode_fetch_tree_request(
body: &[u8],
limits: Limits,
) -> Result<FetchTreeRequest, CodecError> {
let value = json::parse(body, BodyKind::Request, limits)?;
json::fetch_tree_request_from_json(&value)
} }
/// Encodes a `fetch_interface` HTTP request. /// Encodes a `fetch_interface` HTTP request.
@@ -407,72 +393,39 @@ pub fn decode_call_operation_request(
json::call_request_from_json(&value, descriptor) json::call_request_from_json(&value, descriptor)
} }
/// Encodes a successful `fetch` response as a direct body with HTTP 200. /// Encodes a successful `observe` response as a direct body with HTTP 200.
pub fn encode_fetch_response( pub fn encode_observe_response(
request_value: &FetchRequest, request_value: &ObserveRequest,
value: &FetchResponse, value: &ObserveResponse,
limits: Limits, limits: Limits,
) -> Result<Response<Vec<u8>>, EncodeHttpError> { ) -> Result<Response<Vec<u8>>, EncodeHttpError> {
request_value request_value
.validate_response(value) .validate_response(value)
.map_err(CodecError::from)?; .map_err(CodecError::from)?;
let body = json::serialize(&json::object_to_json(value), BodyKind::Response, limits)?;
Ok(response(StatusCode::OK, body)?)
}
/// Decodes and classifies a `fetch` client response.
pub fn decode_fetch_response<B: AsRef<[u8]>>(
request_value: &FetchRequest,
value: &Response<B>,
limits: Limits,
) -> Result<DecodedResponse<FetchResponse>, ClientResponseError> {
require_response_content_type(value)?;
response::decode(
value.status(),
value.body().as_ref(),
ProtocolInteraction::Fetch,
limits,
|json| {
let object = json::object_from_json(json, "response")?;
request_value.validate_response(&object)?;
Ok(object)
},
)
}
/// Encodes a successful `fetch_tree` response as a direct body with HTTP 200.
pub fn encode_fetch_tree_response(
request_value: &FetchTreeRequest,
value: &FetchTreeResponse,
limits: Limits,
) -> Result<Response<Vec<u8>>, EncodeHttpError> {
value
.validate_for(request_value)
.map_err(CodecError::from)?;
let body = json::serialize( let body = json::serialize(
&json::fetch_tree_response_to_json(value), &json::observe_response_to_json(value),
BodyKind::Response, BodyKind::Response,
limits, limits,
)?; )?;
Ok(response(StatusCode::OK, body)?) Ok(response(StatusCode::OK, body)?)
} }
/// Decodes and classifies a `fetch_tree` client response. /// Decodes and classifies an `observe` client response.
pub fn decode_fetch_tree_response<B: AsRef<[u8]>>( pub fn decode_observe_response<B: AsRef<[u8]>>(
request_value: &FetchTreeRequest, request_value: &ObserveRequest,
value: &Response<B>, value: &Response<B>,
limits: Limits, limits: Limits,
) -> Result<DecodedResponse<FetchTreeResponse>, ClientResponseError> { ) -> Result<DecodedResponse<ObserveResponse>, ClientResponseError> {
require_response_content_type(value)?; require_response_content_type(value)?;
response::decode( response::decode(
value.status(), value.status(),
value.body().as_ref(), value.body().as_ref(),
ProtocolInteraction::FetchTree, ProtocolInteraction::Observe,
limits, limits,
|json| { |json| {
let tree = json::fetch_tree_response_from_json(json)?; let observation = json::observe_response_from_json(json)?;
tree.validate_for(request_value)?; request_value.validate_response(&observation)?;
Ok(tree) Ok(observation)
}, },
) )
} }
@@ -583,9 +536,8 @@ fn encode_protocol_error_with_status(
/// The code must be allowed for `interaction`. This API accepts only /// The code must be allowed for `interaction`. This API accepts only
/// [`ProtocolError`], so client-local `InvalidResponse` and /// [`ProtocolError`], so client-local `InvalidResponse` and
/// `UnsupportedDescriptorFormat` classifications cannot be emitted as Host /// `UnsupportedDescriptorFormat` classifications cannot be emitted as Host
/// error envelopes. Resource-limit errors use the logical-limit status; callers /// error envelopes. Logical `resource_limit_exceeded` failures use HTTP 422;
/// that know the concrete limit class should use /// representation and deployment limits are not protocol errors.
/// [`encode_resource_limit_error_response`].
pub fn encode_protocol_error_response( pub fn encode_protocol_error_response(
interaction: ProtocolInteraction, interaction: ProtocolInteraction,
error: &ProtocolError, error: &ProtocolError,
@@ -599,28 +551,6 @@ pub fn encode_protocol_error_response(
) )
} }
/// Encodes a `resource_limit_exceeded` envelope with its class-specific status.
pub fn encode_resource_limit_error_response(
interaction: ProtocolInteraction,
error: &ProtocolError,
kind: ResourceLimitKind,
limits: Limits,
) -> Result<Response<Vec<u8>>, EncodeHttpError> {
if error.code != wip_protocol::ProtocolErrorCode::ResourceLimitExceeded {
return Err(CodecError::InvalidField {
field: "error.code".into(),
reason: "resource-limit encoder requires resource_limit_exceeded".into(),
}
.into());
}
encode_protocol_error_with_status(
interaction,
error,
response::status_for_resource_limit(kind),
limits,
)
}
/// Failure while building an outbound framework-neutral HTTP message. /// Failure while building an outbound framework-neutral HTTP message.
#[derive(Debug)] #[derive(Debug)]
pub enum EncodeHttpError { pub enum EncodeHttpError {
@@ -6,19 +6,15 @@ use url::Url;
/// The media type emitted for every WIP request and every response with a body. /// The media type emitted for every WIP request and every response with a body.
pub const JSON_CONTENT_TYPE: &str = "application/json; charset=utf-8"; pub const JSON_CONTENT_TYPE: &str = "application/json; charset=utf-8";
/// A fixed standard WIP over HTTPS v1 route. /// A fixed standard WIP over HTTP v1 route.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Route { pub enum Route {
/// Fetch one object. /// Observe one object and its indexable descendants.
Fetch, Observe,
/// Fetch a bounded object tree.
FetchTree,
/// Fetch an interface descriptor. /// Fetch an interface descriptor.
FetchInterface, FetchInterface,
/// Call one operation. /// Call one operation.
CallOperation, CallOperation,
/// Package independent retrieval interactions.
Batch,
} }
impl Route { impl Route {
@@ -26,31 +22,27 @@ impl Route {
#[must_use] #[must_use]
pub const fn relative_reference(self) -> &'static str { pub const fn relative_reference(self) -> &'static str {
match self { match self {
Self::Fetch => "v1/fetch", Self::Observe => "v1/observe",
Self::FetchTree => "v1/fetch_tree",
Self::FetchInterface => "v1/fetch_interface", Self::FetchInterface => "v1/fetch_interface",
Self::CallOperation => "v1/call_operation", Self::CallOperation => "v1/call_operation",
Self::Batch => "v1/batch",
} }
} }
} }
/// A canonical absolute HTTPS base URL whose path always ends in `/`. /// A canonical absolute hierarchical base URL whose path always ends in `/`.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Endpoint(Url); pub struct Endpoint(Url);
impl Endpoint { impl Endpoint {
/// Parses and canonicalizes an endpoint. /// Parses and canonicalizes an endpoint.
/// ///
/// The input must be an absolute HTTPS URL without a query or fragment. Its /// The input must be an absolute hierarchical URL with a host and without a
/// path is an endpoint prefix, not a Worldspace path. A trailing slash is /// query or fragment. Its path is an endpoint prefix, not a Worldspace path.
/// appended when absent so RFC 3986 relative-reference resolution preserves /// A trailing slash is appended when absent so RFC 3986 relative-reference
/// the final prefix segment. /// resolution preserves the final prefix segment. The supplied scheme is
/// preserved; scheme and transport security are deployment concerns.
pub fn parse(input: &str) -> Result<Self, EndpointError> { pub fn parse(input: &str) -> Result<Self, EndpointError> {
let mut url = Url::parse(input).map_err(EndpointError::InvalidUrl)?; let mut url = Url::parse(input).map_err(EndpointError::InvalidUrl)?;
if url.scheme() != "https" {
return Err(EndpointError::NotHttps);
}
if url.cannot_be_a_base() || url.host_str().is_none() { if url.cannot_be_a_base() || url.host_str().is_none() {
return Err(EndpointError::NotAbsoluteBase); return Err(EndpointError::NotAbsoluteBase);
} }
@@ -87,13 +79,7 @@ impl Endpoint {
/// are not route components and therefore never match. /// are not route components and therefore never match.
#[must_use] #[must_use]
pub fn recognize(&self, candidate: &Url) -> Option<Route> { pub fn recognize(&self, candidate: &Url) -> Option<Route> {
const ROUTES: [Route; 5] = [ const ROUTES: [Route; 3] = [Route::Observe, Route::FetchInterface, Route::CallOperation];
Route::Fetch,
Route::FetchTree,
Route::FetchInterface,
Route::CallOperation,
Route::Batch,
];
if candidate.query().is_some() || candidate.fragment().is_some() { if candidate.query().is_some() || candidate.fragment().is_some() {
return None; return None;
} }
@@ -117,13 +103,11 @@ impl TryFrom<&str> for Endpoint {
} }
} }
/// Why an endpoint is not a valid standard WIP HTTPS base. /// Why an endpoint is not a valid standard WIP over HTTP base.
#[derive(Debug)] #[derive(Debug)]
pub enum EndpointError { pub enum EndpointError {
/// URL parsing failed. /// URL parsing failed.
InvalidUrl(url::ParseError), InvalidUrl(url::ParseError),
/// The URL scheme is not `https`.
NotHttps,
/// The URL is not a hierarchical absolute URL with an authority. /// The URL is not a hierarchical absolute URL with an authority.
NotAbsoluteBase, NotAbsoluteBase,
/// A query component was supplied. /// A query component was supplied.
@@ -136,7 +120,6 @@ impl Display for EndpointError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
match self { match self {
Self::InvalidUrl(error) => write!(formatter, "invalid endpoint URL: {error}"), Self::InvalidUrl(error) => write!(formatter, "invalid endpoint URL: {error}"),
Self::NotHttps => formatter.write_str("WIP endpoint must use HTTPS"),
Self::NotAbsoluteBase => { Self::NotAbsoluteBase => {
formatter.write_str("WIP endpoint must be an absolute base URL") formatter.write_str("WIP endpoint must be an absolute base URL")
} }
@@ -10,8 +10,6 @@ pub struct Limits {
pub(crate) max_response_bytes: usize, pub(crate) max_response_bytes: usize,
/// Largest accepted JSON container nesting depth. /// Largest accepted JSON container nesting depth.
pub(crate) max_nesting: usize, pub(crate) max_nesting: usize,
/// Largest accepted number of batch items.
pub(crate) max_batch_items: usize,
} }
impl Limits { impl Limits {
@@ -20,7 +18,6 @@ impl Limits {
max_request_bytes: usize, max_request_bytes: usize,
max_response_bytes: usize, max_response_bytes: usize,
max_nesting: usize, max_nesting: usize,
max_batch_items: usize,
) -> Result<Self, LimitConfigurationError> { ) -> Result<Self, LimitConfigurationError> {
if max_request_bytes == 0 { if max_request_bytes == 0 {
return Err(LimitConfigurationError::ZeroRequestBytes); return Err(LimitConfigurationError::ZeroRequestBytes);
@@ -31,14 +28,10 @@ impl Limits {
if max_nesting == 0 { if max_nesting == 0 {
return Err(LimitConfigurationError::ZeroNesting); return Err(LimitConfigurationError::ZeroNesting);
} }
if max_batch_items == 0 {
return Err(LimitConfigurationError::ZeroBatchItems);
}
Ok(Self { Ok(Self {
max_request_bytes, max_request_bytes,
max_response_bytes, max_response_bytes,
max_nesting, max_nesting,
max_batch_items,
}) })
} }
@@ -59,12 +52,6 @@ impl Limits {
pub const fn max_nesting(self) -> usize { pub const fn max_nesting(self) -> usize {
self.max_nesting self.max_nesting
} }
/// Returns the batch item count limit.
#[must_use]
pub const fn max_batch_items(self) -> usize {
self.max_batch_items
}
} }
/// Why a [`Limits`] configuration is not bounded. /// Why a [`Limits`] configuration is not bounded.
@@ -76,8 +63,6 @@ pub enum LimitConfigurationError {
ZeroResponseBytes, ZeroResponseBytes,
/// Nesting limit was zero. /// Nesting limit was zero.
ZeroNesting, ZeroNesting,
/// Batch-item limit was zero.
ZeroBatchItems,
} }
impl Display for LimitConfigurationError { impl Display for LimitConfigurationError {
@@ -86,7 +71,6 @@ impl Display for LimitConfigurationError {
Self::ZeroRequestBytes => "request byte limit must be nonzero", Self::ZeroRequestBytes => "request byte limit must be nonzero",
Self::ZeroResponseBytes => "response byte limit must be nonzero", Self::ZeroResponseBytes => "response byte limit must be nonzero",
Self::ZeroNesting => "nesting limit must be nonzero", Self::ZeroNesting => "nesting limit must be nonzero",
Self::ZeroBatchItems => "batch item limit must be nonzero",
}) })
} }
} }
@@ -102,15 +86,6 @@ pub enum BodyKind {
Response, Response,
} }
/// The class of a resource limit exceeded by a wire representation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResourceLimitKind {
/// A request or response representation exceeded its byte-size limit.
RepresentationBytes,
/// A logical limit such as nesting depth, batch count, or collection size was exceeded.
Logical,
}
/// A malformed or locally disallowed wire representation. /// A malformed or locally disallowed wire representation.
#[derive(Debug)] #[derive(Debug)]
pub enum CodecError { pub enum CodecError {
@@ -157,47 +132,6 @@ pub enum CodecError {
}, },
/// Logical protocol validation rejected the decoded representation. /// Logical protocol validation rejected the decoded representation.
ProtocolValidation(wip_protocol::ValidationError), ProtocolValidation(wip_protocol::ValidationError),
/// The batch exceeds its configured item count.
TooManyBatchItems {
/// Actual item count.
actual: usize,
/// Configured maximum.
maximum: usize,
},
/// Batch item identifiers are not unique.
DuplicateBatchId {
/// Repeated identifier.
id: String,
},
/// A response contains a batch identifier that was not requested.
UnexpectedBatchId {
/// Unexpected identifier.
id: String,
},
/// A response omitted a requested batch identifier.
MissingBatchId {
/// Missing identifier.
id: String,
},
/// A batch item result does not match its requested interaction.
BatchInteractionMismatch {
/// Item identifier.
id: String,
},
}
impl CodecError {
/// Classifies this failure when it represents a canonical resource limit.
#[must_use]
pub const fn resource_limit_kind(&self) -> Option<ResourceLimitKind> {
match self {
Self::BodyTooLarge { .. } => Some(ResourceLimitKind::RepresentationBytes),
Self::NestingTooDeep { .. } | Self::TooManyBatchItems { .. } => {
Some(ResourceLimitKind::Logical)
}
_ => None,
}
}
} }
impl Display for CodecError { impl Display for CodecError {
@@ -225,15 +159,6 @@ impl Display for CodecError {
write!(formatter, "field `{field}` is not canonical base64") write!(formatter, "field `{field}` is not canonical base64")
} }
Self::ProtocolValidation(error) => error.fmt(formatter), Self::ProtocolValidation(error) => error.fmt(formatter),
Self::TooManyBatchItems { actual, maximum } => {
write!(formatter, "batch has {actual} items; maximum is {maximum}")
}
Self::DuplicateBatchId { id } => write!(formatter, "duplicate batch id `{id}`"),
Self::UnexpectedBatchId { id } => write!(formatter, "unexpected batch id `{id}`"),
Self::MissingBatchId { id } => write!(formatter, "missing batch id `{id}`"),
Self::BatchInteractionMismatch { id } => {
write!(formatter, "batch result does not match request `{id}`")
}
} }
} }
} }
@@ -7,9 +7,9 @@ use serde::de::{self, Deserialize, Deserializer, MapAccess, SeqAccess, Visitor};
use serde_json::{Map, Number, Value as Json}; use serde_json::{Map, Number, Value as Json};
use wip_protocol::{ use wip_protocol::{
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest, CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
FetchInterfaceResponse, FetchRequest, FetchTreeRequest, FetchTreeResponse, FieldDeclaration, FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceTarget, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, InterfaceTarget, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, Object, ObjectObservation, ObserveRequest,
Object, ObjectTreeNode, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target, ObserveResponse, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target,
TypeDeclaration, TypeExpr, UnionCase, Value, TypeDeclaration, TypeExpr, UnionCase, Value,
}; };
@@ -271,29 +271,15 @@ fn insert_optional_bytes(map: &mut Map<String, Json>, name: &str, value: &Option
} }
} }
pub(crate) fn fetch_request_to_json(request: &FetchRequest) -> Json { pub(crate) fn observe_request_to_json(request: &ObserveRequest) -> Json {
serde_json::json!({"path": request.path}) serde_json::json!({"path": request.path, "depth": request.depth})
} }
pub(crate) fn fetch_request_from_json(value: &Json) -> Result<FetchRequest, CodecError> { pub(crate) fn observe_request_from_json(value: &Json) -> Result<ObserveRequest, CodecError> {
let fields = exact_object(value, "request", &["path"])?; let fields = exact_object(value, "request", &["path", "depth"])?;
let request = FetchRequest { let request = ObserveRequest {
path: string(required(fields, "path")?, "path")?,
};
request.validate()?;
Ok(request)
}
pub(crate) fn fetch_tree_request_to_json(request: &FetchTreeRequest) -> Json {
serde_json::json!({"path": request.path, "depth": request.depth, "limit": request.limit})
}
pub(crate) fn fetch_tree_request_from_json(value: &Json) -> Result<FetchTreeRequest, CodecError> {
let fields = exact_object(value, "request", &["path", "depth", "limit"])?;
let request = FetchTreeRequest {
path: string(required(fields, "path")?, "path")?, path: string(required(fields, "path")?, "path")?,
depth: u32_number(required(fields, "depth")?, "depth")?, depth: u32_number(required(fields, "depth")?, "depth")?,
limit: u32_number(required(fields, "limit")?, "limit")?,
}; };
request.validate()?; request.validate()?;
Ok(request) Ok(request)
@@ -360,35 +346,43 @@ pub(crate) fn object_from_json(value: &Json, field: &str) -> Result<Object, Code
Ok(object) Ok(object)
} }
fn tree_node_to_json(node: &ObjectTreeNode) -> Json { fn observation_to_json(node: &ObjectObservation) -> Json {
serde_json::json!({ let mut fields = Map::new();
"object": object_to_json(&node.object), fields.insert("object".into(), object_to_json(&node.object));
"children": node.children.iter().map(tree_node_to_json).collect::<Vec<_>>() if let Some(children) = &node.children {
}) fields.insert(
"children".into(),
Json::Array(children.iter().map(observation_to_json).collect()),
);
}
Json::Object(fields)
} }
fn tree_node_from_json(value: &Json, field: &str) -> Result<ObjectTreeNode, CodecError> { fn observation_from_json(value: &Json, field: &str) -> Result<ObjectObservation, CodecError> {
let fields = exact_object(value, field, &["object", "children"])?; let fields = exact_object(value, field, &["object", "children"])?;
Ok(ObjectTreeNode { let children = optional(fields, "children")?
object: object_from_json(required(fields, "object")?, &qualified(field, "object"))?, .map(|value| {
children: array(required(fields, "children")?, &qualified(field, "children"))? array(value, &qualified(field, "children"))?
.iter() .iter()
.enumerate() .enumerate()
.map(|(index, child)| tree_node_from_json(child, &format!("{field}.children[{index}]"))) .map(|(index, child)| {
.collect::<Result<Vec<_>, _>>()?, observation_from_json(child, &format!("{field}.children[{index}]"))
})
.collect::<Result<Vec<_>, _>>()
})
.transpose()?;
Ok(ObjectObservation {
object: object_from_json(required(fields, "object")?, &qualified(field, "object"))?,
children,
}) })
} }
pub(crate) fn fetch_tree_response_to_json(response: &FetchTreeResponse) -> Json { pub(crate) fn observe_response_to_json(response: &ObserveResponse) -> Json {
serde_json::json!({"root": tree_node_to_json(&response.root), "truncated": response.truncated}) observation_to_json(response)
} }
pub(crate) fn fetch_tree_response_from_json(value: &Json) -> Result<FetchTreeResponse, CodecError> { pub(crate) fn observe_response_from_json(value: &Json) -> Result<ObserveResponse, CodecError> {
let fields = exact_object(value, "response", &["root", "truncated"])?; observation_from_json(value, "response")
Ok(FetchTreeResponse {
root: tree_node_from_json(required(fields, "root")?, "root")?,
truncated: boolean(required(fields, "truncated")?, "truncated")?,
})
} }
fn documentation_to_json(documentation: &Documentation) -> Json { fn documentation_to_json(documentation: &Documentation) -> Json {
+59
View File
@@ -0,0 +1,59 @@
//! 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. It deliberately performs no I/O and depends on no
//! server framework, TLS implementation, filesystem 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_http::{Endpoint, Limits, encode_observe_request};
//! use wip_protocol::ObserveRequest;
//!
//! let endpoint = Endpoint::parse("http://example.test/api/wip")?;
//! let limits = Limits::new(64 * 1024, 1024 * 1024, 64)?;
//! let request = encode_observe_request(
//! &endpoint,
//! &ObserveRequest { path: "/items/123".into(), depth: 0 },
//! limits,
//! )?;
//! assert_eq!(request.uri(), "http://example.test/api/wip/v1/observe");
//! assert_eq!(request.method(), http::Method::POST);
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```
#![deny(missing_docs)]
mod codec;
mod endpoint;
mod error;
mod json;
mod response;
pub use codec::{
CallOperationMetadata, EncodeHttpError, RequestMetadataError, decode_call_operation_metadata,
decode_call_operation_request, decode_call_operation_response, decode_fetch_interface_request,
decode_fetch_interface_response, decode_observe_request, decode_observe_response,
encode_call_operation_request, encode_call_operation_response, encode_fetch_interface_request,
encode_fetch_interface_response, encode_observe_request, encode_observe_response,
encode_protocol_error_response, is_json_content_type, validate_http_request,
};
pub use endpoint::{Endpoint, EndpointError, JSON_CONTENT_TYPE, Route};
pub use error::{BodyKind, CodecError, LimitConfigurationError, Limits};
pub use response::{
ClientResponseError, DecodedResponse, InvalidResponseKind, StatusMismatch,
TransportBindingFailure, TransportFailureKind, error_code_name, status_for_error,
};
/// Framework-neutral HTTP types used by this binding's public API.
pub use http;
/// URL type used by [`Endpoint`].
pub use url;
/// Transport-independent logical protocol model used by this binding.
pub use wip_protocol;
@@ -5,7 +5,7 @@ use http::StatusCode;
use serde_json::{Map, Value as Json}; use serde_json::{Map, Value as Json};
use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction}; use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction};
use crate::{BodyKind, CodecError, Limits, ResourceLimitKind, json}; use crate::{BodyKind, CodecError, Limits, json};
/// A successfully classified client response. /// A successfully classified client response.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -37,6 +37,8 @@ pub enum TransportFailureKind {
AuthenticationRequired, AuthenticationRequired,
/// HTTP 403, commonly emitted by endpoint policy or infrastructure. /// HTTP 403, commonly emitted by endpoint policy or infrastructure.
EndpointForbidden, EndpointForbidden,
/// The response uses a content coding the client does not support.
UnsupportedContentCoding,
/// Another non-success HTTP response without a WIP error envelope. /// Another non-success HTTP response without a WIP error envelope.
HttpStatus, HttpStatus,
} }
@@ -63,8 +65,6 @@ pub enum InvalidResponseKind {
NonCanonicalSuccessStatus, NonCanonicalSuccessStatus,
/// A 2xx body was malformed or semantically invalid. /// A 2xx body was malformed or semantically invalid.
InvalidSuccessBody, InvalidSuccessBody,
/// A batch response did not correlate exactly with its request.
InvalidBatch,
} }
/// A local failure while classifying or validating a peer response. /// A local failure while classifying or validating a peer response.
@@ -119,10 +119,6 @@ impl Display for ClientResponseError {
impl Error for ClientResponseError {} impl Error for ClientResponseError {}
/// Returns the canonical HTTP status for a wire-level protocol error. /// Returns the canonical HTTP status for a wire-level protocol error.
///
/// [`ProtocolErrorCode::ResourceLimitExceeded`] defaults to the logical-limit
/// status. Use [`status_for_resource_limit`] when the concrete limit class is
/// known.
#[must_use] #[must_use]
pub const fn status_for_error(code: ProtocolErrorCode) -> StatusCode { pub const fn status_for_error(code: ProtocolErrorCode) -> StatusCode {
match code { match code {
@@ -145,22 +141,8 @@ pub const fn status_for_error(code: ProtocolErrorCode) -> StatusCode {
} }
} }
/// Returns the canonical HTTP status for a concrete resource-limit class.
#[must_use]
pub const fn status_for_resource_limit(kind: ResourceLimitKind) -> StatusCode {
match kind {
ResourceLimitKind::RepresentationBytes => StatusCode::PAYLOAD_TOO_LARGE,
ResourceLimitKind::Logical => StatusCode::UNPROCESSABLE_ENTITY,
}
}
fn status_matches_error(status: StatusCode, code: ProtocolErrorCode) -> bool { fn status_matches_error(status: StatusCode, code: ProtocolErrorCode) -> bool {
match code { status == status_for_error(code)
ProtocolErrorCode::ResourceLimitExceeded => {
status == StatusCode::PAYLOAD_TOO_LARGE || status == StatusCode::UNPROCESSABLE_ENTITY
}
_ => status == status_for_error(code),
}
} }
/// Returns the stable snake-case wire spelling of a protocol code. /// Returns the stable snake-case wire spelling of a protocol code.
@@ -213,14 +195,7 @@ pub(crate) fn error_to_json(error: &ProtocolError) -> Json {
}) })
} }
pub(crate) fn item_error_to_json(error: &ProtocolError) -> Json { fn protocol_error_from_json(value: &Json) -> Result<ProtocolError, CodecError> {
serde_json::json!({
"code": error_code_name(error.code),
"message": error.message,
})
}
pub(crate) fn item_error_from_json(value: &Json) -> Result<ProtocolError, CodecError> {
let fields = value let fields = value
.as_object() .as_object()
.ok_or_else(|| invalid_field("error", "expected object"))?; .ok_or_else(|| invalid_field("error", "expected object"))?;
@@ -239,7 +214,7 @@ fn envelope_error_from_json(value: &Json) -> Result<Option<ProtocolError>, Codec
if fields.len() != 1 || !fields.contains_key("error") { if fields.len() != 1 || !fields.contains_key("error") {
return Ok(None); return Ok(None);
} }
item_error_from_json(&fields["error"]).map(Some) protocol_error_from_json(&fields["error"]).map(Some)
} }
fn exact(fields: &Map<String, Json>, allowed: &[&str], parent: &str) -> Result<(), CodecError> { fn exact(fields: &Map<String, Json>, allowed: &[&str], parent: &str) -> Result<(), CodecError> {
@@ -355,6 +330,13 @@ pub(crate) fn decode<T>(
} }
} }
pub(crate) fn unsupported_content_coding(status: StatusCode) -> ClientResponseError {
ClientResponseError::TransportBinding(TransportBindingFailure {
status,
kind: TransportFailureKind::UnsupportedContentCoding,
})
}
pub(crate) fn transport_failure(status: StatusCode) -> ClientResponseError { pub(crate) fn transport_failure(status: StatusCode) -> ClientResponseError {
let kind = match status { let kind = match status {
StatusCode::UNAUTHORIZED => TransportFailureKind::AuthenticationRequired, StatusCode::UNAUTHORIZED => TransportFailureKind::AuthenticationRequired,
@@ -1,26 +1,24 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use wip_https::http::StatusCode; use wip_http::http::StatusCode;
use wip_https::wip_protocol::{ use wip_http::wip_protocol::{
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest, CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
FetchInterfaceResponse, FetchRequest, FetchTreeRequest, FetchTreeResponse, FieldDeclaration, FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceTarget, MAX_SAFE_INTEGER, Object, InterfaceTarget, MAX_SAFE_INTEGER, Object, ObjectObservation, ObserveRequest,
ObjectTreeNode, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target, TypeDeclaration,
TypeDeclaration, TypeExpr, UnionCase, Value, TypeExpr, UnionCase, Value,
}; };
use wip_https::{ use wip_http::{
ClientResponseError, CodecError, DecodedResponse, Endpoint, InvalidResponseKind, Limits, ClientResponseError, CodecError, DecodedResponse, Endpoint, InvalidResponseKind, Limits,
ResourceLimitKind, decode_call_operation_metadata, decode_call_operation_request, decode_call_operation_metadata, decode_call_operation_request, decode_call_operation_response,
decode_call_operation_response, decode_fetch_interface_request, decode_fetch_interface_request, decode_fetch_interface_response, decode_observe_request,
decode_fetch_interface_response, decode_fetch_request, decode_fetch_response, decode_observe_response, encode_call_operation_request, encode_call_operation_response,
decode_fetch_tree_request, decode_fetch_tree_response, encode_call_operation_request, encode_fetch_interface_request, encode_fetch_interface_response, encode_observe_request,
encode_call_operation_response, encode_fetch_interface_request, encode_observe_response,
encode_fetch_interface_response, encode_fetch_request, encode_fetch_response,
encode_fetch_tree_request, encode_fetch_tree_response,
}; };
fn limits() -> Limits { fn limits() -> Limits {
Limits::new(1_000_000, 1_000_000, 100, 100).unwrap() Limits::new(1_000_000, 1_000_000, 100).unwrap()
} }
fn endpoint() -> Endpoint { fn endpoint() -> Endpoint {
@@ -196,14 +194,11 @@ fn call_request() -> CallOperationRequest {
#[test] #[test]
fn every_single_interaction_request_accepts_json() { fn every_single_interaction_request_accepts_json() {
let fetch = let observe = encode_observe_request(
encode_fetch_request(&endpoint(), &FetchRequest { path: "/".into() }, limits()).unwrap();
let fetch_tree = encode_fetch_tree_request(
&endpoint(), &endpoint(),
&FetchTreeRequest { &ObserveRequest {
path: "/".into(), path: "/".into(),
depth: 1, depth: 0,
limit: 1,
}, },
limits(), limits(),
) )
@@ -219,59 +214,75 @@ fn every_single_interaction_request_accepts_json() {
let call = encode_call_operation_request(&endpoint(), &call_request(), &descriptor(), limits()) let call = encode_call_operation_request(&endpoint(), &call_request(), &descriptor(), limits())
.unwrap(); .unwrap();
for request in [fetch, fetch_tree, fetch_interface, call] { for request in [observe, fetch_interface, call] {
assert_eq!(request.headers()["accept"], "application/json"); assert_eq!(request.headers()["accept"], "application/json");
} }
} }
#[test] #[test]
fn fetch_round_trips_direct_body_and_body_validator() { fn depth_zero_observe_round_trips_direct_body_and_validator() {
let request = FetchRequest { let request = ObserveRequest {
path: "/items/123".into(), path: "/items/123".into(),
depth: 0,
}; };
let encoded_request = encode_fetch_request(&endpoint(), &request, limits()).unwrap(); let encoded_request = encode_observe_request(&endpoint(), &request, limits()).unwrap();
assert_eq!(encoded_request.uri().path(), "/wip/v1/fetch"); assert_eq!(encoded_request.uri().path(), "/wip/v1/observe");
assert_eq!( assert_eq!(
decode_fetch_request(encoded_request.body(), limits()).unwrap(), decode_observe_request(encoded_request.body(), limits()).unwrap(),
request request
); );
let expected = object("123", &[0, 1, 2, 255]); let expected = ObjectObservation {
let encoded = encode_fetch_response(&request, &expected, limits()).unwrap(); object: object("123", &[0, 1, 2, 255]),
children: None,
};
let encoded = encode_observe_response(&request, &expected, limits()).unwrap();
assert_eq!(encoded.status(), StatusCode::OK); assert_eq!(encoded.status(), StatusCode::OK);
assert!(
!std::str::from_utf8(encoded.body())
.unwrap()
.contains("children")
);
assert_eq!( assert_eq!(
decode_fetch_response(&request, &encoded, limits()).unwrap(), decode_observe_response(&request, &encoded, limits()).unwrap(),
DecodedResponse::Success(expected) DecodedResponse::Success(expected)
); );
assert!(encoded.headers().get("etag").is_none()); assert!(encoded.headers().get("etag").is_none());
} }
#[test] #[test]
fn tree_and_interface_round_trip_nested_logical_models() { fn observe_and_interface_round_trip_nested_logical_models() {
let tree_request = FetchTreeRequest { let observe_request = ObserveRequest {
path: "/items".into(), path: "/items".into(),
depth: 1, depth: 1,
limit: 2,
}; };
let encoded_request = encode_fetch_tree_request(&endpoint(), &tree_request, limits()).unwrap(); let encoded_request = encode_observe_request(&endpoint(), &observe_request, limits()).unwrap();
assert_eq!( assert_eq!(
decode_fetch_tree_request(encoded_request.body(), limits()).unwrap(), decode_observe_request(encoded_request.body(), limits()).unwrap(),
tree_request observe_request
); );
let tree = FetchTreeResponse { let observation = ObjectObservation {
root: ObjectTreeNode {
object: object("items", b"root-validator"), object: object("items", b"root-validator"),
children: vec![ObjectTreeNode { children: Some(vec![ObjectObservation {
object: object("123", b"child-validator"), object: object("123", b"child-validator"),
children: vec![], children: None,
}], }]),
},
truncated: false,
}; };
let encoded = encode_fetch_tree_response(&tree_request, &tree, limits()).unwrap(); let encoded = encode_observe_response(&observe_request, &observation, limits()).unwrap();
assert_eq!( assert_eq!(
decode_fetch_tree_response(&tree_request, &encoded, limits()).unwrap(), decode_observe_response(&observe_request, &encoded, limits()).unwrap(),
DecodedResponse::Success(tree) DecodedResponse::Success(observation)
);
let empty = ObjectObservation {
object: object("items", b"root-validator"),
children: Some(vec![]),
};
let encoded_empty = encode_observe_response(&observe_request, &empty, limits()).unwrap();
assert!(
std::str::from_utf8(encoded_empty.body())
.unwrap()
.contains("\"children\":[]")
); );
let interface_request = FetchInterfaceRequest { let interface_request = FetchInterfaceRequest {
@@ -363,8 +374,10 @@ fn descriptor_guided_values_and_validators_round_trip_both_directions() {
#[test] #[test]
fn malformed_unknown_null_and_noncanonical_base64_are_rejected() { fn malformed_unknown_null_and_noncanonical_base64_are_rejected() {
let limits = limits(); let limits = limits();
assert!(decode_fetch_request(br#"{"path":"/","extra":true}"#, limits).is_err()); assert!(decode_observe_request(br#"{"path":"/","depth":0,"extra":true}"#, limits).is_err());
assert!(decode_fetch_request(br#"{"path":"relative","path":"/"}"#, limits).is_err()); assert!(
decode_observe_request(br#"{"path":"relative","path":"/","depth":0}"#, limits).is_err()
);
let request = call_request(); let request = call_request();
let descriptor = descriptor(); let descriptor = descriptor();
@@ -391,25 +404,17 @@ fn malformed_unknown_null_and_noncanonical_base64_are_rejected() {
} }
#[test] #[test]
fn caller_limits_bound_bytes_and_nesting() { fn caller_limits_remain_local_codec_failures() {
let tiny = Limits::new(8, 8, 2, 1).unwrap(); let tiny = Limits::new(8, 8, 2).unwrap();
let too_large = decode_fetch_request(br#"{"path":"/long"}"#, tiny).unwrap_err(); let too_large = decode_observe_request(br#"{"path":"/long","depth":0}"#, tiny).unwrap_err();
assert!(matches!(&too_large, CodecError::BodyTooLarge { .. })); assert!(matches!(&too_large, CodecError::BodyTooLarge { .. }));
assert_eq!(
too_large.resource_limit_kind(),
Some(ResourceLimitKind::RepresentationBytes)
);
let nested = br#"{"path":[[["/"]]]}"#; let nested = br#"{"path":[[["/"]]],"depth":0}"#;
let nesting_limited = Limits::new(1024, 1024, 2, 1).unwrap(); let nesting_limited = Limits::new(1024, 1024, 2).unwrap();
let too_deep = decode_fetch_request(nested, nesting_limited).unwrap_err(); let too_deep = decode_observe_request(nested, nesting_limited).unwrap_err();
assert!(matches!(&too_deep, CodecError::NestingTooDeep { .. })); assert!(matches!(&too_deep, CodecError::NestingTooDeep { .. }));
assert_eq!(
too_deep.resource_limit_kind(),
Some(ResourceLimitKind::Logical)
);
let outbound_limited = Limits::new(1_000_000, 1_000_000, 1, 10).unwrap(); let outbound_limited = Limits::new(1_000_000, 1_000_000, 1).unwrap();
assert!( assert!(
encode_call_operation_request( encode_call_operation_request(
&endpoint(), &endpoint(),
@@ -420,11 +425,15 @@ fn caller_limits_bound_bytes_and_nesting() {
.is_err() .is_err()
); );
assert!( assert!(
encode_fetch_response( encode_observe_response(
&FetchRequest { &ObserveRequest {
path: "/item".into(), path: "/item".into(),
depth: 0,
},
&ObjectObservation {
object: object("item", b"validator"),
children: None,
}, },
&object("item", b"validator"),
outbound_limited, outbound_limited,
) )
.is_err() .is_err()
@@ -449,7 +458,7 @@ fn unsupported_descriptor_format_is_client_local() {
ClientResponseError::UnsupportedDescriptorFormat { .. } ClientResponseError::UnsupportedDescriptorFormat { .. }
)); ));
let future_shape = wip_https::http::Response::builder() let future_shape = wip_http::http::Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8") .header("content-type", "application/json; charset=utf-8")
.body( .body(
@@ -468,7 +477,7 @@ fn unsupported_descriptor_format_is_client_local() {
br#"{"interface":"interface-v1","descriptor":{"format":"wip-interface/2"},"validator":null}"# br#"{"interface":"interface-v1","descriptor":{"format":"wip-interface/2"},"validator":null}"#
.as_slice(), .as_slice(),
] { ] {
let response = wip_https::http::Response::builder() let response = wip_http::http::Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8") .header("content-type", "application/json; charset=utf-8")
.body(malformed.to_vec()) .body(malformed.to_vec())
@@ -485,14 +494,15 @@ fn unsupported_descriptor_format_is_client_local() {
#[test] #[test]
fn invalid_success_is_classified_as_invalid_response() { fn invalid_success_is_classified_as_invalid_response() {
let response = wip_https::http::Response::builder() let response = wip_http::http::Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8") .header("content-type", "application/json; charset=utf-8")
.body(br#"{"name":"wrong","interfaces":[]}"#.to_vec()) .body(br#"{"object":{"name":"wrong","interfaces":[]}}"#.to_vec())
.unwrap(); .unwrap();
let error = decode_fetch_response( let error = decode_observe_response(
&FetchRequest { &ObserveRequest {
path: "/expected".into(), path: "/expected".into(),
depth: 0,
}, },
&response, &response,
limits(), limits(),
@@ -1,20 +1,22 @@
use wip_https::http::{Response, StatusCode}; use wip_http::http::{Response, StatusCode};
use wip_https::wip_protocol::{ use wip_http::wip_protocol::{
FetchRequest, ProtocolError, ProtocolErrorCode, ProtocolInteraction, ObserveRequest, ProtocolError, ProtocolErrorCode, ProtocolInteraction,
}; };
use wip_https::{ use wip_http::{
ClientResponseError, DecodedResponse, InvalidResponseKind, Limits, ResourceLimitKind, ClientResponseError, DecodedResponse, InvalidResponseKind, Limits, TransportFailureKind,
TransportFailureKind, decode_fetch_response, encode_protocol_error_response, decode_observe_response, encode_protocol_error_response, error_code_name, is_json_content_type,
encode_resource_limit_error_response, error_code_name, is_json_content_type, status_for_error, status_for_error,
status_for_resource_limit,
}; };
fn limits() -> Limits { fn limits() -> Limits {
Limits::new(64 * 1024, 64 * 1024, 32, 16).unwrap() Limits::new(64 * 1024, 64 * 1024, 32).unwrap()
} }
fn request() -> FetchRequest { fn request() -> ObserveRequest {
FetchRequest { path: "/".into() } ObserveRequest {
path: "/".into(),
depth: 0,
}
} }
fn response(status: StatusCode, body: impl Into<Vec<u8>>) -> Response<Vec<u8>> { fn response(status: StatusCode, body: impl Into<Vec<u8>>) -> Response<Vec<u8>> {
@@ -117,41 +119,45 @@ fn every_protocol_code_has_stable_name_and_status() {
} }
#[test] #[test]
fn resource_limit_statuses_encode_and_decode_by_concrete_limit_class() { fn logical_resource_limit_uses_only_422_and_bare_413_is_transport_failure() {
let error = ProtocolError { let error = ProtocolError {
code: ProtocolErrorCode::ResourceLimitExceeded, code: ProtocolErrorCode::ResourceLimitExceeded,
message: "bounded".into(), message: "logical observation limit exceeded".into(),
}; };
let cases = [ let encoded =
( encode_protocol_error_response(ProtocolInteraction::Observe, &error, limits()).unwrap();
ResourceLimitKind::RepresentationBytes, assert_eq!(encoded.status(), StatusCode::UNPROCESSABLE_ENTITY);
StatusCode::PAYLOAD_TOO_LARGE,
),
(ResourceLimitKind::Logical, StatusCode::UNPROCESSABLE_ENTITY),
];
for (kind, status) in cases {
assert_eq!(status_for_resource_limit(kind), status);
let encoded = encode_resource_limit_error_response(
ProtocolInteraction::Fetch,
&error,
kind,
limits(),
)
.unwrap();
assert_eq!(encoded.status(), status);
assert_eq!( assert_eq!(
decode_fetch_response(&request(), &encoded, limits()).unwrap(), decode_observe_response(&request(), &encoded, limits()).unwrap(),
DecodedResponse::ProtocolFailure { DecodedResponse::ProtocolFailure {
error: error.clone(), error: error.clone(),
status_mismatch: None, status_mismatch: None,
} }
); );
}
let default = let mut noncanonical = encoded;
encode_protocol_error_response(ProtocolInteraction::Fetch, &error, limits()).unwrap(); *noncanonical.status_mut() = StatusCode::PAYLOAD_TOO_LARGE;
assert_eq!(default.status(), StatusCode::UNPROCESSABLE_ENTITY); assert_eq!(
assert_ne!(default.status(), StatusCode::TOO_MANY_REQUESTS); decode_observe_response(&request(), &noncanonical, limits()).unwrap(),
DecodedResponse::ProtocolFailure {
error,
status_mismatch: Some(wip_http::StatusMismatch {
actual: StatusCode::PAYLOAD_TOO_LARGE,
expected: StatusCode::UNPROCESSABLE_ENTITY,
}),
}
);
let bare_413 = Response::builder()
.status(StatusCode::PAYLOAD_TOO_LARGE)
.body(Vec::<u8>::new())
.unwrap();
assert!(matches!(
decode_observe_response(&request(), &bare_413, limits()).unwrap_err(),
ClientResponseError::TransportBinding(failure)
if failure.status == StatusCode::PAYLOAD_TOO_LARGE
&& failure.kind == TransportFailureKind::HttpStatus
));
} }
#[test] #[test]
@@ -161,19 +167,62 @@ fn rate_limit_status_without_protocol_envelope_stays_transport_failure() {
.body(b"rate limited".to_vec()) .body(b"rate limited".to_vec())
.unwrap(); .unwrap();
assert!(matches!( assert!(matches!(
decode_fetch_response(&request(), &response, limits()).unwrap_err(), decode_observe_response(&request(), &response, limits()).unwrap_err(),
ClientResponseError::TransportBinding(failure) ClientResponseError::TransportBinding(failure)
if failure.status == StatusCode::TOO_MANY_REQUESTS if failure.status == StatusCode::TOO_MANY_REQUESTS
&& failure.kind == TransportFailureKind::HttpStatus && failure.kind == TransportFailureKind::HttpStatus
)); ));
} }
#[test]
fn unsupported_response_content_coding_is_transport_failure() {
let mut encoded = encode_protocol_error_response(
ProtocolInteraction::Observe,
&ProtocolError {
code: ProtocolErrorCode::NotFound,
message: "missing".into(),
},
limits(),
)
.unwrap();
encoded
.headers_mut()
.insert("content-encoding", "gzip".parse().unwrap());
assert!(matches!(
decode_observe_response(&request(), &encoded, limits()).unwrap_err(),
ClientResponseError::TransportBinding(failure)
if failure.status == StatusCode::NOT_FOUND
&& failure.kind == TransportFailureKind::UnsupportedContentCoding
));
let success = response(StatusCode::OK, br#"{"object":{"name":"","interfaces":[]}}"#);
let mut identity = success.clone();
identity
.headers_mut()
.insert("content-encoding", "identity".parse().unwrap());
assert!(matches!(
decode_observe_response(&request(), &identity, limits()).unwrap(),
DecodedResponse::Success(_)
));
let mut unsupported = success;
unsupported
.headers_mut()
.insert("content-encoding", "br".parse().unwrap());
assert!(matches!(
decode_observe_response(&request(), &unsupported, limits()).unwrap_err(),
ClientResponseError::TransportBinding(failure)
if failure.status == StatusCode::OK
&& failure.kind == TransportFailureKind::UnsupportedContentCoding
));
}
#[test] #[test]
fn valid_success_body_requires_exactly_http_200() { fn valid_success_body_requires_exactly_http_200() {
let success_body = br#"{"name":"","interfaces":[]}"#; let success_body = br#"{"object":{"name":"","interfaces":[]}}"#;
for status in [StatusCode::CREATED, StatusCode::ACCEPTED] { for status in [StatusCode::CREATED, StatusCode::ACCEPTED] {
assert!(matches!( assert!(matches!(
decode_fetch_response(&request(), &response(status, success_body), limits()) decode_observe_response(&request(), &response(status, success_body), limits())
.unwrap_err(), .unwrap_err(),
ClientResponseError::InvalidResponse { ClientResponseError::InvalidResponse {
kind: InvalidResponseKind::NonCanonicalSuccessStatus, kind: InvalidResponseKind::NonCanonicalSuccessStatus,
@@ -187,9 +236,11 @@ fn valid_success_body_requires_exactly_http_200() {
fn server_encoder_rejects_codes_disallowed_for_the_interaction() { fn server_encoder_rejects_codes_disallowed_for_the_interaction() {
let error = ProtocolError { let error = ProtocolError {
code: ProtocolErrorCode::InterfaceNotFound, code: ProtocolErrorCode::InterfaceNotFound,
message: "not valid for fetch".into(), message: "not valid for observe".into(),
}; };
assert!(encode_protocol_error_response(ProtocolInteraction::Fetch, &error, limits()).is_err()); assert!(
encode_protocol_error_response(ProtocolInteraction::Observe, &error, limits()).is_err()
);
} }
#[test] #[test]
@@ -199,14 +250,14 @@ fn valid_envelope_code_is_authoritative_when_status_mismatches() {
message: "missing".into(), message: "missing".into(),
}; };
let mut encoded = let mut encoded =
encode_protocol_error_response(ProtocolInteraction::Fetch, &error, limits()).unwrap(); encode_protocol_error_response(ProtocolInteraction::Observe, &error, limits()).unwrap();
*encoded.status_mut() = StatusCode::IM_A_TEAPOT; *encoded.status_mut() = StatusCode::IM_A_TEAPOT;
assert_eq!( assert_eq!(
decode_fetch_response(&request(), &encoded, limits()).unwrap(), decode_observe_response(&request(), &encoded, limits()).unwrap(),
DecodedResponse::ProtocolFailure { DecodedResponse::ProtocolFailure {
error, error,
status_mismatch: Some(wip_https::StatusMismatch { status_mismatch: Some(wip_http::StatusMismatch {
actual: StatusCode::IM_A_TEAPOT, actual: StatusCode::IM_A_TEAPOT,
expected: StatusCode::NOT_FOUND, expected: StatusCode::NOT_FOUND,
}), }),
@@ -218,7 +269,7 @@ fn valid_envelope_code_is_authoritative_when_status_mismatches() {
fn envelope_and_status_contradictions_are_invalid_responses() { fn envelope_and_status_contradictions_are_invalid_responses() {
let error_body = br#"{"error":{"code":"not_found","message":"missing"}}"#; let error_body = br#"{"error":{"code":"not_found","message":"missing"}}"#;
let on_success = let on_success =
decode_fetch_response(&request(), &response(StatusCode::OK, error_body), limits()) decode_observe_response(&request(), &response(StatusCode::OK, error_body), limits())
.unwrap_err(); .unwrap_err();
assert!(matches!( assert!(matches!(
on_success, on_success,
@@ -228,8 +279,8 @@ fn envelope_and_status_contradictions_are_invalid_responses() {
} }
)); ));
let success_body = br#"{"name":"","interfaces":[]}"#; let success_body = br#"{"object":{"name":"","interfaces":[]}}"#;
let on_error = decode_fetch_response( let on_error = decode_observe_response(
&request(), &request(),
&response(StatusCode::BAD_GATEWAY, success_body), &response(StatusCode::BAD_GATEWAY, success_body),
limits(), limits(),
@@ -247,7 +298,8 @@ fn envelope_and_status_contradictions_are_invalid_responses() {
#[test] #[test]
fn disallowed_protocol_code_is_invalid_instead_of_reclassified() { fn disallowed_protocol_code_is_invalid_instead_of_reclassified() {
let body = br#"{"error":{"code":"interface_not_found","message":"wrong interaction"}}"#; let body = br#"{"error":{"code":"interface_not_found","message":"wrong interaction"}}"#;
let error = decode_fetch_response(&request(), &response(StatusCode::NOT_FOUND, body), limits()) let error =
decode_observe_response(&request(), &response(StatusCode::NOT_FOUND, body), limits())
.unwrap_err(); .unwrap_err();
assert!(matches!( assert!(matches!(
error, error,
@@ -282,7 +334,7 @@ fn proxy_authentication_and_endpoint_failures_stay_transport_failures() {
.status(status) .status(status)
.body(body.to_vec()) .body(body.to_vec())
.unwrap(); .unwrap();
let error = decode_fetch_response(&request(), &response, limits()).unwrap_err(); let error = decode_observe_response(&request(), &response, limits()).unwrap_err();
assert!(matches!( assert!(matches!(
error, error,
ClientResponseError::TransportBinding(failure) ClientResponseError::TransportBinding(failure)
@@ -298,7 +350,7 @@ fn malformed_non_success_envelope_is_transport_binding_failure() {
br#"{"error":{"code":"not_found","message":null}}"#, br#"{"error":{"code":"not_found","message":null}}"#,
); );
assert!(matches!( assert!(matches!(
decode_fetch_response(&request(), &malformed, limits()).unwrap_err(), decode_observe_response(&request(), &malformed, limits()).unwrap_err(),
ClientResponseError::TransportBinding(_) ClientResponseError::TransportBinding(_)
)); ));
} }
+182
View File
@@ -0,0 +1,182 @@
use wip_http::http::{Method, Request, StatusCode};
use wip_http::{Endpoint, RequestMetadataError, Route, validate_http_request};
#[test]
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.to_string(),
format!("{scheme}://example.test/root/prefix/")
);
let expected = [
(Route::Observe, "v1/observe"),
(Route::FetchInterface, "v1/fetch_interface"),
(Route::CallOperation, "v1/call_operation"),
];
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
);
}
}
#[test]
fn routes_never_mix_worldspace_paths_into_endpoint_prefixes() {
let endpoint = Endpoint::parse("https://example.test/wip/%E4%B8%96%E7%95%8C/").unwrap();
let route = endpoint.route(Route::Observe);
assert_eq!(route.path(), "/wip/%E4%B8%96%E7%95%8C/v1/observe");
assert!(!route.as_str().contains("items"));
}
#[test]
fn inbound_http_metadata_enforces_post_route_and_json() {
let endpoint = Endpoint::parse("https://example.test/wip").unwrap();
let valid = Request::builder()
.method(Method::POST)
.uri("/wip/v1/observe")
.header("content-type", "application/json; charset=utf-8")
.body(Vec::<u8>::new())
.unwrap();
assert!(validate_http_request(&endpoint, Route::Observe, &valid).is_ok());
let wrong_method = Request::builder()
.method(Method::GET)
.uri("/wip/v1/observe")
.header("content-type", "application/json")
.body(Vec::<u8>::new())
.unwrap();
let error = validate_http_request(&endpoint, Route::Observe, &wrong_method).unwrap_err();
assert!(matches!(&error, RequestMetadataError::MethodNotAllowed));
assert_eq!(error.status(), StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(error.allow(), Some("POST"));
let wrong_route = Request::builder()
.method(Method::POST)
.uri("/wip/v1/fetch_interface")
.header("content-type", "application/json")
.body(Vec::<u8>::new())
.unwrap();
let error = validate_http_request(&endpoint, Route::Observe, &wrong_route).unwrap_err();
assert!(matches!(&error, RequestMetadataError::NotFound));
assert_eq!(error.status(), StatusCode::NOT_FOUND);
assert_eq!(error.allow(), None);
let query_and_wrong_method = Request::builder()
.method(Method::GET)
.uri("/wip/v1/observe?unexpected=true")
.header("content-type", "application/json")
.body(Vec::<u8>::new())
.unwrap();
assert!(matches!(
validate_http_request(&endpoint, Route::Observe, &query_and_wrong_method).unwrap_err(),
RequestMetadataError::NotFound
));
for accept in [
"text/plain, */*;q=1, application/json;q=0",
"application/json; charset=utf-16",
"application/json; profile=future",
] {
let incompatible_accept = Request::builder()
.method(Method::POST)
.uri("/wip/v1/observe")
.header("content-type", "application/json")
.header("accept", accept)
.body(Vec::<u8>::new())
.unwrap();
let error =
validate_http_request(&endpoint, Route::Observe, &incompatible_accept).unwrap_err();
assert!(matches!(&error, RequestMetadataError::NotAcceptable));
assert_eq!(error.status(), StatusCode::NOT_ACCEPTABLE);
}
for accept in [
"application/json; charset=UTF-8",
"application/json; q=1; profile=accept-extension",
] {
let compatible_accept = Request::builder()
.method(Method::POST)
.uri("/wip/v1/observe")
.header("content-type", "application/json")
.header("accept", accept)
.body(Vec::<u8>::new())
.unwrap();
assert!(validate_http_request(&endpoint, Route::Observe, &compatible_accept).is_ok());
}
let unsupported_media_type = Request::builder()
.method(Method::POST)
.uri("/wip/v1/observe")
.header("content-type", "text/plain")
.header("accept", "application/json")
.body(Vec::<u8>::new())
.unwrap();
let error =
validate_http_request(&endpoint, Route::Observe, &unsupported_media_type).unwrap_err();
assert!(matches!(&error, RequestMetadataError::UnsupportedMediaType));
assert_eq!(error.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
for coding in ["gzip", "identity, gzip"] {
let unsupported_coding = Request::builder()
.method(Method::POST)
.uri("/wip/v1/observe")
.header("content-type", "application/json")
.header("content-encoding", coding)
.body(Vec::<u8>::new())
.unwrap();
let error =
validate_http_request(&endpoint, Route::Observe, &unsupported_coding).unwrap_err();
assert!(matches!(&error, RequestMetadataError::UnsupportedMediaType));
assert_eq!(error.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
}
let identity_coding = Request::builder()
.method(Method::POST)
.uri("/wip/v1/observe")
.header("content-type", "application/json")
.header("content-encoding", "identity")
.body(Vec::<u8>::new())
.unwrap();
assert!(validate_http_request(&endpoint, Route::Observe, &identity_coding).is_ok());
}
#[test]
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/batch",
"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
);
}
}
-596
View File
@@ -1,596 +0,0 @@
use std::collections::{BTreeMap, BTreeSet};
use http::header::{ACCEPT, CONTENT_TYPE};
use http::{Method, Request, Response, StatusCode};
use serde_json::{Map, Value as Json};
use wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, FetchRequest, FetchResponse, FetchTreeRequest,
FetchTreeResponse, INTERFACE_FORMAT_V1, ProtocolError, ProtocolErrorCode, ProtocolInteraction,
};
use crate::response::{self, DecodedResponse};
use crate::{
BodyKind, ClientResponseError, CodecError, EncodeHttpError, Endpoint, InvalidResponseKind,
JSON_CONTENT_TYPE, Limits, ResourceLimitKind, Route, is_json_content_type, json,
};
/// A retrieval method accepted by the standard v1 batch endpoint.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BatchMethod {
/// Fetch one object.
Fetch,
/// Fetch one bounded tree.
FetchTree,
/// Fetch one interface descriptor.
FetchInterface,
}
impl BatchMethod {
fn name(self) -> &'static str {
match self {
Self::Fetch => "fetch",
Self::FetchTree => "fetch_tree",
Self::FetchInterface => "fetch_interface",
}
}
fn interaction(self) -> ProtocolInteraction {
match self {
Self::Fetch => ProtocolInteraction::Fetch,
Self::FetchTree => ProtocolInteraction::FetchTree,
Self::FetchInterface => ProtocolInteraction::FetchInterface,
}
}
}
/// One retrieval interaction accepted by the standard v1 batch endpoint.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BatchRequestKind {
/// Fetch one object.
Fetch(FetchRequest),
/// Fetch one bounded tree.
FetchTree(FetchTreeRequest),
/// Fetch one interface descriptor.
FetchInterface(FetchInterfaceRequest),
/// A known method whose `arguments` are not a valid logical request.
Invalid {
/// Method that was successfully identified from the item envelope.
method: BatchMethod,
/// Item-local `invalid_request` to return without rejecting other items.
error: ProtocolError,
},
}
impl BatchRequestKind {
fn interaction(&self) -> ProtocolInteraction {
match self {
Self::Fetch(_) => ProtocolInteraction::Fetch,
Self::FetchTree(_) => ProtocolInteraction::FetchTree,
Self::FetchInterface(_) => ProtocolInteraction::FetchInterface,
Self::Invalid { method, .. } => method.interaction(),
}
}
}
/// One uniquely identified batch subrequest.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BatchRequestItem {
/// Identifier used only to correlate the corresponding subresponse.
pub id: String,
/// Retrieval interaction and arguments.
pub request: BatchRequestKind,
}
/// A standard v1 batch request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BatchRequest {
/// Independent subrequests. Identifiers must be unique.
pub requests: Vec<BatchRequestItem>,
}
/// Successful result of one batch item.
#[derive(Debug, Clone, PartialEq)]
pub enum BatchSuccess {
/// Successful `fetch` result.
Fetch(FetchResponse),
/// Successful `fetch_tree` result.
FetchTree(FetchTreeResponse),
/// Successful `fetch_interface` result.
FetchInterface(FetchInterfaceResponse),
}
/// One batch subresponse, correlated by `id` rather than array position.
#[derive(Debug, Clone, PartialEq)]
pub struct BatchResponseItem {
/// Identifier copied from its subrequest.
pub id: String,
/// Direct interaction result or canonical item-local protocol failure.
pub outcome: Result<BatchSuccess, ProtocolError>,
}
/// A valid standard v1 batch response.
#[derive(Debug, Clone, PartialEq)]
pub struct BatchResponse {
/// Exactly one response for each requested identifier, in any order.
pub responses: Vec<BatchResponseItem>,
}
fn validate_ids<'a>(ids: impl Iterator<Item = &'a str>, maximum: usize) -> Result<(), CodecError> {
let mut seen = BTreeSet::new();
let mut count = 0;
for id in ids {
count += 1;
if !seen.insert(id) {
return Err(CodecError::DuplicateBatchId { id: id.into() });
}
}
if count > maximum {
return Err(CodecError::TooManyBatchItems {
actual: count,
maximum,
});
}
Ok(())
}
fn request_item_to_json(item: &BatchRequestItem) -> Result<Json, CodecError> {
let (method, arguments) = match &item.request {
BatchRequestKind::Fetch(value) => ("fetch", json::fetch_request_to_json(value)),
BatchRequestKind::FetchTree(value) => {
("fetch_tree", json::fetch_tree_request_to_json(value))
}
BatchRequestKind::FetchInterface(value) => (
"fetch_interface",
json::fetch_interface_request_to_json(value),
),
BatchRequestKind::Invalid { .. } => {
return Err(CodecError::InvalidField {
field: "requests".into(),
reason: "item-local invalid requests cannot be encoded as client requests".into(),
});
}
};
Ok(serde_json::json!({"id": item.id, "method": method, "arguments": arguments}))
}
fn request_to_json(request: &BatchRequest, limits: Limits) -> Result<Json, CodecError> {
validate_ids(
request.requests.iter().map(|item| item.id.as_str()),
limits.max_batch_items,
)?;
for item in &request.requests {
match &item.request {
BatchRequestKind::Fetch(value) => value.validate()?,
BatchRequestKind::FetchTree(value) => value.validate()?,
BatchRequestKind::FetchInterface(_) | BatchRequestKind::Invalid { .. } => {}
}
}
let requests = request
.requests
.iter()
.map(request_item_to_json)
.collect::<Result<Vec<_>, _>>()?;
Ok(serde_json::json!({"requests": requests}))
}
fn request_from_json(value: &Json, limits: Limits) -> Result<BatchRequest, CodecError> {
let fields = exact_object(value, "batch", &["requests"])?;
let values = array(required(fields, "requests")?, "requests")?;
if values.len() > limits.max_batch_items {
return Err(CodecError::TooManyBatchItems {
actual: values.len(),
maximum: limits.max_batch_items,
});
}
let mut requests = Vec::with_capacity(values.len());
for (index, value) in values.iter().enumerate() {
let field = format!("requests[{index}]");
let fields = exact_object(value, &field, &["id", "method", "arguments"])?;
let id = string(required(fields, "id")?, &format!("{field}.id"))?;
let method_name = string(required(fields, "method")?, &format!("{field}.method"))?;
let arguments = required(fields, "arguments")?;
let method = match method_name.as_str() {
"fetch" => BatchMethod::Fetch,
"fetch_tree" => BatchMethod::FetchTree,
"fetch_interface" => BatchMethod::FetchInterface,
_ => {
return Err(CodecError::InvalidField {
field: format!("{field}.method"),
reason: "batch supports only fetch, fetch_tree, and fetch_interface".into(),
});
}
};
let decoded = match method {
BatchMethod::Fetch => {
json::fetch_request_from_json(arguments).map(BatchRequestKind::Fetch)
}
BatchMethod::FetchTree => {
json::fetch_tree_request_from_json(arguments).map(BatchRequestKind::FetchTree)
}
BatchMethod::FetchInterface => json::fetch_interface_request_from_json(arguments)
.map(BatchRequestKind::FetchInterface),
};
let request = decoded.unwrap_or_else(|error| BatchRequestKind::Invalid {
method,
error: ProtocolError {
code: ProtocolErrorCode::InvalidRequest,
message: format!("invalid {} arguments: {error}", method.name()),
},
});
requests.push(BatchRequestItem { id, request });
}
validate_ids(
requests.iter().map(|item| item.id.as_str()),
limits.max_batch_items,
)?;
Ok(BatchRequest { requests })
}
/// Encodes a batch request using `POST v1/batch`.
pub fn encode_batch_request(
endpoint: &Endpoint,
value: &BatchRequest,
limits: Limits,
) -> Result<Request<Vec<u8>>, EncodeHttpError> {
let body = json::serialize(&request_to_json(value, limits)?, BodyKind::Request, limits)?;
Ok(Request::builder()
.method(Method::POST)
.uri(endpoint.route(Route::Batch).as_str())
.header(CONTENT_TYPE, JSON_CONTENT_TYPE)
.header(ACCEPT, "application/json")
.body(body)?)
}
/// Decodes and validates a batch request body.
pub fn decode_batch_request(body: &[u8], limits: Limits) -> Result<BatchRequest, CodecError> {
let value = json::parse(body, BodyKind::Request, limits)?;
request_from_json(&value, limits)
}
fn success_to_json(success: &BatchSuccess) -> Json {
match success {
BatchSuccess::Fetch(value) => json::object_to_json(value),
BatchSuccess::FetchTree(value) => json::fetch_tree_response_to_json(value),
BatchSuccess::FetchInterface(value) => json::fetch_interface_response_to_json(value),
}
}
fn response_to_json(
request: &BatchRequest,
response: &BatchResponse,
limits: Limits,
) -> Result<Json, CodecError> {
correlate(request, response, limits)?;
Ok(serde_json::json!({
"responses": response.responses.iter().map(|item| {
let mut fields = Map::new();
fields.insert("id".into(), Json::String(item.id.clone()));
match &item.outcome {
Ok(success) => { fields.insert("result".into(), success_to_json(success)); }
Err(error) => { fields.insert("error".into(), response::item_error_to_json(error)); }
}
Json::Object(fields)
}).collect::<Vec<_>>()
}))
}
fn correlate(
request: &BatchRequest,
response: &BatchResponse,
limits: Limits,
) -> Result<(), CodecError> {
validate_ids(
request.requests.iter().map(|item| item.id.as_str()),
limits.max_batch_items,
)?;
validate_ids(
response.responses.iter().map(|item| item.id.as_str()),
limits.max_batch_items,
)?;
let requested: BTreeMap<&str, &BatchRequestKind> = request
.requests
.iter()
.map(|item| (item.id.as_str(), &item.request))
.collect();
for item in &response.responses {
let Some(kind) = requested.get(item.id.as_str()) else {
return Err(CodecError::UnexpectedBatchId {
id: item.id.clone(),
});
};
match (&item.outcome, *kind) {
(Ok(BatchSuccess::Fetch(value)), BatchRequestKind::Fetch(request)) => {
request.validate_response(value)?;
}
(Ok(BatchSuccess::FetchTree(value)), BatchRequestKind::FetchTree(request)) => {
value.validate_for(request)?;
}
(
Ok(BatchSuccess::FetchInterface(value)),
BatchRequestKind::FetchInterface(request),
) => {
value.validate_for(request)?;
}
(
Err(error),
BatchRequestKind::Invalid {
error: expected, ..
},
) if error == expected => {}
(Err(error), kind)
if !matches!(kind, BatchRequestKind::Invalid { .. })
&& error.code.is_allowed_for(kind.interaction()) => {}
_ => {
return Err(CodecError::BatchInteractionMismatch {
id: item.id.clone(),
});
}
}
}
for item in &request.requests {
if !response.responses.iter().any(|other| other.id == item.id) {
return Err(CodecError::MissingBatchId {
id: item.id.clone(),
});
}
}
Ok(())
}
/// Encodes a valid batch response. Item failures remain inside HTTP 200.
pub fn encode_batch_response(
request: &BatchRequest,
value: &BatchResponse,
limits: Limits,
) -> Result<Response<Vec<u8>>, EncodeHttpError> {
let body = json::serialize(
&response_to_json(request, value, limits)?,
BodyKind::Response,
limits,
)?;
Ok(Response::builder()
.status(StatusCode::OK)
.header(CONTENT_TYPE, JSON_CONTENT_TYPE)
.body(body)?)
}
/// Encodes a top-level batch-envelope failure.
///
/// Item-local failures belong in [`BatchResponseItem`]. Only failures that can
/// reject the batch envelope before item execution are accepted here. A
/// resource-limit failure defaults to the logical-limit status; use
/// [`encode_batch_resource_limit_error_response`] when the concrete class is
/// known.
pub fn encode_batch_error_response(
error: &ProtocolError,
limits: Limits,
) -> Result<Response<Vec<u8>>, EncodeHttpError> {
if !matches!(
error.code,
ProtocolErrorCode::InvalidRequest
| ProtocolErrorCode::ResourceLimitExceeded
| ProtocolErrorCode::Internal
) {
return Err(CodecError::InvalidField {
field: "error.code".into(),
reason: "code is not valid for a top-level batch failure".into(),
}
.into());
}
let body = json::serialize(&response::error_to_json(error), BodyKind::Response, limits)?;
Ok(Response::builder()
.status(response::status_for_error(error.code))
.header(CONTENT_TYPE, JSON_CONTENT_TYPE)
.body(body)?)
}
/// Encodes a class-specific top-level batch `resource_limit_exceeded` failure.
pub fn encode_batch_resource_limit_error_response(
error: &ProtocolError,
kind: ResourceLimitKind,
limits: Limits,
) -> Result<Response<Vec<u8>>, EncodeHttpError> {
if error.code != ProtocolErrorCode::ResourceLimitExceeded {
return Err(CodecError::InvalidField {
field: "error.code".into(),
reason: "resource-limit encoder requires resource_limit_exceeded".into(),
}
.into());
}
let body = json::serialize(&response::error_to_json(error), BodyKind::Response, limits)?;
Ok(Response::builder()
.status(response::status_for_resource_limit(kind))
.header(CONTENT_TYPE, JSON_CONTENT_TYPE)
.body(body)?)
}
fn response_from_json(
value: &Json,
request: &BatchRequest,
limits: Limits,
) -> Result<BatchResponse, CodecError> {
let fields = exact_object(value, "batch", &["responses"])?;
let values = array(required(fields, "responses")?, "responses")?;
if values.len() > limits.max_batch_items {
return Err(CodecError::TooManyBatchItems {
actual: values.len(),
maximum: limits.max_batch_items,
});
}
let requested: BTreeMap<&str, &BatchRequestKind> = request
.requests
.iter()
.map(|item| (item.id.as_str(), &item.request))
.collect();
let mut responses = Vec::with_capacity(values.len());
for (index, value) in values.iter().enumerate() {
let field = format!("responses[{index}]");
let fields = object(value, &field)?;
for name in fields.keys() {
if !["id", "result", "error"].contains(&name.as_str()) {
return Err(CodecError::UnknownField {
field: format!("{field}.{name}"),
});
}
}
let id = string(required(fields, "id")?, &format!("{field}.id"))?;
let Some(kind) = requested.get(id.as_str()) else {
return Err(CodecError::UnexpectedBatchId { id });
};
let outcome = match (fields.get("result"), fields.get("error")) {
(Some(_), Some(_)) | (None, None) => {
return Err(CodecError::InvalidField {
field,
reason: "batch item must contain exactly one of result or error".into(),
});
}
(None, Some(error)) => {
let error = response::item_error_from_json(error)?;
match kind {
BatchRequestKind::Invalid {
error: expected, ..
} if error == *expected => {}
BatchRequestKind::Invalid { .. } => {
return Err(CodecError::BatchInteractionMismatch { id });
}
kind if error.code.is_allowed_for(kind.interaction()) => {}
_ => return Err(CodecError::BatchInteractionMismatch { id }),
}
Err(error)
}
(Some(result), None) => Ok(match kind {
BatchRequestKind::Fetch(request) => {
let value = json::object_from_json(result, "result")?;
request.validate_response(&value)?;
BatchSuccess::Fetch(value)
}
BatchRequestKind::FetchTree(request) => {
let value = json::fetch_tree_response_from_json(result)?;
value.validate_for(request)?;
BatchSuccess::FetchTree(value)
}
BatchRequestKind::FetchInterface(request) => {
let value = json::fetch_interface_response_from_json(result)?;
value.validate_for(request)?;
BatchSuccess::FetchInterface(value)
}
BatchRequestKind::Invalid { .. } => {
return Err(CodecError::BatchInteractionMismatch { id });
}
}),
};
responses.push(BatchResponseItem { id, outcome });
}
let response = BatchResponse { responses };
correlate(request, &response, limits)?;
Ok(response)
}
/// Decodes a batch response, including item-local failures within HTTP 200.
pub fn decode_batch_response<B: AsRef<[u8]>>(
request: &BatchRequest,
value: &Response<B>,
limits: Limits,
) -> Result<DecodedResponse<BatchResponse>, ClientResponseError> {
let valid_content_type = value
.headers()
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.is_some_and(is_json_content_type);
if !valid_content_type {
if value.status().is_success() {
return Err(ClientResponseError::invalid(
InvalidResponseKind::InvalidBatch,
"missing or non-UTF-8 application/json Content-Type",
));
}
return Err(response::transport_failure(value.status()));
}
let decoded = response::decode(
value.status(),
value.body().as_ref(),
ProtocolInteraction::Fetch,
limits,
|json| response_from_json(json, request, limits),
)
.map_err(|error| match error {
ClientResponseError::InvalidResponse { detail, .. } => {
ClientResponseError::invalid(InvalidResponseKind::InvalidBatch, detail)
}
other => other,
})?;
if let DecodedResponse::ProtocolFailure { error, .. } = &decoded
&& !matches!(
error.code,
ProtocolErrorCode::InvalidRequest
| ProtocolErrorCode::ResourceLimitExceeded
| ProtocolErrorCode::Internal
)
{
return Err(ClientResponseError::invalid(
InvalidResponseKind::InvalidBatch,
"protocol code is not valid for a top-level batch failure",
));
}
if let DecodedResponse::Success(success) = &decoded {
for item in &success.responses {
if let Ok(BatchSuccess::FetchInterface(interface)) = &item.outcome
&& interface.descriptor.format != INTERFACE_FORMAT_V1
{
return Err(ClientResponseError::UnsupportedDescriptorFormat {
format: interface.descriptor.format.clone(),
});
}
}
}
Ok(decoded)
}
fn object<'a>(value: &'a Json, field: &str) -> Result<&'a Map<String, Json>, CodecError> {
value.as_object().ok_or_else(|| CodecError::InvalidField {
field: field.into(),
reason: "expected object".into(),
})
}
fn exact_object<'a>(
value: &'a Json,
field: &str,
allowed: &[&str],
) -> Result<&'a Map<String, Json>, CodecError> {
let fields = object(value, field)?;
for name in fields.keys() {
if !allowed.contains(&name.as_str()) {
return Err(CodecError::UnknownField {
field: format!("{field}.{name}"),
});
}
}
Ok(fields)
}
fn required<'a>(fields: &'a Map<String, Json>, name: &str) -> Result<&'a Json, CodecError> {
fields
.get(name)
.ok_or_else(|| CodecError::MissingField { field: name.into() })
}
fn string(value: &Json, field: &str) -> Result<String, CodecError> {
value
.as_str()
.map(str::to_owned)
.ok_or_else(|| CodecError::InvalidField {
field: field.into(),
reason: "expected string".into(),
})
}
fn array<'a>(value: &'a Json, field: &str) -> Result<&'a [Json], CodecError> {
value
.as_array()
.map(Vec::as_slice)
.ok_or_else(|| CodecError::InvalidField {
field: field.into(),
reason: "expected array".into(),
})
}
-67
View File
@@ -1,67 +0,0 @@
//! Framework-independent standard WIP over HTTPS 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.
//!
//! 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_fetch_request};
//! use wip_protocol::FetchRequest;
//!
//! let endpoint = Endpoint::parse("https://example.test/api/wip")?;
//! let limits = Limits::new(64 * 1024, 1024 * 1024, 64, 100)?;
//! let request = encode_fetch_request(
//! &endpoint,
//! &FetchRequest { path: "/items/123".into() },
//! limits,
//! )?;
//! assert_eq!(request.uri(), "https://example.test/api/wip/v1/fetch");
//! assert_eq!(request.method(), http::Method::POST);
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```
#![deny(missing_docs)]
mod batch;
mod codec;
mod endpoint;
mod error;
mod json;
mod response;
pub use batch::{
BatchMethod, BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse,
BatchResponseItem, BatchSuccess, decode_batch_request, decode_batch_response,
encode_batch_error_response, encode_batch_request, encode_batch_resource_limit_error_response,
encode_batch_response,
};
pub use codec::{
CallOperationMetadata, EncodeHttpError, RequestMetadataError, decode_call_operation_metadata,
decode_call_operation_request, decode_call_operation_response, decode_fetch_interface_request,
decode_fetch_interface_response, decode_fetch_request, decode_fetch_response,
decode_fetch_tree_request, decode_fetch_tree_response, encode_call_operation_request,
encode_call_operation_response, encode_fetch_interface_request,
encode_fetch_interface_response, encode_fetch_request, encode_fetch_response,
encode_fetch_tree_request, encode_fetch_tree_response, encode_protocol_error_response,
encode_resource_limit_error_response, is_json_content_type, validate_http_request,
};
pub use endpoint::{Endpoint, EndpointError, JSON_CONTENT_TYPE, Route};
pub use error::{BodyKind, CodecError, LimitConfigurationError, Limits, ResourceLimitKind};
pub use response::{
ClientResponseError, DecodedResponse, InvalidResponseKind, StatusMismatch,
TransportBindingFailure, TransportFailureKind, error_code_name, status_for_error,
status_for_resource_limit,
};
/// Framework-neutral HTTP types used by this binding's public API.
pub use http;
/// URL type used by [`Endpoint`].
pub use url;
/// Transport-independent logical protocol model used by this binding.
pub use wip_protocol;
-353
View File
@@ -1,353 +0,0 @@
use wip_https::http::StatusCode;
use wip_https::wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, FetchRequest, INTERFACE_FORMAT_V1,
InterfaceDescriptor, Object, ProtocolError, ProtocolErrorCode,
};
use wip_https::{
BatchMethod, BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse,
BatchResponseItem, BatchSuccess, ClientResponseError, DecodedResponse, Endpoint,
InvalidResponseKind, Limits, ResourceLimitKind, decode_batch_request, decode_batch_response,
encode_batch_error_response, encode_batch_request, encode_batch_resource_limit_error_response,
encode_batch_response,
};
fn limits() -> Limits {
Limits::new(100_000, 100_000, 64, 4).unwrap()
}
fn request() -> BatchRequest {
BatchRequest {
requests: vec![
BatchRequestItem {
id: "object".into(),
request: BatchRequestKind::Fetch(FetchRequest {
path: "/item".into(),
}),
},
BatchRequestItem {
id: "interface".into(),
request: BatchRequestKind::FetchInterface(FetchInterfaceRequest {
interface: "interface-v1".into(),
}),
},
],
}
}
#[test]
fn batch_round_trips_by_unique_id_with_item_failures_and_validators() {
let request = request();
let endpoint = Endpoint::parse("https://example.test/wip").unwrap();
let encoded_request = encode_batch_request(&endpoint, &request, limits()).unwrap();
assert_eq!(encoded_request.uri().path(), "/wip/v1/batch");
assert_eq!(encoded_request.headers()["accept"], "application/json");
assert_eq!(
decode_batch_request(encoded_request.body(), limits()).unwrap(),
request
);
let response = BatchResponse {
responses: vec![
BatchResponseItem {
id: "interface".into(),
outcome: Err(ProtocolError {
code: ProtocolErrorCode::InterfaceNotFound,
message: "gone".into(),
}),
},
BatchResponseItem {
id: "object".into(),
outcome: Ok(BatchSuccess::Fetch(Object {
name: "item".into(),
description: None,
interfaces: vec![],
r#ref: None,
validator: Some(vec![0, 1, 2, 255]),
})),
},
],
};
let encoded = encode_batch_response(&request, &response, limits()).unwrap();
assert_eq!(encoded.status(), StatusCode::OK);
assert!(
std::str::from_utf8(encoded.body())
.unwrap()
.contains("AAEC/w==")
);
assert_eq!(
decode_batch_response(&request, &encoded, limits()).unwrap(),
DecodedResponse::Success(response)
);
}
#[test]
fn batch_interface_validator_round_trips_in_item_body() {
let request = BatchRequest {
requests: vec![BatchRequestItem {
id: "interface".into(),
request: BatchRequestKind::FetchInterface(FetchInterfaceRequest {
interface: "interface-v1".into(),
}),
}],
};
let response = BatchResponse {
responses: vec![BatchResponseItem {
id: "interface".into(),
outcome: Ok(BatchSuccess::FetchInterface(FetchInterfaceResponse {
interface: "interface-v1".into(),
descriptor: InterfaceDescriptor {
format: INTERFACE_FORMAT_V1.into(),
documentation: None,
types: vec![],
operations: vec![],
},
validator: Some(vec![255, 254, 253]),
})),
}],
};
let encoded = encode_batch_response(&request, &response, limits()).unwrap();
assert!(
std::str::from_utf8(encoded.body())
.unwrap()
.contains("//79")
);
assert_eq!(
decode_batch_response(&request, &encoded, limits()).unwrap(),
DecodedResponse::Success(response)
);
}
#[test]
fn malformed_known_method_arguments_are_item_local() {
let body = br#"{
"requests": [
{"id":"valid","method":"fetch","arguments":{"path":"/item"}},
{"id":"invalid","method":"fetch_tree","arguments":{"path":"/","depth":"deep","limit":1}}
]
}"#;
let decoded = decode_batch_request(body, limits()).unwrap();
assert!(matches!(
decoded.requests[0].request,
BatchRequestKind::Fetch(_)
));
let invalid = match &decoded.requests[1].request {
BatchRequestKind::Invalid { method, error } => {
assert_eq!(*method, BatchMethod::FetchTree);
assert_eq!(error.code, ProtocolErrorCode::InvalidRequest);
error.clone()
}
other => panic!("expected item-local invalid request, got {other:?}"),
};
let response = BatchResponse {
responses: vec![
BatchResponseItem {
id: "valid".into(),
outcome: Ok(BatchSuccess::Fetch(Object {
name: "item".into(),
description: None,
interfaces: vec![],
r#ref: None,
validator: None,
})),
},
BatchResponseItem {
id: "invalid".into(),
outcome: Err(invalid),
},
],
};
let encoded = encode_batch_response(&decoded, &response, limits()).unwrap();
assert_eq!(encoded.status(), StatusCode::OK);
assert_eq!(
decode_batch_response(&decoded, &encoded, limits()).unwrap(),
DecodedResponse::Success(response)
);
}
#[test]
fn invalid_batch_envelopes_are_rejected_at_top_level() {
assert!(decode_batch_request(br#"{}"#, limits()).is_err());
let malformed_item = br#"{
"requests": [
{"id":"item","method":"fetch","arguments":{"path":"/"},"extra":true}
]
}"#;
assert!(decode_batch_request(malformed_item, limits()).is_err());
let duplicate = br#"{
"requests": [
{"id":"same","method":"fetch","arguments":{"path":"/a"}},
{"id":"same","method":"fetch","arguments":{"path":"/b"}}
]
}"#;
assert!(decode_batch_request(duplicate, limits()).is_err());
let operation = br#"{
"requests": [
{"id":"call","method":"call_operation","arguments":{}}
]
}"#;
assert!(decode_batch_request(operation, limits()).is_err());
let too_many = Limits::new(100_000, 100_000, 64, 1).unwrap();
assert!(decode_batch_request(duplicate, too_many).is_err());
}
#[test]
fn batch_resource_limits_use_413_or_422_and_never_429() {
let error = ProtocolError {
code: ProtocolErrorCode::ResourceLimitExceeded,
message: "bounded".into(),
};
for (kind, status) in [
(
ResourceLimitKind::RepresentationBytes,
StatusCode::PAYLOAD_TOO_LARGE,
),
(ResourceLimitKind::Logical, StatusCode::UNPROCESSABLE_ENTITY),
] {
let encoded = encode_batch_resource_limit_error_response(&error, kind, limits()).unwrap();
assert_eq!(encoded.status(), status);
assert_ne!(encoded.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(
decode_batch_response(&request(), &encoded, limits()).unwrap(),
DecodedResponse::ProtocolFailure {
error: error.clone(),
status_mismatch: None,
}
);
}
}
#[test]
fn batch_success_body_requires_exactly_http_200() {
let request = request();
let response = BatchResponse {
responses: vec![
BatchResponseItem {
id: "object".into(),
outcome: Ok(BatchSuccess::Fetch(Object {
name: "item".into(),
description: None,
interfaces: vec![],
r#ref: None,
validator: None,
})),
},
BatchResponseItem {
id: "interface".into(),
outcome: Err(ProtocolError {
code: ProtocolErrorCode::InterfaceNotFound,
message: "gone".into(),
}),
},
],
};
let encoded = encode_batch_response(&request, &response, limits()).unwrap();
for status in [StatusCode::CREATED, StatusCode::ACCEPTED] {
let mut noncanonical = encoded.clone();
*noncanonical.status_mut() = status;
assert!(matches!(
decode_batch_response(&request, &noncanonical, limits()).unwrap_err(),
ClientResponseError::InvalidResponse { .. }
));
}
}
#[test]
fn top_level_batch_errors_use_the_batch_allowed_code_set() {
let invalid = ProtocolError {
code: ProtocolErrorCode::InvalidRequest,
message: "bad envelope".into(),
};
let encoded = encode_batch_error_response(&invalid, limits()).unwrap();
assert_eq!(
decode_batch_response(&request(), &encoded, limits()).unwrap(),
DecodedResponse::ProtocolFailure {
error: invalid,
status_mismatch: None,
}
);
let not_found = wip_https::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())
.unwrap();
assert!(matches!(
decode_batch_response(&request(), &not_found, limits()).unwrap_err(),
ClientResponseError::InvalidResponse {
kind: InvalidResponseKind::InvalidBatch,
..
}
));
}
#[test]
fn unsupported_batch_descriptor_waits_for_envelope_correlation() {
let duplicate = wip_https::http::Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8")
.body(
br#"{"responses":[{"id":"interface","result":{"interface":"interface-v1","descriptor":{"format":"wip-interface/2","future":true}}},{"id":"interface","result":{"interface":"interface-v1","descriptor":{"format":"wip-interface/2"}}}]}"#
.to_vec(),
)
.unwrap();
assert!(matches!(
decode_batch_response(&request(), &duplicate, limits()).unwrap_err(),
ClientResponseError::InvalidResponse {
kind: InvalidResponseKind::InvalidBatch,
..
}
));
let missing = wip_https::http::Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8")
.body(
br#"{"responses":[{"id":"interface","result":{"interface":"interface-v1","descriptor":{"format":"wip-interface/2"}}}]}"#
.to_vec(),
)
.unwrap();
assert!(matches!(
decode_batch_response(&request(), &missing, limits()).unwrap_err(),
ClientResponseError::InvalidResponse {
kind: InvalidResponseKind::InvalidBatch,
..
}
));
let request = BatchRequest {
requests: vec![BatchRequestItem {
id: "interface".into(),
request: BatchRequestKind::FetchInterface(FetchInterfaceRequest {
interface: "interface-v1".into(),
}),
}],
};
assert!(matches!(
decode_batch_response(&request, &missing, limits()).unwrap_err(),
ClientResponseError::UnsupportedDescriptorFormat { format }
if format == "wip-interface/2"
));
}
#[test]
fn batch_response_must_have_exactly_the_requested_ids() {
let request = request();
let missing = BatchResponse {
responses: vec![BatchResponseItem {
id: "object".into(),
outcome: Ok(BatchSuccess::Fetch(Object {
name: "item".into(),
description: None,
interfaces: vec![],
r#ref: None,
validator: None,
})),
}],
};
assert!(encode_batch_response(&request, &missing, limits()).is_err());
}
-127
View File
@@ -1,127 +0,0 @@
use wip_https::http::{Method, Request, StatusCode};
use wip_https::{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::Fetch, "https://example.test/root/prefix/v1/fetch"),
(
Route::FetchTree,
"https://example.test/root/prefix/v1/fetch_tree",
),
(
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);
assert_eq!(
endpoint.recognize(&wip_https::url::Url::parse(url).unwrap()),
Some(route)
);
}
assert_eq!(
endpoint.recognize(
&wip_https::url::Url::parse("https://example.test/root/prefix/v1/fetch?path=/items")
.unwrap()
),
None
);
}
#[test]
fn routes_never_mix_worldspace_paths_into_endpoint_prefixes() {
let endpoint = Endpoint::parse("https://example.test/wip/%E4%B8%96%E7%95%8C/").unwrap();
let route = endpoint.route(Route::Fetch);
assert_eq!(route.path(), "/wip/%E4%B8%96%E7%95%8C/v1/fetch");
assert!(!route.as_str().contains("items"));
}
#[test]
fn inbound_http_metadata_enforces_post_route_and_json() {
let endpoint = Endpoint::parse("https://example.test/wip").unwrap();
let valid = Request::builder()
.method(Method::POST)
.uri("/wip/v1/fetch")
.header("content-type", "application/json; charset=utf-8")
.body(Vec::<u8>::new())
.unwrap();
assert!(validate_http_request(&endpoint, Route::Fetch, &valid).is_ok());
let wrong_method = Request::builder()
.method(Method::GET)
.uri("/wip/v1/fetch")
.header("content-type", "application/json")
.body(Vec::<u8>::new())
.unwrap();
assert!(validate_http_request(&endpoint, Route::Fetch, &wrong_method).is_err());
let wrong_route = Request::builder()
.method(Method::POST)
.uri("/wip/v1/fetch_tree")
.header("content-type", "application/json")
.body(Vec::<u8>::new())
.unwrap();
assert!(validate_http_request(&endpoint, Route::Fetch, &wrong_route).is_err());
for accept in [
"text/plain, */*;q=1, application/json;q=0",
"application/json; charset=utf-16",
"application/json; profile=future",
] {
let incompatible_accept = Request::builder()
.method(Method::POST)
.uri("/wip/v1/fetch")
.header("content-type", "application/json")
.header("accept", accept)
.body(Vec::<u8>::new())
.unwrap();
let error =
validate_http_request(&endpoint, Route::Fetch, &incompatible_accept).unwrap_err();
assert!(matches!(&error, RequestMetadataError::NotAcceptable));
assert_eq!(error.status(), StatusCode::NOT_ACCEPTABLE);
}
for accept in [
"application/json; charset=UTF-8",
"application/json; q=1; profile=accept-extension",
] {
let compatible_accept = Request::builder()
.method(Method::POST)
.uri("/wip/v1/fetch")
.header("content-type", "application/json")
.header("accept", accept)
.body(Vec::<u8>::new())
.unwrap();
assert!(validate_http_request(&endpoint, Route::Fetch, &compatible_accept).is_ok());
}
let unsupported_media_type = Request::builder()
.method(Method::POST)
.uri("/wip/v1/fetch")
.header("content-type", "text/plain")
.header("accept", "application/json")
.body(Vec::<u8>::new())
.unwrap();
let error =
validate_http_request(&endpoint, Route::Fetch, &unsupported_media_type).unwrap_err();
assert!(matches!(&error, RequestMetadataError::UnsupportedMediaType));
assert_eq!(error.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
}
#[test]
fn invalid_endpoint_components_are_rejected() {
assert!(Endpoint::parse("http://example.test/wip").is_err());
assert!(Endpoint::parse("/relative").is_err());
assert!(Endpoint::parse("https://example.test/wip?q=1").is_err());
assert!(Endpoint::parse("https://example.test/wip#fragment").is_err());
}
+13 -17
View File
@@ -62,7 +62,7 @@ pub enum PathSegment {
Return, Return,
/// Object at a Worldspace path. /// Object at a Worldspace path.
Object(String), Object(String),
/// Child object in a tree response. /// Child object in an observation response.
Child(String), Child(String),
} }
@@ -163,17 +163,15 @@ pub enum ValidationErrorKind {
/// Shared opaque object reference. /// Shared opaque object reference.
reference: String, reference: String,
}, },
/// A `fetch_tree` request used a zero limit. /// An observation omitted children before reaching the requested depth.
ZeroTreeLimit, ChildrenMissingBeforeDepth {
/// A tree response exceeds the requested maximum depth. /// Node depth where children were required.
TreeDepthExceeded { depth: u32,
/// Requested maximum depth.
maximum: u32,
}, },
/// A tree response exceeds the requested maximum node count. /// An observation included children at the requested depth boundary.
TreeLimitExceeded { ChildrenAtDepthBoundary {
/// Requested maximum node count. /// Boundary depth where children must be unobserved.
limit: u32, depth: u32,
}, },
/// A response refers to a different interface than its request. /// A response refers to a different interface than its request.
InterfaceResponseMismatch { InterfaceResponseMismatch {
@@ -226,10 +224,8 @@ impl Error for ValidationError {}
/// One core interaction whose failure codes form a closed set. /// One core interaction whose failure codes form a closed set.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProtocolInteraction { pub enum ProtocolInteraction {
/// Fetch one object by its Worldspace path. /// Observe one object and its indexable descendants to a requested depth.
Fetch, Observe,
/// Fetch a bounded tree of objects.
FetchTree,
/// Fetch one interface descriptor by its opaque reference. /// Fetch one interface descriptor by its opaque reference.
FetchInterface, FetchInterface,
/// Invoke one operation on a path-targeted object. /// Invoke one operation on a path-targeted object.
@@ -315,11 +311,11 @@ impl ProtocolErrorCode {
OperationNotFound, OperationOutcomeUnknown, PermissionDenied, ResourceLimitExceeded, OperationNotFound, OperationOutcomeUnknown, PermissionDenied, ResourceLimitExceeded,
ValidatorMismatch, ValidatorRequired, ValidatorMismatch, ValidatorRequired,
}; };
use ProtocolInteraction::{CallOperation, Fetch, FetchInterface, FetchTree}; use ProtocolInteraction::{CallOperation, FetchInterface, Observe};
match (interaction, self) { match (interaction, self) {
(_, InvalidRequest | ResourceLimitExceeded | Internal) => true, (_, InvalidRequest | ResourceLimitExceeded | Internal) => true,
(Fetch | FetchTree, NotFound) => true, (Observe, NotFound) => true,
(FetchInterface, InterfaceNotFound) => true, (FetchInterface, InterfaceNotFound) => true,
( (
CallOperation, CallOperation,
+4 -6
View File
@@ -11,8 +11,7 @@
//! | Interface Descriptor | [`InterfaceDescriptor`] | //! | Interface Descriptor | [`InterfaceDescriptor`] |
//! | Named declaration and all schema expressions | [`TypeDeclaration`] / [`TypeExpr`] | //! | Named declaration and all schema expressions | [`TypeDeclaration`] / [`TypeExpr`] |
//! | Schema-neutral primitive/composite value | [`Value`] | //! | Schema-neutral primitive/composite value | [`Value`] |
//! | `fetch` | [`FetchRequest`] / [`FetchResponse`] | //! | `observe` | [`ObserveRequest`] / [`ObserveResponse`] |
//! | `fetch_tree` | [`FetchTreeRequest`] / [`FetchTreeResponse`] |
//! | `fetch_interface` | [`FetchInterfaceRequest`] / [`FetchInterfaceResponse`] | //! | `fetch_interface` | [`FetchInterfaceRequest`] / [`FetchInterfaceResponse`] |
//! | Object and interface call preconditions | [`Target`] / [`InterfaceTarget`] | //! | Object and interface call preconditions | [`Target`] / [`InterfaceTarget`] |
//! | `call_operation` | [`CallOperationRequest`] / [`CallOperationResponse`] | //! | `call_operation` | [`CallOperationRequest`] / [`CallOperationResponse`] |
@@ -53,10 +52,9 @@ pub use error::{
}; };
pub use model::{ pub use model::{
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest, CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
FetchInterfaceResponse, FetchRequest, FetchResponse, FetchTreeRequest, FetchTreeResponse, FetchInterfaceResponse, FieldDeclaration, InterfaceDescriptor, InterfaceTarget, Object,
FieldDeclaration, InterfaceDescriptor, InterfaceTarget, Object, ObjectTreeNode, ObjectObservation, ObserveRequest, ObserveResponse, OperationDeclaration, ParameterDeclaration,
OperationDeclaration, ParameterDeclaration, ProtocolResult, ReturnDeclaration, Target, ProtocolResult, ReturnDeclaration, Target, TypeDeclaration, TypeExpr, UnionCase, Value,
TypeDeclaration, TypeExpr, UnionCase, Value,
}; };
/// Validates a canonical absolute Worldspace path. /// Validates a canonical absolute Worldspace path.
+15 -50
View File
@@ -218,44 +218,26 @@ impl Value {
} }
} }
/// Request for the object currently published at one path. /// Request for the object currently published at one path and its indexable descendants.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct FetchRequest { pub struct ObserveRequest {
/// Canonical absolute Worldspace path. /// Canonical absolute path of the observation root.
pub path: String, pub path: String,
} /// Maximum observed depth, where the requested object has depth zero.
/// Successful `fetch` response.
pub type FetchResponse = Object;
/// Request for a bounded breadth-first object-tree observation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FetchTreeRequest {
/// Canonical absolute path of the tree root.
pub path: String,
/// Maximum depth, where the requested object has depth zero.
pub depth: u32, pub depth: u32,
/// Maximum total node count, including the root. Must be nonzero.
pub limit: u32,
} }
/// One node in a tree observation. /// One node in an object observation.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObjectTreeNode { pub struct ObjectObservation {
/// Object observed at this tree position. /// Object observed at this Worldspace position.
pub object: Object, pub object: Object,
/// Direct children in host-defined traversal order. /// Complete direct children when observed, or `None` at the requested depth boundary.
pub children: Vec<ObjectTreeNode>, pub children: Option<Vec<ObjectObservation>>,
} }
/// Successful `fetch_tree` response. /// Successful `observe` response.
#[derive(Debug, Clone, PartialEq, Eq)] pub type ObserveResponse = ObjectObservation;
pub struct FetchTreeResponse {
/// Observed tree rooted at the requested path.
pub root: ObjectTreeNode,
/// Whether public indexable children were omitted by depth or limit.
pub truncated: bool,
}
/// Request for the current descriptor of one opaque interface reference. /// Request for the current descriptor of one opaque interface reference.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -372,35 +354,18 @@ impl InterfaceDescriptor {
} }
} }
impl FetchRequest { impl ObserveRequest {
/// Validates the request path. /// Validates the request path.
pub fn validate(&self) -> Result<(), ValidationError> { pub fn validate(&self) -> Result<(), ValidationError> {
crate::validation::validate_path(&self.path) crate::validation::validate_path(&self.path)
} }
/// Validates an object returned for this request. /// Validates observation shape, names, and requested depth.
/// ///
/// Failure means the Client received an invalid success response; it is not a /// Failure means the Client received an invalid success response; it is not a
/// Host-returned [`crate::ProtocolErrorCode`]. /// Host-returned [`crate::ProtocolErrorCode`].
pub fn validate_response(&self, response: &FetchResponse) -> Result<(), ValidationError> { pub fn validate_response(&self, response: &ObserveResponse) -> Result<(), ValidationError> {
response.validate_at_path(&self.path) crate::validation::validate_observe_response(response, self)
}
}
impl FetchTreeRequest {
/// Validates the request path and nonzero node limit.
pub fn validate(&self) -> Result<(), ValidationError> {
crate::validation::validate_fetch_tree_request(self)
}
}
impl FetchTreeResponse {
/// Validates tree shape, names, depth, and node limit for `request`.
///
/// Failure means the Client received an invalid success response; it is not a
/// Host-returned [`crate::ProtocolErrorCode`].
pub fn validate_for(&self, request: &FetchTreeRequest) -> Result<(), ValidationError> {
crate::validation::validate_fetch_tree_response(self, request)
} }
} }
+22 -51
View File
@@ -1,10 +1,10 @@
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use crate::{ use crate::{
CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, FetchTreeRequest, CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, InterfaceDescriptor,
FetchTreeResponse, InterfaceDescriptor, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, NameNamespace, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, NameNamespace, Object, ObjectObservation, ObserveRequest,
Object, ObjectTreeNode, PathSegment, TypeDeclaration, TypeExpr, UNION_CASE_FIELD, PathSegment, TypeDeclaration, TypeExpr, UNION_CASE_FIELD, UNION_VALUE_FIELD, ValidationError,
UNION_VALUE_FIELD, ValidationError, ValidationErrorKind, Value, ValueKind, ValidationErrorKind, Value, ValueKind,
}; };
pub(crate) fn validate_object(object: &Object) -> Result<(), ValidationError> { pub(crate) fn validate_object(object: &Object) -> Result<(), ValidationError> {
@@ -604,60 +604,38 @@ fn find_operation<'a>(
}) })
} }
pub(crate) fn validate_fetch_tree_request( pub(crate) fn validate_observe_response(
request: &FetchTreeRequest, response: &ObjectObservation,
) -> Result<(), ValidationError> { request: &ObserveRequest,
validate_path(&request.path)?;
if request.limit == 0 {
Err(ValidationError::new(ValidationErrorKind::ZeroTreeLimit))
} else {
Ok(())
}
}
pub(crate) fn validate_fetch_tree_response(
response: &FetchTreeResponse,
request: &FetchTreeRequest,
) -> Result<(), ValidationError> { ) -> Result<(), ValidationError> {
request.validate()?; request.validate()?;
let mut node_count = 0_u32; validate_observation_node(response, &request.path, 0, request.depth)
validate_tree_node(
&response.root,
&request.path,
0,
request.depth,
request.limit,
&mut node_count,
)
} }
fn validate_tree_node( fn validate_observation_node(
node: &ObjectTreeNode, node: &ObjectObservation,
path: &str, path: &str,
depth: u32, depth: u32,
maximum_depth: u32, maximum_depth: u32,
limit: u32,
node_count: &mut u32,
) -> Result<(), ValidationError> { ) -> Result<(), ValidationError> {
if depth > maximum_depth { node.object.validate_at_path(path)?;
if depth == maximum_depth {
if node.children.is_some() {
return Err( return Err(
ValidationError::new(ValidationErrorKind::TreeDepthExceeded { ValidationError::new(ValidationErrorKind::ChildrenAtDepthBoundary { depth })
maximum: maximum_depth,
})
.prepend(PathSegment::Object(path.to_owned())), .prepend(PathSegment::Object(path.to_owned())),
); );
} }
*node_count = node_count.saturating_add(1); return Ok(());
if *node_count > limit {
return Err(
ValidationError::new(ValidationErrorKind::TreeLimitExceeded { limit })
.prepend(PathSegment::Object(path.to_owned())),
);
} }
node.object.validate_at_path(path)?; let children = node.children.as_ref().ok_or_else(|| {
ValidationError::new(ValidationErrorKind::ChildrenMissingBeforeDepth { depth })
.prepend(PathSegment::Object(path.to_owned()))
})?;
let mut names = HashSet::new(); let mut names = HashSet::new();
for child in &node.children { for child in children {
validate_object_name(&child.object.name, false) validate_object_name(&child.object.name, false)
.map_err(|error| error.prepend(PathSegment::Child(child.object.name.clone())))?; .map_err(|error| error.prepend(PathSegment::Child(child.object.name.clone())))?;
if !names.insert(child.object.name.as_str()) { if !names.insert(child.object.name.as_str()) {
@@ -673,14 +651,7 @@ fn validate_tree_node(
} else { } else {
format!("{path}/{}", child.object.name) format!("{path}/{}", child.object.name)
}; };
validate_tree_node( validate_observation_node(child, &child_path, depth + 1, maximum_depth)?;
child,
&child_path,
depth.saturating_add(1),
maximum_depth,
limit,
node_count,
)?;
} }
Ok(()) Ok(())
} }
+63 -65
View File
@@ -2,11 +2,11 @@ use std::collections::BTreeMap;
use wip_protocol::{ use wip_protocol::{
CallOperationRequest, CallOperationResponse, EnumCase, FetchInterfaceRequest, CallOperationRequest, CallOperationResponse, EnumCase, FetchInterfaceRequest,
FetchInterfaceResponse, FetchRequest, FetchTreeRequest, FetchTreeResponse, FieldDeclaration, FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceTarget, MAX_SAFE_INTEGER, NameNamespace, InterfaceTarget, MAX_SAFE_INTEGER, NameNamespace, Object, ObjectObservation, ObserveRequest,
Object, ObjectTreeNode, OperationDeclaration, ParameterDeclaration, PathSegment, ProtocolError, OperationDeclaration, ParameterDeclaration, PathSegment, ProtocolError, ProtocolErrorCode,
ProtocolErrorCode, ProtocolInteraction, ProtocolResult, ReturnDeclaration, Target, ProtocolInteraction, ProtocolResult, ReturnDeclaration, Target, TypeDeclaration, TypeExpr,
TypeDeclaration, TypeExpr, UnionCase, ValidationErrorKind, Value, ValueKind, UnionCase, ValidationErrorKind, Value, ValueKind,
}; };
fn record(entries: impl IntoIterator<Item = (&'static str, Value)>) -> Value { fn record(entries: impl IntoIterator<Item = (&'static str, Value)>) -> Value {
@@ -617,13 +617,26 @@ fn calls_validate_canonical_paths_named_arguments_and_unit_results() {
} }
#[test] #[test]
fn fetch_and_fetch_interface_dtos_validate_request_response_correspondence() { fn observe_and_fetch_interface_dtos_validate_request_response_correspondence() {
let fetch = FetchRequest { let observe = ObserveRequest {
path: "/articles/one".into(), path: "/articles/one".into(),
depth: 0,
}; };
fetch.validate().unwrap(); observe.validate().unwrap();
fetch.validate_response(&object("one")).unwrap(); observe
assert!(fetch.validate_response(&object("two")).is_err()); .validate_response(&ObjectObservation {
object: object("one"),
children: None,
})
.unwrap();
assert!(
observe
.validate_response(&ObjectObservation {
object: object("two"),
children: None,
})
.is_err()
);
for invalid in [ for invalid in [
"", "",
@@ -634,8 +647,9 @@ fn fetch_and_fetch_interface_dtos_validate_request_response_correspondence() {
"/a/../b", "/a/../b",
] { ] {
assert!(matches!( assert!(matches!(
FetchRequest { ObserveRequest {
path: invalid.into() path: invalid.into(),
depth: 0,
} }
.validate() .validate()
.unwrap_err() .unwrap_err()
@@ -666,61 +680,51 @@ fn fetch_and_fetch_interface_dtos_validate_request_response_correspondence() {
} }
#[test] #[test]
fn fetch_tree_dtos_validate_names_depth_limit_and_siblings() { fn observe_response_validates_complete_children_and_depth_boundary() {
let request = FetchTreeRequest { let request = ObserveRequest {
path: "/articles".into(), path: "/articles".into(),
depth: 1, depth: 1,
limit: 3,
}; };
let leaf = |name: &str| ObjectTreeNode { let leaf = |name: &str| ObjectObservation {
object: object(name), object: object(name),
children: vec![], children: None,
}; };
let response = FetchTreeResponse { let response = ObjectObservation {
root: ObjectTreeNode {
object: object("articles"), object: object("articles"),
children: vec![leaf("one"), leaf("two")], children: Some(vec![leaf("one"), leaf("two")]),
},
truncated: false,
}; };
response.validate_for(&request).unwrap(); request.validate_response(&response).unwrap();
let zero = FetchTreeRequest { let empty = ObjectObservation {
limit: 0, object: object("articles"),
..request.clone() children: Some(vec![]),
}; };
assert_eq!( request.validate_response(&empty).unwrap();
zero.validate().unwrap_err().kind,
ValidationErrorKind::ZeroTreeLimit
);
let shallow = FetchTreeRequest { let missing = ObjectObservation {
object: object("articles"),
children: None,
};
assert!(matches!(
request.validate_response(&missing).unwrap_err().kind,
ValidationErrorKind::ChildrenMissingBeforeDepth { depth: 0 }
));
let boundary = ObserveRequest {
depth: 0, depth: 0,
..request.clone() ..request.clone()
}; };
assert!(matches!( assert!(matches!(
response.validate_for(&shallow).unwrap_err().kind, boundary.validate_response(&response).unwrap_err().kind,
ValidationErrorKind::TreeDepthExceeded { .. } ValidationErrorKind::ChildrenAtDepthBoundary { depth: 0 }
)); ));
let small = FetchTreeRequest { let duplicate = ObjectObservation {
limit: 2,
..request.clone()
};
assert!(matches!(
response.validate_for(&small).unwrap_err().kind,
ValidationErrorKind::TreeLimitExceeded { .. }
));
let duplicate = FetchTreeResponse {
root: ObjectTreeNode {
object: object("articles"), object: object("articles"),
children: vec![leaf("one"), leaf("one")], children: Some(vec![leaf("one"), leaf("one")]),
},
truncated: false,
}; };
assert!(matches!( assert!(matches!(
duplicate.validate_for(&request).unwrap_err().kind, request.validate_response(&duplicate).unwrap_err().kind,
ValidationErrorKind::DuplicateName { ValidationErrorKind::DuplicateName {
namespace: NameNamespace::ChildObject, namespace: NameNamespace::ChildObject,
.. ..
@@ -778,22 +782,9 @@ fn assert_allowed_codes(interaction: ProtocolInteraction, expected: &[ProtocolEr
} }
#[test] #[test]
fn fetch_error_codes_are_exhaustive() { fn observe_error_codes_are_exhaustive() {
assert_allowed_codes( assert_allowed_codes(
ProtocolInteraction::Fetch, ProtocolInteraction::Observe,
&[
ProtocolErrorCode::InvalidRequest,
ProtocolErrorCode::NotFound,
ProtocolErrorCode::ResourceLimitExceeded,
ProtocolErrorCode::Internal,
],
);
}
#[test]
fn fetch_tree_error_codes_are_exhaustive() {
assert_allowed_codes(
ProtocolInteraction::FetchTree,
&[ &[
ProtocolErrorCode::InvalidRequest, ProtocolErrorCode::InvalidRequest,
ProtocolErrorCode::NotFound, ProtocolErrorCode::NotFound,
@@ -840,11 +831,18 @@ fn call_operation_error_codes_are_exhaustive() {
#[test] #[test]
fn invalid_success_payloads_remain_client_local_validation_failures() { fn invalid_success_payloads_remain_client_local_validation_failures() {
let fetch = FetchRequest { let observe = ObserveRequest {
path: "/articles/one".into(), path: "/articles/one".into(),
depth: 0,
}; };
assert!(matches!( assert!(matches!(
fetch.validate_response(&object("other")).unwrap_err().kind, observe
.validate_response(&ObjectObservation {
object: object("other"),
children: None,
})
.unwrap_err()
.kind,
ValidationErrorKind::ObjectNameMismatch { .. } ValidationErrorKind::ObjectNameMismatch { .. }
)); ));
+1 -1
View File
@@ -190,7 +190,7 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
audit: audit.clone(), audit: audit.clone(),
}; };
let object = provider.fetch(OBJECT_PATH)?; let object = provider.observe_object(OBJECT_PATH)?;
let catalog_response = registry let catalog_response = registry
.fetch_interface( .fetch_interface(
object object
+1 -1
View File
@@ -14,7 +14,7 @@ ratatui = "0.29.0"
serde_json = "1" serde_json = "1"
ureq = "3.1.2" ureq = "3.1.2"
wip-client = { path = "../wip-client" } wip-client = { path = "../wip-client" }
wip-https = { path = "../wip-https" } wip-http = { path = "../wip-http" }
wip-protocol = { path = "../wip-protocol" } wip-protocol = { path = "../wip-protocol" }
[dev-dependencies] [dev-dependencies]
+25 -28
View File
@@ -8,12 +8,17 @@ owned by the client runtime.
## Start ## Start
HTTPS is the normal mode: Supply one `http` or `https` endpoint:
```console ```console
cargo run -p wip-tui -- --endpoint https://wip.example.test/api/wip cargo run -p wip-tui -- --endpoint https://wip.example.test/api/wip
``` ```
The same canonical endpoint is used unchanged to construct requests, perform
HTTP I/O, and partition the client cache. WIP over HTTP does not define separate
HTTPS and development-HTTP profiles; URL scheme, TLS, certificate, authentication,
and proxy policy belong to the HTTP implementation and deployment.
The endpoint may instead be supplied as `WIP_ENDPOINT`. A non-secret cache The endpoint may instead be supplied as `WIP_ENDPOINT`. A non-secret cache
partition identity may be supplied with `--security-context NAME` or partition identity may be supplied with `--security-context NAME` or
`WIP_SECURITY_CONTEXT`. The default request timeout is 30 seconds and can be `WIP_SECURITY_CONTEXT`. The default request timeout is 30 seconds and can be
@@ -31,16 +36,6 @@ Use `--authorization-env OTHER_VARIABLE` to select another variable. The value i
redacted from `Debug` and is never rendered. Shell and process environment security redacted from `Debug` and is never rendered. Shell and process environment security
remain the operator's responsibility. remain the operator's responsibility.
Plain HTTP is rejected by default. It is available only as an explicit loopback
development mode:
```console
cargo run -p wip-tui -- http://127.0.0.1:8080/wip --allow-http
```
Non-loopback HTTP remains rejected even with `--allow-http`. For production, use a
real HTTPS endpoint and a trusted TLS deployment.
### Filesystem server example ### Filesystem server example
In one terminal: In one terminal:
@@ -52,22 +47,22 @@ cargo run -p wip-fs-server -- \
--root /tmp/wip-example \ --root /tmp/wip-example \
--listen 127.0.0.1:8080 \ --listen 127.0.0.1:8080 \
--base-path /wip \ --base-path /wip \
--development-http \
--read-write --read-write
``` ```
In another: In another:
```console ```console
cargo run -p wip-tui -- http://127.0.0.1:8080/wip --allow-http cargo run -p wip-tui -- http://127.0.0.1:8080/wip
``` ```
Expand `/`, select `notes.txt`, focus the Interface/Operation pane, load the read Expand `/`, select `notes.txt`, focus the Interface/Operation pane, and choose
interface, and choose `read`. The editor labels `offset` and `length` as optional; `read`; descriptors advertised by visible Objects are normally prefetched already.
leave both blank to read the bounded file. `Bytes` results are shown as a bounded The editor labels `offset` and `length` as optional; leave both blank to read the
hex preview. For `replace`, enter `contents` as RFC 4648 base64—for example, bounded file. `Bytes` results are shown as a bounded hex preview. For `replace`,
`dXBkYXRlZA==` for `updated`—then review the target Entry, interface reference, enter `contents` as RFC 4648 base64—for example, `dXBkYXRlZA==` for
operation, and value on the confirmation screen before pressing Enter again. `updated`—then review the target Entry, interface reference, operation, and value
on the confirmation screen before pressing Enter again.
## Keyboard bindings ## Keyboard bindings
@@ -80,7 +75,6 @@ All required actions are keyboard-accessible.
| `←`/`h` | Collapse a node or move to its parent | | `←`/`h` | Collapse a node or move to its parent |
| `Tab` | Cycle Tree → Selected Entry → Operations → History | | `Tab` | Cycle Tree → Selected Entry → Operations → History |
| `r` | Reobserve the selected Entry and its direct subtree | | `r` | Reobserve the selected Entry and its direct subtree |
| `+` | Double the selected node limit and retry an incomplete/truncated tree |
| `c` | Cancel the newest retrieval; a dispatched operation is never presented as canceled | | `c` | Cancel the newest retrieval; a dispatched operation is never presented as canceled |
| `R` | Reconnect after connection failure | | `R` | Reconnect after connection failure |
| `?` | Open help | | `?` | Open help |
@@ -93,17 +87,20 @@ a second explicit Enter (or `e`) to execute once. There is no operation auto-ret
## Screen model ## Screen model
- **Worldspace tree** starts at `/` and materializes direct children only when a - **Worldspace tree** starts at `/`, lazily materializes expanded nodes, and
node is expanded. Text labels distinguish unloaded, loading, empty, stale, prefetches children for each visible Object so emptiness and the next expansion
failed, and truncated state; color is supplemental. A truncated node is never frontier are known without selecting that Object first. Text labels distinguish
presented as a complete tree. unloaded, loading, empty, stale, and failed state; color is supplemental. Every
successful expansion is complete for its requested depth.
- **Selected Entry** shows only public WIP path, object name, description, ordered - **Selected Entry** shows only public WIP path, object name, description, ordered
interfaces, and freshness. Validators, credentials, Host internals, and backend interfaces, and freshness. Validators, credentials, Host internals, and backend
paths are deliberately hidden. If current metadata disappears, old text is not paths are deliberately hidden. If current metadata disappears, old text is not
presented as current. presented as current.
- **Interfaces / Operations** identifies every operation by `(interface reference, - **Interfaces / Operations** identifies every operation by `(interface reference,
operation name)`. It shows descriptor documentation, ordered parameters, operation name)`. Descriptor lookahead follows fresh visible Objects, shares the
required/optional status, and result types. client's in-flight/cache bounds, and never retries retained failures implicitly.
The pane shows descriptor documentation, ordered parameters, required/optional
status, and result types.
- **Argument editor** supports Unit, Boolean, Integer, Number, String, base64 Bytes, - **Argument editor** supports Unit, Boolean, Integer, Number, String, base64 Bytes,
Record, List, Entry, Enum, Union, Json, and descriptor-local named types. Record, Record, List, Entry, Enum, Union, Json, and descriptor-local named types. Record,
List, Union, and Json values use JSON syntax. Union input is List, Union, and Json values use JSON syntax. Union input is
@@ -121,8 +118,8 @@ layout.
## Validator and failure behavior ## Validator and failure behavior
Object and interface validators are observed and attached automatically by Object and interface validators are observed and attached automatically by
`wip-client`; users neither copy nor edit them. A successful operation conservatively `wip-client`; users neither copy nor edit them. A successful operation may update the
marks related object observations stale because metadata may have changed. cached Object validator but does not by itself make the Object observation stale.
Validator mismatch, interface mismatch, `OperationOutcomeUnknown`, undecodable Validator mismatch, interface mismatch, `OperationOutcomeUnknown`, undecodable
post-dispatch responses, and post-dispatch timeout/disconnect are displayed using post-dispatch responses, and post-dispatch timeout/disconnect are displayed using
the runtime's authoritative transition. the runtime's authoritative transition.
+83 -52
View File
@@ -31,8 +31,6 @@ pub enum InputKey {
Backspace, Backspace,
/// Refresh the selected Entry. /// Refresh the selected Entry.
Refresh, Refresh,
/// Retry a truncated tree with a larger limit.
IncreaseLimit,
/// Cancel the newest cancel-safe retrieval. /// Cancel the newest cancel-safe retrieval.
CancelRead, CancelRead,
/// Show key help. /// Show key help.
@@ -123,14 +121,10 @@ pub struct EntryView {
pub tree_state: ViewState, pub tree_state: ViewState,
/// Whether the user expanded this row. /// Whether the user expanded this row.
pub expanded: bool, pub expanded: bool,
/// Whether the observed child list is incomplete.
pub truncated: bool,
/// Current bounded expansion limit.
pub limit: u32,
} }
impl EntryView { impl EntryView {
fn placeholder(path: impl Into<String>, default_limit: u32) -> Self { fn placeholder(path: impl Into<String>) -> Self {
Self { Self {
path: path.into(), path: path.into(),
object: None, object: None,
@@ -138,8 +132,6 @@ impl EntryView {
children: Vec::new(), children: Vec::new(),
tree_state: ViewState::Unloaded, tree_state: ViewState::Unloaded,
expanded: false, expanded: false,
truncated: false,
limit: default_limit,
} }
} }
} }
@@ -242,15 +234,11 @@ pub enum Action {
Expand { Expand {
/// Tree root path. /// Tree root path.
path: String, path: String,
/// Maximum returned nodes.
limit: u32,
}, },
/// Reobserve an Entry and its direct children. /// Reobserve an Entry and its direct children.
Refresh { Refresh {
/// Selected path. /// Selected path.
path: String, path: String,
/// Current tree limit.
limit: u32,
}, },
/// Retrieve one interface descriptor. /// Retrieve one interface descriptor.
FetchInterface(String), FetchInterface(String),
@@ -267,7 +255,7 @@ pub enum Action {
}, },
/// Cancel the newest safe retrieval. /// Cancel the newest safe retrieval.
CancelRead, CancelRead,
/// Reconnect and fetch the root. /// Reconnect and observe the root.
Reconnect, Reconnect,
/// Navigate to a descriptor-declared Entry result. /// Navigate to a descriptor-declared Entry result.
NavigateEntry(String), NavigateEntry(String),
@@ -302,8 +290,6 @@ pub struct App {
pub history: Vec<HistoryItem>, pub history: Vec<HistoryItem>,
/// Maximum retained presentation history. /// Maximum retained presentation history.
pub history_limit: usize, pub history_limit: usize,
/// Default node limit for lazy expansion.
pub default_tree_limit: u32,
/// One-line status for local action feedback. /// One-line status for local action feedback.
pub status: String, pub status: String,
} }
@@ -312,9 +298,10 @@ impl App {
/// Creates a disconnected model with `/` selected. /// Creates a disconnected model with `/` selected.
#[must_use] #[must_use]
pub fn new(endpoint: impl Into<String>) -> Self { pub fn new(endpoint: impl Into<String>) -> Self {
let default_tree_limit = 64;
let mut entries = BTreeMap::new(); let mut entries = BTreeMap::new();
entries.insert("/".into(), EntryView::placeholder("/", default_tree_limit)); let mut root = EntryView::placeholder("/");
root.expanded = true;
entries.insert("/".into(), root);
Self { Self {
endpoint: endpoint.into(), endpoint: endpoint.into(),
connection: ConnectionState::Disconnected, connection: ConnectionState::Disconnected,
@@ -328,7 +315,6 @@ impl App {
in_flight: BTreeMap::new(), in_flight: BTreeMap::new(),
history: Vec::new(), history: Vec::new(),
history_limit: 64, history_limit: 64,
default_tree_limit,
status: "press ? for help".into(), status: "press ? for help".into(),
} }
} }
@@ -441,14 +427,11 @@ impl App {
let node = self let node = self
.entries .entries
.entry(self.selected_path.clone()) .entry(self.selected_path.clone())
.or_insert_with(|| { .or_insert_with(|| EntryView::placeholder(&self.selected_path));
EntryView::placeholder(&self.selected_path, self.default_tree_limit)
});
node.expanded = true; node.expanded = true;
if !matches!(node.tree_state, ViewState::Fresh) || node.truncated { if !matches!(node.tree_state, ViewState::Fresh) {
return vec![Action::Expand { return vec![Action::Expand {
path: node.path.clone(), path: node.path.clone(),
limit: node.limit,
}]; }];
} }
} }
@@ -464,18 +447,6 @@ impl App {
} }
} }
InputKey::Refresh => return vec![self.refresh_action()], InputKey::Refresh => return vec![self.refresh_action()],
InputKey::IncreaseLimit => {
let node = self
.entries
.get_mut(&self.selected_path)
.expect("selected exists");
node.limit = node.limit.saturating_mul(2).max(1);
node.expanded = true;
return vec![Action::Expand {
path: node.path.clone(),
limit: node.limit,
}];
}
_ => {} _ => {}
} }
self.operation_cursor = 0; self.operation_cursor = 0;
@@ -537,13 +508,8 @@ impl App {
} }
fn refresh_action(&self) -> Action { fn refresh_action(&self) -> Action {
let limit = self
.entries
.get(&self.selected_path)
.map_or(self.default_tree_limit, |entry| entry.limit);
Action::Refresh { Action::Refresh {
path: self.selected_path.clone(), path: self.selected_path.clone(),
limit,
} }
} }
@@ -591,9 +557,7 @@ impl App {
let children = observation.children.clone(); let children = observation.children.clone();
let entry = self.ensure_entry(&observation.path); let entry = self.ensure_entry(&observation.path);
entry.children = children.clone(); entry.children = children.clone();
entry.truncated = observation.truncated;
entry.tree_state = ViewState::from(&observation.state); entry.tree_state = ViewState::from(&observation.state);
entry.expanded = true;
for path in children { for path in children {
self.ensure_entry(&path); self.ensure_entry(&path);
} }
@@ -648,6 +612,30 @@ impl App {
visible visible
} }
/// Returns interface references advertised by fresh visible Objects in display
/// and per-Object declaration order, with duplicates removed.
#[must_use]
pub fn visible_interface_references(&self) -> Vec<String> {
let mut references = Vec::new();
let mut seen = BTreeSet::new();
for path in self.visible_paths() {
let Some(object) = self
.entries
.get(&path)
.filter(|entry| entry.state == ViewState::Fresh)
.and_then(|entry| entry.object.as_ref())
else {
continue;
};
for reference in &object.interfaces {
if seen.insert(reference.clone()) {
references.push(reference.clone());
}
}
}
references
}
fn collect_visible(&self, path: &str, seen: &mut BTreeSet<String>, visible: &mut Vec<String>) { fn collect_visible(&self, path: &str, seen: &mut BTreeSet<String>, visible: &mut Vec<String>) {
if !seen.insert(path.to_owned()) { if !seen.insert(path.to_owned()) {
return; return;
@@ -725,7 +713,7 @@ impl App {
} }
self.entries self.entries
.entry(path.to_owned()) .entry(path.to_owned())
.or_insert_with(|| EntryView::placeholder(path, self.default_tree_limit)) .or_insert_with(|| EntryView::placeholder(path))
} }
fn ensure_interface(&mut self, reference: &str) -> &mut InterfaceView { fn ensure_interface(&mut self, reference: &str) -> &mut InterfaceView {
@@ -799,26 +787,69 @@ mod tests {
} }
#[test] #[test]
fn tree_navigation_expansion_parent_and_limit_are_pure() { fn tree_navigation_and_lazy_expansion_are_pure() {
let mut app = App::new("https://example.test/wip"); let mut app = App::new("https://example.test/wip");
app.apply_tree(TreeObservation { app.apply_tree(TreeObservation {
path: "/".into(), path: "/".into(),
children: vec!["/a".into(), "/b".into()], children: vec!["/a".into(), "/b".into()],
truncated: true,
revision: request_id(), revision: request_id(),
state: ObservationState::Fresh, state: ObservationState::Fresh,
}); });
assert_eq!(app.visible_paths(), ["/", "/a", "/b"]); assert_eq!(app.visible_paths(), ["/", "/a", "/b"]);
app.reduce(InputKey::Down); app.reduce(InputKey::Down);
assert_eq!(app.selected_path, "/a"); assert_eq!(app.selected_path, "/a");
app.reduce(InputKey::Left);
assert_eq!(app.selected_path, "/");
assert!(matches!( assert!(matches!(
app.reduce(InputKey::IncreaseLimit).as_slice(), app.reduce(InputKey::Right).as_slice(),
[Action::Expand { path, limit: 128 }] if path == "/" [Action::Expand { path }] if path == "/a"
)); ));
} }
#[test]
fn visible_interface_references_are_ordered_deduplicated_and_fresh() {
let mut app = App::new("https://example.test/wip");
app.apply_tree(TreeObservation {
path: "/".into(),
children: vec!["/a".into(), "/stale".into()],
revision: request_id(),
state: ObservationState::Fresh,
});
app.apply_object(ObjectObservation {
path: "/".into(),
object: Some(object("", &["root", "shared"])),
validator: None,
state: ObservationState::Fresh,
});
app.apply_object(ObjectObservation {
path: "/a".into(),
object: Some(object("a", &["shared", "child"])),
validator: None,
state: ObservationState::Fresh,
});
app.apply_object(ObjectObservation {
path: "/stale".into(),
object: Some(object("stale", &["ignored"])),
validator: None,
state: ObservationState::Stale,
});
app.apply_tree(TreeObservation {
path: "/a".into(),
children: vec!["/a/hidden".into()],
revision: request_id(),
state: ObservationState::Fresh,
});
app.apply_object(ObjectObservation {
path: "/a/hidden".into(),
object: Some(object("hidden", &["hidden"])),
validator: None,
state: ObservationState::Fresh,
});
assert_eq!(
app.visible_interface_references(),
["root", "shared", "child"]
);
}
#[test] #[test]
fn focus_modal_and_explicit_confirmation_gate_execution() { fn focus_modal_and_explicit_confirmation_gate_execution() {
let mut app = App::new("https://example.test/wip"); let mut app = App::new("https://example.test/wip");
@@ -908,7 +939,7 @@ mod tests {
// A request identity has no public constructor; derive one through the runtime. // A request identity has no public constructor; derive one through the runtime.
let mut client = wip_client::Client::new( let mut client = wip_client::Client::new(
wip_client::ClientLimits::new(1, 4, 4, 4, 4).unwrap(), wip_client::ClientLimits::new(1, 4, 4, 4, 4).unwrap(),
wip_https::Limits::new(1024, 1024, 8, 8).unwrap(), wip_http::Limits::new(1024, 1024, 8).unwrap(),
); );
let session = client let session = client
.open_session( .open_session(
@@ -916,6 +947,6 @@ mod tests {
wip_client::SecurityContext::new("test"), wip_client::SecurityContext::new("test"),
) )
.unwrap(); .unwrap();
client.prepare_fetch(&session, "/").unwrap().id client.refresh_object(&session, "/").unwrap().id
} }
} }
+32 -108
View File
@@ -1,10 +1,10 @@
//! Secure-by-default command-line and environment configuration. //! Command-line and environment configuration for the HTTP client.
use std::error::Error; use std::error::Error;
use std::fmt::{self, Debug, Display, Formatter}; use std::fmt::{self, Debug, Display, Formatter};
use std::time::Duration; use std::time::Duration;
use wip_https::url::Url; use wip_http::Endpoint;
/// Credential wrapper whose debug output is always redacted. /// Credential wrapper whose debug output is always redacted.
#[derive(Clone)] #[derive(Clone)]
@@ -25,45 +25,33 @@ impl Debug for Secret {
/// Validated runtime configuration. /// Validated runtime configuration.
#[derive(Clone)] #[derive(Clone)]
pub struct Config { pub struct Config {
/// Canonical logical HTTPS endpoint used by `wip-client`. /// Canonical endpoint used for request construction, transport, and cache partitioning.
logical_endpoint: String, endpoint: String,
/// Actual request origin; differs only for explicit loopback development HTTP.
transport_endpoint: String,
/// Non-secret cache partition identity. /// Non-secret cache partition identity.
security_context: String, security_context: String,
/// Bounded network timeout. /// Bounded network timeout.
timeout: Duration, timeout: Duration,
/// Optional complete Authorization header value, never rendered or logged. /// Optional complete Authorization header value, never rendered or logged.
authorization: Option<Secret>, authorization: Option<Secret>,
/// Whether loopback request URIs are deliberately downgraded to HTTP.
development_http: bool,
} }
impl Debug for Config { impl Debug for Config {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
formatter formatter
.debug_struct("Config") .debug_struct("Config")
.field("logical_endpoint", &self.logical_endpoint) .field("endpoint", &self.endpoint)
.field("transport_endpoint", &self.transport_endpoint)
.field("security_context", &self.security_context) .field("security_context", &self.security_context)
.field("timeout", &self.timeout) .field("timeout", &self.timeout)
.field("authorization", &self.authorization) .field("authorization", &self.authorization)
.field("development_http", &self.development_http)
.finish() .finish()
} }
} }
impl Config { impl Config {
/// Returns the canonical HTTPS endpoint used for protocol request construction. /// Returns the canonical endpoint used by the client and HTTP transport.
#[must_use] #[must_use]
pub fn logical_endpoint(&self) -> &str { pub fn endpoint(&self) -> &str {
&self.logical_endpoint &self.endpoint
}
/// Returns the validated effective transport endpoint shown in the UI.
#[must_use]
pub fn transport_endpoint(&self) -> &str {
&self.transport_endpoint
} }
/// Returns the non-secret session cache partition identity. /// Returns the non-secret session cache partition identity.
@@ -78,12 +66,6 @@ impl Config {
self.timeout self.timeout
} }
/// Whether explicit loopback development HTTP is enabled.
#[must_use]
pub const fn development_http(&self) -> bool {
self.development_http
}
pub(crate) fn authorization(&self) -> Option<&Secret> { pub(crate) fn authorization(&self) -> Option<&Secret> {
self.authorization.as_ref() self.authorization.as_ref()
} }
@@ -115,7 +97,6 @@ impl Config {
F: FnMut(&str) -> Option<String>, F: FnMut(&str) -> Option<String>,
{ {
let mut endpoint = None; let mut endpoint = None;
let mut allow_http = false;
let mut timeout = Duration::from_secs(30); let mut timeout = Duration::from_secs(30);
let mut security_context = security_context_env.unwrap_or_else(|| "anonymous".into()); let mut security_context = security_context_env.unwrap_or_else(|| "anonymous".into());
let mut authorization_env = "WIP_AUTHORIZATION".to_owned(); let mut authorization_env = "WIP_AUTHORIZATION".to_owned();
@@ -123,7 +104,6 @@ impl Config {
while let Some(argument) = iter.next() { while let Some(argument) = iter.next() {
match argument.as_str() { match argument.as_str() {
"--endpoint" => endpoint = Some(next_value(&mut iter, "--endpoint")?), "--endpoint" => endpoint = Some(next_value(&mut iter, "--endpoint")?),
"--allow-http" => allow_http = true,
"--security-context" => { "--security-context" => {
security_context = next_value(&mut iter, "--security-context")?; security_context = next_value(&mut iter, "--security-context")?;
} }
@@ -149,52 +129,22 @@ impl Config {
_ => return Err(ConfigError::Invalid("multiple endpoints supplied".into())), _ => return Err(ConfigError::Invalid("multiple endpoints supplied".into())),
} }
} }
let endpoint = endpoint let supplied = endpoint
.or(endpoint_env) .or(endpoint_env)
.ok_or_else(|| ConfigError::Invalid("endpoint is required".into()))?; .ok_or_else(|| ConfigError::Invalid("endpoint is required".into()))?;
let mut actual = Url::parse(&endpoint) let endpoint =
.map_err(|_| ConfigError::Invalid("endpoint is not a valid absolute URL".into()))?; Endpoint::parse(&supplied).map_err(|error| ConfigError::Invalid(error.to_string()))?;
if actual.query().is_some() if !endpoint.as_url().username().is_empty() || endpoint.as_url().password().is_some() {
|| actual.fragment().is_some()
|| actual.host_str().is_none()
|| !actual.username().is_empty()
|| actual.password().is_some()
{
return Err(ConfigError::Invalid( return Err(ConfigError::Invalid(
"endpoint must be an absolute URL without userinfo, query, or fragment".into(), "endpoint userinfo is not accepted; supply credentials through the configured environment variable"
.into(),
)); ));
} }
if !actual.path().ends_with('/') { if !matches!(endpoint.as_url().scheme(), "http" | "https") {
let path = format!("{}/", actual.path());
actual.set_path(&path);
}
let development_http = actual.scheme() == "http";
match actual.scheme() {
"https" => {}
"http" if allow_http && loopback_host(&actual) => {}
"http" if !allow_http => {
return Err(ConfigError::Invalid( return Err(ConfigError::Invalid(
"plain HTTP requires the explicit --allow-http development option".into(), "this HTTP client supports http and https endpoint schemes".into(),
)); ));
} }
"http" => {
return Err(ConfigError::Invalid(
"development HTTP is restricted to loopback endpoints".into(),
));
}
_ => return Err(ConfigError::Invalid("endpoint must use HTTPS".into())),
}
let transport_endpoint = actual.to_string();
let mut logical = actual;
if development_http {
logical
.set_scheme("https")
.expect("http and https are interchangeable hierarchical schemes");
}
// Let the binding enforce all remaining canonical endpoint rules.
let logical_endpoint = wip_https::Endpoint::parse(logical.as_str())
.map_err(|error| ConfigError::Invalid(error.to_string()))?
.to_string();
if security_context.is_empty() { if security_context.is_empty() {
return Err(ConfigError::Invalid( return Err(ConfigError::Invalid(
"security context must not be empty".into(), "security context must not be empty".into(),
@@ -204,20 +154,18 @@ impl Config {
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.map(Secret); .map(Secret);
Ok(Self { Ok(Self {
logical_endpoint, endpoint: endpoint.to_string(),
transport_endpoint,
security_context, security_context,
timeout, timeout,
authorization, authorization,
development_http,
}) })
} }
/// Returns concise command usage without mentioning credential values. /// Returns concise command usage without mentioning credential values.
#[must_use] #[must_use]
pub const fn usage() -> &'static str { pub const fn usage() -> &'static str {
"usage: wip-tui [--endpoint] URL [--allow-http] [--security-context NAME] [--authorization-env VAR] [--timeout SECONDS]\n\ "usage: wip-tui [--endpoint] URL [--security-context NAME] [--authorization-env VAR] [--timeout SECONDS]\n\
URL may also be supplied as WIP_ENDPOINT; HTTPS is required unless loopback HTTP is explicitly enabled." URL may also be supplied as WIP_ENDPOINT; the HTTP adapter supports http and https."
} }
} }
@@ -229,15 +177,6 @@ fn next_value(
.ok_or_else(|| ConfigError::Invalid(format!("{option} requires a value"))) .ok_or_else(|| ConfigError::Invalid(format!("{option} requires a value")))
} }
fn loopback_host(url: &Url) -> bool {
match url.host() {
Some(wip_https::url::Host::Ipv4(address)) => address.is_loopback(),
Some(wip_https::url::Host::Ipv6(address)) => address.is_loopback(),
Some(wip_https::url::Host::Domain(name)) => name.eq_ignore_ascii_case("localhost"),
None => false,
}
}
/// Startup configuration failure. /// Startup configuration failure.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigError { pub enum ConfigError {
@@ -263,7 +202,7 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn https_is_default_and_secrets_are_redacted() { fn endpoint_is_canonical_and_secrets_are_redacted() {
let config = Config::parse( let config = Config::parse(
["https://example.test/wip", "--authorization-env", "TOKEN"], ["https://example.test/wip", "--authorization-env", "TOKEN"],
None, None,
@@ -271,7 +210,7 @@ mod tests {
|name| (name == "TOKEN").then(|| "Bearer secret-value".into()), |name| (name == "TOKEN").then(|| "Bearer secret-value".into()),
) )
.unwrap(); .unwrap();
assert_eq!(config.logical_endpoint(), "https://example.test/wip/"); assert_eq!(config.endpoint(), "https://example.test/wip/");
let debug = format!("{config:?}"); let debug = format!("{config:?}");
assert!(debug.contains("[REDACTED]")); assert!(debug.contains("[REDACTED]"));
assert!(!debug.contains("secret-value")); assert!(!debug.contains("secret-value"));
@@ -279,37 +218,22 @@ mod tests {
#[test] #[test]
fn endpoint_userinfo_is_rejected_before_it_can_reach_debug_or_render_state() { fn endpoint_userinfo_is_rejected_before_it_can_reach_debug_or_render_state() {
for arguments in [ for endpoint in [
vec!["https://user:password@example.test/wip"], "https://user:password@example.test/wip",
vec!["http://user:password@localhost:3000/wip", "--allow-http"], "http://user:password@localhost:3000/wip",
] { ] {
let error = Config::parse(arguments, None, None, |_| None).unwrap_err(); let error = Config::parse([endpoint], None, None, |_| None).unwrap_err();
assert!(error.to_string().contains("without userinfo")); assert!(error.to_string().contains("userinfo"));
assert!(!error.to_string().contains("password")); assert!(!error.to_string().contains("password"));
} }
} }
#[test] #[test]
fn http_requires_explicit_loopback_development_mode() { fn http_and_https_are_preserved_as_the_single_endpoint() {
assert!(Config::parse(["http://localhost:3000/wip"], None, None, |_| None).is_err()); for expected in ["http://example.test:3000/wip/", "https://example.test/wip/"] {
let config = Config::parse( let supplied = expected.trim_end_matches('/');
["http://127.0.0.1:3000/wip", "--allow-http"], let config = Config::parse([supplied], None, None, |_| None).unwrap();
None, assert_eq!(config.endpoint(), expected);
None, }
|_| None,
)
.unwrap();
assert_eq!(config.logical_endpoint(), "https://127.0.0.1:3000/wip/");
assert_eq!(config.transport_endpoint(), "http://127.0.0.1:3000/wip/");
assert!(config.development_http());
assert!(
Config::parse(
["http://example.test/wip", "--allow-http"],
None,
None,
|_| None,
)
.is_err()
);
} }
} }
+32 -21
View File
@@ -113,13 +113,8 @@ fn render_tree(frame: &mut Frame<'_>, area: Rect, app: &App) {
ViewState::Fresh if node.children.is_empty() => " [empty]", ViewState::Fresh if node.children.is_empty() => " [empty]",
_ => "", _ => "",
}; };
let incomplete = if node.truncated {
" [truncated: + to increase limit]"
} else {
""
};
let object = state_suffix(&node.state); let object = state_suffix(&node.state);
format!("{object}{tree}{incomplete}") format!("{object}{tree}")
}, },
); );
suffix = sanitize(&suffix); suffix = sanitize(&suffix);
@@ -178,14 +173,6 @@ fn render_selected(frame: &mut Frame<'_>, area: Rect, app: &App) {
"No current object metadata; refresh required (old metadata hidden).", "No current object metadata; refresh required (old metadata hidden).",
)); ));
} }
if entry.truncated {
lines.push(Line::styled(
"TRUNCATED: this is not a complete subtree.",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
}
} }
frame.render_widget( frame.render_widget(
Paragraph::new(lines) Paragraph::new(lines)
@@ -355,7 +342,7 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
frame.render_widget(Clear, area); frame.render_widget(Clear, area);
frame.render_widget( frame.render_widget(
Paragraph::new( Paragraph::new(
"Keyboard help\n\n↑/↓ or j/k move selection / editor field\n←/→ or h/l collapse/expand or parent\nTab next pane\nEnter load interface, edit operation, confirm execute\nr refresh selected Entry and subtree\n+ increase truncated tree limit and retry\nc cancel newest retrieval (never cancels a sent operation)\nR reconnect after failure\nEsc close modal\nq quit\n\nOperations are never sent until the confirmation screen is accepted.", "Keyboard help\n\n↑/↓ or j/k move selection / editor field\n←/→ or h/l collapse/expand or parent\nTab next pane\nEnter load interface, edit operation, confirm execute\nr refresh selected Entry and subtree\nc cancel newest retrieval (never cancels a sent operation)\nR reconnect after failure\nEsc close modal\nq quit\n\nOperations are never sent until the confirmation screen is accepted.",
) )
.block(Block::default().borders(Borders::ALL).title(" Help ")) .block(Block::default().borders(Borders::ALL).title(" Help "))
.wrap(Wrap { trim: false }), .wrap(Wrap { trim: false }),
@@ -621,11 +608,40 @@ mod tests {
.join("\n") .join("\n")
} }
#[test]
fn observed_empty_visible_entry_is_labeled_without_expansion() {
let mut app = App::new("https://example.test/wip/");
{
let root = app.entries.get_mut("/").unwrap();
root.children = vec!["/empty".into()];
root.tree_state = ViewState::Fresh;
}
app.entries.insert(
"/empty".into(),
EntryView {
path: "/empty".into(),
object: Some(Object {
name: "empty".into(),
description: None,
interfaces: vec![],
r#ref: None,
validator: None,
}),
state: ViewState::Fresh,
children: vec![],
tree_state: ViewState::Fresh,
expanded: false,
},
);
let snapshot = rendered(&app, 80, 20);
assert!(snapshot.contains("empty [empty]"));
}
#[test] #[test]
fn snapshot_contains_non_color_state_markers_and_hides_validators() { fn snapshot_contains_non_color_state_markers_and_hides_validators() {
let mut app = App::new("https://example.test/wip/"); let mut app = App::new("https://example.test/wip/");
app.connection = ConnectionState::Connected; app.connection = ConnectionState::Connected;
app.entries.get_mut("/").unwrap().truncated = true;
app.entries.get_mut("/").unwrap().state = ViewState::Stale; app.entries.get_mut("/").unwrap().state = ViewState::Stale;
app.push_history(HistoryItem { app.push_history(HistoryItem {
kind: HistoryKind::OutcomeUnknown, kind: HistoryKind::OutcomeUnknown,
@@ -634,7 +650,6 @@ mod tests {
entry_target: None, entry_target: None,
}); });
let snapshot = rendered(&app, 110, 30); let snapshot = rendered(&app, 110, 30);
assert!(snapshot.contains("truncated"));
assert!(snapshot.contains("stale")); assert!(snapshot.contains("stale"));
assert!(snapshot.contains("OUTCOME UNKNOWN")); assert!(snapshot.contains("OUTCOME UNKNOWN"));
assert!(!snapshot.contains("validator")); assert!(!snapshot.contains("validator"));
@@ -740,8 +755,6 @@ mod tests {
children: vec![], children: vec![],
tree_state: ViewState::Unloaded, tree_state: ViewState::Unloaded,
expanded: false, expanded: false,
truncated: false,
limit: 1,
}, },
); );
} }
@@ -832,8 +845,6 @@ mod tests {
children: vec![], children: vec![],
tree_state: ViewState::Unloaded, tree_state: ViewState::Unloaded,
expanded: false, expanded: false,
truncated: false,
limit: 1,
}; };
assert_eq!(entry.path, "/"); assert_eq!(entry.path, "/");
} }
+98 -106
View File
@@ -13,8 +13,8 @@ use wip_client::{
DispatchedTransportFailure, OutcomeUnknownReason, PreparedRequest, RequestId, SecurityContext, DispatchedTransportFailure, OutcomeUnknownReason, PreparedRequest, RequestId, SecurityContext,
SessionId, SessionId,
}; };
use wip_https::Limits; use wip_http::Limits;
use wip_https::http::{Request, Response}; use wip_http::http::{Request, Response};
use wip_protocol::{InterfaceDescriptor, TypeExpr, Value}; use wip_protocol::{InterfaceDescriptor, TypeExpr, Value};
use crate::app::{ use crate::app::{
@@ -68,18 +68,17 @@ pub trait Transport: Send + Sync + 'static {
fn execute(&self, request: Self::Prepared) -> Result<Response<Vec<u8>>, TransportError>; fn execute(&self, request: Self::Prepared) -> Result<Response<Vec<u8>>, TransportError>;
} }
/// HTTPS transport with redirects disabled and bounded response bodies. /// HTTP transport with redirects disabled and bounded response bodies.
#[derive(Clone)] #[derive(Clone)]
pub struct HttpTransport { pub struct HttpTransport {
agent: ureq::Agent, agent: ureq::Agent,
logical_endpoint: wip_https::Endpoint, endpoint: wip_http::Endpoint,
transport_endpoint: wip_https::url::Url, authorization: Option<wip_http::http::HeaderValue>,
authorization: Option<wip_https::http::HeaderValue>,
max_response_bytes: usize, max_response_bytes: usize,
} }
impl HttpTransport { impl HttpTransport {
/// Builds the default TLS transport and revalidates its immutable origins. /// Builds the HTTP transport and revalidates its immutable endpoint.
pub fn new(config: &Config, max_response_bytes: usize) -> Result<Self, TransportError> { pub fn new(config: &Config, max_response_bytes: usize) -> Result<Self, TransportError> {
if max_response_bytes == 0 { if max_response_bytes == 0 {
return Err(TransportError { return Err(TransportError {
@@ -87,29 +86,18 @@ impl HttpTransport {
detail: "transport response limit must be nonzero".into(), detail: "transport response limit must be nonzero".into(),
}); });
} }
let logical_endpoint = let endpoint =
wip_https::Endpoint::parse(config.logical_endpoint()).map_err(|_| TransportError { wip_http::Endpoint::parse(config.endpoint()).map_err(|_| TransportError {
kind: TransportErrorKind::Disconnect, kind: TransportErrorKind::Disconnect,
detail: "logical endpoint failed transport validation".into(), detail: "endpoint failed transport validation".into(),
})?; })?;
let transport_endpoint =
wip_https::url::Url::parse(config.transport_endpoint()).map_err(|_| {
TransportError {
kind: TransportErrorKind::Disconnect,
detail: "transport endpoint failed validation".into(),
}
})?;
validate_transport_origin(config, &logical_endpoint, &transport_endpoint)?;
let authorization = config let authorization = config
.authorization() .authorization()
.map(|secret| { .map(|secret| {
wip_https::http::HeaderValue::from_str(secret.expose()).map_err(|_| { wip_http::http::HeaderValue::from_str(secret.expose()).map_err(|_| TransportError {
TransportError {
kind: TransportErrorKind::Disconnect, kind: TransportErrorKind::Disconnect,
detail: detail: "authorization environment variable is not a valid HTTP header value"
"authorization environment variable is not a valid HTTP header value"
.into(), .into(),
}
}) })
}) })
.transpose()?; .transpose()?;
@@ -121,67 +109,24 @@ impl HttpTransport {
.into(); .into();
Ok(Self { Ok(Self {
agent, agent,
logical_endpoint, endpoint,
transport_endpoint,
authorization, authorization,
max_response_bytes, max_response_bytes,
}) })
} }
fn effective_uri(&self, uri: &str) -> Result<String, TransportError> { fn effective_uri(&self, uri: &str) -> Result<String, TransportError> {
let candidate = wip_https::url::Url::parse(uri).map_err(|_| TransportError { let candidate = wip_http::url::Url::parse(uri).map_err(|_| TransportError {
kind: TransportErrorKind::Disconnect, kind: TransportErrorKind::Disconnect,
detail: "refused a request with an invalid absolute URI".into(), detail: "refused a request with an invalid absolute URI".into(),
})?; })?;
let route = self self.endpoint
.logical_endpoint
.recognize(&candidate) .recognize(&candidate)
.ok_or_else(|| TransportError { .ok_or_else(|| TransportError {
kind: TransportErrorKind::Disconnect, kind: TransportErrorKind::Disconnect,
detail: "refused a request outside the configured WIP origin and routes".into(), detail: "refused a request outside the configured WIP origin and routes".into(),
})?; })?;
Ok(self Ok(candidate.to_string())
.transport_endpoint
.join(route.relative_reference())
.expect("validated endpoint joins a fixed route")
.to_string())
}
}
fn validate_transport_origin(
config: &Config,
logical: &wip_https::Endpoint,
transport: &wip_https::url::Url,
) -> Result<(), TransportError> {
let common_invalid = transport.query().is_some()
|| transport.fragment().is_some()
|| transport.host_str().is_none()
|| !transport.username().is_empty()
|| transport.password().is_some();
let valid = if config.development_http() {
let loopback = match transport.host() {
Some(wip_https::url::Host::Ipv4(address)) => address.is_loopback(),
Some(wip_https::url::Host::Ipv6(address)) => address.is_loopback(),
Some(wip_https::url::Host::Domain(name)) => name.eq_ignore_ascii_case("localhost"),
None => false,
};
let mut upgraded = transport.clone();
let changed = upgraded.set_scheme("https").is_ok();
!common_invalid
&& transport.scheme() == "http"
&& loopback
&& changed
&& &upgraded == logical.as_url()
} else {
!common_invalid && transport == logical.as_url()
};
if valid {
Ok(())
} else {
Err(TransportError {
kind: TransportErrorKind::Disconnect,
detail: "transport endpoint does not match the validated logical origin".into(),
})
} }
} }
@@ -196,10 +141,9 @@ impl Transport for HttpTransport {
detail: "transport could not construct the configured request URI".into(), detail: "transport could not construct the configured request URI".into(),
})?; })?;
if let Some(authorization) = &self.authorization { if let Some(authorization) = &self.authorization {
parts.headers.insert( parts
wip_https::http::header::AUTHORIZATION, .headers
authorization.clone(), .insert(wip_http::http::header::AUTHORIZATION, authorization.clone());
);
} }
Ok(Request::from_parts(parts, body)) Ok(Request::from_parts(parts, body))
} }
@@ -242,7 +186,7 @@ fn classify_ureq_error(error: ureq::Error) -> TransportError {
}, },
_ => TransportError { _ => TransportError {
kind: TransportErrorKind::Disconnect, kind: TransportErrorKind::Disconnect,
detail: "HTTP/TLS transport failed".into(), detail: "HTTP transport failed".into(),
}, },
} }
} }
@@ -274,7 +218,7 @@ impl<T: Transport> Runtime<T> {
); );
let session = client let session = client
.open_session( .open_session(
config.logical_endpoint(), config.endpoint(),
SecurityContext::new(config.security_context()), SecurityContext::new(config.security_context()),
) )
.map_err(|error| RuntimeError(error.to_string()))?; .map_err(|error| RuntimeError(error.to_string()))?;
@@ -282,7 +226,7 @@ impl<T: Transport> Runtime<T> {
Ok(Self { Ok(Self {
client, client,
session, session,
app: App::new(config.transport_endpoint()), app: App::new(config.endpoint()),
transport: Arc::new(transport), transport: Arc::new(transport),
sender, sender,
receiver, receiver,
@@ -304,8 +248,7 @@ impl<T: Transport> Runtime<T> {
/// Starts or restarts observation at Worldspace `/`. /// Starts or restarts observation at Worldspace `/`.
pub fn connect(&mut self) { pub fn connect(&mut self) {
self.app.connection = ConnectionState::Connecting; self.app.connection = ConnectionState::Connecting;
let limit = self.app.default_tree_limit; match self.client.refresh_children(&self.session, "/") {
match self.client.prepare_tree(&self.session, "/", 1, limit) {
Ok(request) => self.dispatch(request, InFlightKind::Tree("/".into()), false), Ok(request) => self.dispatch(request, InFlightKind::Tree("/".into()), false),
Err(error) => self.local_error("connect", error), Err(error) => self.local_error("connect", error),
} }
@@ -318,6 +261,9 @@ impl<T: Transport> Runtime<T> {
for action in actions { for action in actions {
quit |= self.perform(action); quit |= self.perform(action);
} }
if !quit {
self.reconcile_visible_lookahead();
}
quit quit
} }
@@ -348,24 +294,14 @@ impl<T: Transport> Runtime<T> {
fn perform(&mut self, action: Action) -> bool { fn perform(&mut self, action: Action) -> bool {
match action { match action {
Action::Expand { path, limit } => { Action::Expand { path } => match self.client.refresh_children(&self.session, &path) {
match self.client.prepare_tree(&self.session, &path, 1, limit) {
Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false), Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false),
Err(error) => self.local_error("tree expansion", error), Err(error) => self.local_error("tree expansion", error),
} },
} Action::Refresh { path } => match self.client.refresh_children(&self.session, &path) {
Action::Refresh { path, limit } => {
match self.client.prepare_fetch(&self.session, &path) {
Ok(request) => {
self.dispatch(request, InFlightKind::Object(path.clone()), false)
}
Err(error) => self.local_error("Entry refresh", error),
}
match self.client.prepare_tree(&self.session, &path, 1, limit) {
Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false), Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false),
Err(error) => self.local_error("subtree refresh", error), Err(error) => self.local_error("subtree refresh", error),
} },
}
Action::FetchInterface(reference) => { Action::FetchInterface(reference) => {
match self.client.prepare_interface(&self.session, &reference) { match self.client.prepare_interface(&self.session, &reference) {
Ok(request) => { Ok(request) => {
@@ -402,7 +338,7 @@ impl<T: Transport> Runtime<T> {
Action::Reconnect => self.connect(), Action::Reconnect => self.connect(),
Action::NavigateEntry(path) => { Action::NavigateEntry(path) => {
self.app.navigate_entry(path.clone()); self.app.navigate_entry(path.clone());
match self.client.prepare_fetch(&self.session, &path) { match self.client.refresh_object(&self.session, &path) {
Ok(request) => self.dispatch(request, InFlightKind::Object(path), false), Ok(request) => self.dispatch(request, InFlightKind::Object(path), false),
Err(error) => self.local_error("Entry navigation", error), Err(error) => self.local_error("Entry navigation", error),
} }
@@ -412,6 +348,38 @@ impl<T: Transport> Runtime<T> {
false false
} }
fn reconcile_visible_lookahead(&mut self) {
if self.app.connection != ConnectionState::Connected {
return;
}
let paths = self.app.visible_paths();
let references = self.app.visible_interface_references();
for path in paths {
match self.client.ensure_children_observed(&self.session, &path) {
Ok(Some(request)) => {
self.dispatch(request, InFlightKind::Tree(path), false);
}
Ok(None) => {}
Err(error) => {
self.local_error("observation lookahead", error);
break;
}
}
}
for reference in references {
match self.client.ensure_interface(&self.session, &reference) {
Ok(Some(request)) => {
self.dispatch(request, InFlightKind::Interface(reference), false);
}
Ok(None) => {}
Err(error) => {
self.local_error("interface lookahead", error);
break;
}
}
}
}
fn dispatch(&mut self, request: PreparedRequest, kind: InFlightKind, operation: bool) { fn dispatch(&mut self, request: PreparedRequest, kind: InFlightKind, operation: bool) {
let id = request.id; let id = request.id;
self.app.request_started(id.get(), kind.clone()); self.app.request_started(id.get(), kind.clone());
@@ -576,19 +544,24 @@ impl<T: Transport> Runtime<T> {
} }
} }
self.reconcile_authoritative(); self.reconcile_authoritative();
self.reconcile_visible_lookahead();
} }
fn apply_completion(&mut self, completion: Completion, kind: Option<&InFlightKind>) { fn apply_completion(&mut self, completion: Completion, kind: Option<&InFlightKind>) {
match completion { match completion {
Completion::Object(observation) => self.app.apply_object(observation), Completion::Object(observation) => {
Completion::Tree(observation) => { let path = observation.path.clone();
let root = observation.path.clone(); self.app.apply_object(observation);
if matches!(kind, Some(InFlightKind::Tree(_))) {
if let Some(observation) = self.client.tree(&self.session, &path).cloned() {
self.app.apply_tree(observation); self.app.apply_tree(observation);
}
self.sync_all_objects(); self.sync_all_objects();
if root == "/" { if path == "/" {
self.app.connection = ConnectionState::Connected; self.app.connection = ConnectionState::Connected;
} }
} }
}
Completion::Interface(observation) => self.app.apply_interface(observation), Completion::Interface(observation) => self.app.apply_interface(observation),
Completion::Call(record) => { Completion::Call(record) => {
let path = record.context.object().path.clone(); let path = record.context.object().path.clone();
@@ -708,7 +681,7 @@ impl<T: Transport> Runtime<T> {
fn push_client_error(&mut self, kind: Option<&InFlightKind>, error: &ClientError) { fn push_client_error(&mut self, kind: Option<&InFlightKind>, error: &ClientError) {
let title = match kind { let title = match kind {
Some(InFlightKind::Object(path)) => format!("fetch {path}"), Some(InFlightKind::Object(path)) => format!("observe {path}"),
Some(InFlightKind::Tree(path)) => format!("tree {path}"), Some(InFlightKind::Tree(path)) => format!("tree {path}"),
Some(InFlightKind::Interface(reference)) => format!("interface {reference}"), Some(InFlightKind::Interface(reference)) => format!("interface {reference}"),
Some(InFlightKind::Operation { operation, .. }) => operation.clone(), Some(InFlightKind::Operation { operation, .. }) => operation.clone(),
@@ -889,8 +862,7 @@ fn collect_entries_at(
/// Standard bounded wire policy used by the executable and integration tests. /// Standard bounded wire policy used by the executable and integration tests.
#[must_use] #[must_use]
pub fn default_wire_limits() -> Limits { pub fn default_wire_limits() -> Limits {
Limits::new(256 * 1024, 2 * 1024 * 1024, 64, 64) Limits::new(256 * 1024, 2 * 1024 * 1024, 64).expect("static runtime wire limits are nonzero")
.expect("static runtime wire limits are nonzero")
} }
/// Runs the interactive executable using process configuration. /// Runs the interactive executable using process configuration.
@@ -1017,6 +989,26 @@ mod tests {
); );
} }
#[test]
fn transport_preserves_the_configured_endpoint_scheme_for_requests() {
for scheme in ["http", "https"] {
let configured = format!("{scheme}://example.test/wip");
let config = Config::parse([configured], None, None, |_| None).unwrap();
let transport = HttpTransport::new(&config, 1024).unwrap();
let endpoint = wip_http::Endpoint::parse(config.endpoint()).unwrap();
let expected = endpoint.route(wip_http::Route::Observe).to_string();
let request = Request::builder()
.method("POST")
.uri(&expected)
.body(Vec::new())
.unwrap();
let prepared = transport.prepare(request).unwrap();
assert_eq!(prepared.uri().to_string(), expected);
}
}
#[test] #[test]
fn authorization_is_attached_only_after_exact_origin_and_route_validation() { fn authorization_is_attached_only_after_exact_origin_and_route_validation() {
let config = Config::parse( let config = Config::parse(
@@ -1029,22 +1021,22 @@ mod tests {
let transport = HttpTransport::new(&config, 1024).unwrap(); let transport = HttpTransport::new(&config, 1024).unwrap();
let off_origin = Request::builder() let off_origin = Request::builder()
.method("POST") .method("POST")
.uri("https://attacker.test/wip/v1/fetch") .uri("https://attacker.test/wip/v1/observe")
.body(Vec::new()) .body(Vec::new())
.unwrap(); .unwrap();
let error = transport.prepare(off_origin).unwrap_err(); let error = transport.prepare(off_origin).unwrap_err();
assert!(error.detail.contains("outside")); assert!(error.detail.contains("outside"));
assert!(!error.detail.contains("sensitive")); assert!(!error.detail.contains("sensitive"));
let endpoint = wip_https::Endpoint::parse(config.logical_endpoint()).unwrap(); let endpoint = wip_http::Endpoint::parse(config.endpoint()).unwrap();
let valid = Request::builder() let valid = Request::builder()
.method("POST") .method("POST")
.uri(endpoint.route(wip_https::Route::Fetch).as_str()) .uri(endpoint.route(wip_http::Route::Observe).as_str())
.body(Vec::new()) .body(Vec::new())
.unwrap(); .unwrap();
let prepared = transport.prepare(valid).unwrap(); let prepared = transport.prepare(valid).unwrap();
assert_eq!( assert_eq!(
prepared.headers()[wip_https::http::header::AUTHORIZATION], prepared.headers()[wip_http::http::header::AUTHORIZATION],
"Bearer sensitive" "Bearer sensitive"
); );
} }
@@ -1052,7 +1044,7 @@ mod tests {
#[test] #[test]
fn transport_error_messages_do_not_echo_underlying_data() { fn transport_error_messages_do_not_echo_underlying_data() {
let error = classify_ureq_error(ureq::Error::BadUri("secret-token".into())); let error = classify_ureq_error(ureq::Error::BadUri("secret-token".into()));
assert_eq!(error.detail, "HTTP/TLS transport failed"); assert_eq!(error.detail, "HTTP transport failed");
assert!(!error.detail.contains("secret-token")); assert!(!error.detail.contains("secret-token"));
} }
} }
-1
View File
@@ -97,7 +97,6 @@ pub fn translate_key(event: KeyEvent, editing: bool) -> Option<InputKey> {
KeyCode::Char('l') => InputKey::Right, KeyCode::Char('l') => InputKey::Right,
KeyCode::Char('r') => InputKey::Refresh, KeyCode::Char('r') => InputKey::Refresh,
KeyCode::Char('R') => InputKey::Reconnect, KeyCode::Char('R') => InputKey::Reconnect,
KeyCode::Char('+') | KeyCode::Char('=') => InputKey::IncreaseLimit,
KeyCode::Char('c') => InputKey::CancelRead, KeyCode::Char('c') => InputKey::CancelRead,
KeyCode::Char('?') => InputKey::Help, KeyCode::Char('?') => InputKey::Help,
KeyCode::Char('q') => InputKey::Quit, KeyCode::Char('q') => InputKey::Quit,
+27 -35
View File
@@ -4,7 +4,7 @@ use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tempfile::tempdir; use tempfile::tempdir;
use wip_fs_server::{AccessMode, ServerConfig, TransportSecurity, WipFsServer}; use wip_fs_server::{AccessMode, ServerConfig, WipFsServer};
use wip_tui::app::{Focus, HistoryKind, InputKey, Modal, ViewState}; use wip_tui::app::{Focus, HistoryKind, InputKey, Modal, ViewState};
use wip_tui::config::Config; use wip_tui::config::Config;
use wip_tui::runtime::{HttpTransport, Runtime, default_wire_limits}; use wip_tui::runtime::{HttpTransport, Runtime, default_wire_limits};
@@ -22,7 +22,6 @@ fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() {
let mut server_config = ServerConfig::for_root(temporary.path()); let mut server_config = ServerConfig::for_root(temporary.path());
server_config.access = AccessMode::ReadWrite; server_config.access = AccessMode::ReadWrite;
server_config.transport_security = TransportSecurity::DevelopmentHttp;
let bound = Arc::new( let bound = Arc::new(
WipFsServer::new(server_config) WipFsServer::new(server_config)
.expect("build filesystem server") .expect("build filesystem server")
@@ -36,12 +35,7 @@ fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() {
thread::spawn(move || bound.serve_until(shutdown)) thread::spawn(move || bound.serve_until(shutdown))
}; };
let endpoint = format!("http://127.0.0.1:{}/wip", bound.local_addr().port()); let endpoint = format!("http://127.0.0.1:{}/wip", bound.local_addr().port());
let config = Config::parse( let config = Config::parse([endpoint], None, Some("filesystem-test".into()), |_| None)
[endpoint, "--allow-http".into()],
None,
Some("filesystem-test".into()),
|_| None,
)
.expect("development config"); .expect("development config");
let transport = HttpTransport::new(&config, default_wire_limits().max_response_bytes()) let transport = HttpTransport::new(&config, default_wire_limits().max_response_bytes())
.expect("HTTP transport"); .expect("HTTP transport");
@@ -53,19 +47,24 @@ fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() {
assert!(runtime.app().entries.contains_key("/hello.txt")); assert!(runtime.app().entries.contains_key("/hello.txt"));
assert!(runtime.app().entries.contains_key("/nested")); assert!(runtime.app().entries.contains_key("/nested"));
assert!(runtime.app().entries.contains_key("/outside-link")); assert!(runtime.app().entries.contains_key("/outside-link"));
let empty_directory = &runtime.app().entries["/nested"];
assert_eq!(empty_directory.tree_state, ViewState::Fresh);
assert!(empty_directory.children.is_empty());
assert!(!empty_directory.expanded);
// Keyboard-select the file and load its read interface and descriptor. // Visible Objects have their interface descriptors prefetched before selection.
runtime.app_mut().selected_path = "/hello.txt".into(); let file_interfaces = runtime.app().entries["/hello.txt"]
runtime.app_mut().focus = Focus::Operations;
runtime.app_mut().operation_cursor = 0;
assert!(!runtime.handle_key(InputKey::Enter));
settle(&mut runtime);
let read_reference = runtime.app().entries["/hello.txt"]
.object .object
.as_ref() .as_ref()
.expect("file object") .expect("file object")
.interfaces[0] .interfaces
.clone(); .clone();
assert!(!file_interfaces.is_empty());
for reference in &file_interfaces {
assert_eq!(runtime.app().interfaces[reference].state, ViewState::Fresh);
assert!(runtime.app().interfaces[reference].descriptor.is_some());
}
let read_reference = file_interfaces[0].clone();
assert_eq!( assert_eq!(
runtime.app().interfaces[&read_reference] runtime.app().interfaces[&read_reference]
.descriptor .descriptor
@@ -76,9 +75,13 @@ fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() {
"read" "read"
); );
// Descriptor-driven read editor, explicit confirmation, and execution. // Keyboard-select the already prefetched read operation.
runtime.app_mut().selected_path = "/hello.txt".into();
runtime.app_mut().focus = Focus::Operations;
runtime.app_mut().operation_cursor = 1; runtime.app_mut().operation_cursor = 1;
runtime.handle_key(InputKey::Enter); assert!(!runtime.handle_key(InputKey::Enter));
// Descriptor-driven read editor, explicit confirmation, and execution.
assert!(matches!(runtime.app().modal, Modal::Editor(_))); assert!(matches!(runtime.app().modal, Modal::Editor(_)));
runtime.handle_key(InputKey::Enter); runtime.handle_key(InputKey::Enter);
assert!(matches!(runtime.app().modal, Modal::Confirm { .. })); assert!(matches!(runtime.app().modal, Modal::Confirm { .. }));
@@ -91,18 +94,11 @@ fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() {
.contains("68656c6c6f20776f726c64") .contains("68656c6c6f20776f726c64")
); );
// Calls conservatively stale the object even for a read operation; refresh assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Fresh);
// before constructing the separately confirmed write call.
runtime.app_mut().focus = Focus::Tree;
runtime.handle_key(InputKey::Refresh);
settle(&mut runtime);
runtime.app_mut().focus = Focus::Operations; runtime.app_mut().focus = Focus::Operations;
// Load the separately-qualified write interface and replace with the // Use the separately-qualified prefetched write interface and replace with the
// automatically attached observed validator. // automatically attached observed validator.
runtime.app_mut().operation_cursor = 2;
runtime.handle_key(InputKey::Enter);
settle(&mut runtime);
runtime.app_mut().operation_cursor = 3; runtime.app_mut().operation_cursor = 3;
runtime.handle_key(InputKey::Enter); runtime.handle_key(InputKey::Enter);
set_only_field(&mut runtime, "dXBkYXRlZA=="); // "updated" set_only_field(&mut runtime, "dXBkYXRlZA=="); // "updated"
@@ -112,15 +108,11 @@ fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() {
std::fs::read(temporary.path().join("hello.txt")).expect("updated file"), std::fs::read(temporary.path().join("hello.txt")).expect("updated file"),
b"updated" b"updated"
); );
assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Stale);
// Reobserve, prepare a replacement from that observation, then mutate the
// file externally before explicit execution. The stale validator is rejected,
// the Entry becomes stale, and no automatic call is created.
runtime.app_mut().focus = Focus::Tree;
runtime.handle_key(InputKey::Refresh);
settle(&mut runtime);
assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Fresh); assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Fresh);
// Prepare another replacement using the post-operation validator, then mutate
// the file externally before explicit execution. The stale validator is rejected,
// the Entry becomes stale, and no automatic call is created.
runtime.app_mut().focus = Focus::Operations; runtime.app_mut().focus = Focus::Operations;
runtime.app_mut().operation_cursor = 3; runtime.app_mut().operation_cursor = 3;
runtime.handle_key(InputKey::Enter); runtime.handle_key(InputKey::Enter);
+8 -8
View File
@@ -5,8 +5,8 @@ use std::time::{Duration, Instant};
use tempfile::tempdir; use tempfile::tempdir;
use wip_fs_server::{ServerConfig, WipFsServer}; use wip_fs_server::{ServerConfig, WipFsServer};
use wip_https::encode_protocol_error_response; use wip_http::encode_protocol_error_response;
use wip_https::http::{Request, Response}; use wip_http::http::{Request, Response};
use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction}; use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction};
use wip_tui::app::{ConnectionState, Focus, HistoryKind, InputKey, Modal, ViewState}; use wip_tui::app::{ConnectionState, Focus, HistoryKind, InputKey, Modal, ViewState};
use wip_tui::config::Config; use wip_tui::config::Config;
@@ -50,7 +50,7 @@ impl Transport for ScriptedTransport {
detail: "scripted local conversion failure".into(), detail: "scripted local conversion failure".into(),
}); });
} }
if self.fail_tree_prepare && request.uri().path().ends_with("/v1/fetch_tree") { if self.fail_tree_prepare && request.uri().path().ends_with("/v1/observe") {
return Err(TransportError { return Err(TransportError {
kind: TransportErrorKind::Disconnect, kind: TransportErrorKind::Disconnect,
detail: "scripted tree conversion failure".into(), detail: "scripted tree conversion failure".into(),
@@ -61,7 +61,7 @@ impl Transport for ScriptedTransport {
fn execute(&self, request: Self::Prepared) -> Result<Response<Vec<u8>>, TransportError> { fn execute(&self, request: Self::Prepared) -> Result<Response<Vec<u8>>, TransportError> {
let sequence = self.sequence.fetch_add(1, Ordering::AcqRel); let sequence = self.sequence.fetch_add(1, Ordering::AcqRel);
if request.uri().path().ends_with("/v1/fetch_tree") if request.uri().path().ends_with("/v1/observe")
&& self && self
.root_tree_protocol_failures .root_tree_protocol_failures
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| { .fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| {
@@ -70,7 +70,7 @@ impl Transport for ScriptedTransport {
.is_ok() .is_ok()
{ {
return Ok(encode_protocol_error_response( return Ok(encode_protocol_error_response(
ProtocolInteraction::FetchTree, ProtocolInteraction::Observe,
&ProtocolError { &ProtocolError {
code: ProtocolErrorCode::Internal, code: ProtocolErrorCode::Internal,
message: "scripted startup failure".into(), message: "scripted startup failure".into(),
@@ -80,9 +80,9 @@ impl Transport for ScriptedTransport {
.expect("encode tree failure")); .expect("encode tree failure"));
} }
if self.delayed_after_startup && sequence > 0 { if self.delayed_after_startup && sequence > 0 {
// Each refresh starts object then tree. Make the first pair lose to // Delay the first refresh so the second wins and exercises exact
// the second pair to exercise exact RequestId ownership. // RequestId ownership.
let delay = if matches!(sequence, 1 | 2) { 80 } else { 5 }; let delay = if sequence == 1 { 80 } else { 5 };
thread::sleep(Duration::from_millis(delay)); thread::sleep(Duration::from_millis(delay));
} }
if self.panic_calls && request.uri().path().ends_with("/v1/call_operation") { if self.panic_calls && request.uri().path().ends_with("/v1/call_operation") {