Compare commits

...
43 changed files with 2135 additions and 1770 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/batching
├── wip-host request-scoped publication and dispatch
└── wip-interface macro-generated filesystem capabilities
wip-public-api-example (workspace-only executable, integration tests, doctest)
├── wip-protocol logical declarations, values, calls, and validation
├── wip-https standard HTTPS v1 routes, JSON codecs, envelopes, and batching
├── wip-http standard HTTP v1 routes, JSON codecs, envelopes, and batching
│ └── wip-protocol
├── wip-client stateful Known Space, lazy tree, observation, and call runtime
│ ├── wip-https
│ ├── wip-http
│ └── wip-protocol
├── wip-interface application DTO codecs and interface authoring facade
│ ├── wip-protocol
@@ -41,18 +41,18 @@ wip-public-api-example (workspace-only executable, integration tests, doctest)
| Crate | Responsibility | Does not own |
| --- | --- | --- |
| `wip-protocol` | Transport-independent descriptors, object/call/result types, logical `Value`s, validation, and stable protocol error categories. | Application DTOs, implementations, persistence, or wire encoding. |
| `wip-https` | Standard HTTPS v1 endpoint/route derivation, UTF-8 JSON codecs, direct success bodies, error/status classification, and retrieval batches. | HTTP I/O, TLS termination, authentication schemes, server frameworks, filesystem projection, or client presentation/cache policy. |
| `wip-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, error/status classification, and retrieval batches. | HTTP I/O, URL scheme or TLS policy, authentication schemes, server frameworks, filesystem projection, or client presentation/cache policy. |
| `wip-client` | Endpoint/security-context-isolated Known Space, normalized object and interface observations, intent-driven object and descriptor coverage with bounded request deduplication, immutable operation call contexts, stale transitions, and bounded histories. | HTTP I/O, credentials, persistent cache, automatic operation retries, or presentation widgets/events. |
| `wip-tui` | Keyboard-only interactive Worldspace tree, selected Entry and qualified operation panes, descriptor-driven argument editing, bounded result/diagnostic rendering, terminal lifecycle, and 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/batch handling, and a loopback HTTP binary. | A new wire profile, implicit symlink following, non-loopback cleartext, authentication policy, TLS termination, or filesystem-wide preregistration. |
| `wip-public-api-example` | An independently compiled consumer of the protocol, authoring, and host public crates. Its DTOs and traits are application-owned; its implementation and backend handle remain host-private. | Reusable library API or a production server. |
The boundary is intentional: protocol types describe what can cross a
transport; application DTOs opt into that model with `WipType`; a host-private
implementation owns credentials, handles, and backend state; and `wip-https`
implementation owns credentials, handles, and backend state; and `wip-http`
maps standard routes/envelopes to `CallOperationRequest` and
`CallOperationResponse` without placing transport-specific types in descriptors
or application traits.
@@ -263,8 +263,10 @@ from Worldspace `/`, lazily expands materialized tree nodes, displays current
object/interface/operation metadata, validates all canonical descriptor types in
a field editor, and requires a separate confirmation action before every call.
It uses the stateful `wip-client` runtime for request correlation, observed
validators, stale transitions, and unknown outcomes. HTTPS is the default;
loopback plain HTTP requires `--allow-http`. Full startup, TLS, key binding,
validators, stale transitions, and unknown outcomes. One canonical `http` or
`https` endpoint is used unchanged for request construction, transport, and
cache partitioning. Scheme, TLS, certificates, authentication, and proxy policy
belong to the HTTP implementation and deployment. Full startup, key binding,
layout, validator, and filesystem-operation examples are in the
[package README](crates/wip-tui/README.md).
@@ -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);
}
}
}
+21 -16
View File
@@ -4,14 +4,15 @@ 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 {
@@ -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!(
@@ -146,7 +147,7 @@ fn valid_error_envelope_is_authoritative_and_status_mismatch_is_diagnostic() {
#[test]
fn non_success_without_valid_envelope_remains_transport_failure() {
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,
@@ -176,15 +177,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(),
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" }
+27 -30
View File
@@ -3,7 +3,7 @@
`wip-fs-server` projects one configured Linux filesystem directory onto WIP
Worldspace `/` at request time. It does not register the filesystem contents or
keep one object instance per entry. It uses `wip-host` for publication and typed
operation dispatch and uses the standard `wip-https` routes, JSON codecs,
operation dispatch and uses the standard `wip-http` routes, JSON codecs,
envelopes, batching, and HTTP status mapping.
## Security model
@@ -28,9 +28,9 @@ envelopes, batching, and HTTP status mapping.
- Protocol metadata and errors never contain the backend absolute path. Startup
prints only the explicitly configured effective root and security-sensitive
modes.
- The built-in listener is cleartext and loopback-only. Production deployments
must pass `--behind-tls-proxy` and terminate HTTPS at a trusted reverse proxy.
Plain development HTTP requires the explicit `--development-http` flag.
- The built-in HTTP listener is loopback-only. TLS termination, certificates,
authentication, an externally visible URL scheme, and proxy routing are
deployment concerns rather than separate WIP transport profiles.
The implementation currently targets Linux because it uses `O_PATH`,
directory-relative descriptor traversal, and `/proc/self/fd` to reopen the same
@@ -38,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,8 +118,8 @@ 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.
`observe` and `fetch_interface` can also be combined through the standard
`POST /wip/v1/batch` route. `call_operation` is never batchable.
## Resource and failure behavior
@@ -133,12 +131,11 @@ 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
+20 -33
View File
@@ -1,15 +1,15 @@
//! Request-time filesystem projection served through the standard WIP HTTPS binding.
//! Request-time filesystem projection served through the standard WIP over HTTP binding.
//!
//! This crate deliberately keeps the boundaries visible:
//!
//! - [`wip_protocol`] owns logical requests and responses;
//! - [`wip_host`] owns object publication, validators, and typed dispatch;
//! - [`wip_https`] owns routes, JSON, envelopes, batching, and status mapping;
//! - [`wip_http`] owns routes, JSON, envelopes, batching, and status mapping;
//! - this crate owns the filesystem capability boundary and the real HTTP listener.
//!
//! TLS is terminated by an explicitly configured reverse proxy. The built-in
//! listener accepts cleartext only on loopback, either behind that proxy or when
//! [`TransportSecurity::DevelopmentHttp`] is deliberately selected.
//! The built-in HTTP listener is intentionally loopback-only. TLS termination,
//! certificates, authentication, and externally visible schemes are independent
//! deployment concerns rather than WIP transport profiles.
#![deny(missing_docs)]
@@ -33,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,
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, 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,
+24 -49
View File
@@ -7,15 +7,13 @@ use std::time::{Duration, Instant};
use http::{Request, Response, StatusCode};
use wip_host::{ExecutionContext, ObjectProvider};
use wip_https::{
use wip_http::{
BatchRequestKind, BatchResponse, BatchResponseItem, BatchSuccess, CodecError, EncodeHttpError,
Endpoint, RequestMetadataError, ResourceLimitKind, Route, decode_batch_request,
decode_call_operation_metadata, decode_fetch_interface_request, decode_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,
decode_call_operation_metadata, decode_fetch_interface_request, decode_observe_request,
encode_batch_error_response, encode_batch_resource_limit_error_response, encode_batch_response,
encode_call_operation_response, encode_fetch_interface_response, encode_observe_response,
encode_protocol_error_response, encode_resource_limit_error_response, validate_http_request,
};
use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction};
@@ -57,7 +55,7 @@ impl WipFsServer {
&self.endpoint
}
/// Handles one already-buffered HTTP request with the standard `wip-https` codecs.
/// Handles one already-buffered HTTP request with the standard `wip-http` codecs.
///
/// Listener integrations must enforce the same body limit while collecting bytes.
#[must_use]
@@ -78,10 +76,9 @@ impl WipFsServer {
}
let context = ExecutionContext::new()
.with_deadline(deadline)
.with_resource_budget(u64::from(self.config.limits.max_tree_nodes));
.with_resource_budget(u64::from(self.config.limits.max_observation_nodes));
match route {
Route::Fetch => self.fetch(request.body(), &context),
Route::FetchTree => self.fetch_tree(request.body(), &context),
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),
@@ -90,8 +87,9 @@ impl WipFsServer {
/// 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()))?;
@@ -110,8 +108,7 @@ impl WipFsServer {
fn route(&self, path: &str) -> Option<Route> {
[
Route::Fetch,
Route::FetchTree,
Route::Observe,
Route::FetchInterface,
Route::CallOperation,
Route::Batch,
@@ -120,33 +117,17 @@ impl WipFsServer {
.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) {
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::Fetch, error),
Err(error) => return self.codec_error(ProtocolInteraction::Observe, 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 fetch_tree(&self, body: &[u8], context: &ExecutionContext) -> Response<Vec<u8>> {
let request = match decode_fetch_tree_request(body, self.config.limits.wire) {
Ok(request) => request,
Err(error) => return self.codec_error(ProtocolInteraction::FetchTree, error),
};
match self.projection.fetch_tree(context, &request) {
Ok(tree) => encode_fetch_tree_response(&request, &tree, self.config.limits.wire)
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, None),
}
}
@@ -236,15 +217,10 @@ impl WipFsServer {
.iter()
.map(|item| {
let outcome = match &item.request {
BatchRequestKind::Fetch(request) => self
BatchRequestKind::Observe(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),
.observe(context, request)
.map(BatchSuccess::Observe),
BatchRequestKind::FetchInterface(request) => self
.projection
.fetch_interface(request)
@@ -674,11 +650,10 @@ fn write_all_with_deadline(
fn interaction(route: Route) -> ProtocolInteraction {
match route {
Route::Fetch => ProtocolInteraction::Fetch,
Route::FetchTree => ProtocolInteraction::FetchTree,
Route::Observe => ProtocolInteraction::Observe,
Route::FetchInterface => ProtocolInteraction::FetchInterface,
Route::CallOperation => ProtocolInteraction::CallOperation,
Route::Batch => ProtocolInteraction::Fetch,
Route::Batch => ProtocolInteraction::Observe,
}
}
+60 -44
View File
@@ -7,28 +7,27 @@ use std::thread;
use std::time::{Duration, Instant};
use tempfile::tempdir;
use wip_fs_server::{AccessMode, ServerConfig, TransportSecurity, WipFsServer};
use wip_https::{
use wip_fs_server::{AccessMode, ServerConfig, WipFsServer};
use wip_http::{
BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse, BatchSuccess, DecodedResponse,
Endpoint, JSON_CONTENT_TYPE, Limits, Route, decode_batch_request, decode_batch_response,
decode_call_operation_response, decode_fetch_interface_response, decode_fetch_response,
decode_fetch_tree_response, encode_batch_request, encode_call_operation_request,
encode_fetch_interface_request, encode_fetch_request, encode_fetch_tree_request,
decode_call_operation_response, decode_fetch_interface_response, decode_observe_response,
encode_batch_request, 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_batch_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 +42,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(),
@@ -122,14 +128,16 @@ fn real_listener_serves_four_core_interactions_batch_and_write() {
requests: vec![
BatchRequestItem {
id: "root".into(),
request: BatchRequestKind::Fetch(FetchRequest { path: "/".into() }),
request: BatchRequestKind::Observe(ObserveRequest {
path: "/".into(),
depth: 0,
}),
},
BatchRequestItem {
id: "tree".into(),
request: BatchRequestKind::FetchTree(FetchTreeRequest {
request: BatchRequestKind::Observe(ObserveRequest {
path: "/nested".into(),
depth: 1,
limit: 4,
}),
},
BatchRequestItem {
@@ -206,7 +214,6 @@ fn real_listener_serves_four_core_interactions_batch_and_write() {
fn stalled_request_body_times_out_and_releases_the_only_worker() {
let temporary = tempdir().expect("temporary root");
let mut config = ServerConfig::for_root(temporary.path());
config.transport_security = TransportSecurity::DevelopmentHttp;
config.limits.max_concurrent_requests = 1;
config.limits.request_timeout = Duration::from_millis(200);
let limits = config.limits.wire;
@@ -230,21 +237,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"
@@ -276,8 +287,8 @@ fn batch_keeps_known_malformed_arguments_item_local() {
let config = ServerConfig::for_root(temporary.path());
let limits = config.limits.wire;
let server = WipFsServer::new(config).expect("build server");
let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint");
let body = br#"{"requests":[{"id":"ok","method":"fetch","arguments":{"path":"/"}},{"id":"bad","method":"fetch_tree","arguments":{"path":"/","depth":"bad","limit":1}}]}"#;
let endpoint = Endpoint::parse("http://localhost/wip").expect("endpoint");
let body = br#"{"requests":[{"id":"ok","method":"observe","arguments":{"path":"/","depth":0}},{"id":"bad","method":"observe","arguments":{"path":"/","depth":"bad"}}]}"#;
let logical = decode_batch_request(body, limits).expect("decode item-local invalid request");
assert!(matches!(
logical.requests[1].request,
@@ -296,7 +307,7 @@ fn batch_keeps_known_malformed_arguments_item_local() {
);
assert!(matches!(
batch.responses[0].outcome,
Ok(BatchSuccess::Fetch(_))
Ok(BatchSuccess::Observe(_))
));
assert!(batch.responses[1].outcome.is_err());
}
@@ -306,7 +317,7 @@ fn call_argument_decoding_does_not_mask_missing_target() {
let temporary = tempdir().expect("temporary root");
let config = ServerConfig::for_root(temporary.path());
let server = WipFsServer::new(config).expect("build server");
let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint");
let endpoint = Endpoint::parse("http://localhost/wip").expect("endpoint");
let body = br#"{"target":{"path":"/missing"},"interface":{"reference":"unknown"},"operation":"missing","arguments":{"bytes":"not-base64"}}"#;
let request = http::Request::builder()
.method(http::Method::POST)
@@ -330,14 +341,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);
}
@@ -353,16 +366,19 @@ fn read_response_size_failure_preserves_non_commit_guarantee() {
config.limits.wire = Limits::new(4_096, 1_024, 64, 64).expect("wire limits");
let limits = config.limits.wire;
let server = WipFsServer::new(config).expect("build server");
let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint");
let endpoint = Endpoint::parse("http://localhost/wip").expect("endpoint");
let 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(),
};
@@ -405,11 +421,11 @@ fn assert_batch_successes(batch: &BatchResponse) {
assert_eq!(batch.responses.len(), 3);
assert!(matches!(
batch.responses[0].outcome,
Ok(BatchSuccess::Fetch(_))
Ok(BatchSuccess::Observe(_))
));
assert!(matches!(
batch.responses[1].outcome,
Ok(BatchSuccess::FetchTree(_))
Ok(BatchSuccess::Observe(_))
));
assert!(matches!(
batch.responses[2].outcome,
+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]
@@ -4,8 +4,8 @@ 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,
FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, ObserveRequest,
ObserveResponse, ProtocolError, ProtocolErrorCode, ProtocolInteraction,
};
use crate::response::{self, DecodedResponse};
@@ -17,10 +17,8 @@ use crate::{
/// 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,
/// Observe one object and its indexable descendants.
Observe,
/// Fetch one interface descriptor.
FetchInterface,
}
@@ -28,16 +26,14 @@ pub enum BatchMethod {
impl BatchMethod {
fn name(self) -> &'static str {
match self {
Self::Fetch => "fetch",
Self::FetchTree => "fetch_tree",
Self::Observe => "observe",
Self::FetchInterface => "fetch_interface",
}
}
fn interaction(self) -> ProtocolInteraction {
match self {
Self::Fetch => ProtocolInteraction::Fetch,
Self::FetchTree => ProtocolInteraction::FetchTree,
Self::Observe => ProtocolInteraction::Observe,
Self::FetchInterface => ProtocolInteraction::FetchInterface,
}
}
@@ -46,10 +42,8 @@ impl BatchMethod {
/// 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),
/// Observe one object and its indexable descendants.
Observe(ObserveRequest),
/// Fetch one interface descriptor.
FetchInterface(FetchInterfaceRequest),
/// A known method whose `arguments` are not a valid logical request.
@@ -64,8 +58,7 @@ pub enum BatchRequestKind {
impl BatchRequestKind {
fn interaction(&self) -> ProtocolInteraction {
match self {
Self::Fetch(_) => ProtocolInteraction::Fetch,
Self::FetchTree(_) => ProtocolInteraction::FetchTree,
Self::Observe(_) => ProtocolInteraction::Observe,
Self::FetchInterface(_) => ProtocolInteraction::FetchInterface,
Self::Invalid { method, .. } => method.interaction(),
}
@@ -91,10 +84,8 @@ pub struct BatchRequest {
/// 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 `observe` result.
Observe(ObserveResponse),
/// Successful `fetch_interface` result.
FetchInterface(FetchInterfaceResponse),
}
@@ -135,10 +126,7 @@ fn validate_ids<'a>(ids: impl Iterator<Item = &'a str>, maximum: usize) -> Resul
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::Observe(value) => ("observe", json::observe_request_to_json(value)),
BatchRequestKind::FetchInterface(value) => (
"fetch_interface",
json::fetch_interface_request_to_json(value),
@@ -160,8 +148,7 @@ fn request_to_json(request: &BatchRequest, limits: Limits) -> Result<Json, Codec
)?;
for item in &request.requests {
match &item.request {
BatchRequestKind::Fetch(value) => value.validate()?,
BatchRequestKind::FetchTree(value) => value.validate()?,
BatchRequestKind::Observe(value) => value.validate()?,
BatchRequestKind::FetchInterface(_) | BatchRequestKind::Invalid { .. } => {}
}
}
@@ -190,22 +177,18 @@ fn request_from_json(value: &Json, limits: Limits) -> Result<BatchRequest, Codec
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,
"observe" => BatchMethod::Observe,
"fetch_interface" => BatchMethod::FetchInterface,
_ => {
return Err(CodecError::InvalidField {
field: format!("{field}.method"),
reason: "batch supports only fetch, fetch_tree, and fetch_interface".into(),
reason: "batch supports only observe 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::Observe => {
json::observe_request_from_json(arguments).map(BatchRequestKind::Observe)
}
BatchMethod::FetchInterface => json::fetch_interface_request_from_json(arguments)
.map(BatchRequestKind::FetchInterface),
@@ -249,8 +232,7 @@ pub fn decode_batch_request(body: &[u8], limits: Limits) -> Result<BatchRequest,
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::Observe(value) => json::observe_response_to_json(value),
BatchSuccess::FetchInterface(value) => json::fetch_interface_response_to_json(value),
}
}
@@ -299,12 +281,9 @@ fn correlate(
});
};
match (&item.outcome, *kind) {
(Ok(BatchSuccess::Fetch(value)), BatchRequestKind::Fetch(request)) => {
(Ok(BatchSuccess::Observe(value)), BatchRequestKind::Observe(request)) => {
request.validate_response(value)?;
}
(Ok(BatchSuccess::FetchTree(value)), BatchRequestKind::FetchTree(request)) => {
value.validate_for(request)?;
}
(
Ok(BatchSuccess::FetchInterface(value)),
BatchRequestKind::FetchInterface(request),
@@ -459,15 +438,10 @@ fn response_from_json(
Err(error)
}
(Some(result), None) => Ok(match kind {
BatchRequestKind::Fetch(request) => {
let value = json::object_from_json(result, "result")?;
BatchRequestKind::Observe(request) => {
let value = json::observe_response_from_json(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)
BatchSuccess::Observe(value)
}
BatchRequestKind::FetchInterface(request) => {
let value = json::fetch_interface_response_from_json(result)?;
@@ -509,7 +483,7 @@ pub fn decode_batch_response<B: AsRef<[u8]>>(
let decoded = response::decode(
value.status(),
value.body().as_ref(),
ProtocolInteraction::Fetch,
ProtocolInteraction::Observe,
limits,
|json| response_from_json(json, request, limits),
)
@@ -5,8 +5,8 @@ use http::header::{ACCEPT, 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};
@@ -264,49 +264,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 +383,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)
},
)
}
@@ -6,13 +6,11 @@ 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.
@@ -26,8 +24,7 @@ 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",
@@ -35,22 +32,20 @@ impl Route {
}
}
/// A canonical absolute HTTPS base URL whose path always ends in `/`.
/// A canonical absolute hierarchical base URL whose path always ends in `/`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Endpoint(Url);
impl Endpoint {
/// Parses and canonicalizes an endpoint.
///
/// The input must be an absolute HTTPS URL without a query or fragment. Its
/// path is an endpoint prefix, not a Worldspace path. A trailing slash is
/// appended when absent so RFC 3986 relative-reference resolution preserves
/// the final prefix segment.
/// The input must be an absolute hierarchical URL with a host and without a
/// query or fragment. Its path is an endpoint prefix, not a Worldspace path.
/// A trailing slash is appended when absent so RFC 3986 relative-reference
/// resolution preserves the final prefix segment. The supplied scheme is
/// preserved; scheme and transport security are deployment concerns.
pub fn parse(input: &str) -> Result<Self, EndpointError> {
let mut url = Url::parse(input).map_err(EndpointError::InvalidUrl)?;
if url.scheme() != "https" {
return Err(EndpointError::NotHttps);
}
if url.cannot_be_a_base() || url.host_str().is_none() {
return Err(EndpointError::NotAbsoluteBase);
}
@@ -87,9 +82,8 @@ 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,
const ROUTES: [Route; 4] = [
Route::Observe,
Route::FetchInterface,
Route::CallOperation,
Route::Batch,
@@ -117,13 +111,11 @@ impl TryFrom<&str> for Endpoint {
}
}
/// Why an endpoint is not a valid standard WIP HTTPS base.
/// Why an endpoint is not a valid standard WIP over HTTP base.
#[derive(Debug)]
pub enum EndpointError {
/// URL parsing failed.
InvalidUrl(url::ParseError),
/// The URL scheme is not `https`.
NotHttps,
/// The URL is not a hierarchical absolute URL with an authority.
NotAbsoluteBase,
/// A query component was supplied.
@@ -136,7 +128,6 @@ impl Display for EndpointError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidUrl(error) => write!(formatter, "invalid endpoint URL: {error}"),
Self::NotHttps => formatter.write_str("WIP endpoint must use HTTPS"),
Self::NotAbsoluteBase => {
formatter.write_str("WIP endpoint must be an absolute base URL")
}
@@ -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 {
@@ -1,27 +1,29 @@
//! Framework-independent standard WIP over HTTPS v1 binding.
//! Framework-independent standard WIP over HTTP v1 binding.
//!
//! This crate owns endpoint and route derivation, UTF-8 JSON encoding, canonical
//! success and error response handling, HTTP status mapping, descriptor-guided
//! operation-value conversion, and retrieval batching. It deliberately performs
//! no I/O and depends on no server framework, TLS implementation, filesystem
//! projection, client cache, or presentation layer.
//! projection, client cache, or presentation layer. URL schemes, TLS,
//! certificates, authentication mechanisms, proxies, and caches are selected by
//! the HTTP implementation and deployment rather than by WIP conformance.
//!
//! Logical values remain owned by [`wip_protocol`]. Validators and `Bytes` are
//! encoded in JSON bodies using canonical RFC 4648 Section 4 base64; this crate
//! does not infer WIP validator semantics from `ETag` or `If-Match`.
//!
//! ```
//! use wip_https::{Endpoint, Limits, encode_fetch_request};
//! use wip_protocol::FetchRequest;
//! use wip_http::{Endpoint, Limits, encode_observe_request};
//! use wip_protocol::ObserveRequest;
//!
//! let endpoint = Endpoint::parse("https://example.test/api/wip")?;
//! let endpoint = Endpoint::parse("http://example.test/api/wip")?;
//! let limits = Limits::new(64 * 1024, 1024 * 1024, 64, 100)?;
//! let request = encode_fetch_request(
//! let request = encode_observe_request(
//! &endpoint,
//! &FetchRequest { path: "/items/123".into() },
//! &ObserveRequest { path: "/items/123".into(), depth: 0 },
//! limits,
//! )?;
//! assert_eq!(request.uri(), "https://example.test/api/wip/v1/fetch");
//! assert_eq!(request.uri(), "http://example.test/api/wip/v1/observe");
//! assert_eq!(request.method(), http::Method::POST);
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```
@@ -44,12 +46,11 @@ pub use batch::{
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,
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, 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};
@@ -1,9 +1,9 @@
use wip_https::http::StatusCode;
use wip_https::wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, FetchRequest, INTERFACE_FORMAT_V1,
InterfaceDescriptor, Object, ProtocolError, ProtocolErrorCode,
use wip_http::http::StatusCode;
use wip_http::wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor,
Object, ObjectObservation, ObserveRequest, ProtocolError, ProtocolErrorCode,
};
use wip_https::{
use wip_http::{
BatchMethod, BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse,
BatchResponseItem, BatchSuccess, ClientResponseError, DecodedResponse, Endpoint,
InvalidResponseKind, Limits, ResourceLimitKind, decode_batch_request, decode_batch_response,
@@ -15,13 +15,27 @@ fn limits() -> Limits {
Limits::new(100_000, 100_000, 64, 4).unwrap()
}
fn observed_item(validator: Option<Vec<u8>>) -> ObjectObservation {
ObjectObservation {
object: Object {
name: "item".into(),
description: None,
interfaces: vec![],
r#ref: None,
validator,
},
children: None,
}
}
fn request() -> BatchRequest {
BatchRequest {
requests: vec![
BatchRequestItem {
id: "object".into(),
request: BatchRequestKind::Fetch(FetchRequest {
request: BatchRequestKind::Observe(ObserveRequest {
path: "/item".into(),
depth: 0,
}),
},
BatchRequestItem {
@@ -57,13 +71,9 @@ fn batch_round_trips_by_unique_id_with_item_failures_and_validators() {
},
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]),
})),
outcome: Ok(BatchSuccess::Observe(observed_item(Some(vec![
0, 1, 2, 255,
])))),
},
],
};
@@ -121,18 +131,18 @@ fn batch_interface_validator_round_trips_in_item_body() {
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}}
{"id":"valid","method":"observe","arguments":{"path":"/item","depth":0}},
{"id":"invalid","method":"observe","arguments":{"path":"/","depth":"deep"}}
]
}"#;
let decoded = decode_batch_request(body, limits()).unwrap();
assert!(matches!(
decoded.requests[0].request,
BatchRequestKind::Fetch(_)
BatchRequestKind::Observe(_)
));
let invalid = match &decoded.requests[1].request {
BatchRequestKind::Invalid { method, error } => {
assert_eq!(*method, BatchMethod::FetchTree);
assert_eq!(*method, BatchMethod::Observe);
assert_eq!(error.code, ProtocolErrorCode::InvalidRequest);
error.clone()
}
@@ -143,13 +153,7 @@ fn malformed_known_method_arguments_are_item_local() {
responses: vec![
BatchResponseItem {
id: "valid".into(),
outcome: Ok(BatchSuccess::Fetch(Object {
name: "item".into(),
description: None,
interfaces: vec![],
r#ref: None,
validator: None,
})),
outcome: Ok(BatchSuccess::Observe(observed_item(None))),
},
BatchResponseItem {
id: "invalid".into(),
@@ -171,15 +175,15 @@ fn invalid_batch_envelopes_are_rejected_at_top_level() {
let malformed_item = br#"{
"requests": [
{"id":"item","method":"fetch","arguments":{"path":"/"},"extra":true}
{"id":"item","method":"observe","arguments":{"path":"/","depth":0},"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"}}
{"id":"same","method":"observe","arguments":{"path":"/a","depth":0}},
{"id":"same","method":"observe","arguments":{"path":"/b","depth":0}}
]
}"#;
assert!(decode_batch_request(duplicate, limits()).is_err());
@@ -228,13 +232,7 @@ fn batch_success_body_requires_exactly_http_200() {
responses: vec![
BatchResponseItem {
id: "object".into(),
outcome: Ok(BatchSuccess::Fetch(Object {
name: "item".into(),
description: None,
interfaces: vec![],
r#ref: None,
validator: None,
})),
outcome: Ok(BatchSuccess::Observe(observed_item(None))),
},
BatchResponseItem {
id: "interface".into(),
@@ -271,7 +269,7 @@ fn top_level_batch_errors_use_the_batch_allowed_code_set() {
}
);
let not_found = wip_https::http::Response::builder()
let not_found = wip_http::http::Response::builder()
.status(StatusCode::NOT_FOUND)
.header("content-type", "application/json; charset=utf-8")
.body(br#"{"error":{"code":"not_found","message":"not a batch failure"}}"#.to_vec())
@@ -287,7 +285,7 @@ fn top_level_batch_errors_use_the_batch_allowed_code_set() {
#[test]
fn unsupported_batch_descriptor_waits_for_envelope_correlation() {
let duplicate = wip_https::http::Response::builder()
let duplicate = wip_http::http::Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8")
.body(
@@ -303,7 +301,7 @@ fn unsupported_batch_descriptor_waits_for_envelope_correlation() {
}
));
let missing = wip_https::http::Response::builder()
let missing = wip_http::http::Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8")
.body(
@@ -340,13 +338,7 @@ fn batch_response_must_have_exactly_the_requested_ids() {
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,
})),
outcome: Ok(BatchSuccess::Observe(observed_item(None))),
}],
};
assert!(encode_batch_response(&request, &missing, limits()).is_err());
@@ -1,22 +1,20 @@
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_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 {
@@ -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();
@@ -393,16 +406,16 @@ 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();
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 nested = br#"{"path":[[["/"]]],"depth":0}"#;
let nesting_limited = Limits::new(1024, 1024, 2, 1).unwrap();
let too_deep = decode_fetch_request(nested, nesting_limited).unwrap_err();
let too_deep = decode_observe_request(nested, nesting_limited).unwrap_err();
assert!(matches!(&too_deep, CodecError::NestingTooDeep { .. }));
assert_eq!(
too_deep.resource_limit_kind(),
@@ -420,11 +433,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 +466,7 @@ fn unsupported_descriptor_format_is_client_local() {
ClientResponseError::UnsupportedDescriptorFormat { .. }
));
let future_shape = wip_https::http::Response::builder()
let future_shape = wip_http::http::Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8")
.body(
@@ -468,7 +485,7 @@ fn unsupported_descriptor_format_is_client_local() {
br#"{"interface":"interface-v1","descriptor":{"format":"wip-interface/2"},"validator":null}"#
.as_slice(),
] {
let response = wip_https::http::Response::builder()
let response = wip_http::http::Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8")
.body(malformed.to_vec())
@@ -485,14 +502,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,10 +1,10 @@
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::{
use wip_http::{
ClientResponseError, DecodedResponse, InvalidResponseKind, Limits, ResourceLimitKind,
TransportFailureKind, decode_fetch_response, encode_protocol_error_response,
TransportFailureKind, decode_observe_response, encode_protocol_error_response,
encode_resource_limit_error_response, error_code_name, is_json_content_type, status_for_error,
status_for_resource_limit,
};
@@ -13,8 +13,11 @@ fn limits() -> Limits {
Limits::new(64 * 1024, 64 * 1024, 32, 16).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>> {
@@ -132,7 +135,7 @@ fn resource_limit_statuses_encode_and_decode_by_concrete_limit_class() {
for (kind, status) in cases {
assert_eq!(status_for_resource_limit(kind), status);
let encoded = encode_resource_limit_error_response(
ProtocolInteraction::Fetch,
ProtocolInteraction::Observe,
&error,
kind,
limits(),
@@ -140,7 +143,7 @@ fn resource_limit_statuses_encode_and_decode_by_concrete_limit_class() {
.unwrap();
assert_eq!(encoded.status(), status);
assert_eq!(
decode_fetch_response(&request(), &encoded, limits()).unwrap(),
decode_observe_response(&request(), &encoded, limits()).unwrap(),
DecodedResponse::ProtocolFailure {
error: error.clone(),
status_mismatch: None,
@@ -149,7 +152,7 @@ fn resource_limit_statuses_encode_and_decode_by_concrete_limit_class() {
}
let default =
encode_protocol_error_response(ProtocolInteraction::Fetch, &error, limits()).unwrap();
encode_protocol_error_response(ProtocolInteraction::Observe, &error, limits()).unwrap();
assert_eq!(default.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_ne!(default.status(), StatusCode::TOO_MANY_REQUESTS);
}
@@ -161,7 +164,7 @@ 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
@@ -170,10 +173,10 @@ fn rate_limit_status_without_protocol_envelope_stays_transport_failure() {
#[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 +190,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 +204,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 +223,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 +233,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 +252,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 +288,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 +304,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(_)
));
}
+142
View File
@@ -0,0 +1,142 @@
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"),
(Route::Batch, "v1/batch"),
];
for (route, suffix) in expected {
let url = format!("{scheme}://example.test/root/prefix/{suffix}");
assert_eq!(endpoint.route(route).as_str(), url);
assert_eq!(
endpoint.recognize(&wip_http::url::Url::parse(&url).unwrap()),
Some(route)
);
}
assert_eq!(
endpoint.recognize(
&wip_http::url::Url::parse(&format!(
"{scheme}://example.test/root/prefix/v1/observe?path=/items"
))
.unwrap()
),
None
);
}
}
#[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();
assert!(validate_http_request(&endpoint, Route::Observe, &wrong_method).is_err());
let wrong_route = Request::builder()
.method(Method::POST)
.uri("/wip/v1/fetch_interface")
.header("content-type", "application/json")
.body(Vec::<u8>::new())
.unwrap();
assert!(validate_http_request(&endpoint, Route::Observe, &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/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);
}
#[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/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
);
}
}
-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, 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, "/");
}
+106 -113
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(),
@@ -1017,6 +990,26 @@ mod tests {
);
}
#[test]
fn transport_preserves_the_configured_endpoint_scheme_for_requests() {
for scheme in ["http", "https"] {
let configured = format!("{scheme}://example.test/wip");
let config = Config::parse([configured], None, None, |_| None).unwrap();
let transport = HttpTransport::new(&config, 1024).unwrap();
let endpoint = wip_http::Endpoint::parse(config.endpoint()).unwrap();
let expected = endpoint.route(wip_http::Route::Observe).to_string();
let request = Request::builder()
.method("POST")
.uri(&expected)
.body(Vec::new())
.unwrap();
let prepared = transport.prepare(request).unwrap();
assert_eq!(prepared.uri().to_string(), expected);
}
}
#[test]
fn authorization_is_attached_only_after_exact_origin_and_route_validation() {
let config = Config::parse(
@@ -1029,22 +1022,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 +1045,7 @@ mod tests {
#[test]
fn transport_error_messages_do_not_echo_underlying_data() {
let error = classify_ureq_error(ureq::Error::BadUri("secret-token".into()));
assert_eq!(error.detail, "HTTP/TLS transport failed");
assert_eq!(error.detail, "HTTP transport failed");
assert!(!error.detail.contains("secret-token"));
}
}
-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") {