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