Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ecc307fc9 | ||
|
|
bf5fa54f44 |
@@ -2,6 +2,15 @@
|
||||
|
||||
All notable changes to the published WIP Rust crates are documented here.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Breaking Protocol/HTTP migration from opaque Interface reference strings to
|
||||
exact `(scope, name)` records in Objects, fetches and calls, with optional
|
||||
opaque `scope_ref` metadata. Adds shape and placement-aware scope validation
|
||||
without changing routes, validators or the wire error-code set.
|
||||
- Mechanically migrates workspace consumers and fixtures. See [MIGRATION.md](MIGRATION.md)
|
||||
for API/wire examples and the deferred Host/Client lifecycle semantics.
|
||||
|
||||
## 0.1.0
|
||||
|
||||
Initial public release of:
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
# Structured Interface reference migration
|
||||
|
||||
This unreleased API/wire change follows `wip-reference` canonical Core Model,
|
||||
Protocol and WIP over HTTP at `6086f3c5ef10aa1464ce9c824750d7f667217bfd`.
|
||||
It replaces the earlier opaque Interface reference contract; HTTP routes and
|
||||
error codes have **not** changed. Peers must upgrade together: the codec does
|
||||
not accept legacy strings or display shorthand as Protocol references.
|
||||
|
||||
## Rust API
|
||||
|
||||
`Object.interfaces` is now `Vec<InterfaceReference>`.
|
||||
`FetchInterfaceRequest.interface`, `FetchInterfaceResponse.interface`, and
|
||||
`InterfaceTarget.reference` all use the same record:
|
||||
|
||||
```rust
|
||||
use wip_protocol::{FetchInterfaceRequest, InterfaceReference, InterfaceTarget};
|
||||
|
||||
let reference = InterfaceReference {
|
||||
scope: "/github".into(),
|
||||
name: "issue.read".into(),
|
||||
};
|
||||
reference.validate()?; // reference shape, no target Object path needed
|
||||
let fetch = FetchInterfaceRequest { interface: reference.clone() };
|
||||
let target = InterfaceTarget {
|
||||
reference,
|
||||
scope_ref: Some("opaque-scope-object-ref".into()),
|
||||
validator: None,
|
||||
};
|
||||
# Ok::<(), wip_protocol::ValidationError>(())
|
||||
```
|
||||
|
||||
Construct scope and name explicitly from your Worldspace publication context.
|
||||
There is no implicit conversion from a string, delimiter parser, normalization,
|
||||
case folding, or URL decoding. A name may contain `/`, `.`, `::`, `#`, or Unicode;
|
||||
only an empty name is invalid. Scope must be a canonical absolute Worldspace
|
||||
path: `/`, or nonempty slash-separated segments other than `.` and `..`, with
|
||||
no empty segments or trailing slash. `%2F` is ordinary field text, not an escape
|
||||
for a Worldspace path separator.
|
||||
|
||||
Equality, hashing and ordering operate on both fields, independent of member
|
||||
order/JSON escape spelling. Use the record itself as a membership/cache key;
|
||||
identical names in different scopes are different references. The descriptor
|
||||
still contains only format, documentation, types and operations, not identity.
|
||||
|
||||
`FetchInterfaceResponse` and `InterfaceTarget` additionally require the Rust
|
||||
field `scope_ref: Option<String>` in struct literals. Use `None` when omitted,
|
||||
not an invented identity. This is an opaque scope Object ref observation/call
|
||||
precondition, **not** a third Interface identity component or a validator.
|
||||
The scope-ref lifecycle/atomic Host enforcement and Client cache-recovery
|
||||
semantics are follow-up work (T-21/T-22); this migration supplies the data model
|
||||
and codec, not a completed lifecycle implementation.
|
||||
|
||||
## Validation boundaries
|
||||
|
||||
- `InterfaceReference::validate()` and `FetchInterfaceRequest::validate()`
|
||||
check reference shape independently of a target Object path.
|
||||
- `Object::validate()` checks shape and duplicate `(scope, name)` pairs;
|
||||
`Object::validate_at_path()` additionally checks placement, including scope.
|
||||
`ObserveRequest::validate_response()` checks every derived node path,
|
||||
including the depth boundary. Invalid success responses are Client-local
|
||||
`InvalidResponse`, not Host-returned Protocol errors.
|
||||
- `InterfaceReference::validate_for_path(path)` checks ancestor-or-self scope
|
||||
compatibility by path segments. Root and self scope are valid; `/a` is not
|
||||
an ancestor of `/ab`. This has no indexability dependency.
|
||||
- `CallOperationRequest::validate()` checks descriptor-independent shape.
|
||||
`validate_with()` adds operation/argument checks but does not replace Host
|
||||
publication lookup. Host interface selection checks scope compatibility and
|
||||
exact membership **after** target lookup: invalid reference shape is
|
||||
`InvalidRequest`, but a valid reference outside the target's scope is
|
||||
`InterfaceMismatch`, with `NotFound` retaining precedence.
|
||||
|
||||
## HTTP wire
|
||||
|
||||
All four reference positions use an exact object with required string fields:
|
||||
|
||||
```json
|
||||
{"scope":"/github","name":"issue.read"}
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"interface": {"scope":"/github","name":"issue.read"},
|
||||
"scope_ref": "opaque-scope-object-ref",
|
||||
"descriptor": {"format":"wip-interface/1","types":[],"operations":[]}
|
||||
}
|
||||
```
|
||||
|
||||
A call uses:
|
||||
|
||||
```json
|
||||
{
|
||||
"target": {"path":"/github/issues/123"},
|
||||
"interface": {
|
||||
"reference": {"scope":"/github","name":"issue.read"},
|
||||
"scope_ref": "opaque-scope-object-ref"
|
||||
},
|
||||
"operation":"refresh",
|
||||
"arguments":{}
|
||||
}
|
||||
```
|
||||
|
||||
Unknown/duplicate members, missing fields, null fields, relative scopes, empty
|
||||
names, legacy `"interface-v1"` and shorthand `"/github::issue.read"` strings
|
||||
are rejected. Field order and JSON escaping do not affect decoded identity.
|
||||
`scope_ref` is a normal opaque string, **not Base64**; omission is allowed but
|
||||
explicit null and nonstrings are invalid. An empty string is a valid opaque
|
||||
ref. Validators remain canonical padded standard Base64.
|
||||
|
||||
Descriptor-independent HTTP call metadata decoding enforces reference shape
|
||||
and the same strict JSON rules without turning scope compatibility into early
|
||||
`InvalidRequest`. Normal descriptor-bound encode/decode uses the same reference
|
||||
codec. Request violations retain the existing `400 invalid_request` boundary;
|
||||
malformed success responses retain Client-local `InvalidResponse`. Standard
|
||||
`POST v1/observe`, `v1/fetch_interface`, `v1/call_operation`, error-code/status
|
||||
mapping and Transport failure classification are unchanged.
|
||||
|
||||
## Downstream status
|
||||
|
||||
The workspace Client, Host, filesystem server, TUI and public-API example
|
||||
are mechanically migrated to structured protocol values and updated fixtures.
|
||||
The existing Host's generated opaque registration handle is bridged explicitly
|
||||
to scope `/` with its full handle as the name; no delimiter parsing or scope
|
||||
registration/lifetime model is introduced here. The existing Client keys use
|
||||
structured values; this change does not implement scope deletion/replacement
|
||||
cache recovery. TUI formatting is only the type-compatibility adaptation, not
|
||||
the full canonical Text View work (T-24). Filesystem/TUI semantics integration
|
||||
remains T-23. Interface authoring/macros are descriptor-only and require no
|
||||
identity fields or declaration changes.
|
||||
@@ -79,6 +79,13 @@ maps standard routes/envelopes to `CallOperationRequest` and
|
||||
`CallOperationResponse` without placing transport-specific types in descriptors
|
||||
or application traits.
|
||||
|
||||
## Structured Interface references
|
||||
|
||||
The unreleased Protocol/HTTP API uses explicit `InterfaceReference { scope,
|
||||
name }` records and optional `scope_ref` observation/precondition metadata.
|
||||
Legacy opaque-string wire references are rejected. See [MIGRATION.md](MIGRATION.md)
|
||||
for construction examples, validation boundaries and downstream migration status.
|
||||
|
||||
## End-to-end example
|
||||
|
||||
[`crates/wip-public-api-example/src/lib.rs`](crates/wip-public-api-example/src/lib.rs)
|
||||
@@ -91,7 +98,7 @@ runs it. It demonstrates all of the following:
|
||||
2. two public traits declared with `#[wip::interface]`;
|
||||
3. a private host struct implementing both traits;
|
||||
4. explicit publication of both interfaces as an ordered set on one object;
|
||||
5. separate observation of opaque interface references, descriptors, and
|
||||
5. separate observation of structured interface references, descriptors, and
|
||||
descriptor validators;
|
||||
6. calls qualified by `target.path`, object validator, interface reference and
|
||||
validator, operation, and schema-neutral `Value` arguments;
|
||||
|
||||
@@ -11,6 +11,8 @@ wip-client = "0.1.0"
|
||||
|
||||
Applications provide the HTTP adapter and presentation layer, send each prepared request exactly as owned by the runtime, then return the corresponding response for completion.
|
||||
|
||||
Interface acquisition and call APIs use `wip_protocol::InterfaceReference { scope, name }` directly. Interface caches and Object membership compare the complete structured value, so equal names in different scopes remain distinct. Interface observations retain the optional `scope_ref`, and calls copy it into `InterfaceTarget` unchanged. Scope-identity recovery is not implemented by this mechanical migration.
|
||||
|
||||
See the [API documentation](https://docs.rs/wip-client) for session, acquisition, cache, and call APIs.
|
||||
|
||||
Licensed under either of Apache-2.0 or MIT at your option.
|
||||
|
||||
@@ -21,8 +21,8 @@ use wip_http::{
|
||||
};
|
||||
use wip_protocol::{
|
||||
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, InterfaceDescriptor,
|
||||
InterfaceTarget, Object, ObjectObservation as ProtocolObjectObservation, ObserveRequest,
|
||||
OperationDeclaration, ProtocolError, ProtocolErrorCode, Target, Value,
|
||||
InterfaceReference, InterfaceTarget, Object, ObjectObservation as ProtocolObjectObservation,
|
||||
ObserveRequest, OperationDeclaration, ProtocolError, ProtocolErrorCode, Target, Value,
|
||||
};
|
||||
|
||||
/// Monotonic identity assigned to one logical request.
|
||||
@@ -226,12 +226,14 @@ pub struct TreeObservation {
|
||||
/// Cached interface descriptor observation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InterfaceObservation {
|
||||
/// Opaque interface reference.
|
||||
pub reference: String,
|
||||
/// Structured interface reference.
|
||||
pub reference: InterfaceReference,
|
||||
/// Last accepted descriptor, if one has been received.
|
||||
pub descriptor: Option<InterfaceDescriptor>,
|
||||
/// Optional interface validator. Absence makes the reference immutable.
|
||||
pub validator: Option<Vec<u8>>,
|
||||
/// Optional opaque identity of the interface scope Object.
|
||||
pub scope_ref: Option<String>,
|
||||
/// Current observation lifecycle.
|
||||
pub state: ObservationState,
|
||||
}
|
||||
@@ -404,7 +406,7 @@ struct Session {
|
||||
objects: BTreeMap<u64, ObjectRecord>,
|
||||
refs: BTreeMap<String, u64>,
|
||||
trees: BTreeMap<String, TreeObservation>,
|
||||
interfaces: BTreeMap<String, InterfaceRecord>,
|
||||
interfaces: BTreeMap<InterfaceReference, InterfaceRecord>,
|
||||
calls: VecDeque<CallRecord>,
|
||||
diagnostics: VecDeque<Diagnostic>,
|
||||
next_object_key: u64,
|
||||
@@ -596,7 +598,11 @@ impl Client {
|
||||
|
||||
/// Returns the current interface observation.
|
||||
#[must_use]
|
||||
pub fn interface(&self, session: &SessionId, reference: &str) -> Option<&InterfaceObservation> {
|
||||
pub fn interface(
|
||||
&self,
|
||||
session: &SessionId,
|
||||
reference: &InterfaceReference,
|
||||
) -> Option<&InterfaceObservation> {
|
||||
self.sessions
|
||||
.get(session)?
|
||||
.interfaces
|
||||
@@ -762,9 +768,8 @@ impl Client {
|
||||
pub fn ensure_interface(
|
||||
&mut self,
|
||||
session: &SessionId,
|
||||
reference: impl Into<String>,
|
||||
reference: InterfaceReference,
|
||||
) -> 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
|
||||
@@ -782,11 +787,11 @@ impl Client {
|
||||
pub fn prepare_interface(
|
||||
&mut self,
|
||||
session: &SessionId,
|
||||
reference: impl Into<String>,
|
||||
reference: InterfaceReference,
|
||||
) -> Result<PreparedRequest, ClientError> {
|
||||
self.ensure_capacity()?;
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: reference.into(),
|
||||
interface: reference,
|
||||
};
|
||||
let endpoint = session_endpoint(session)?;
|
||||
let encoded = encode_fetch_interface_request(&endpoint, &request, self.wire_limits)
|
||||
@@ -804,6 +809,7 @@ impl Client {
|
||||
.or_insert(InterfaceRecord {
|
||||
observation: InterfaceObservation {
|
||||
reference: request.interface.clone(),
|
||||
scope_ref: None,
|
||||
descriptor: None,
|
||||
validator: None,
|
||||
state: ObservationState::Loading(id),
|
||||
@@ -833,7 +839,7 @@ impl Client {
|
||||
&mut self,
|
||||
session: &SessionId,
|
||||
path: &str,
|
||||
interface: &str,
|
||||
interface: &InterfaceReference,
|
||||
operation: impl Into<String>,
|
||||
arguments: BTreeMap<String, Value>,
|
||||
) -> Result<PreparedRequest, ClientError> {
|
||||
@@ -900,7 +906,8 @@ impl Client {
|
||||
validator: object.validator.clone(),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: interface.to_owned(),
|
||||
reference: interface.clone(),
|
||||
scope_ref: interface_observation.scope_ref.clone(),
|
||||
validator: interface_observation.validator.clone(),
|
||||
},
|
||||
operation,
|
||||
@@ -1182,6 +1189,7 @@ impl Client {
|
||||
let touched = state.tick();
|
||||
let observation = InterfaceObservation {
|
||||
reference: value.interface,
|
||||
scope_ref: value.scope_ref,
|
||||
descriptor: Some(value.descriptor),
|
||||
validator: value.validator,
|
||||
state: ObservationState::Fresh,
|
||||
@@ -1466,7 +1474,7 @@ impl Client {
|
||||
fn interface_request_is_current(
|
||||
&self,
|
||||
session: &SessionId,
|
||||
reference: &str,
|
||||
reference: &InterfaceReference,
|
||||
id: RequestId,
|
||||
) -> bool {
|
||||
matches!(
|
||||
@@ -1511,7 +1519,7 @@ impl Client {
|
||||
#[derive(Debug)]
|
||||
enum RetrievalSubject {
|
||||
Observe(ObserveRequest),
|
||||
Interface(String),
|
||||
Interface(InterfaceReference),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -1617,7 +1625,7 @@ fn set_observe_subject_state(
|
||||
|
||||
fn ensure_interface_slot(
|
||||
state: &mut Session,
|
||||
reference: &str,
|
||||
reference: &InterfaceReference,
|
||||
maximum: usize,
|
||||
) -> Result<(), ClientError> {
|
||||
if state.interfaces.contains_key(reference) || state.interfaces.len() < maximum {
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||
wip_protocol::InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use wip_client::{
|
||||
@@ -39,7 +46,7 @@ fn object() -> Object {
|
||||
Object {
|
||||
name: "item".into(),
|
||||
description: None,
|
||||
interfaces: vec!["math".into()],
|
||||
interfaces: vec![root_reference("math")],
|
||||
r#ref: None,
|
||||
validator: Some(b"object-v1".to_vec()),
|
||||
}
|
||||
@@ -72,7 +79,7 @@ fn malformed_json_unknown_fields_duplicates_and_invalid_success_are_invalid_resp
|
||||
let cases: &[&[u8]] = &[
|
||||
b"{not json",
|
||||
br#"{"name":"item","interfaces":[],"unknown":true}"#,
|
||||
br#"{"name":"item","interfaces":["math","math"]}"#,
|
||||
br#"{"name":"item","interfaces":[{"scope":"/","name":"math"},{"scope":"/","name":"math"}]}"#,
|
||||
br#"{"name":"wrong","interfaces":[]}"#,
|
||||
];
|
||||
for body in cases {
|
||||
@@ -181,12 +188,15 @@ fn non_success_without_valid_envelope_remains_transport_failure() {
|
||||
#[test]
|
||||
fn unsupported_descriptor_format_is_distinct_from_invalid_response() {
|
||||
let (mut client, session) = setup();
|
||||
let request = client.prepare_interface(&session, "math").unwrap();
|
||||
let request = client
|
||||
.prepare_interface(&session, root_reference("math"))
|
||||
.unwrap();
|
||||
let logical = FetchInterfaceRequest {
|
||||
interface: "math".into(),
|
||||
interface: root_reference("math"),
|
||||
};
|
||||
let value = FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor("future-format/2"),
|
||||
validator: Some(b"v2".to_vec()),
|
||||
};
|
||||
@@ -216,16 +226,19 @@ fn malformed_call_success_after_dispatch_is_outcome_unknown_and_stales_object()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let interface = client.prepare_interface(&session, "math").unwrap();
|
||||
let interface = client
|
||||
.prepare_interface(&session, root_reference("math"))
|
||||
.unwrap();
|
||||
client
|
||||
.complete(
|
||||
interface.id,
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: "math".into(),
|
||||
interface: root_reference("math"),
|
||||
},
|
||||
&FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(INTERFACE_FORMAT_V1),
|
||||
validator: None,
|
||||
},
|
||||
@@ -238,7 +251,7 @@ fn malformed_call_success_after_dispatch_is_outcome_unknown_and_stales_object()
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
"math",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
BTreeMap::from([("value".into(), Value::Integer(2))]),
|
||||
)
|
||||
@@ -293,16 +306,19 @@ fn plain_413_after_call_dispatch_is_unknown_transport_outcome() {
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let interface = client.prepare_interface(&session, "math").unwrap();
|
||||
let interface = client
|
||||
.prepare_interface(&session, root_reference("math"))
|
||||
.unwrap();
|
||||
client
|
||||
.complete(
|
||||
interface.id,
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: "math".into(),
|
||||
interface: root_reference("math"),
|
||||
},
|
||||
&FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(INTERFACE_FORMAT_V1),
|
||||
validator: None,
|
||||
},
|
||||
@@ -315,7 +331,7 @@ fn plain_413_after_call_dispatch_is_unknown_transport_outcome() {
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
"math",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
BTreeMap::from([("value".into(), Value::Integer(2))]),
|
||||
)
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||
wip_protocol::InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use wip_client::{
|
||||
@@ -65,7 +72,7 @@ fn object(name: &str, reference: Option<&str>, validator: &[u8]) -> Object {
|
||||
Object {
|
||||
name: name.into(),
|
||||
description: Some(format!("object {name}")),
|
||||
interfaces: vec!["math".into()],
|
||||
interfaces: vec![root_reference("math")],
|
||||
r#ref: reference.map(str::to_owned),
|
||||
validator: Some(validator.to_vec()),
|
||||
}
|
||||
@@ -296,12 +303,18 @@ fn ensure_interface_deduplicates_loading_fresh_and_retained_failure() {
|
||||
.unwrap();
|
||||
|
||||
let request = client
|
||||
.ensure_interface(&session, "math")
|
||||
.ensure_interface(&session, root_reference("math"))
|
||||
.unwrap()
|
||||
.expect("missing descriptor starts a fetch");
|
||||
assert!(client.ensure_interface(&session, "math").unwrap().is_none());
|
||||
assert!(
|
||||
client
|
||||
.ensure_interface(&session, root_reference("math"))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let value = FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: Some(b"math-v1".to_vec()),
|
||||
};
|
||||
@@ -310,7 +323,7 @@ fn ensure_interface_deduplicates_loading_fresh_and_retained_failure() {
|
||||
request.id,
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: "math".into(),
|
||||
interface: root_reference("math"),
|
||||
},
|
||||
&value,
|
||||
wire_limits(),
|
||||
@@ -318,10 +331,15 @@ fn ensure_interface_deduplicates_loading_fresh_and_retained_failure() {
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(client.ensure_interface(&session, "math").unwrap().is_none());
|
||||
assert!(
|
||||
client
|
||||
.ensure_interface(&session, root_reference("math"))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let failed = client
|
||||
.ensure_interface(&session, "broken")
|
||||
.ensure_interface(&session, root_reference("broken"))
|
||||
.unwrap()
|
||||
.expect("new descriptor starts a fetch");
|
||||
assert_eq!(
|
||||
@@ -333,7 +351,7 @@ fn ensure_interface_deduplicates_loading_fresh_and_retained_failure() {
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.ensure_interface(&session, "broken")
|
||||
.ensure_interface(&session, root_reference("broken"))
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"retained interface failures require an explicit refresh"
|
||||
@@ -348,7 +366,10 @@ fn ensure_interface_waits_for_request_and_cache_capacity_without_evicting() {
|
||||
.unwrap();
|
||||
let pending_object = client.refresh_object(&session, "/busy").unwrap();
|
||||
assert!(
|
||||
client.ensure_interface(&session, "math").unwrap().is_none(),
|
||||
client
|
||||
.ensure_interface(&session, root_reference("math"))
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"lookahead waits while the in-flight bound is full"
|
||||
);
|
||||
client
|
||||
@@ -359,11 +380,12 @@ fn ensure_interface_waits_for_request_and_cache_capacity_without_evicting() {
|
||||
.unwrap();
|
||||
|
||||
let request = client
|
||||
.ensure_interface(&session, "math")
|
||||
.ensure_interface(&session, root_reference("math"))
|
||||
.unwrap()
|
||||
.expect("lookahead resumes after capacity becomes available");
|
||||
let value = FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: None,
|
||||
};
|
||||
@@ -372,7 +394,7 @@ fn ensure_interface_waits_for_request_and_cache_capacity_without_evicting() {
|
||||
request.id,
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: "math".into(),
|
||||
interface: root_reference("math"),
|
||||
},
|
||||
&value,
|
||||
wire_limits(),
|
||||
@@ -383,18 +405,32 @@ fn ensure_interface_waits_for_request_and_cache_capacity_without_evicting() {
|
||||
|
||||
assert!(
|
||||
client
|
||||
.ensure_interface(&session, "other")
|
||||
.ensure_interface(&session, root_reference("other"))
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"lookahead does not churn a full descriptor cache"
|
||||
);
|
||||
assert!(client.interface(&session, "math").is_some());
|
||||
assert!(client.interface(&session, "other").is_none());
|
||||
assert!(
|
||||
client
|
||||
.interface(&session, &root_reference("math"))
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.interface(&session, &root_reference("other"))
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let explicit = client.prepare_interface(&session, "other").unwrap();
|
||||
assert!(client.interface(&session, "math").is_none());
|
||||
let explicit = client
|
||||
.prepare_interface(&session, root_reference("other"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
client
|
||||
.interface(&session, &root_reference("math"))
|
||||
.is_none()
|
||||
);
|
||||
assert!(matches!(
|
||||
client.interface(&session, "other").unwrap().state,
|
||||
client.interface(&session, &root_reference("other")).unwrap().state,
|
||||
ObservationState::Loading(id) if id == explicit.id
|
||||
));
|
||||
}
|
||||
@@ -408,7 +444,9 @@ fn independent_retrievals_run_concurrently_and_complete_out_of_order() {
|
||||
|
||||
let first = client.refresh_observed(&session, "/one", 0).unwrap();
|
||||
let second = client.refresh_observed(&session, "/two", 0).unwrap();
|
||||
let interface = client.prepare_interface(&session, "math").unwrap();
|
||||
let interface = client
|
||||
.prepare_interface(&session, root_reference("math"))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(first.request.uri().path(), "/wip/v1/observe");
|
||||
assert_eq!(second.request.uri().path(), "/wip/v1/observe");
|
||||
@@ -426,10 +464,11 @@ fn independent_retrievals_run_concurrently_and_complete_out_of_order() {
|
||||
interface.id,
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: "math".into(),
|
||||
interface: root_reference("math"),
|
||||
},
|
||||
&FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: Some(b"math-v1".to_vec()),
|
||||
},
|
||||
@@ -454,7 +493,10 @@ fn independent_retrievals_run_concurrently_and_complete_out_of_order() {
|
||||
ObservationState::Fresh
|
||||
);
|
||||
assert_eq!(
|
||||
client.interface(&session, "math").unwrap().state,
|
||||
client
|
||||
.interface(&session, &root_reference("math"))
|
||||
.unwrap()
|
||||
.state,
|
||||
ObservationState::Fresh
|
||||
);
|
||||
assert_eq!(client.in_flight_len(), 0);
|
||||
@@ -941,14 +983,21 @@ fn call_context_fixes_membership_validators_descriptor_and_result_validation() {
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: Some(b"interface-v1".to_vec()),
|
||||
},
|
||||
);
|
||||
|
||||
let call = client
|
||||
.prepare_call(&session, "/item", "math", "calculate", args(7))
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
args(7),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client
|
||||
@@ -965,7 +1014,8 @@ fn call_context_fixes_membership_validators_descriptor_and_result_validation() {
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::String),
|
||||
validator: Some(b"interface-v2".to_vec()),
|
||||
},
|
||||
@@ -981,7 +1031,8 @@ fn call_context_fixes_membership_validators_descriptor_and_result_validation() {
|
||||
validator: Some(b"object-v1".to_vec()),
|
||||
},
|
||||
interface: wip_protocol::InterfaceTarget {
|
||||
reference: "math".into(),
|
||||
scope_ref: None,
|
||||
reference: root_reference("math"),
|
||||
validator: Some(b"interface-v1".to_vec()),
|
||||
},
|
||||
operation: "calculate".into(),
|
||||
@@ -1015,7 +1066,10 @@ fn call_context_fixes_membership_validators_descriptor_and_result_validation() {
|
||||
);
|
||||
assert!(matches!(history_context.outcome, CallOutcome::Success(_)));
|
||||
assert_eq!(
|
||||
client.interface(&session, "math").unwrap().descriptor,
|
||||
client
|
||||
.interface(&session, &root_reference("math"))
|
||||
.unwrap()
|
||||
.descriptor,
|
||||
Some(descriptor(TypeExpr::String))
|
||||
);
|
||||
let observation = client.object(&session, "/item").unwrap();
|
||||
@@ -1039,14 +1093,21 @@ fn successful_call_without_validator_preserves_observed_validator() {
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: None,
|
||||
},
|
||||
);
|
||||
|
||||
let call = client
|
||||
.prepare_call(&session, "/item", "math", "calculate", args(1))
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
args(1),
|
||||
)
|
||||
.unwrap();
|
||||
let request = client
|
||||
.pending_call_context(call.id)
|
||||
@@ -1080,7 +1141,13 @@ fn successful_call_without_validator_preserves_observed_validator() {
|
||||
assert_eq!(observation.validator, Some(b"object-v1".to_vec()));
|
||||
|
||||
let next = client
|
||||
.prepare_call(&session, "/item", "math", "calculate", args(2))
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
args(2),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client
|
||||
@@ -1109,14 +1176,21 @@ fn newer_observation_prevents_older_success_validator_overwrite() {
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: None,
|
||||
},
|
||||
);
|
||||
|
||||
let call = client
|
||||
.prepare_call(&session, "/item", "math", "calculate", args(1))
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
args(1),
|
||||
)
|
||||
.unwrap();
|
||||
let request = client
|
||||
.pending_call_context(call.id)
|
||||
@@ -1173,7 +1247,8 @@ fn older_retrieval_cannot_overwrite_success_validator() {
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: Some(b"interface-v1".to_vec()),
|
||||
},
|
||||
@@ -1181,7 +1256,13 @@ fn older_retrieval_cannot_overwrite_success_validator() {
|
||||
|
||||
let older_alias = client.refresh_observed(&session, "/alias/item", 0).unwrap();
|
||||
let call = client
|
||||
.prepare_call(&session, "/item", "math", "calculate", args(7))
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
args(7),
|
||||
)
|
||||
.unwrap();
|
||||
let request = client
|
||||
.pending_call_context(call.id)
|
||||
@@ -1227,7 +1308,13 @@ fn older_retrieval_cannot_overwrite_success_validator() {
|
||||
assert_eq!(alias.validator, Some(b"object-v2".to_vec()));
|
||||
assert_eq!(
|
||||
client
|
||||
.prepare_call(&session, "/alias/item", "math", "calculate", args(1))
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/alias/item",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
args(1)
|
||||
)
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ClientErrorKind::MissingObservation
|
||||
@@ -1243,7 +1330,13 @@ fn older_retrieval_cannot_overwrite_success_validator() {
|
||||
.refresh_observed(&session, "/second/item", 0)
|
||||
.unwrap();
|
||||
let call = client
|
||||
.prepare_call(&session, "/item", "math", "calculate", args(8))
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
args(8),
|
||||
)
|
||||
.unwrap();
|
||||
client.mark_dispatched(call.id).unwrap();
|
||||
let unknown = ProtocolError {
|
||||
@@ -1292,13 +1385,20 @@ fn in_flight_calls_pin_target_identity_until_staleness_is_applied() {
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: Some(b"interface-v1".to_vec()),
|
||||
},
|
||||
);
|
||||
let call = client
|
||||
.prepare_call(&session, "/item", "math", "calculate", args(7))
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
args(7),
|
||||
)
|
||||
.unwrap();
|
||||
client.mark_dispatched(call.id).unwrap();
|
||||
|
||||
@@ -1350,13 +1450,20 @@ fn validator_mismatch_stales_only_its_observation_and_never_retries() {
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: Some(b"i1".to_vec()),
|
||||
},
|
||||
);
|
||||
let call = client
|
||||
.prepare_call(&session, "/item", "math", "calculate", args(1))
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
args(1),
|
||||
)
|
||||
.unwrap();
|
||||
client.mark_dispatched(call.id).unwrap();
|
||||
let error = ProtocolError {
|
||||
@@ -1369,7 +1476,10 @@ fn validator_mismatch_stales_only_its_observation_and_never_retries() {
|
||||
client.complete(call.id, response).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
client.interface(&session, "math").unwrap().state,
|
||||
client
|
||||
.interface(&session, &root_reference("math"))
|
||||
.unwrap()
|
||||
.state,
|
||||
ObservationState::Stale
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1384,7 +1494,8 @@ fn validator_mismatch_stales_only_its_observation_and_never_retries() {
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: Some(b"i2".to_vec()),
|
||||
},
|
||||
@@ -1404,14 +1515,21 @@ fn protocol_and_transport_unknown_outcomes_stale_without_retry() {
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: None,
|
||||
},
|
||||
);
|
||||
|
||||
let call = client
|
||||
.prepare_call(&session, "/item", "math", "calculate", args(1))
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
args(1),
|
||||
)
|
||||
.unwrap();
|
||||
client.mark_dispatched(call.id).unwrap();
|
||||
let unknown = ProtocolError {
|
||||
@@ -1447,7 +1565,13 @@ fn protocol_and_transport_unknown_outcomes_stale_without_retry() {
|
||||
|
||||
observe_object(&mut client, &session, "/item", object("item", None, b"v2"));
|
||||
let second = client
|
||||
.prepare_call(&session, "/item", "math", "calculate", args(2))
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
&root_reference("math"),
|
||||
"calculate",
|
||||
args(2),
|
||||
)
|
||||
.unwrap();
|
||||
client.mark_dispatched(second.id).unwrap();
|
||||
let completion = client
|
||||
@@ -1695,8 +1819,12 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let old = client.prepare_interface(&session, "math").unwrap();
|
||||
let newer = client.prepare_interface(&session, "math").unwrap();
|
||||
let old = client
|
||||
.prepare_interface(&session, root_reference("math"))
|
||||
.unwrap();
|
||||
let newer = client
|
||||
.prepare_interface(&session, root_reference("math"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client
|
||||
.fail_transport(old.id, "old connection failed")
|
||||
@@ -1704,11 +1832,12 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() {
|
||||
Completion::StaleResponseRejected(old.id)
|
||||
);
|
||||
assert!(matches!(
|
||||
client.interface(&session, "math").unwrap().state,
|
||||
client.interface(&session, &root_reference("math")).unwrap().state,
|
||||
ObservationState::Loading(id) if id == newer.id
|
||||
));
|
||||
let value = FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: Some(b"current".to_vec()),
|
||||
};
|
||||
@@ -1717,7 +1846,7 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() {
|
||||
newer.id,
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: "math".into(),
|
||||
interface: root_reference("math"),
|
||||
},
|
||||
&value,
|
||||
wire_limits(),
|
||||
@@ -1737,15 +1866,19 @@ fn validator_free_interface_observation_remains_immutable() {
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: None,
|
||||
},
|
||||
);
|
||||
|
||||
let refresh = client.prepare_interface(&session, "math").unwrap();
|
||||
let refresh = client
|
||||
.prepare_interface(&session, root_reference("math"))
|
||||
.unwrap();
|
||||
let contradictory = FetchInterfaceResponse {
|
||||
interface: "math".into(),
|
||||
scope_ref: None,
|
||||
interface: root_reference("math"),
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
validator: Some(b"became-mutable".to_vec()),
|
||||
};
|
||||
@@ -1754,7 +1887,7 @@ fn validator_free_interface_observation_remains_immutable() {
|
||||
refresh.id,
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: "math".into(),
|
||||
interface: root_reference("math"),
|
||||
},
|
||||
&contradictory,
|
||||
wire_limits(),
|
||||
@@ -1763,7 +1896,13 @@ fn validator_free_interface_observation_remains_immutable() {
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, ClientErrorKind::InvalidResponse);
|
||||
assert_eq!(client.interface(&session, "math").unwrap().validator, None);
|
||||
assert_eq!(
|
||||
client
|
||||
.interface(&session, &root_reference("math"))
|
||||
.unwrap()
|
||||
.validator,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1876,3 +2015,64 @@ fn in_flight_and_history_are_bounded() {
|
||||
.unwrap_err();
|
||||
assert_eq!(client.diagnostics(&session).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_interface_keys_membership_and_scope_ref_are_preserved() {
|
||||
let mut client = client(8, 8, 8, 8);
|
||||
let session = client
|
||||
.open_session("https://example.test/wip", SecurityContext::new("subject"))
|
||||
.unwrap();
|
||||
let root = root_reference("same#::操作");
|
||||
let local = wip_protocol::InterfaceReference {
|
||||
scope: "/item".into(),
|
||||
name: root.name.clone(),
|
||||
};
|
||||
let mut value = object("item", None, b"object-v1");
|
||||
value.interfaces = vec![local.clone()];
|
||||
observe_object(&mut client, &session, "/item", value);
|
||||
for (reference, scope_ref) in [
|
||||
(root.clone(), None),
|
||||
(local.clone(), Some("scope-identity".into())),
|
||||
] {
|
||||
observe_interface(
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: reference,
|
||||
descriptor: descriptor(TypeExpr::Integer),
|
||||
scope_ref,
|
||||
validator: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
assert!(client.interface(&session, &root).is_some());
|
||||
assert_eq!(
|
||||
client
|
||||
.interface(&session, &local)
|
||||
.unwrap()
|
||||
.scope_ref
|
||||
.as_deref(),
|
||||
Some("scope-identity")
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.prepare_call(&session, "/item", &root, "calculate", args(1))
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ClientErrorKind::InvalidRequest
|
||||
);
|
||||
let call = client
|
||||
.prepare_call(&session, "/item", &local, "calculate", args(1))
|
||||
.unwrap();
|
||||
let context = client.pending_call_context(call.id).unwrap();
|
||||
assert_eq!(context.request().interface.reference, local);
|
||||
assert_eq!(
|
||||
context.request().interface.scope_ref.as_deref(),
|
||||
Some("scope-identity")
|
||||
);
|
||||
assert_eq!(
|
||||
context.interface().scope_ref.as_deref(),
|
||||
Some("scope-identity")
|
||||
);
|
||||
assert_eq!(client.interface(&session, &root).unwrap().scope_ref, None);
|
||||
}
|
||||
|
||||
@@ -23,8 +23,9 @@ For an external backend/composition:
|
||||
|
||||
1. Implement `BackendEntry` on a local, cloneable, root-confined stable capability.
|
||||
Its `WipObject` metadata is public-only; its validator binds identity and the
|
||||
protected state. Implement `FilesystemBackend` to resolve capabilities,
|
||||
enumerate bounded children, and execute against an already-resolved entry.
|
||||
protected state. Implement `FilesystemBackend` to resolve one connected
|
||||
`ResolvedEntry<Entry>` (root-to-parent ancestry plus pinned target), enumerate
|
||||
bounded children, and execute against that already-resolved lineage.
|
||||
2. Register generated `#[wip_interface::interface]` definitions using the existing
|
||||
`wip_host::InterfaceRegistry`. `DefaultInterfaces::register` optionally supplies
|
||||
reusable built-in tokens; using those tokens does not force their publication.
|
||||
@@ -55,10 +56,26 @@ listener, a global object registry, or dynamic plugin loading.
|
||||
### Extension security contract
|
||||
|
||||
Backend implementations and Operation authors are trusted code, not sandboxed
|
||||
plugins. `FilesystemBackend::execute` must refresh state/validators **inside** its
|
||||
transaction or lock before calling the engine callback, retain the same resolved
|
||||
target through validation and dispatch, and return the committed post-operation
|
||||
validator. Confirm rollback only after rollback succeeds. Pre-dispatch failures
|
||||
plugins. `FilesystemBackend::resolve` returns `ResolvedEntry::new(ancestors, target)`
|
||||
from one connected backend traversal/snapshot, not independent path-prefix
|
||||
resolutions. A root target has no ancestors; other targets retain the root first,
|
||||
then intermediate parent containers. This is backend-private context, never wire
|
||||
metadata. Backend `resolve` and `execute` signatures both carry this type;
|
||||
compositions still receive only the exact scoped target `Entry`.
|
||||
|
||||
`FilesystemBackend::execute` must validate ancestor membership/identity and
|
||||
refresh **all** lineage state/validators inside its transaction or lock before
|
||||
calling the engine callback, retaining the same target and connected ancestry
|
||||
through validation and dispatch. Replaced/detached ancestors must return
|
||||
`NotFound` without callback invocation, even when the replacement is visible.
|
||||
The engine evaluates Object visibility for every refreshed ancestor and target
|
||||
inside that same boundary; a hidden ancestor rejects the entire subtree before
|
||||
publication/typed dispatch. Never fix a changed lineage by resolving the raw path
|
||||
again and substituting capabilities. Participating policy-relevant backend state
|
||||
changes must be serialized by this boundary, not just target-content writes.
|
||||
Return the committed post-operation validator. Retained ancestry is subject to
|
||||
handle/work ceilings; resource exhaustion is an error, not missing ancestry.
|
||||
Confirm rollback only after rollback succeeds. Pre-dispatch failures
|
||||
must never commit; failed started operations without a non-commit guarantee remain
|
||||
`OperationOutcomeUnknown`. Object implementations must delegate validators to
|
||||
that entry and declare requirements per `(InterfaceRef, operation)`, rather than
|
||||
@@ -68,13 +85,16 @@ cancellation and resource budgets throughout backend work and report effects
|
||||
truthfully. POSIX symlinks, identity, atomic mutations and locking cannot be assumed
|
||||
for other backends.
|
||||
|
||||
The default Linux backend uses advisory exclusive locks on regular files,
|
||||
refreshes validators after acquiring the lock and holds it through post-dispatch
|
||||
state observation. This serializes cooperating library calls; it does **not**
|
||||
promise transactional isolation from unrelated processes ignoring `flock`.
|
||||
Descriptor-relative pinned handles still prevent replacement from redirecting an
|
||||
operation to a different file. Linux capabilities only expose bounded methods,
|
||||
not raw descriptors or backend paths.
|
||||
The default Linux backend acquires advisory exclusive locks in root-to-parent
|
||||
order and then on the lockable target (directory or regular file). It compares
|
||||
retained ancestor identity to no-follow descriptor-relative lookup results,
|
||||
refreshes lineage snapshots after acquiring the locks, and holds the guards
|
||||
through post-dispatch state observation. The configured root remains its pinned
|
||||
capability even if its textual path is renamed; replaced intermediate ancestors
|
||||
are rejected. Leaf path replacement never redirects execution to a new inode.
|
||||
This serializes cooperating library calls; it does **not** promise transactional
|
||||
isolation from unrelated processes ignoring `flock`. Linux capabilities only
|
||||
expose bounded methods, not raw descriptors or backend paths.
|
||||
|
||||
A policy must be stable/deterministic within one effective Worldspace view.
|
||||
Interface/operation visibility is uniform per registered reference within that
|
||||
@@ -175,13 +195,15 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/observe \
|
||||
--data '{"path":"/","depth":2}'
|
||||
```
|
||||
|
||||
A regular-file observation returns the opaque read interface reference and an object
|
||||
validator. Retrieve that descriptor with:
|
||||
A regular-file observation returns a structured read interface reference and an
|
||||
object validator. Built-in legacy registrations use scope `/` and copy the opaque
|
||||
registration string verbatim into `name`; they do not emit `scope_ref`. Copy the
|
||||
complete reference from the observation to retrieve that descriptor:
|
||||
|
||||
```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-observation>"}'
|
||||
--data '{"interface":{"scope":"/","name":"<read-interface-name-from-observation>"}}'
|
||||
```
|
||||
|
||||
Then call a bounded range read:
|
||||
@@ -191,7 +213,7 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/call_operation \
|
||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||
--data '{
|
||||
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
||||
"interface":{"reference":"<read-interface>"},
|
||||
"interface":{"reference":{"scope":"/","name":"<read-interface-name>"}},
|
||||
"operation":"read",
|
||||
"arguments":{"offset":0,"length":4096}
|
||||
}'
|
||||
@@ -205,7 +227,7 @@ curl -sS -X POST http://127.0.0.1:8080/wip/v1/call_operation \
|
||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||
--data '{
|
||||
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
||||
"interface":{"reference":"<write-interface>"},
|
||||
"interface":{"reference":{"scope":"/","name":"<write-interface-name>"}},
|
||||
"operation":"replace",
|
||||
"arguments":{"contents":"dXBkYXRlZA=="}
|
||||
}'
|
||||
|
||||
@@ -32,6 +32,39 @@ pub trait BackendEntry: WipObject + Clone {
|
||||
fn kind(&self) -> EntryKind;
|
||||
}
|
||||
|
||||
/// A single resolved target together with its retained root-to-parent lineage.
|
||||
///
|
||||
/// The backend must resolve a connected lineage, never independent raw-path
|
||||
/// prefixes. At execution it validates ancestor membership/identity, refreshes
|
||||
/// their policy metadata, and pins that snapshot through dispatch. Target
|
||||
/// replacement must not redirect the original capability. This value is private
|
||||
/// backend context, not protocol metadata.
|
||||
#[derive(Clone)]
|
||||
pub struct ResolvedEntry<E> {
|
||||
ancestors: Vec<E>,
|
||||
target: E,
|
||||
}
|
||||
|
||||
impl<E> ResolvedEntry<E> {
|
||||
/// Create backend resolution context. `/` has no ancestors; a non-root target
|
||||
/// has the pinned root first, followed by its intermediate parent containers.
|
||||
pub fn new(ancestors: Vec<E>, target: E) -> Self {
|
||||
Self { ancestors, target }
|
||||
}
|
||||
/// Pinned ancestor capabilities, ordered from the root to the direct parent.
|
||||
pub fn ancestors(&self) -> &[E] {
|
||||
&self.ancestors
|
||||
}
|
||||
/// The original resolved target capability, never a redirected replacement.
|
||||
pub fn target(&self) -> &E {
|
||||
&self.target
|
||||
}
|
||||
/// Consume this snapshot for refresh or request-time publication.
|
||||
pub fn into_parts(self) -> (Vec<E>, E) {
|
||||
(self.ancestors, self.target)
|
||||
}
|
||||
}
|
||||
|
||||
/// Trusted backend boundary. Implementations need not use POSIX semantics.
|
||||
///
|
||||
/// Resolve and enumerate pinned capabilities within the configured root. Enforce
|
||||
@@ -39,17 +72,28 @@ pub trait BackendEntry: WipObject + Clone {
|
||||
/// advertise capabilities the backend cannot guarantee. Errors must not include
|
||||
/// backend paths, credentials or hidden metadata.
|
||||
///
|
||||
/// `execute` must retain the same target and serialize validator observation,
|
||||
/// publication, validation, typed dispatch and commit/rollback. Refresh entry
|
||||
/// validators *inside* the boundary before invoking the callback, and return the
|
||||
/// committed post-operation validator on success. Never upgrade an unknown
|
||||
/// outcome except after confirmed rollback. Locks only protect against actors
|
||||
/// participating in that locking protocol; document any external-writer limits.
|
||||
/// `execute` must retain the same target and complete lineage and serialize
|
||||
/// ancestor identity/membership checks, policy metadata, validator observation,
|
||||
/// publication, validation, typed dispatch and commit/rollback. Changed/detached
|
||||
/// ancestors must return `NotFound` before the callback; never substitute a new
|
||||
/// ancestor or redirect the target by re-resolving its raw path. Refresh *all*
|
||||
/// lineage metadata/validators inside the boundary and retain that snapshot
|
||||
/// through dispatch, then return the committed post-operation validator. Never
|
||||
/// upgrade an unknown outcome except after confirmed rollback. Locks only protect
|
||||
/// actors participating in the protocol; document any external-writer limits.
|
||||
pub trait FilesystemBackend {
|
||||
/// Stable capability supplied to the composition.
|
||||
type Entry: BackendEntry;
|
||||
/// Resolve a canonical WIP path without escaping the configured root.
|
||||
fn resolve(&self, context: &ExecutionContext, path: &str) -> ProviderResult<Self::Entry>;
|
||||
/// Resolve a canonical WIP path and retain its connected ancestry.
|
||||
///
|
||||
/// Obtain the target and lineage in one backend traversal/snapshot. Enforce
|
||||
/// configured handle/work budgets while retaining ancestors; do not silently
|
||||
/// omit ancestry on resource exhaustion.
|
||||
fn resolve(
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
path: &str,
|
||||
) -> ProviderResult<ResolvedEntry<Self::Entry>>;
|
||||
/// Enumerate direct children, at most `maximum`; excess must fail, not truncate.
|
||||
fn children(
|
||||
&self,
|
||||
@@ -61,8 +105,8 @@ pub trait FilesystemBackend {
|
||||
fn execute(
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
entry: Self::Entry,
|
||||
operation: impl FnOnce(Self::Entry) -> OperationAttempt,
|
||||
entry: ResolvedEntry<Self::Entry>,
|
||||
operation: impl FnOnce(ResolvedEntry<Self::Entry>) -> OperationAttempt,
|
||||
) -> ProviderResult<OperationAttempt>;
|
||||
}
|
||||
|
||||
@@ -205,39 +249,40 @@ where
|
||||
C: Composition<B::Entry>,
|
||||
P: ProjectionPolicy<B::Entry, C::Object>,
|
||||
{
|
||||
fn resolve(&self, context: &ExecutionContext, path: &str) -> ProviderResult<B::Entry> {
|
||||
fn resolve(
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
path: &str,
|
||||
) -> ProviderResult<ResolvedEntry<B::Entry>> {
|
||||
if let Err(error) = preflight(context) {
|
||||
return ProviderResult::Failure(error);
|
||||
}
|
||||
if wip_protocol::validate_path(path).is_err() {
|
||||
return ProviderResult::NotFound;
|
||||
}
|
||||
// Check ancestors as well: guessing a descendant cannot bypass hidden parents.
|
||||
let mut end = 1;
|
||||
loop {
|
||||
let prefix = if end == 1 { "/" } else { &path[..end] };
|
||||
let entry = match self.projection.backend.resolve(context, prefix) {
|
||||
ProviderResult::Found(entry) => entry,
|
||||
ProviderResult::NotFound => return ProviderResult::NotFound,
|
||||
ProviderResult::Failure(error) => return ProviderResult::Failure(error),
|
||||
};
|
||||
if !self
|
||||
.projection
|
||||
.policy
|
||||
.object_visible(self.principal, &entry)
|
||||
{
|
||||
return ProviderResult::NotFound;
|
||||
match self.projection.backend.resolve(context, path) {
|
||||
ProviderResult::Found(resolved) => {
|
||||
if !lineage_matches_path(&resolved, path) {
|
||||
return ProviderResult::Failure(internal());
|
||||
}
|
||||
if !self.lineage_visible(&resolved) {
|
||||
return ProviderResult::NotFound;
|
||||
}
|
||||
ProviderResult::Found(resolved)
|
||||
}
|
||||
if prefix == path {
|
||||
return ProviderResult::Found(entry);
|
||||
}
|
||||
let start = if end == 1 { 1 } else { end + 1 };
|
||||
end = path[start..]
|
||||
.find('/')
|
||||
.map_or(path.len(), |index| start + index);
|
||||
ProviderResult::NotFound => ProviderResult::NotFound,
|
||||
ProviderResult::Failure(error) => ProviderResult::Failure(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn lineage_visible(&self, resolved: &ResolvedEntry<B::Entry>) -> bool {
|
||||
resolved
|
||||
.ancestors()
|
||||
.iter()
|
||||
.chain(std::iter::once(resolved.target()))
|
||||
.all(|entry| self.projection.policy.object_visible(self.principal, entry))
|
||||
}
|
||||
|
||||
fn publication(
|
||||
&self,
|
||||
entry: B::Entry,
|
||||
@@ -342,6 +387,7 @@ where
|
||||
}
|
||||
ProviderResult::Failure(error) => return Err(read_error(error)),
|
||||
};
|
||||
let (_ancestors, entry) = entry.into_parts();
|
||||
let mut remaining = u64::from(self.projection.limits.max_observation_nodes)
|
||||
.min(context.resource_budget().unwrap_or(u64::MAX));
|
||||
let response = self.observe_entry(context, entry, request.depth, &mut remaining)?;
|
||||
@@ -426,10 +472,13 @@ where
|
||||
operation: impl FnOnce(&PublishedObject<Self::Object>) -> R,
|
||||
) -> ProviderResult<R> {
|
||||
match self.resolve(context, path) {
|
||||
ProviderResult::Found(entry) => match self.publication(entry, context) {
|
||||
Ok(publication) => ProviderResult::Found(operation(&publication)),
|
||||
Err(error) => ProviderResult::Failure(error),
|
||||
},
|
||||
ProviderResult::Found(resolved) => {
|
||||
let (_ancestors, entry) = resolved.into_parts();
|
||||
match self.publication(entry, context) {
|
||||
Ok(publication) => ProviderResult::Found(operation(&publication)),
|
||||
Err(error) => ProviderResult::Failure(error),
|
||||
}
|
||||
}
|
||||
ProviderResult::NotFound => ProviderResult::NotFound,
|
||||
ProviderResult::Failure(error) => ProviderResult::Failure(error),
|
||||
}
|
||||
@@ -445,16 +494,16 @@ where
|
||||
ProviderResult::NotFound => return ProviderResult::NotFound,
|
||||
ProviderResult::Failure(error) => return ProviderResult::Failure(error),
|
||||
};
|
||||
self.projection.backend.execute(context, entry, |entry| {
|
||||
// Recheck visibility using the atomic-boundary snapshot.
|
||||
// Failure here is still pre-dispatch, with no operation effects.
|
||||
if !self
|
||||
.projection
|
||||
.policy
|
||||
.object_visible(self.principal, &entry)
|
||||
{
|
||||
self.projection.backend.execute(context, entry, |resolved| {
|
||||
// Backend validates retained lineage membership/identity and refreshes
|
||||
// all metadata before this callback under the same boundary as dispatch.
|
||||
if !lineage_matches_path(&resolved, path) {
|
||||
return OperationAttempt::reject(wip_host::CallErrorKind::Internal);
|
||||
}
|
||||
if !self.lineage_visible(&resolved) {
|
||||
return OperationAttempt::reject(wip_host::CallErrorKind::NotFound);
|
||||
}
|
||||
let (_ancestors, entry) = resolved.into_parts();
|
||||
if let Err(error) = preflight(context) {
|
||||
return OperationAttempt::reject(match error.kind() {
|
||||
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
|
||||
@@ -544,6 +593,24 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
fn lineage_matches_path<E: BackendEntry>(resolved: &ResolvedEntry<E>, path: &str) -> bool {
|
||||
let names: Vec<_> = if path == "/" {
|
||||
vec![""]
|
||||
} else {
|
||||
std::iter::once("").chain(path[1..].split('/')).collect()
|
||||
};
|
||||
if resolved.ancestors().len() + 1 != names.len()
|
||||
|| resolved.target().name() != names[names.len() - 1]
|
||||
{
|
||||
return false;
|
||||
}
|
||||
resolved
|
||||
.ancestors()
|
||||
.iter()
|
||||
.zip(names)
|
||||
.all(|(entry, name)| entry.kind() == EntryKind::Directory && entry.name() == name)
|
||||
}
|
||||
|
||||
fn read_boundary<T>(
|
||||
operation: impl FnOnce() -> Result<T, ProtocolError>,
|
||||
) -> Result<T, ProtocolError> {
|
||||
|
||||
@@ -58,7 +58,7 @@ use std::time::Duration;
|
||||
pub use defaults::{DefaultComposition, DefaultInterfaces, DefaultObject, default_projection};
|
||||
pub use engine::{
|
||||
BackendEntry, Composition, DefaultPolicy, EntryKind, FilesystemBackend, Projection,
|
||||
ProjectionPolicy, ProjectionView,
|
||||
ProjectionPolicy, ProjectionView, ResolvedEntry,
|
||||
};
|
||||
pub use http_adapter::HttpAdapter;
|
||||
pub use interfaces::{
|
||||
|
||||
@@ -7,12 +7,12 @@ use std::sync::{Arc, Mutex, MutexGuard, TryLockError};
|
||||
|
||||
use crate::engine::{BackendEntry, EntryKind, FilesystemBackend};
|
||||
use crate::interfaces::{FileRead, FileWrite, SymlinkInspect};
|
||||
use crate::{AccessMode, ResourceLimits};
|
||||
use crate::{AccessMode, ResolvedEntry, ResourceLimits};
|
||||
use rustix::fs::{
|
||||
CWD, FileType, FlockOperation, Mode as FsMode, OFlags, RawDir, flock, fstat, ftruncate, openat,
|
||||
readlinkat,
|
||||
};
|
||||
use rustix::io::{dup, pread, pwrite};
|
||||
use rustix::io::{pread, pwrite};
|
||||
use wip_host::{
|
||||
EffectGuarantee, ExecutionContext, HostError, OperationAttempt, ProviderResult, Validator,
|
||||
WipObject,
|
||||
@@ -130,6 +130,7 @@ impl LinuxEntry {
|
||||
|
||||
fn refresh_validator(&mut self) -> Result<(), ProjectionError> {
|
||||
let stat = fstat(self.fd()).map_err(|_| ProjectionError::Internal)?;
|
||||
self.kind = entry_kind(stat.st_mode);
|
||||
let validator = matches!(self.kind, EntryKind::Regular | EntryKind::Symlink)
|
||||
.then(|| validator_from_stat(&stat));
|
||||
self.initial_validator = validator.clone();
|
||||
@@ -180,10 +181,12 @@ enum FailureInjection {
|
||||
|
||||
/// Linux filesystem backend using pinned, no-follow handles.
|
||||
///
|
||||
/// Regular-file execution holds an advisory exclusive `flock` across validator
|
||||
/// refresh, dispatch, and post-operation observation. Serialization only covers
|
||||
/// actors that participate in this locking protocol; external writers can still
|
||||
/// modify the inode without taking the lock. Symlinks are inspected, not followed.
|
||||
/// Execution holds advisory exclusive `flock` guards on directories in
|
||||
/// root-to-parent order and on directory or regular-file targets across lineage
|
||||
/// validation, validator refresh, dispatch, and post-operation observation.
|
||||
/// Serialization only covers actors that participate in this locking protocol;
|
||||
/// external writers can still mutate entries without taking the locks. Symlinks
|
||||
/// are inspected, not followed.
|
||||
pub struct LinuxBackend {
|
||||
root: OwnedFd,
|
||||
access: AccessMode,
|
||||
@@ -220,22 +223,28 @@ impl LinuxBackend {
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
path: &str,
|
||||
) -> Result<LinuxEntry, ProjectionError> {
|
||||
) -> Result<ResolvedEntry<LinuxEntry>, ProjectionError> {
|
||||
check_context(context)?;
|
||||
wip_protocol::validate_path(path).map_err(|_| ProjectionError::NotFound)?;
|
||||
let root = self.entry(
|
||||
context,
|
||||
String::new(),
|
||||
reopen(&self.root, OFlags::RDONLY | OFlags::DIRECTORY)?,
|
||||
)?;
|
||||
if path == "/" {
|
||||
let fd = reopen(&self.root, OFlags::RDONLY | OFlags::DIRECTORY)?;
|
||||
return self.entry(context, String::new(), fd);
|
||||
return Ok(ResolvedEntry::new(Vec::new(), root));
|
||||
}
|
||||
let mut parent = dup(&self.root).map_err(|_| ProjectionError::Internal)?;
|
||||
// Retain the exact directories traversed, rather than resolving prefixes again.
|
||||
let mut ancestors = vec![root];
|
||||
let mut segments = path[1..].split('/').peekable();
|
||||
while let Some(segment) = segments.next() {
|
||||
check_context(context)?;
|
||||
if segment.as_bytes().contains(&0) {
|
||||
return Err(ProjectionError::NotFound);
|
||||
}
|
||||
let parent = ancestors.last().ok_or(ProjectionError::Internal)?;
|
||||
let probe = openat(
|
||||
&parent,
|
||||
parent.fd(),
|
||||
segment,
|
||||
OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
FsMode::empty(),
|
||||
@@ -243,14 +252,15 @@ impl LinuxBackend {
|
||||
.map_err(classify_lookup)?;
|
||||
let stat = fstat(&probe).map_err(|_| ProjectionError::Internal)?;
|
||||
let kind = entry_kind(stat.st_mode);
|
||||
if segments.peek().is_some() && kind != EntryKind::Directory {
|
||||
return Err(ProjectionError::NotFound);
|
||||
}
|
||||
let fd = self.reopen_final(probe, kind)?;
|
||||
let entry = self.entry(context, segment.to_owned(), fd)?;
|
||||
if segments.peek().is_some() {
|
||||
if kind != EntryKind::Directory {
|
||||
return Err(ProjectionError::NotFound);
|
||||
}
|
||||
parent = reopen(&probe, OFlags::RDONLY | OFlags::DIRECTORY)?;
|
||||
ancestors.push(entry);
|
||||
} else {
|
||||
let fd = self.reopen_final(probe, kind)?;
|
||||
return self.entry(context, segment.to_owned(), fd);
|
||||
return Ok(ResolvedEntry::new(ancestors, entry));
|
||||
}
|
||||
}
|
||||
Err(ProjectionError::NotFound)
|
||||
@@ -323,6 +333,49 @@ impl LinuxBackend {
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_ancestry(
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
ancestors: &[LinuxEntry],
|
||||
target: &LinuxEntry,
|
||||
) -> Result<(), ProjectionError> {
|
||||
check_context(context)?;
|
||||
let root = ancestors.first().unwrap_or(target);
|
||||
let configured = fstat(&self.root).map_err(|_| ProjectionError::Internal)?;
|
||||
let retained = fstat(root.fd()).map_err(|_| ProjectionError::Internal)?;
|
||||
// The configured root is a capability, not its possibly renamed textual path.
|
||||
if entry_kind(retained.st_mode) != EntryKind::Directory
|
||||
|| retained.st_dev != configured.st_dev
|
||||
|| retained.st_ino != configured.st_ino
|
||||
{
|
||||
return Err(ProjectionError::NotFound);
|
||||
}
|
||||
for pair in ancestors.windows(2) {
|
||||
check_context(context)?;
|
||||
let parent = &pair[0];
|
||||
let child = &pair[1];
|
||||
let visible = openat(
|
||||
parent.fd(),
|
||||
child.name.as_str(),
|
||||
OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
FsMode::empty(),
|
||||
)
|
||||
.map_err(classify_lookup)?;
|
||||
let visible = fstat(&visible).map_err(|_| ProjectionError::Internal)?;
|
||||
let retained = fstat(child.fd()).map_err(|_| ProjectionError::Internal)?;
|
||||
if entry_kind(visible.st_mode) != EntryKind::Directory
|
||||
|| entry_kind(retained.st_mode) != EntryKind::Directory
|
||||
|| visible.st_dev != retained.st_dev
|
||||
|| visible.st_ino != retained.st_ino
|
||||
{
|
||||
return Err(ProjectionError::NotFound);
|
||||
}
|
||||
}
|
||||
// Deliberately do not look up the target name: leaf replacement must not
|
||||
// redirect execution away from its original pinned inode.
|
||||
check_context(context)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn set_injection(&self, injection: FailureInjection) {
|
||||
*self
|
||||
@@ -341,7 +394,11 @@ impl BackendEntry for LinuxEntry {
|
||||
impl FilesystemBackend for LinuxBackend {
|
||||
type Entry = LinuxEntry;
|
||||
|
||||
fn resolve(&self, context: &ExecutionContext, path: &str) -> ProviderResult<LinuxEntry> {
|
||||
fn resolve(
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
path: &str,
|
||||
) -> ProviderResult<ResolvedEntry<LinuxEntry>> {
|
||||
match self.resolve_entry(context, path) {
|
||||
Ok(entry) => ProviderResult::Found(entry),
|
||||
Err(error) => error.host(),
|
||||
@@ -393,16 +450,32 @@ impl FilesystemBackend for LinuxBackend {
|
||||
fn execute(
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
mut entry: LinuxEntry,
|
||||
callback: impl FnOnce(LinuxEntry) -> OperationAttempt,
|
||||
resolved: ResolvedEntry<LinuxEntry>,
|
||||
callback: impl FnOnce(ResolvedEntry<LinuxEntry>) -> OperationAttempt,
|
||||
) -> ProviderResult<OperationAttempt> {
|
||||
if let Err(error) = check_context(context) {
|
||||
return error.host();
|
||||
}
|
||||
let lock_entry = Arc::clone(&entry.inner);
|
||||
let _lock = if entry.kind == EntryKind::Regular {
|
||||
match lock_with_context(&lock_entry, context, self.limits.request_timeout) {
|
||||
Ok(lock) => Some(lock),
|
||||
let (mut ancestors, mut entry) = resolved.into_parts();
|
||||
// Independent resolutions have independent open-file descriptions; clones
|
||||
// also need the per-entry mutex because flock alone treats them as one owner.
|
||||
// Always acquire directories root-to-parent, then the target (if lockable).
|
||||
let mut lock_entries: Vec<_> = ancestors
|
||||
.iter()
|
||||
.map(|ancestor| Arc::clone(&ancestor.inner))
|
||||
.collect();
|
||||
if matches!(entry.kind, EntryKind::Directory | EntryKind::Regular) {
|
||||
lock_entries.push(Arc::clone(&entry.inner));
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
let mut locks = Vec::with_capacity(lock_entries.len());
|
||||
for inner in &lock_entries {
|
||||
let remaining = self
|
||||
.limits
|
||||
.request_timeout
|
||||
.saturating_sub(started.elapsed());
|
||||
match lock_with_context(inner, context, remaining) {
|
||||
Ok(lock) => locks.push(lock),
|
||||
Err(ProjectionError::Resource) => {
|
||||
return ProviderResult::Failure(HostError::resource_limit(
|
||||
"filesystem lock deadline exceeded",
|
||||
@@ -416,15 +489,22 @@ impl FilesystemBackend for LinuxBackend {
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Both snapshots must describe the state under the lock, not at resolve time.
|
||||
}
|
||||
if let Err(error) = self.validate_ancestry(context, &ancestors, &entry) {
|
||||
return error.host();
|
||||
}
|
||||
for ancestor in &mut ancestors {
|
||||
if ancestor.refresh_validator().is_err() {
|
||||
return ProviderResult::Failure(internal_not_committed());
|
||||
}
|
||||
ancestor.context = context.clone();
|
||||
}
|
||||
// Both snapshots must describe the state under the locks, not at resolve time.
|
||||
if entry.refresh_validator().is_err() {
|
||||
return ProviderResult::Failure(internal_not_committed());
|
||||
}
|
||||
entry.context = context.clone();
|
||||
let mut attempt = callback(entry.clone());
|
||||
let mut attempt = callback(ResolvedEntry::new(ancestors, entry.clone()));
|
||||
if let OperationAttempt::Started(Ok(result)) = &mut attempt {
|
||||
if entry.refresh_validator().is_err() {
|
||||
return ProviderResult::Failure(HostError::internal(
|
||||
@@ -735,7 +815,7 @@ mod tests {
|
||||
use super::{FailureInjection, FlockOperation, LinuxBackend, LinuxEntry};
|
||||
use crate::engine::{DefaultPolicy, FilesystemBackend, Projection};
|
||||
use crate::interfaces::{FileRead, FileWrite};
|
||||
use crate::{AccessMode, DefaultComposition, ResourceLimits};
|
||||
use crate::{AccessMode, DefaultComposition, ResolvedEntry, ResourceLimits};
|
||||
|
||||
type DefaultProjection = Projection<LinuxBackend, DefaultComposition, DefaultPolicy>;
|
||||
|
||||
@@ -747,7 +827,7 @@ mod tests {
|
||||
projection: &DefaultProjection,
|
||||
path: &str,
|
||||
validator: Option<Vec<u8>>,
|
||||
interface: String,
|
||||
interface: wip_protocol::InterfaceReference,
|
||||
operation: &str,
|
||||
arguments: BTreeMap<String, Value>,
|
||||
) -> Result<wip_protocol::CallOperationResponse, wip_host::CallError> {
|
||||
@@ -757,6 +837,7 @@ mod tests {
|
||||
validator,
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
scope_ref: None,
|
||||
reference: interface,
|
||||
validator: None,
|
||||
},
|
||||
@@ -765,7 +846,11 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
fn resolved(backend: &LinuxBackend, context: &ExecutionContext, path: &str) -> LinuxEntry {
|
||||
fn resolved(
|
||||
backend: &LinuxBackend,
|
||||
context: &ExecutionContext,
|
||||
path: &str,
|
||||
) -> ResolvedEntry<LinuxEntry> {
|
||||
match backend.resolve(context, path) {
|
||||
ProviderResult::Found(entry) => entry,
|
||||
ProviderResult::NotFound => panic!("entry should resolve"),
|
||||
@@ -786,8 +871,8 @@ mod tests {
|
||||
.expect("open backend");
|
||||
let context = ExecutionContext::new();
|
||||
let entry = resolved(&backend, &context, "/file");
|
||||
let original = entry.initial_validator.clone();
|
||||
let pinned = entry.clone();
|
||||
let original = entry.target().initial_validator.clone();
|
||||
let pinned = entry.target().clone();
|
||||
// Simulate a cooperating writer committing between resolution and execution.
|
||||
fs::write(&path, b"changed before lock acquisition").expect("external mutation");
|
||||
let lock_probe = fs::OpenOptions::new()
|
||||
@@ -796,7 +881,8 @@ mod tests {
|
||||
.open(&path)
|
||||
.expect("open independent lock probe");
|
||||
let mut callback_validator = None;
|
||||
let attempt = backend.execute(&context, entry, |entry| {
|
||||
let attempt = backend.execute(&context, entry, |resolved| {
|
||||
let entry = resolved.target();
|
||||
assert!(std::sync::Arc::ptr_eq(&entry.inner, &pinned.inner));
|
||||
assert_ne!(entry.initial_validator, original);
|
||||
assert_eq!(
|
||||
@@ -824,7 +910,9 @@ mod tests {
|
||||
assert_ne!(result.validator, callback_validator);
|
||||
assert_eq!(
|
||||
result.validator,
|
||||
resolved(&backend, &context, "/file").initial_validator,
|
||||
resolved(&backend, &context, "/file")
|
||||
.target()
|
||||
.initial_validator,
|
||||
);
|
||||
assert_eq!(fs::read(&path).expect("read written file"), b"new");
|
||||
// The retained clone must not keep the operation's flock held.
|
||||
@@ -846,10 +934,11 @@ mod tests {
|
||||
.expect("open backend");
|
||||
let context = ExecutionContext::new();
|
||||
let entry = resolved(&backend, &context, "/file");
|
||||
let pinned = entry.clone();
|
||||
let pinned = entry.target().clone();
|
||||
fs::remove_file(&path).expect("unlink pinned entry");
|
||||
fs::write(&path, b"replacement").expect("replace path");
|
||||
let attempt = backend.execute(&context, entry, |entry| {
|
||||
let attempt = backend.execute(&context, entry, |resolved| {
|
||||
let entry = resolved.target();
|
||||
assert!(std::sync::Arc::ptr_eq(&entry.inner, &pinned.inner));
|
||||
entry
|
||||
.replace(wip_interface::Bytes(b"pinned write".to_vec()))
|
||||
@@ -870,6 +959,239 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detached_or_replaced_ancestors_reject_before_callback_without_effects() {
|
||||
// Exercise both the immediate parent and a higher ancestor, with a
|
||||
// visible directory replacement, no replacement, and a symlink replacement.
|
||||
for ancestor in ["a", "a/b"] {
|
||||
for replacement in ["directory", "missing", "symlink"] {
|
||||
let temporary = tempdir().expect("temporary root");
|
||||
fs::create_dir_all(temporary.path().join("a/b")).expect("create lineage");
|
||||
fs::write(temporary.path().join("a/b/file"), b"original").expect("write original");
|
||||
let backend = LinuxBackend::open(
|
||||
temporary.path(),
|
||||
AccessMode::ReadWrite,
|
||||
ResourceLimits::default(),
|
||||
)
|
||||
.expect("open backend");
|
||||
let context = ExecutionContext::new();
|
||||
let entry = resolved(&backend, &context, "/a/b/file");
|
||||
let pinned = entry.target().clone();
|
||||
let detached = temporary.path().join("detached");
|
||||
let ancestor_path = temporary.path().join(ancestor);
|
||||
fs::rename(&ancestor_path, &detached).expect("detach ancestor");
|
||||
if replacement == "directory" {
|
||||
fs::create_dir(&ancestor_path).expect("replace directory");
|
||||
} else if replacement == "symlink" {
|
||||
std::os::unix::fs::symlink(&detached, &ancestor_path)
|
||||
.expect("replace ancestor with symlink to original");
|
||||
}
|
||||
let visible_leaf =
|
||||
ancestor_path.join(if ancestor == "a" { "b/file" } else { "file" });
|
||||
if replacement == "directory" {
|
||||
fs::create_dir_all(visible_leaf.parent().expect("leaf parent"))
|
||||
.expect("create replacement lineage");
|
||||
fs::write(&visible_leaf, b"replacement").expect("write replacement");
|
||||
}
|
||||
let mut called = false;
|
||||
let attempt = backend.execute(&context, entry, |entry| {
|
||||
called = true;
|
||||
entry
|
||||
.target()
|
||||
.replace(wip_interface::Bytes(b"bad".to_vec()))
|
||||
.expect("would write pinned inode");
|
||||
OperationAttempt::reject(CallErrorKind::ValidatorMismatch)
|
||||
});
|
||||
assert!(
|
||||
matches!(attempt, ProviderResult::NotFound),
|
||||
"{ancestor}: {replacement}"
|
||||
);
|
||||
assert!(!called);
|
||||
assert_eq!(
|
||||
pinned.read(None, None).expect("read original inode").0,
|
||||
b"original"
|
||||
);
|
||||
if replacement != "missing" {
|
||||
assert_eq!(
|
||||
fs::read(&visible_leaf).expect("read visible leaf"),
|
||||
if replacement == "directory" {
|
||||
b"replacement".as_slice()
|
||||
} else {
|
||||
b"original".as_slice()
|
||||
}
|
||||
);
|
||||
}
|
||||
// Rejection releases every guard, even while the target is retained.
|
||||
let root_probe = fs::File::open(temporary.path()).expect("open root probe");
|
||||
rustix::fs::flock(&root_probe, FlockOperation::NonBlockingLockExclusive)
|
||||
.expect("rejection released root guard");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_retains_and_refreshes_ancestors_under_ordered_directory_guards() {
|
||||
let temporary = tempdir().expect("temporary root");
|
||||
fs::create_dir_all(temporary.path().join("a/b")).expect("create lineage");
|
||||
fs::write(temporary.path().join("a/b/file"), b"original").expect("write file");
|
||||
let backend = LinuxBackend::open(
|
||||
temporary.path(),
|
||||
AccessMode::ReadOnly,
|
||||
ResourceLimits::default(),
|
||||
)
|
||||
.expect("open backend");
|
||||
let context = ExecutionContext::new();
|
||||
let entry = resolved(&backend, &context, "/a/b/file");
|
||||
assert_eq!(
|
||||
entry
|
||||
.ancestors()
|
||||
.iter()
|
||||
.map(WipObject::name)
|
||||
.collect::<Vec<_>>(),
|
||||
["", "a", "b"]
|
||||
);
|
||||
let pinned_ancestors = entry.ancestors().to_vec();
|
||||
let (mut ancestors, target) = entry.into_parts();
|
||||
// Stale snapshots must not escape the execution boundary.
|
||||
for ancestor in &mut ancestors {
|
||||
ancestor.validator = Some(wip_host::Validator::new(vec![1]));
|
||||
ancestor.initial_validator = Some(vec![1]);
|
||||
}
|
||||
let probes: Vec<_> = ["", "a", "a/b", "a/b/file"]
|
||||
.into_iter()
|
||||
.map(|path| fs::File::open(temporary.path().join(path)).expect("open lock probe"))
|
||||
.collect();
|
||||
let attempt = backend.execute(&context, ResolvedEntry::new(ancestors, target), |entry| {
|
||||
for (ancestor, pinned) in entry.ancestors().iter().zip(&pinned_ancestors) {
|
||||
assert!(std::sync::Arc::ptr_eq(&ancestor.inner, &pinned.inner));
|
||||
assert!(ancestor.validator().is_none());
|
||||
assert!(ancestor.initial_validator.is_none());
|
||||
}
|
||||
for probe in &probes {
|
||||
assert_eq!(
|
||||
rustix::fs::flock(probe, FlockOperation::NonBlockingLockExclusive),
|
||||
Err(rustix::io::Errno::WOULDBLOCK)
|
||||
);
|
||||
}
|
||||
OperationAttempt::reject(CallErrorKind::ValidatorMismatch)
|
||||
});
|
||||
assert!(matches!(attempt, ProviderResult::Found(_)));
|
||||
for probe in &probes {
|
||||
rustix::fs::flock(probe, FlockOperation::NonBlockingLockExclusive)
|
||||
.expect("guard released after callback");
|
||||
rustix::fs::flock(probe, FlockOperation::Unlock).expect("unlock probe");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_root_stays_pinned_after_textual_rename_and_replacement() {
|
||||
let temporary = tempdir().expect("temporary parent");
|
||||
let configured = temporary.path().join("root");
|
||||
fs::create_dir_all(configured.join("dir")).expect("create original root");
|
||||
fs::write(configured.join("dir/file"), b"original").expect("write original file");
|
||||
let backend = LinuxBackend::open(
|
||||
&configured,
|
||||
AccessMode::ReadWrite,
|
||||
ResourceLimits::default(),
|
||||
)
|
||||
.expect("open backend");
|
||||
let context = ExecutionContext::new();
|
||||
let root = resolved(&backend, &context, "/");
|
||||
assert!(root.ancestors().is_empty());
|
||||
let file = resolved(&backend, &context, "/dir/file");
|
||||
let moved = temporary.path().join("moved");
|
||||
fs::rename(&configured, &moved).expect("rename configured root");
|
||||
fs::create_dir_all(configured.join("dir")).expect("replace textual root");
|
||||
fs::write(configured.join("dir/file"), b"replacement").expect("write replacement file");
|
||||
for entry in [
|
||||
root,
|
||||
resolved(&backend, &context, "/"),
|
||||
resolved(&backend, &context, "/dir"),
|
||||
] {
|
||||
let attempt = backend.execute(&context, entry, |entry| {
|
||||
assert_eq!(entry.target().kind, crate::EntryKind::Directory);
|
||||
OperationAttempt::reject(CallErrorKind::ValidatorMismatch)
|
||||
});
|
||||
assert!(
|
||||
matches!(attempt, ProviderResult::Found(_)),
|
||||
"root/directory locks once"
|
||||
);
|
||||
}
|
||||
let attempt = backend.execute(&context, file, |entry| {
|
||||
entry
|
||||
.target()
|
||||
.replace(wip_interface::Bytes(b"pinned write".to_vec()))
|
||||
.expect("write beneath original root");
|
||||
OperationAttempt::Started(Ok(wip_host::CallResult {
|
||||
result: Value::Bytes(Vec::new()),
|
||||
validator: None,
|
||||
}))
|
||||
});
|
||||
assert!(matches!(
|
||||
attempt,
|
||||
ProviderResult::Found(OperationAttempt::Started(Ok(_)))
|
||||
));
|
||||
assert_eq!(
|
||||
fs::read(moved.join("dir/file")).expect("read original root"),
|
||||
b"pinned write"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(configured.join("dir/file")).expect("read textual replacement"),
|
||||
b"replacement"
|
||||
);
|
||||
assert_eq!(
|
||||
resolved(&backend, &context, "/dir/file")
|
||||
.target()
|
||||
.read(None, None)
|
||||
.expect("resolution still uses pinned root")
|
||||
.0,
|
||||
b"pinned write"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_resolution_counts_all_retained_ancestors_against_handle_budget() {
|
||||
let temporary = tempdir().expect("temporary root");
|
||||
fs::create_dir_all(temporary.path().join("a/b")).expect("create lineage");
|
||||
fs::write(temporary.path().join("a/b/file"), b"original").expect("write file");
|
||||
let context = ExecutionContext::new();
|
||||
for maximum in [3, 4] {
|
||||
let backend = LinuxBackend::open(
|
||||
temporary.path(),
|
||||
AccessMode::ReadOnly,
|
||||
ResourceLimits {
|
||||
max_open_handles: maximum,
|
||||
..ResourceLimits::default()
|
||||
},
|
||||
)
|
||||
.expect("open backend");
|
||||
match backend.resolve(&context, "/a/b/file") {
|
||||
ProviderResult::Failure(error) if maximum == 3 => {
|
||||
assert_eq!(error.kind(), wip_host::HostErrorKind::ResourceLimitExceeded);
|
||||
assert_eq!(
|
||||
error.effect_guarantee(),
|
||||
wip_host::EffectGuarantee::NotCommitted
|
||||
);
|
||||
}
|
||||
ProviderResult::Found(entry) if maximum == 4 => {
|
||||
assert_eq!(entry.ancestors().len(), 3);
|
||||
assert_eq!(backend.handles.current.load(super::Ordering::Acquire), 4);
|
||||
let cloned =
|
||||
ResolvedEntry::new(entry.ancestors().to_vec(), entry.target().clone());
|
||||
drop(entry);
|
||||
assert_eq!(backend.handles.current.load(super::Ordering::Acquire), 4);
|
||||
drop(cloned);
|
||||
}
|
||||
_ => panic!("unexpected resolution outcome for budget {maximum}"),
|
||||
}
|
||||
assert_eq!(backend.handles.current.load(super::Ordering::Acquire), 0);
|
||||
let root = resolved(&backend, &context, "/");
|
||||
assert_eq!(backend.handles.current.load(super::Ordering::Acquire), 1);
|
||||
drop(root);
|
||||
assert_eq!(backend.handles.current.load(super::Ordering::Acquire), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_execution_releases_lock_even_when_callback_retains_entry() {
|
||||
let temporary = tempdir().expect("temporary root");
|
||||
@@ -914,7 +1236,7 @@ mod tests {
|
||||
.expect("open backend");
|
||||
let context = ExecutionContext::new();
|
||||
let entry = resolved(&backend, &context, "/file");
|
||||
let cloned = entry.clone();
|
||||
let cloned = ResolvedEntry::new(entry.ancestors().to_vec(), entry.target().clone());
|
||||
let attempt = backend.execute(&context, entry, |entry| {
|
||||
let started = std::time::Instant::now();
|
||||
let nested = backend.execute(&context, entry, |_| {
|
||||
@@ -956,10 +1278,10 @@ mod tests {
|
||||
.expect("open backend");
|
||||
let context = ExecutionContext::new();
|
||||
let root = resolved(&backend, &context, "/");
|
||||
assert!(backend.children(&context, &root, 1).is_err());
|
||||
assert!(backend.children(&context, root.target(), 1).is_err());
|
||||
assert_eq!(backend.handles.current.load(super::Ordering::Acquire), 1);
|
||||
let children = backend
|
||||
.children(&context, &root, 2)
|
||||
.children(&context, root.target(), 2)
|
||||
.expect("complete children");
|
||||
assert_eq!(children.len(), 2);
|
||||
let cloned = children[0].clone();
|
||||
@@ -986,13 +1308,14 @@ mod tests {
|
||||
.expect("open backend");
|
||||
let context = ExecutionContext::new();
|
||||
let entry = resolved(&backend, &context, "/file");
|
||||
assert_eq!(entry.access_mode(), AccessMode::ReadOnly);
|
||||
assert!(entry.supports_file_read());
|
||||
assert!(!entry.supports_file_write());
|
||||
assert!(!entry.supports_symlink_inspect());
|
||||
assert!(entry.read(None, None).is_err());
|
||||
assert_eq!(entry.read(None, Some(2)).expect("bounded read").0, b"ol");
|
||||
assert!(entry.replace(wip_interface::Bytes(Vec::new())).is_err());
|
||||
let target = entry.target();
|
||||
assert_eq!(target.access_mode(), AccessMode::ReadOnly);
|
||||
assert!(target.supports_file_read());
|
||||
assert!(!target.supports_file_write());
|
||||
assert!(!target.supports_symlink_inspect());
|
||||
assert!(target.read(None, None).is_err());
|
||||
assert_eq!(target.read(None, Some(2)).expect("bounded read").0, b"ol");
|
||||
assert!(target.replace(wip_interface::Bytes(Vec::new())).is_err());
|
||||
assert_eq!(fs::read(&path).expect("read unchanged file"), b"old");
|
||||
let cancellation = wip_host::CancellationToken::new();
|
||||
cancellation.cancel();
|
||||
|
||||
@@ -98,6 +98,7 @@ fn real_listener_serves_three_core_interactions_and_write() {
|
||||
validator: file.validator.clone(),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
scope_ref: None,
|
||||
reference: file.interfaces[0].clone(),
|
||||
validator: None,
|
||||
},
|
||||
@@ -141,6 +142,7 @@ fn real_listener_serves_three_core_interactions_and_write() {
|
||||
validator: file.validator,
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
scope_ref: None,
|
||||
reference: file.interfaces[1].clone(),
|
||||
validator: None,
|
||||
},
|
||||
@@ -412,7 +414,7 @@ fn call_argument_decoding_does_not_mask_missing_target() {
|
||||
let config = ServerConfig::for_root(temporary.path());
|
||||
let server = WipFsServer::new(config).expect("build server");
|
||||
let endpoint = Endpoint::parse("http://localhost/wip").expect("endpoint");
|
||||
let body = br#"{"target":{"path":"/missing"},"interface":{"reference":"unknown"},"operation":"missing","arguments":{"bytes":"not-base64"}}"#;
|
||||
let body = br#"{"target":{"path":"/missing"},"interface":{"reference":{"scope":"/","name":"unknown"}},"operation":"missing","arguments":{"bytes":"not-base64"}}"#;
|
||||
let request = http::Request::builder()
|
||||
.method(http::Method::POST)
|
||||
.uri(endpoint.route(Route::CallOperation).as_str())
|
||||
@@ -490,6 +492,7 @@ fn read_response_size_failure_is_transport_local() {
|
||||
validator: file.validator,
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
scope_ref: None,
|
||||
reference: file.interfaces[0].clone(),
|
||||
validator: None,
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use wip_fs_server::{
|
||||
BackendEntry, Composition, DefaultInterfaces, EntryKind, FileRead, FilesystemBackend,
|
||||
HttpAdapter, Projection, ProjectionPolicy, ResourceLimits,
|
||||
HttpAdapter, Projection, ProjectionPolicy, ResolvedEntry, ResourceLimits,
|
||||
};
|
||||
use wip_host::{
|
||||
CancellationToken, EffectGuarantee, ExecutionContext, HostError, InterfaceRef,
|
||||
@@ -77,15 +77,20 @@ struct Entry {
|
||||
path: String,
|
||||
node: Arc<Node>,
|
||||
validator: Validator,
|
||||
hidden: bool,
|
||||
}
|
||||
impl Entry {
|
||||
fn snapshot(path: String, node: Arc<Node>) -> Self {
|
||||
let revision = node.data.lock().unwrap().revision;
|
||||
let data = node.data.lock().unwrap();
|
||||
let revision = data.revision;
|
||||
let hidden = data.hidden;
|
||||
drop(data);
|
||||
let validator = Validator::new(format!("{}:{revision}", node.id).into_bytes());
|
||||
Self {
|
||||
path,
|
||||
node,
|
||||
validator,
|
||||
hidden,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,6 +123,8 @@ struct Seen {
|
||||
struct Controls {
|
||||
bump_before_execute: bool,
|
||||
hide_before_execute: bool,
|
||||
hide_ancestor: Option<String>,
|
||||
replace_ancestor: Option<String>,
|
||||
replace_mapping: bool,
|
||||
rollback: bool,
|
||||
cancel_on_execute: bool,
|
||||
@@ -168,7 +175,11 @@ fn check_context(context: &ExecutionContext) -> Result<(), HostError> {
|
||||
}
|
||||
impl FilesystemBackend for MemoryBackend {
|
||||
type Entry = Entry;
|
||||
fn resolve(&self, context: &ExecutionContext, path: &str) -> ProviderResult<Entry> {
|
||||
fn resolve(
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
path: &str,
|
||||
) -> ProviderResult<ResolvedEntry<Entry>> {
|
||||
self.0.record("resolve", path, context);
|
||||
if let Err(error) = check_context(context) {
|
||||
return ProviderResult::Failure(error);
|
||||
@@ -179,11 +190,33 @@ impl FilesystemBackend for MemoryBackend {
|
||||
EffectGuarantee::NotCommitted,
|
||||
));
|
||||
}
|
||||
// Map lookup only: there is no host path to reopen or escape.
|
||||
match self.0.nodes.lock().unwrap().get(path).cloned() {
|
||||
Some(node) => ProviderResult::Found(Entry::snapshot(path.into(), node)),
|
||||
None => ProviderResult::NotFound,
|
||||
// One connected map snapshot, serialized with participating mutations.
|
||||
let _transaction = self.0.transaction.lock().unwrap();
|
||||
let nodes = self.0.nodes.lock().unwrap();
|
||||
let Some(node) = nodes.get(path) else {
|
||||
return ProviderResult::NotFound;
|
||||
};
|
||||
let mut ancestors = Vec::new();
|
||||
if path != "/" {
|
||||
let prefixes = std::iter::once("/").chain(
|
||||
path.match_indices('/')
|
||||
.skip(1)
|
||||
.map(|(index, _)| &path[..index]),
|
||||
);
|
||||
for prefix in prefixes {
|
||||
let Some(parent) = nodes.get(prefix) else {
|
||||
return ProviderResult::NotFound;
|
||||
};
|
||||
if parent.kind != EntryKind::Directory {
|
||||
return ProviderResult::NotFound;
|
||||
}
|
||||
ancestors.push(Entry::snapshot(prefix.into(), parent.clone()));
|
||||
}
|
||||
}
|
||||
ProviderResult::Found(ResolvedEntry::new(
|
||||
ancestors,
|
||||
Entry::snapshot(path.into(), node.clone()),
|
||||
))
|
||||
}
|
||||
fn children(
|
||||
&self,
|
||||
@@ -224,10 +257,11 @@ impl FilesystemBackend for MemoryBackend {
|
||||
fn execute(
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
entry: Entry,
|
||||
operation: impl FnOnce(Entry) -> OperationAttempt,
|
||||
resolved: ResolvedEntry<Entry>,
|
||||
operation: impl FnOnce(ResolvedEntry<Entry>) -> OperationAttempt,
|
||||
) -> ProviderResult<OperationAttempt> {
|
||||
let _lock = self.0.transaction.lock().unwrap();
|
||||
let (ancestors, entry) = resolved.into_parts();
|
||||
self.0.record("execute", &entry.path, context);
|
||||
if let Err(error) = check_context(context) {
|
||||
return ProviderResult::Failure(error);
|
||||
@@ -243,6 +277,24 @@ impl FilesystemBackend for MemoryBackend {
|
||||
if controls.hide_before_execute {
|
||||
entry.node.data.lock().unwrap().hidden = true;
|
||||
}
|
||||
if let Some(path) = controls.hide_ancestor.take() {
|
||||
self.0.node(&path).data.lock().unwrap().hidden = true;
|
||||
}
|
||||
if let Some(path) = controls.replace_ancestor.take() {
|
||||
self.0.nodes.lock().unwrap().insert(
|
||||
path,
|
||||
Arc::new(Node {
|
||||
id: 1000,
|
||||
kind: EntryKind::Directory,
|
||||
data: Mutex::new(Data {
|
||||
bytes: b"replacement ancestor".to_vec(),
|
||||
revision: 1,
|
||||
hidden: false,
|
||||
}),
|
||||
calls: Mutex::new(Vec::new()),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if controls.replace_mapping {
|
||||
// The callback must still receive the original capability, not this decoy.
|
||||
self.0.nodes.lock().unwrap().insert(
|
||||
@@ -265,9 +317,25 @@ impl FilesystemBackend for MemoryBackend {
|
||||
}
|
||||
let rollback = controls.rollback;
|
||||
drop(controls);
|
||||
// Verify lineage identity/membership within the same transaction. The
|
||||
// callback cannot see a substituted ancestor, nor a redirected target.
|
||||
let nodes = self.0.nodes.lock().unwrap();
|
||||
for ancestor in &ancestors {
|
||||
if !nodes
|
||||
.get(&ancestor.path)
|
||||
.is_some_and(|node| Arc::ptr_eq(node, &ancestor.node))
|
||||
{
|
||||
return ProviderResult::NotFound;
|
||||
}
|
||||
}
|
||||
drop(nodes);
|
||||
let refreshed_ancestors = ancestors
|
||||
.into_iter()
|
||||
.map(|ancestor| Entry::snapshot(ancestor.path, ancestor.node))
|
||||
.collect();
|
||||
let before = entry.node.data.lock().unwrap().clone();
|
||||
let refreshed = Entry::snapshot(entry.path.clone(), entry.node.clone());
|
||||
let attempt = operation(refreshed);
|
||||
let attempt = operation(ResolvedEntry::new(refreshed_ancestors, refreshed));
|
||||
let attempt = match attempt {
|
||||
OperationAttempt::Started(Ok(mut result)) => {
|
||||
result.validator = Some(
|
||||
@@ -437,8 +505,7 @@ struct Policy {
|
||||
impl ProjectionPolicy<Entry, Object> for Policy {
|
||||
type Principal = Principal;
|
||||
fn object_visible(&self, principal: &Principal, entry: &Entry) -> bool {
|
||||
!entry.node.data.lock().unwrap().hidden
|
||||
&& (!matches!(principal, Principal::Limited) || entry.path != "/secret")
|
||||
!entry.hidden && (!matches!(principal, Principal::Limited) || entry.path != "/secret")
|
||||
}
|
||||
fn interface_visible(&self, principal: &Principal, interface: &InterfaceRef) -> bool {
|
||||
!(matches!(principal, Principal::Limited) && self.hide_read && interface == &self.read)
|
||||
@@ -509,7 +576,7 @@ fn observation_and_fetch_policy_panics_remain_sanitized_read_failures() {
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let defaults = DefaultInterfaces::register(&mut registry).unwrap();
|
||||
let custom = registry.register(MemoryOperationsDefinition, None).unwrap();
|
||||
let reference = custom.reference().as_str().to_owned();
|
||||
let reference = custom.reference().protocol_reference();
|
||||
let engine = Projection::new(
|
||||
MemoryBackend(fixture.shared.clone()),
|
||||
registry,
|
||||
@@ -549,8 +616,8 @@ type Engine = Projection<MemoryBackend, MemoryComposition, Policy>;
|
||||
struct Fixture {
|
||||
engine: Engine,
|
||||
shared: Arc<Shared>,
|
||||
custom: String,
|
||||
read: String,
|
||||
custom: wip_protocol::InterfaceReference,
|
||||
read: wip_protocol::InterfaceReference,
|
||||
limits: ResourceLimits,
|
||||
}
|
||||
impl Fixture {
|
||||
@@ -629,8 +696,8 @@ impl Fixture {
|
||||
limits,
|
||||
),
|
||||
shared,
|
||||
custom: custom_ref.as_str().into(),
|
||||
read: read_ref.as_str().into(),
|
||||
custom: custom_ref.protocol_reference(),
|
||||
read: read_ref.protocol_reference(),
|
||||
limits,
|
||||
}
|
||||
}
|
||||
@@ -640,7 +707,11 @@ impl Fixture {
|
||||
.observe(&ExecutionContext::new(), &observe_request(path, depth))
|
||||
.unwrap()
|
||||
}
|
||||
fn descriptor(&self, principal: &Principal, reference: &str) -> InterfaceDescriptor {
|
||||
fn descriptor(
|
||||
&self,
|
||||
principal: &Principal,
|
||||
reference: &wip_protocol::InterfaceReference,
|
||||
) -> InterfaceDescriptor {
|
||||
self.engine
|
||||
.view(principal)
|
||||
.fetch_interface(&fetch_request(reference))
|
||||
@@ -650,7 +721,7 @@ impl Fixture {
|
||||
fn request(
|
||||
&self,
|
||||
path: &str,
|
||||
reference: &str,
|
||||
reference: &wip_protocol::InterfaceReference,
|
||||
operation: &str,
|
||||
validator: Option<Vec<u8>>,
|
||||
) -> CallOperationRequest {
|
||||
@@ -660,7 +731,8 @@ impl Fixture {
|
||||
validator,
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: reference.into(),
|
||||
scope_ref: None,
|
||||
reference: reference.clone(),
|
||||
validator: None,
|
||||
},
|
||||
operation: operation.into(),
|
||||
@@ -690,15 +762,118 @@ impl Fixture {
|
||||
(response, decoded)
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn ancestor_visibility_refresh_inside_boundary_prevents_descendant_invocation_and_effects() {
|
||||
for (target, ancestor) in [
|
||||
("/added", "/"),
|
||||
("/secret/nested/data", "/"),
|
||||
("/secret/nested/data", "/secret"),
|
||||
("/secret/nested/data", "/secret/nested"),
|
||||
] {
|
||||
for operation in ["denied", "read", "change"] {
|
||||
let fixture = Fixture::new();
|
||||
let observation = fixture.observe(&Principal::Admin, target, 0);
|
||||
let descriptor = fixture.descriptor(&Principal::Admin, &fixture.custom);
|
||||
let mut request = fixture.request(
|
||||
target,
|
||||
&fixture.custom,
|
||||
operation,
|
||||
observation.object.validator,
|
||||
);
|
||||
if operation == "change" {
|
||||
request
|
||||
.arguments
|
||||
.insert("contents".into(), Value::Bytes(b"must not commit".to_vec()));
|
||||
}
|
||||
let leaf = fixture.shared.node(target);
|
||||
let before = leaf.data.lock().unwrap().clone();
|
||||
fixture.shared.controls.lock().unwrap().hide_ancestor = Some(ancestor.into());
|
||||
let (wire, response) = fixture.http_call(
|
||||
&Principal::Admin,
|
||||
&request,
|
||||
&descriptor,
|
||||
&ExecutionContext::new(),
|
||||
);
|
||||
wire_error(response, ProtocolErrorCode::NotFound);
|
||||
assert!(leaf.calls.lock().unwrap().is_empty());
|
||||
assert_eq!(*leaf.data.lock().unwrap(), before);
|
||||
assert!(!String::from_utf8_lossy(wire.body()).contains(BACKEND_SECRET));
|
||||
let error = fixture
|
||||
.engine
|
||||
.view(&Principal::Admin)
|
||||
.observe(&ExecutionContext::new(), &observe_request(target, 0))
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, ProtocolErrorCode::NotFound);
|
||||
// The entire target resolves once, not as unrelated path prefixes.
|
||||
let seen = fixture.shared.seen.lock().unwrap();
|
||||
assert!(
|
||||
seen.iter()
|
||||
.filter(|event| event.stage == "resolve")
|
||||
.all(|event| event.path == target)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaced_visible_ancestors_reject_without_redirecting_or_dispatching_the_pinned_target() {
|
||||
for (target, ancestor) in [
|
||||
("/added", "/"),
|
||||
("/secret/nested/data", "/secret"),
|
||||
("/secret/nested/data", "/secret/nested"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let observation = fixture.observe(&Principal::Admin, target, 0);
|
||||
let descriptor = fixture.descriptor(&Principal::Admin, &fixture.custom);
|
||||
let mut request = fixture.request(
|
||||
target,
|
||||
&fixture.custom,
|
||||
"change",
|
||||
observation.object.validator,
|
||||
);
|
||||
request
|
||||
.arguments
|
||||
.insert("contents".into(), Value::Bytes(b"must not commit".to_vec()));
|
||||
let leaf = fixture.shared.node(target);
|
||||
let original_ancestor = fixture.shared.node(ancestor);
|
||||
let before = leaf.data.lock().unwrap().clone();
|
||||
fixture.shared.controls.lock().unwrap().replace_ancestor = Some(ancestor.into());
|
||||
let (_, response) = fixture.http_call(
|
||||
&Principal::Admin,
|
||||
&request,
|
||||
&descriptor,
|
||||
&ExecutionContext::new(),
|
||||
);
|
||||
wire_error(response, ProtocolErrorCode::NotFound);
|
||||
assert!(leaf.calls.lock().unwrap().is_empty());
|
||||
assert_eq!(*leaf.data.lock().unwrap(), before);
|
||||
assert!(!Arc::ptr_eq(
|
||||
&original_ancestor,
|
||||
&fixture.shared.node(ancestor)
|
||||
));
|
||||
assert!(
|
||||
fixture
|
||||
.shared
|
||||
.node(ancestor)
|
||||
.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
// A replacement is visible but cannot authorize a previously resolved lineage.
|
||||
assert!(!fixture.shared.node(ancestor).data.lock().unwrap().hidden);
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_request(path: &str, depth: u32) -> ObserveRequest {
|
||||
ObserveRequest {
|
||||
path: path.into(),
|
||||
depth,
|
||||
}
|
||||
}
|
||||
fn fetch_request(reference: &str) -> FetchInterfaceRequest {
|
||||
fn fetch_request(reference: &wip_protocol::InterfaceReference) -> FetchInterfaceRequest {
|
||||
FetchInterfaceRequest {
|
||||
interface: reference.into(),
|
||||
interface: reference.clone(),
|
||||
}
|
||||
}
|
||||
fn success<T: std::fmt::Debug>(response: DecodedResponse<T>) -> T {
|
||||
@@ -868,8 +1043,8 @@ fn principal_views_hide_children_and_guessed_descendants_for_observation_and_cal
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|seen| !seen.path.starts_with("/secret/")),
|
||||
"hidden descendants were resolved"
|
||||
.all(|seen| seen.stage == "resolve"),
|
||||
"hidden lineage was published, enumerated, or dispatched"
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
@@ -1634,8 +1809,8 @@ fn policy_panics_and_guessed_operation_diagnostics_do_not_leak_to_http() {
|
||||
// This valid metadata names a nonexistent operation; bypass client-side
|
||||
// descriptor validation to exercise server diagnostics for untrusted names.
|
||||
let body = format!(
|
||||
r#"{{"target":{{"path":"/added"}},"interface":{{"reference":"{}"}},"operation":"{}","arguments":{{}}}}"#,
|
||||
fixture.custom, POLICY_SECRET
|
||||
r#"{{"target":{{"path":"/added"}},"interface":{{"reference":{{"scope":"{}","name":"{}"}}}},"operation":"{}","arguments":{{}}}}"#,
|
||||
fixture.custom.scope, fixture.custom.name, POLICY_SECRET
|
||||
);
|
||||
let request = http::Request::builder()
|
||||
.method(http::Method::POST)
|
||||
|
||||
@@ -10,6 +10,10 @@ wip-host = "0.1.0"
|
||||
wip-interface = "0.1.0"
|
||||
```
|
||||
|
||||
Protocol publication and dispatch use `wip_protocol::InterfaceReference { scope, name }` and compare both fields exactly. The legacy `InterfaceRef` registration token remains Host-private: `protocol_reference()` maps its opaque string verbatim to the name at root scope `/`, without parsing it. Registry lookup and fetch accept structured references; non-root references do not resolve legacy registrations. Legacy responses omit `scope_ref`.
|
||||
|
||||
Malformed interface-reference shapes fail calls as `InvalidRequest` before object resolution. A well-shaped reference outside the target path's ancestor-or-self scope fails at the membership check as `InterfaceMismatch`.
|
||||
|
||||
See the [API documentation](https://docs.rs/wip-host) for publication, provider, execution-context, and effect-guarantee boundaries.
|
||||
|
||||
Licensed under either of Apache-2.0 or MIT at your option.
|
||||
|
||||
+57
-17
@@ -49,8 +49,8 @@ pub use wip_protocol::{
|
||||
Target,
|
||||
};
|
||||
use wip_protocol::{
|
||||
FetchInterfaceRequest, FetchInterfaceResponse, InterfaceDescriptor, Object, ProtocolError,
|
||||
ProtocolErrorCode, ValidationError, Value,
|
||||
FetchInterfaceRequest, FetchInterfaceResponse, InterfaceDescriptor, InterfaceReference, Object,
|
||||
ProtocolError, ProtocolErrorCode, ValidationError, Value,
|
||||
};
|
||||
|
||||
/// Opaque bytes used as a conditional execution precondition.
|
||||
@@ -192,11 +192,22 @@ impl ExecutionContext {
|
||||
pub struct InterfaceRef(String);
|
||||
|
||||
impl InterfaceRef {
|
||||
/// Returns the opaque reference for a protocol transport.
|
||||
/// Returns the legacy opaque registration reference.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Maps the legacy registration reference to a root-scoped protocol reference.
|
||||
///
|
||||
/// The opaque registration string is copied as the name without parsing it.
|
||||
#[must_use]
|
||||
pub fn protocol_reference(&self) -> InterfaceReference {
|
||||
InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: self.0.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One atomic observation of an interface reference, descriptor, and validator.
|
||||
@@ -229,13 +240,14 @@ impl InterfaceObservation {
|
||||
/// local validation error.
|
||||
pub fn protocol_response(&self) -> Result<FetchInterfaceResponse, ProtocolError> {
|
||||
let response = FetchInterfaceResponse {
|
||||
interface: self.reference.0.clone(),
|
||||
interface: self.reference.protocol_reference(),
|
||||
descriptor: self.descriptor.clone(),
|
||||
scope_ref: None,
|
||||
validator: self.validator.as_ref().map(|validator| validator.0.clone()),
|
||||
};
|
||||
response
|
||||
.validate_for(&FetchInterfaceRequest {
|
||||
interface: self.reference.0.clone(),
|
||||
interface: self.reference.protocol_reference(),
|
||||
})
|
||||
.map_err(|_| ProtocolError {
|
||||
code: ProtocolErrorCode::Internal,
|
||||
@@ -354,10 +366,16 @@ impl InterfaceRegistry {
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves a transport-provided opaque reference string in this registry.
|
||||
/// Resolves a structured protocol reference to a legacy registration token.
|
||||
///
|
||||
/// Legacy artifacts are published only at root scope. The name is looked up
|
||||
/// verbatim; no scope or name parsing is performed.
|
||||
#[must_use]
|
||||
pub fn resolve(&self, reference: &str) -> Option<InterfaceRef> {
|
||||
let reference = InterfaceRef(reference.to_owned());
|
||||
pub fn resolve(&self, reference: &InterfaceReference) -> Option<InterfaceRef> {
|
||||
if reference.scope != "/" {
|
||||
return None;
|
||||
}
|
||||
let reference = InterfaceRef(reference.name.clone());
|
||||
self.artifacts.contains_key(&reference).then_some(reference)
|
||||
}
|
||||
|
||||
@@ -367,14 +385,20 @@ impl InterfaceRegistry {
|
||||
self.artifacts.get(reference).map(Self::observation)
|
||||
}
|
||||
|
||||
/// Implements canonical `fetch_interface` semantics for an opaque reference.
|
||||
/// Implements canonical `fetch_interface` semantics for a structured reference.
|
||||
///
|
||||
/// Unknown and currently unpublished references map to `InterfaceNotFound`,
|
||||
/// not object membership errors or authorization failures.
|
||||
pub fn fetch_interface(&self, reference: &str) -> Result<InterfaceObservation, ProtocolError> {
|
||||
let reference = InterfaceRef(reference.to_owned());
|
||||
self.artifacts
|
||||
.get(&reference)
|
||||
/// Malformed references map to `InvalidRequest`. Unknown and currently
|
||||
/// unpublished references (including non-root scopes) map to `InterfaceNotFound`.
|
||||
pub fn fetch_interface(
|
||||
&self,
|
||||
reference: &InterfaceReference,
|
||||
) -> Result<InterfaceObservation, ProtocolError> {
|
||||
reference.validate().map_err(|error| ProtocolError {
|
||||
code: ProtocolErrorCode::InvalidRequest,
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
self.resolve(reference)
|
||||
.and_then(|reference| self.artifacts.get(&reference))
|
||||
.map(Self::observation)
|
||||
.ok_or_else(|| ProtocolError {
|
||||
code: ProtocolErrorCode::InterfaceNotFound,
|
||||
@@ -625,7 +649,7 @@ impl<T: WipObject> PublishedObject<T> {
|
||||
interfaces: self
|
||||
.interfaces
|
||||
.iter()
|
||||
.map(|bound| bound.artifact.reference.0.clone())
|
||||
.map(|bound| bound.artifact.reference.protocol_reference())
|
||||
.collect(),
|
||||
r#ref: self.implementation.reference().map(str::to_owned),
|
||||
validator: self
|
||||
@@ -678,10 +702,17 @@ impl<T: WipObject> PublishedObject<T> {
|
||||
);
|
||||
}
|
||||
|
||||
if interface.reference.validate_for_path(&target.path).is_err() {
|
||||
return pre_dispatch_failure(
|
||||
CallErrorKind::InterfaceMismatch,
|
||||
"interface scope is not an ancestor of the target path",
|
||||
);
|
||||
}
|
||||
|
||||
let Some(bound) = self
|
||||
.interfaces
|
||||
.iter()
|
||||
.find(|bound| bound.artifact.reference.as_str() == interface.reference.as_str())
|
||||
.find(|bound| bound.artifact.reference.protocol_reference() == interface.reference)
|
||||
else {
|
||||
return pre_dispatch_failure(
|
||||
CallErrorKind::InterfaceMismatch,
|
||||
@@ -1039,6 +1070,11 @@ pub trait ObjectProvider {
|
||||
) -> Result<CallResult, CallError> {
|
||||
wip_protocol::validate_path(&request.target.path)
|
||||
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||
request
|
||||
.interface
|
||||
.reference
|
||||
.validate()
|
||||
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||
let path = request.target.path.clone();
|
||||
let execution = catch_unwind(AssertUnwindSafe(|| {
|
||||
self.execute_operation(context, &path, |publication| {
|
||||
@@ -1088,6 +1124,10 @@ pub trait ObjectProvider {
|
||||
) -> Result<CallResult, CallError> {
|
||||
wip_protocol::validate_path(&target.path)
|
||||
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||
interface
|
||||
.reference
|
||||
.validate()
|
||||
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||
let path = target.path.clone();
|
||||
let execution = catch_unwind(AssertUnwindSafe(|| {
|
||||
self.execute_operation(context, &path, |publication| {
|
||||
|
||||
+133
-14
@@ -35,7 +35,10 @@ fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_to
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
projected.protocol_object().interfaces,
|
||||
vec![write.reference().as_str(), read.reference().as_str()]
|
||||
vec![
|
||||
write.reference().protocol_reference(),
|
||||
read.reference().protocol_reference()
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
publication()
|
||||
@@ -58,14 +61,18 @@ fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_to
|
||||
.interfaces
|
||||
.is_empty()
|
||||
);
|
||||
let full = registry.fetch_interface(read.reference().as_str()).unwrap();
|
||||
let full = registry
|
||||
.fetch_interface(&read.reference().protocol_reference())
|
||||
.unwrap();
|
||||
let filtered = full
|
||||
.clone()
|
||||
.project_operations(|operation| operation == "label");
|
||||
assert_eq!(filtered.descriptor.operations.len(), 1);
|
||||
assert_eq!(full.descriptor.operations.len(), 3);
|
||||
assert_eq!(
|
||||
registry.fetch_interface(read.reference().as_str()).unwrap(),
|
||||
registry
|
||||
.fetch_interface(&read.reference().protocol_reference())
|
||||
.unwrap(),
|
||||
full
|
||||
);
|
||||
}
|
||||
@@ -478,7 +485,8 @@ fn request(
|
||||
validator: object_validator.map(<[u8]>::to_vec),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: reference.as_str().to_owned(),
|
||||
reference: reference.protocol_reference(),
|
||||
scope_ref: None,
|
||||
validator: interface_validator.map(<[u8]>::to_vec),
|
||||
},
|
||||
operation: operation.into(),
|
||||
@@ -497,17 +505,22 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
|
||||
let reference = provider.reader.reference();
|
||||
|
||||
assert_ne!(reference.as_str(), "Reader");
|
||||
let observation = registry.fetch_interface(reference.as_str()).unwrap();
|
||||
let observation = registry
|
||||
.fetch_interface(&reference.protocol_reference())
|
||||
.unwrap();
|
||||
assert_eq!(observation.reference, *reference);
|
||||
assert_eq!(observation.descriptor, ReaderDefinition::descriptor());
|
||||
assert_eq!(observation.validator, Some(Validator::new(b"schema-v1")));
|
||||
let response = observation.protocol_response().unwrap();
|
||||
assert_eq!(response.interface, reference.as_str());
|
||||
assert_eq!(response.interface, reference.protocol_reference());
|
||||
assert_eq!(response.scope_ref, None);
|
||||
assert_eq!(response.descriptor, ReaderDefinition::descriptor());
|
||||
assert_eq!(response.validator.as_deref(), Some(b"schema-v1".as_slice()));
|
||||
response.descriptor.validate().unwrap();
|
||||
|
||||
let mut invalid_observation = registry.fetch_interface(reference.as_str()).unwrap();
|
||||
let mut invalid_observation = registry
|
||||
.fetch_interface(&reference.protocol_reference())
|
||||
.unwrap();
|
||||
let duplicate_operation = invalid_observation.descriptor.operations[0].clone();
|
||||
invalid_observation
|
||||
.descriptor
|
||||
@@ -516,7 +529,12 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
|
||||
let invalid_response = invalid_observation.protocol_response().unwrap_err();
|
||||
assert_eq!(invalid_response.code, ProtocolErrorCode::Internal);
|
||||
|
||||
let error = registry.fetch_interface("unknown").unwrap_err();
|
||||
let error = registry
|
||||
.fetch_interface(&wip_protocol::InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: "unknown".into(),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, ProtocolErrorCode::InterfaceNotFound);
|
||||
}
|
||||
|
||||
@@ -532,8 +550,8 @@ fn canonical_object_projection_supports_optional_metadata_and_ordered_sets() {
|
||||
assert_eq!(
|
||||
object.interfaces,
|
||||
vec![
|
||||
provider.writer.reference().as_str().to_owned(),
|
||||
provider.reader.reference().as_str().to_owned(),
|
||||
provider.writer.reference().protocol_reference(),
|
||||
provider.reader.reference().protocol_reference(),
|
||||
]
|
||||
);
|
||||
assert!(!format!("{object:?}").contains("secret-handle"));
|
||||
@@ -814,7 +832,8 @@ fn descriptor_guided_decoder_runs_inside_canonical_resolution_boundary() {
|
||||
validator: Some(b"stale".to_vec()),
|
||||
},
|
||||
InterfaceTarget {
|
||||
reference: hidden.reference().as_str().into(),
|
||||
reference: hidden.reference().protocol_reference(),
|
||||
scope_ref: None,
|
||||
validator: None,
|
||||
},
|
||||
"missing".into(),
|
||||
@@ -832,7 +851,8 @@ fn descriptor_guided_decoder_runs_inside_canonical_resolution_boundary() {
|
||||
validator: Some(b"stale".to_vec()),
|
||||
},
|
||||
InterfaceTarget {
|
||||
reference: hidden.reference().as_str().into(),
|
||||
reference: hidden.reference().protocol_reference(),
|
||||
scope_ref: None,
|
||||
validator: None,
|
||||
},
|
||||
"hidden".into(),
|
||||
@@ -850,7 +870,8 @@ fn descriptor_guided_decoder_runs_inside_canonical_resolution_boundary() {
|
||||
validator: Some(b"object-v1".to_vec()),
|
||||
},
|
||||
InterfaceTarget {
|
||||
reference: hidden.reference().as_str().into(),
|
||||
reference: hidden.reference().protocol_reference(),
|
||||
scope_ref: None,
|
||||
validator: None,
|
||||
},
|
||||
"hidden".into(),
|
||||
@@ -868,7 +889,8 @@ fn descriptor_guided_decoder_runs_inside_canonical_resolution_boundary() {
|
||||
validator: Some(b"object-v1".to_vec()),
|
||||
},
|
||||
InterfaceTarget {
|
||||
reference: provider.reader.reference().as_str().into(),
|
||||
reference: provider.reader.reference().protocol_reference(),
|
||||
scope_ref: None,
|
||||
validator: Some(b"schema-v1".to_vec()),
|
||||
},
|
||||
"add".into(),
|
||||
@@ -1122,3 +1144,100 @@ fn execution_context_is_propagated_and_preflight_prevents_invocation() {
|
||||
);
|
||||
assert_eq!(provider.calls.get(), calls);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_registry_boundaries_copy_names_verbatim_and_match_exact_scope() {
|
||||
let (registry, provider, _) = registry_and_provider();
|
||||
let legacy = provider.writer.reference();
|
||||
let reference = legacy.protocol_reference();
|
||||
assert_eq!(reference.scope, "/");
|
||||
assert_eq!(reference.name, legacy.as_str());
|
||||
// The registry's colon-delimited name remains opaque, not parsed.
|
||||
assert!(reference.name.starts_with("wip-interface:"));
|
||||
assert_eq!(registry.resolve(&reference).as_ref(), Some(legacy));
|
||||
let nested = wip_protocol::InterfaceReference {
|
||||
scope: "/open".into(),
|
||||
name: reference.name.clone(),
|
||||
};
|
||||
assert!(registry.resolve(&nested).is_none());
|
||||
assert_eq!(
|
||||
registry.fetch_interface(&nested).unwrap_err().code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
let malformed = wip_protocol::InterfaceReference {
|
||||
scope: "relative".into(),
|
||||
name: reference.name,
|
||||
};
|
||||
assert_eq!(
|
||||
registry.fetch_interface(&malformed).unwrap_err().code,
|
||||
ProtocolErrorCode::InvalidRequest
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_reference_shape_precedes_resolution_and_scope_membership_is_exact() {
|
||||
let (_, provider, _) = registry_and_provider();
|
||||
for (scope, name) in [("relative", "valid"), ("/", ""), ("/bad/", "valid")] {
|
||||
let mut call = request(
|
||||
"/open",
|
||||
None,
|
||||
provider.writer.reference(),
|
||||
None,
|
||||
"label",
|
||||
BTreeMap::new(),
|
||||
);
|
||||
call.interface.reference = wip_protocol::InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: name.into(),
|
||||
};
|
||||
assert_eq!(
|
||||
provider.call_operation(call.clone()).unwrap_err().kind(),
|
||||
CallErrorKind::InvalidRequest
|
||||
);
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation_with_context_and_decoder(
|
||||
&ExecutionContext::new(),
|
||||
call.target,
|
||||
call.interface,
|
||||
call.operation,
|
||||
|_| Ok::<_, ()>(BTreeMap::new()),
|
||||
)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InvalidRequest
|
||||
);
|
||||
}
|
||||
assert_eq!(provider.resolutions.get(), 0);
|
||||
// /other is outside the target scope; /open is self but has no legacy artifact.
|
||||
for scope in ["/other", "/open"] {
|
||||
let mut call = request(
|
||||
"/open",
|
||||
None,
|
||||
provider.writer.reference(),
|
||||
None,
|
||||
"label",
|
||||
BTreeMap::new(),
|
||||
);
|
||||
call.interface.reference.scope = scope.into();
|
||||
assert_eq!(
|
||||
provider.call_operation(call.clone()).unwrap_err().kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
assert_eq!(
|
||||
provider
|
||||
.call_operation_with_context_and_decoder(
|
||||
&ExecutionContext::new(),
|
||||
call.target,
|
||||
call.interface,
|
||||
call.operation,
|
||||
|_| -> Result<BTreeMap<String, Value>, ()> { panic!("must not decode") },
|
||||
)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
}
|
||||
assert_eq!(provider.calls.get(), 0);
|
||||
assert_eq!(provider.authorization_checks.get(), 0);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
```
|
||||
|
||||
## Interface references and scope identity
|
||||
|
||||
Every interface reference is encoded as an exact JSON object with required
|
||||
`scope` and `name` strings, for example `{"scope":"/github","name":"issue.read"}`.
|
||||
This applies to `Object.interfaces` elements, fetch request/response `interface`,
|
||||
and call `interface.reference`. Scope must be a canonical absolute Worldspace
|
||||
path and name must be nonempty. Legacy opaque strings, short-form strings,
|
||||
relative scopes, missing fields, and extra members are rejected. Unicode, `::`,
|
||||
and `#` in names remain literal data; comparison uses decoded field values,
|
||||
independent of JSON member order and escapes. HTTP endpoint prefixes do not
|
||||
change Worldspace scope.
|
||||
|
||||
Fetch responses and call interface targets additionally carry optional
|
||||
`scope_ref`, an opaque string copied from the scope Object's `ref` observation.
|
||||
It is not base64 and is not part of interface identity. Absent values are omitted;
|
||||
null and nonstring values are rejected. Descriptor-independent call metadata
|
||||
validates reference shape, but leaves scope ancestry and identity preconditions
|
||||
to the Host's `InterfaceMismatch` check, preserving protocol error precedence.
|
||||
Invalid success responses remain client-local `InvalidResponse` errors; protocol
|
||||
error envelopes and transport failures retain their existing classification.
|
||||
|
||||
See the [API documentation](https://docs.rs/wip-http) for codec and failure-boundary details.
|
||||
|
||||
Licensed under either of Apache-2.0 or MIT at your option.
|
||||
|
||||
@@ -296,11 +296,14 @@ pub fn decode_observe_request(body: &[u8], limits: Limits) -> Result<ObserveRequ
|
||||
}
|
||||
|
||||
/// Encodes a `fetch_interface` HTTP request.
|
||||
///
|
||||
/// Validates reference shape without requiring a target Object path.
|
||||
pub fn encode_fetch_interface_request(
|
||||
endpoint: &Endpoint,
|
||||
value: &FetchInterfaceRequest,
|
||||
limits: Limits,
|
||||
) -> Result<Request<Vec<u8>>, EncodeHttpError> {
|
||||
value.interface.validate().map_err(CodecError::from)?;
|
||||
let body = json::serialize(
|
||||
&json::fetch_interface_request_to_json(value),
|
||||
BodyKind::Request,
|
||||
@@ -358,6 +361,10 @@ impl CallOperationMetadata {
|
||||
}
|
||||
|
||||
/// Decodes descriptor-independent `call_operation` routing metadata.
|
||||
///
|
||||
/// Reference shape is validated, but scope ancestry and `scope_ref` identity
|
||||
/// are left to Host resolution so `NotFound`, validator failures, and
|
||||
/// `InterfaceMismatch` retain their protocol precedence.
|
||||
pub fn decode_call_operation_metadata(
|
||||
body: &[u8],
|
||||
limits: Limits,
|
||||
|
||||
+49
-14
@@ -8,9 +8,9 @@ use serde_json::{Map, Number, Value as Json};
|
||||
use wip_protocol::{
|
||||
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
|
||||
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,
|
||||
InterfaceReference, InterfaceTarget, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, Object,
|
||||
ObjectObservation, ObserveRequest, ObserveResponse, OperationDeclaration, ParameterDeclaration,
|
||||
ReturnDeclaration, Target, TypeDeclaration, TypeExpr, UnionCase, Value,
|
||||
};
|
||||
|
||||
use crate::{BodyKind, CodecError, Limits};
|
||||
@@ -285,8 +285,25 @@ pub(crate) fn observe_request_from_json(value: &Json) -> Result<ObserveRequest,
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
fn interface_reference_to_json(reference: &InterfaceReference) -> Json {
|
||||
serde_json::json!({"scope": reference.scope, "name": reference.name})
|
||||
}
|
||||
|
||||
fn interface_reference_from_json(
|
||||
value: &Json,
|
||||
field: &str,
|
||||
) -> Result<InterfaceReference, CodecError> {
|
||||
let fields = exact_object(value, field, &["scope", "name"])?;
|
||||
let reference = InterfaceReference {
|
||||
scope: string(required(fields, "scope")?, &qualified(field, "scope"))?,
|
||||
name: string(required(fields, "name")?, &qualified(field, "name"))?,
|
||||
};
|
||||
reference.validate()?;
|
||||
Ok(reference)
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_interface_request_to_json(request: &FetchInterfaceRequest) -> Json {
|
||||
serde_json::json!({"interface": request.interface})
|
||||
serde_json::json!({"interface": interface_reference_to_json(&request.interface)})
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_interface_request_from_json(
|
||||
@@ -294,7 +311,7 @@ pub(crate) fn fetch_interface_request_from_json(
|
||||
) -> Result<FetchInterfaceRequest, CodecError> {
|
||||
let fields = exact_object(value, "request", &["interface"])?;
|
||||
Ok(FetchInterfaceRequest {
|
||||
interface: string(required(fields, "interface")?, "interface")?,
|
||||
interface: interface_reference_from_json(required(fields, "interface")?, "interface")?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -308,8 +325,7 @@ pub(crate) fn object_to_json(object: &Object) -> Json {
|
||||
object
|
||||
.interfaces
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(Json::String)
|
||||
.map(interface_reference_to_json)
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
@@ -327,7 +343,7 @@ pub(crate) fn object_from_json(value: &Json, field: &str) -> Result<Object, Code
|
||||
let interfaces = array(required(fields, "interfaces")?, "interfaces")?
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| string(value, &format!("interfaces[{index}]")))
|
||||
.map(|(index, value)| interface_reference_from_json(value, &format!("interfaces[{index}]")))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let object = Object {
|
||||
name: string(required(fields, "name")?, "name")?,
|
||||
@@ -745,7 +761,11 @@ pub(crate) fn descriptor_from_json(
|
||||
|
||||
pub(crate) fn fetch_interface_response_to_json(response: &FetchInterfaceResponse) -> Json {
|
||||
let mut fields = Map::new();
|
||||
fields.insert("interface".into(), Json::String(response.interface.clone()));
|
||||
fields.insert(
|
||||
"interface".into(),
|
||||
interface_reference_to_json(&response.interface),
|
||||
);
|
||||
insert_optional_string(&mut fields, "scope_ref", &response.scope_ref);
|
||||
fields.insert(
|
||||
"descriptor".into(),
|
||||
descriptor_to_json(&response.descriptor),
|
||||
@@ -757,8 +777,15 @@ pub(crate) fn fetch_interface_response_to_json(response: &FetchInterfaceResponse
|
||||
pub(crate) fn fetch_interface_response_from_json(
|
||||
value: &Json,
|
||||
) -> Result<FetchInterfaceResponse, CodecError> {
|
||||
let fields = exact_object(value, "response", &["interface", "descriptor", "validator"])?;
|
||||
let interface = string(required(fields, "interface")?, "interface")?;
|
||||
let fields = exact_object(
|
||||
value,
|
||||
"response",
|
||||
&["interface", "scope_ref", "descriptor", "validator"],
|
||||
)?;
|
||||
let interface = interface_reference_from_json(required(fields, "interface")?, "interface")?;
|
||||
let scope_ref = optional(fields, "scope_ref")?
|
||||
.map(|value| string(value, "scope_ref"))
|
||||
.transpose()?;
|
||||
let validator = optional(fields, "validator")?
|
||||
.map(|value| base64_decode(value, "validator"))
|
||||
.transpose()?;
|
||||
@@ -780,6 +807,7 @@ pub(crate) fn fetch_interface_response_from_json(
|
||||
};
|
||||
Ok(FetchInterfaceResponse {
|
||||
interface,
|
||||
scope_ref,
|
||||
descriptor,
|
||||
validator,
|
||||
})
|
||||
@@ -1121,18 +1149,25 @@ fn target_from_json(value: &Json, field: &str) -> Result<Target, CodecError> {
|
||||
|
||||
fn interface_target_to_json(target: &InterfaceTarget) -> Json {
|
||||
let mut fields = Map::new();
|
||||
fields.insert("reference".into(), Json::String(target.reference.clone()));
|
||||
fields.insert(
|
||||
"reference".into(),
|
||||
interface_reference_to_json(&target.reference),
|
||||
);
|
||||
insert_optional_string(&mut fields, "scope_ref", &target.scope_ref);
|
||||
insert_optional_bytes(&mut fields, "validator", &target.validator);
|
||||
Json::Object(fields)
|
||||
}
|
||||
|
||||
fn interface_target_from_json(value: &Json, field: &str) -> Result<InterfaceTarget, CodecError> {
|
||||
let fields = exact_object(value, field, &["reference", "validator"])?;
|
||||
let fields = exact_object(value, field, &["reference", "scope_ref", "validator"])?;
|
||||
Ok(InterfaceTarget {
|
||||
reference: string(
|
||||
reference: interface_reference_from_json(
|
||||
required(fields, "reference")?,
|
||||
&qualified(field, "reference"),
|
||||
)?,
|
||||
scope_ref: optional(fields, "scope_ref")?
|
||||
.map(|value| string(value, &qualified(field, "scope_ref")))
|
||||
.transpose()?,
|
||||
validator: optional(fields, "validator")?
|
||||
.map(|value| base64_decode(value, &qualified(field, "validator")))
|
||||
.transpose()?,
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
//! encoded in JSON bodies using canonical RFC 4648 Section 4 base64; this crate
|
||||
//! does not infer WIP validator semantics from `ETag` or `If-Match`.
|
||||
//!
|
||||
//! Interface references use exact JSON objects with required `scope` and `name`
|
||||
//! strings in every position. Legacy strings and display shorthand are rejected.
|
||||
//! Scope is a canonical absolute Worldspace path, not an HTTP endpoint prefix.
|
||||
//! Optional `scope_ref` metadata on fetch responses and call interface targets is
|
||||
//! an opaque string, never base64; absent fields are omitted and null is rejected.
|
||||
//! Call metadata validates reference shape without resolving scope ancestry or
|
||||
//! identity, which remain Host checks with `InterfaceMismatch` precedence.
|
||||
//!
|
||||
//! ```
|
||||
//! use wip_http::{Endpoint, Limits, encode_observe_request};
|
||||
//! use wip_protocol::ObserveRequest;
|
||||
|
||||
@@ -4,9 +4,9 @@ use wip_http::http::StatusCode;
|
||||
use wip_http::wip_protocol::{
|
||||
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
|
||||
FetchInterfaceResponse, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
||||
InterfaceTarget, MAX_SAFE_INTEGER, Object, ObjectObservation, ObserveRequest,
|
||||
OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target, TypeDeclaration,
|
||||
TypeExpr, UnionCase, Value,
|
||||
InterfaceReference, InterfaceTarget, MAX_SAFE_INTEGER, Object, ObjectObservation,
|
||||
ObserveRequest, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target,
|
||||
TypeDeclaration, TypeExpr, UnionCase, Value,
|
||||
};
|
||||
use wip_http::{
|
||||
ClientResponseError, CodecError, DecodedResponse, Endpoint, InvalidResponseKind, Limits,
|
||||
@@ -25,11 +25,18 @@ fn endpoint() -> Endpoint {
|
||||
Endpoint::parse("https://example.test/wip").unwrap()
|
||||
}
|
||||
|
||||
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||
InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn object(name: &str, validator: &[u8]) -> Object {
|
||||
Object {
|
||||
name: name.into(),
|
||||
description: Some("description".into()),
|
||||
interfaces: vec!["interface-v1".into()],
|
||||
interfaces: vec![reference("/", "interface-v1")],
|
||||
r#ref: Some(format!("object:{name}")),
|
||||
validator: Some(validator.to_vec()),
|
||||
}
|
||||
@@ -152,7 +159,8 @@ fn call_request() -> CallOperationRequest {
|
||||
validator: Some(vec![0, 1, 2, 255]),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: "interface-v1".into(),
|
||||
reference: reference("/", "interface-v1"),
|
||||
scope_ref: None,
|
||||
validator: Some(vec![9, 8, 7]),
|
||||
},
|
||||
operation: "everything".into(),
|
||||
@@ -206,7 +214,7 @@ fn every_single_interaction_request_accepts_json() {
|
||||
let fetch_interface = encode_fetch_interface_request(
|
||||
&endpoint(),
|
||||
&FetchInterfaceRequest {
|
||||
interface: "interface-v1".into(),
|
||||
interface: reference("/", "interface-v1"),
|
||||
},
|
||||
limits(),
|
||||
)
|
||||
@@ -286,7 +294,7 @@ fn observe_and_interface_round_trip_nested_logical_models() {
|
||||
);
|
||||
|
||||
let interface_request = FetchInterfaceRequest {
|
||||
interface: "interface-v1".into(),
|
||||
interface: reference("/", "interface-v1"),
|
||||
};
|
||||
let encoded_request =
|
||||
encode_fetch_interface_request(&endpoint(), &interface_request, limits()).unwrap();
|
||||
@@ -296,6 +304,7 @@ fn observe_and_interface_round_trip_nested_logical_models() {
|
||||
);
|
||||
let interface = FetchInterfaceResponse {
|
||||
interface: interface_request.interface.clone(),
|
||||
scope_ref: None,
|
||||
descriptor: descriptor(),
|
||||
validator: Some(vec![4, 5, 6, 255]),
|
||||
};
|
||||
@@ -383,7 +392,7 @@ fn malformed_unknown_null_and_noncanonical_base64_are_rejected() {
|
||||
let descriptor = descriptor();
|
||||
let body = serde_json::to_vec(&serde_json::json!({
|
||||
"target": {"path": "/items/123", "validator": null},
|
||||
"interface": {"reference": "interface-v1"},
|
||||
"interface": {"reference": {"scope": "/", "name": "interface-v1"}},
|
||||
"operation": "everything",
|
||||
"arguments": {}
|
||||
}))
|
||||
@@ -443,10 +452,11 @@ fn caller_limits_remain_local_codec_failures() {
|
||||
#[test]
|
||||
fn unsupported_descriptor_format_is_client_local() {
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: "interface-v1".into(),
|
||||
interface: reference("/", "interface-v1"),
|
||||
};
|
||||
let mut interface = FetchInterfaceResponse {
|
||||
interface: request.interface.clone(),
|
||||
scope_ref: None,
|
||||
descriptor: descriptor(),
|
||||
validator: None,
|
||||
};
|
||||
@@ -462,7 +472,7 @@ fn unsupported_descriptor_format_is_client_local() {
|
||||
.status(StatusCode::OK)
|
||||
.header("content-type", "application/json; charset=utf-8")
|
||||
.body(
|
||||
br#"{"interface":"interface-v1","descriptor":{"format":"wip-interface/2","future_shape":true}}"#
|
||||
br#"{"interface":{"scope":"/","name":"interface-v1"},"descriptor":{"format":"wip-interface/2","future_shape":true}}"#
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -473,8 +483,8 @@ fn unsupported_descriptor_format_is_client_local() {
|
||||
));
|
||||
|
||||
for malformed in [
|
||||
br#"{"interface":"wrong","descriptor":{"format":"wip-interface/2"}}"#.as_slice(),
|
||||
br#"{"interface":"interface-v1","descriptor":{"format":"wip-interface/2"},"validator":null}"#
|
||||
br#"{"interface":{"scope":"/","name":"wrong"},"descriptor":{"format":"wip-interface/2"}}"#.as_slice(),
|
||||
br#"{"interface":{"scope":"/","name":"interface-v1"},"descriptor":{"format":"wip-interface/2"},"validator":null}"#
|
||||
.as_slice(),
|
||||
] {
|
||||
let response = wip_http::http::Response::builder()
|
||||
|
||||
@@ -0,0 +1,740 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Value as Json, json};
|
||||
use wip_http::http::{Response, StatusCode};
|
||||
use wip_http::wip_protocol::{
|
||||
CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1,
|
||||
InterfaceDescriptor, InterfaceReference, InterfaceTarget, Object, ObjectObservation,
|
||||
ObserveRequest, OperationDeclaration, ProtocolError, ProtocolErrorCode, ProtocolInteraction,
|
||||
ReturnDeclaration, Target, TypeExpr,
|
||||
};
|
||||
use wip_http::{
|
||||
ClientResponseError, CodecError, DecodedResponse, Endpoint, InvalidResponseKind, Limits,
|
||||
TransportFailureKind, decode_call_operation_metadata, decode_call_operation_request,
|
||||
decode_call_operation_response, decode_fetch_interface_request,
|
||||
decode_fetch_interface_response, decode_observe_response, encode_call_operation_request,
|
||||
encode_fetch_interface_request, encode_fetch_interface_response, encode_observe_response,
|
||||
encode_protocol_error_response,
|
||||
};
|
||||
|
||||
fn limits() -> Limits {
|
||||
Limits::new(64 * 1024, 64 * 1024, 64).unwrap()
|
||||
}
|
||||
|
||||
fn endpoint() -> Endpoint {
|
||||
Endpoint::parse("https://example.test/deployment/prefix").unwrap()
|
||||
}
|
||||
|
||||
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||
InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn descriptor() -> InterfaceDescriptor {
|
||||
InterfaceDescriptor {
|
||||
format: INTERFACE_FORMAT_V1.into(),
|
||||
documentation: None,
|
||||
types: vec![],
|
||||
operations: vec![OperationDeclaration {
|
||||
name: "run#::操作".into(),
|
||||
documentation: None,
|
||||
parameters: vec![],
|
||||
returns: ReturnDeclaration {
|
||||
documentation: None,
|
||||
r#type: TypeExpr::Unit,
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn call(reference: InterfaceReference, scope_ref: Option<String>) -> CallOperationRequest {
|
||||
CallOperationRequest {
|
||||
target: Target {
|
||||
path: "/世界::#/item".into(),
|
||||
validator: Some(vec![0, 255]),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference,
|
||||
scope_ref,
|
||||
validator: Some(vec![1, 255]),
|
||||
},
|
||||
operation: "run#::操作".into(),
|
||||
arguments: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn observation(references: Vec<InterfaceReference>) -> ObjectObservation {
|
||||
ObjectObservation {
|
||||
object: Object {
|
||||
name: "item".into(),
|
||||
description: None,
|
||||
interfaces: references,
|
||||
r#ref: None,
|
||||
validator: None,
|
||||
},
|
||||
children: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_request() -> ObserveRequest {
|
||||
ObserveRequest {
|
||||
path: "/世界::#/item".into(),
|
||||
depth: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn response(status: StatusCode, value: Json) -> Response<Vec<u8>> {
|
||||
raw_response(status, &serde_json::to_vec(&value).unwrap())
|
||||
}
|
||||
|
||||
fn raw_response(status: StatusCode, body: &[u8]) -> Response<Vec<u8>> {
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header("content-type", "application/json")
|
||||
.body(body.to_vec())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn fetch_json(interface: Json) -> Json {
|
||||
json!({
|
||||
"interface": interface,
|
||||
"descriptor": {"format": INTERFACE_FORMAT_V1, "types": [], "operations": []}
|
||||
})
|
||||
}
|
||||
|
||||
fn call_json(reference: Json) -> Json {
|
||||
json!({
|
||||
"target": {"path": "/世界::#/item"},
|
||||
"interface": {"reference": reference},
|
||||
"operation": "run#::操作",
|
||||
"arguments": {}
|
||||
})
|
||||
}
|
||||
|
||||
fn invalid_success<T: std::fmt::Debug>(result: Result<T, ClientResponseError>) {
|
||||
assert!(matches!(
|
||||
result.unwrap_err(),
|
||||
ClientResponseError::InvalidResponse {
|
||||
kind: InvalidResponseKind::InvalidSuccessBody,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_structured_reference_is_encoded_and_round_tripped_in_every_position() {
|
||||
let interface_ref = reference("/世界::#", "é::接口#read/with\"quote\\slash");
|
||||
let expected = json!({"scope": interface_ref.scope, "name": interface_ref.name});
|
||||
let fetch_request = FetchInterfaceRequest {
|
||||
interface: interface_ref.clone(),
|
||||
};
|
||||
let encoded = encode_fetch_interface_request(&endpoint(), &fetch_request, limits()).unwrap();
|
||||
assert_eq!(
|
||||
encoded.uri().path(),
|
||||
"/deployment/prefix/v1/fetch_interface"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Json>(encoded.body()).unwrap(),
|
||||
json!({"interface": expected})
|
||||
);
|
||||
assert_eq!(
|
||||
decode_fetch_interface_request(encoded.body(), limits()).unwrap(),
|
||||
fetch_request
|
||||
);
|
||||
|
||||
let fetch_response = FetchInterfaceResponse {
|
||||
interface: interface_ref.clone(),
|
||||
scope_ref: Some("opaque:世界::# not base64!".into()),
|
||||
descriptor: descriptor(),
|
||||
validator: Some(vec![1, 255]),
|
||||
};
|
||||
let encoded =
|
||||
encode_fetch_interface_response(&fetch_request, &fetch_response, limits()).unwrap();
|
||||
let body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||
assert_eq!(body["interface"], expected);
|
||||
assert_eq!(body["scope_ref"], "opaque:世界::# not base64!");
|
||||
assert_eq!(body["validator"], "Af8=");
|
||||
assert_eq!(
|
||||
decode_fetch_interface_response(&fetch_request, &encoded, limits()).unwrap(),
|
||||
DecodedResponse::Success(fetch_response)
|
||||
);
|
||||
|
||||
let observation = observation(vec![interface_ref.clone(), reference("/", "root")]);
|
||||
let encoded = encode_observe_response(&observe_request(), &observation, limits()).unwrap();
|
||||
let body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||
assert_eq!(
|
||||
body["object"]["interfaces"],
|
||||
json!([expected, {"scope": "/", "name": "root"}])
|
||||
);
|
||||
assert_eq!(
|
||||
decode_observe_response(&observe_request(), &encoded, limits()).unwrap(),
|
||||
DecodedResponse::Success(observation)
|
||||
);
|
||||
|
||||
let request = call(interface_ref, Some("opaque:世界::# not base64!".into()));
|
||||
let encoded =
|
||||
encode_call_operation_request(&endpoint(), &request, &descriptor(), limits()).unwrap();
|
||||
let body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||
assert_eq!(body["interface"]["reference"], expected);
|
||||
assert_eq!(body["interface"]["scope_ref"], "opaque:世界::# not base64!");
|
||||
assert_eq!(body["interface"]["validator"], "Af8=");
|
||||
assert_eq!(body["target"]["validator"], "AP8=");
|
||||
assert_eq!(body["operation"], "run#::操作");
|
||||
assert_eq!(
|
||||
decode_call_operation_request(encoded.body(), &descriptor(), limits()).unwrap(),
|
||||
request
|
||||
);
|
||||
assert_eq!(
|
||||
decode_call_operation_metadata(encoded.body(), limits())
|
||||
.unwrap()
|
||||
.interface,
|
||||
request.interface
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_reference_corpus_is_rejected_in_all_positions() {
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: reference("/", "interface-v1"),
|
||||
};
|
||||
for bad in [
|
||||
json!("interface-v1"),
|
||||
json!("/::interface-v1"),
|
||||
json!("interface-v1#run"),
|
||||
Json::Null,
|
||||
json!(false),
|
||||
json!(1),
|
||||
json!([]),
|
||||
json!({}),
|
||||
json!({"name": "interface-v1"}),
|
||||
json!({"scope": "/"}),
|
||||
json!({"scope": null, "name": "interface-v1"}),
|
||||
json!({"scope": 1, "name": "interface-v1"}),
|
||||
json!({"scope": "/", "name": null}),
|
||||
json!({"scope": "/", "name": []}),
|
||||
json!({"scope": "/", "name": ""}),
|
||||
json!({"scope": "/", "name": "interface-v1", "extra": true}),
|
||||
json!({"scope": "/", "name": "interface-v1", "scope_ref": "identity"}),
|
||||
json!({"Scope": "/", "name": "interface-v1"}),
|
||||
json!({"scope": "", "name": "interface-v1"}),
|
||||
json!({"scope": "relative", "name": "interface-v1"}),
|
||||
json!({"scope": ".", "name": "interface-v1"}),
|
||||
json!({"scope": "..", "name": "interface-v1"}),
|
||||
json!({"scope": "/a/", "name": "interface-v1"}),
|
||||
json!({"scope": "//a", "name": "interface-v1"}),
|
||||
json!({"scope": "/a//b", "name": "interface-v1"}),
|
||||
json!({"scope": "/a/./b", "name": "interface-v1"}),
|
||||
json!({"scope": "/a/../b", "name": "interface-v1"}),
|
||||
] {
|
||||
let body = serde_json::to_vec(&json!({"interface": bad})).unwrap();
|
||||
assert!(
|
||||
decode_fetch_interface_request(&body, limits()).is_err(),
|
||||
"{bad}"
|
||||
);
|
||||
let body = serde_json::to_vec(&call_json(bad.clone())).unwrap();
|
||||
assert!(
|
||||
decode_call_operation_metadata(&body, limits()).is_err(),
|
||||
"{bad}"
|
||||
);
|
||||
assert!(
|
||||
decode_call_operation_request(&body, &descriptor(), limits()).is_err(),
|
||||
"{bad}"
|
||||
);
|
||||
invalid_success(decode_fetch_interface_response(
|
||||
&request,
|
||||
&response(StatusCode::OK, fetch_json(bad.clone())),
|
||||
limits(),
|
||||
));
|
||||
let mut future = fetch_json(bad.clone());
|
||||
future["descriptor"] = json!({"format": "future-format", "future": true});
|
||||
invalid_success(decode_fetch_interface_response(
|
||||
&request,
|
||||
&response(StatusCode::OK, future),
|
||||
limits(),
|
||||
));
|
||||
invalid_success(decode_observe_response(
|
||||
&observe_request(),
|
||||
&response(
|
||||
StatusCode::OK,
|
||||
json!({"object": {"name": "item", "interfaces": [bad]}}),
|
||||
),
|
||||
limits(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_reference_members_are_rejected_even_when_escaped_or_equal() {
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: reference("/", "x"),
|
||||
};
|
||||
for bad in [
|
||||
r#"{"scope":"/","scope":"/","name":"x"}"#,
|
||||
r#"{"scope":"/","name":"x","name":"x"}"#,
|
||||
r#"{"scope":"/","name":"x","na\u006de":"x"}"#,
|
||||
] {
|
||||
let fetch = format!(r#"{{"interface":{bad}}}"#);
|
||||
assert!(matches!(
|
||||
decode_fetch_interface_request(fetch.as_bytes(), limits()).unwrap_err(),
|
||||
CodecError::InvalidJson(_)
|
||||
));
|
||||
let call = format!(
|
||||
r#"{{"target":{{"path":"/"}},"interface":{{"reference":{bad}}},"operation":"run","arguments":{{}}}}"#
|
||||
);
|
||||
assert!(matches!(
|
||||
decode_call_operation_metadata(call.as_bytes(), limits()).unwrap_err(),
|
||||
CodecError::InvalidJson(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
decode_call_operation_request(call.as_bytes(), &descriptor(), limits()).unwrap_err(),
|
||||
CodecError::InvalidJson(_)
|
||||
));
|
||||
let fetch_response = format!(
|
||||
r#"{{"interface":{bad},"descriptor":{{"format":"wip-interface/1","types":[],"operations":[]}}}}"#
|
||||
);
|
||||
invalid_success(decode_fetch_interface_response(
|
||||
&request,
|
||||
&raw_response(StatusCode::OK, fetch_response.as_bytes()),
|
||||
limits(),
|
||||
));
|
||||
let observe = format!(r#"{{"object":{{"name":"item","interfaces":[{bad}]}}}}"#);
|
||||
invalid_success(decode_observe_response(
|
||||
&observe_request(),
|
||||
&raw_response(StatusCode::OK, observe.as_bytes()),
|
||||
limits(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_order_and_json_escapes_do_not_change_reference_identity() {
|
||||
let expected = reference("/世界::#", "é::接口#");
|
||||
let raw_reference = r#"{"name":"\u00e9::\u63a5\u53e3#","scope":"\/\u4e16\u754c::#"}"#;
|
||||
let fetch = format!(r#"{{"interface":{raw_reference}}}"#);
|
||||
let request = decode_fetch_interface_request(fetch.as_bytes(), limits()).unwrap();
|
||||
assert_eq!(request.interface, expected);
|
||||
let fetch_response = format!(
|
||||
r#"{{"descriptor":{{"operations":[],"types":[],"format":"wip-interface/1"}},"interface":{raw_reference}}}"#
|
||||
);
|
||||
assert!(
|
||||
matches!(decode_fetch_interface_response(&request, &raw_response(StatusCode::OK, fetch_response.as_bytes()), limits()).unwrap(), DecodedResponse::Success(value) if value.interface == expected)
|
||||
);
|
||||
let call = format!(
|
||||
r#"{{"arguments":{{}},"operation":"run#::操作","interface":{{"reference":{raw_reference}}},"target":{{"path":"/世界::#/item"}}}}"#
|
||||
);
|
||||
assert_eq!(
|
||||
decode_call_operation_metadata(call.as_bytes(), limits())
|
||||
.unwrap()
|
||||
.interface
|
||||
.reference,
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
decode_call_operation_request(call.as_bytes(), &descriptor(), limits())
|
||||
.unwrap()
|
||||
.interface
|
||||
.reference,
|
||||
expected
|
||||
);
|
||||
let observe = format!(r#"{{"object":{{"interfaces":[{raw_reference}],"name":"item"}}}}"#);
|
||||
assert!(
|
||||
matches!(decode_observe_response(&observe_request(), &raw_response(StatusCode::OK, observe.as_bytes()), limits()).unwrap(), DecodedResponse::Success(value) if value.object.interfaces == vec![expected])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_comparison_is_exact_without_normalization_case_or_url_decoding() {
|
||||
for different in [
|
||||
reference("/", "e\u{301}"),
|
||||
reference("/", "É"),
|
||||
reference("/", "%C3%A9"),
|
||||
reference("/other", "é"),
|
||||
] {
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: reference("/", "é"),
|
||||
};
|
||||
invalid_success(decode_fetch_interface_response(
|
||||
&request,
|
||||
&response(
|
||||
StatusCode::OK,
|
||||
fetch_json(json!({"scope": different.scope, "name": different.name})),
|
||||
),
|
||||
limits(),
|
||||
));
|
||||
}
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: reference("/%2E", "é"),
|
||||
};
|
||||
let encoded = encode_fetch_interface_request(&endpoint(), &request, limits()).unwrap();
|
||||
assert_eq!(
|
||||
decode_fetch_interface_request(encoded.body(), limits()).unwrap(),
|
||||
request
|
||||
);
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: reference("/", "."),
|
||||
};
|
||||
assert!(encode_fetch_interface_request(&endpoint(), &request, limits()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_encoding_rejects_invalid_reference_shape_without_needing_a_fetch_target() {
|
||||
for bad in [
|
||||
reference("relative", "x"),
|
||||
reference("", "x"),
|
||||
reference("/a/../b", "x"),
|
||||
reference("/", ""),
|
||||
] {
|
||||
assert!(
|
||||
encode_fetch_interface_request(
|
||||
&endpoint(),
|
||||
&FetchInterfaceRequest {
|
||||
interface: bad.clone()
|
||||
},
|
||||
limits()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
encode_call_operation_request(&endpoint(), &call(bad, None), &descriptor(), limits())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: reference("/unrelated/scope", "x"),
|
||||
};
|
||||
let encoded = encode_fetch_interface_request(&endpoint(), &request, limits()).unwrap();
|
||||
assert_eq!(
|
||||
decode_fetch_interface_request(encoded.body(), limits()).unwrap(),
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_metadata_leaves_valid_out_of_scope_references_and_identity_to_host_resolution() {
|
||||
let request = call(
|
||||
reference("/elsewhere", "x"),
|
||||
Some("not-current-identity".into()),
|
||||
);
|
||||
let encoded =
|
||||
encode_call_operation_request(&endpoint(), &request, &descriptor(), limits()).unwrap();
|
||||
let metadata = decode_call_operation_metadata(encoded.body(), limits()).unwrap();
|
||||
assert_eq!(metadata.interface, request.interface);
|
||||
assert_eq!(
|
||||
metadata
|
||||
.decode_request(encoded.body(), &descriptor(), limits())
|
||||
.unwrap(),
|
||||
request
|
||||
);
|
||||
|
||||
// Neither an unknown operation nor descriptor-bound argument values are
|
||||
// resolved by the metadata boundary before Host precondition checks.
|
||||
let mut body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||
body["operation"] = json!("missing");
|
||||
body["arguments"] = json!({"not_declared": [null, {"anything": true}]});
|
||||
let metadata =
|
||||
decode_call_operation_metadata(&serde_json::to_vec(&body).unwrap(), limits()).unwrap();
|
||||
assert_eq!(metadata.operation, "missing");
|
||||
assert_eq!(metadata.interface, request.interface);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observation_scope_validation_uses_each_nodes_path_and_structural_duplicates() {
|
||||
let request = ObserveRequest {
|
||||
path: "/a".into(),
|
||||
depth: 1,
|
||||
};
|
||||
let valid = json!({
|
||||
"object": {"name": "a", "interfaces": [{"scope": "/", "name": "x"}, {"scope": "/a", "name": "x"}]},
|
||||
"children": [{"object": {"name": "b", "interfaces": [{"scope": "/a/b", "name": "x"}]}}]
|
||||
});
|
||||
assert!(
|
||||
decode_observe_response(&request, &response(StatusCode::OK, valid.clone()), limits())
|
||||
.is_ok()
|
||||
);
|
||||
for scope in ["/ab", "/elsewhere", "/a/b/c"] {
|
||||
let mut bad = valid.clone();
|
||||
bad["children"][0]["object"]["interfaces"][0]["scope"] = json!(scope);
|
||||
invalid_success(decode_observe_response(
|
||||
&request,
|
||||
&response(StatusCode::OK, bad),
|
||||
limits(),
|
||||
));
|
||||
}
|
||||
let mut duplicate = valid;
|
||||
duplicate["object"]["interfaces"] =
|
||||
json!([{"scope": "/a", "name": "x"}, {"name": "x", "scope": "/a"}]);
|
||||
invalid_success(decode_observe_response(
|
||||
&request,
|
||||
&response(StatusCode::OK, duplicate),
|
||||
limits(),
|
||||
));
|
||||
let root_request = ObserveRequest {
|
||||
path: "/".into(),
|
||||
depth: 0,
|
||||
};
|
||||
for (scope, valid) in [("/", true), ("/a", false)] {
|
||||
let result = decode_observe_response(
|
||||
&root_request,
|
||||
&response(
|
||||
StatusCode::OK,
|
||||
json!({"object": {"name": "", "interfaces": [{"scope": scope, "name": "x"}]}}),
|
||||
),
|
||||
limits(),
|
||||
);
|
||||
if valid {
|
||||
assert!(result.is_ok());
|
||||
} else {
|
||||
invalid_success(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_ref_omission_and_opaque_strings_round_trip_in_both_positions() {
|
||||
for scope_ref in [
|
||||
None,
|
||||
Some(String::new()),
|
||||
Some("opaque::# 世界 ! not base64 /+=".into()),
|
||||
Some("\"\\\n\u{0}".into()),
|
||||
] {
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: reference("/", "x"),
|
||||
};
|
||||
let expected = FetchInterfaceResponse {
|
||||
interface: request.interface.clone(),
|
||||
scope_ref: scope_ref.clone(),
|
||||
descriptor: descriptor(),
|
||||
validator: None,
|
||||
};
|
||||
let encoded = encode_fetch_interface_response(&request, &expected, limits()).unwrap();
|
||||
let body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||
assert_eq!(
|
||||
body.get("scope_ref"),
|
||||
scope_ref
|
||||
.as_ref()
|
||||
.map(|value| Json::String(value.clone()))
|
||||
.as_ref()
|
||||
);
|
||||
assert!(body.get("validator").is_none());
|
||||
assert_eq!(
|
||||
decode_fetch_interface_response(&request, &encoded, limits()).unwrap(),
|
||||
DecodedResponse::Success(expected)
|
||||
);
|
||||
let call = call(request.interface, scope_ref);
|
||||
let encoded =
|
||||
encode_call_operation_request(&endpoint(), &call, &descriptor(), limits()).unwrap();
|
||||
let body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||
assert_eq!(
|
||||
body["interface"].get("scope_ref"),
|
||||
call.interface
|
||||
.scope_ref
|
||||
.as_ref()
|
||||
.map(|value| Json::String(value.clone()))
|
||||
.as_ref()
|
||||
);
|
||||
assert_eq!(
|
||||
decode_call_operation_metadata(encoded.body(), limits())
|
||||
.unwrap()
|
||||
.interface,
|
||||
call.interface
|
||||
);
|
||||
assert_eq!(
|
||||
decode_call_operation_request(encoded.body(), &descriptor(), limits()).unwrap(),
|
||||
call
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_ref_null_and_every_nonstring_type_are_rejected_before_descriptor_resolution() {
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: reference("/", "x"),
|
||||
};
|
||||
for bad in [Json::Null, json!(true), json!(0), json!([]), json!({})] {
|
||||
let mut fetch = fetch_json(json!({"scope": "/", "name": "x"}));
|
||||
fetch["scope_ref"] = bad.clone();
|
||||
invalid_success(decode_fetch_interface_response(
|
||||
&request,
|
||||
&response(StatusCode::OK, fetch.clone()),
|
||||
limits(),
|
||||
));
|
||||
// Unknown format does not mask malformed common response fields.
|
||||
fetch["descriptor"] = json!({"format": "future-format", "future": true});
|
||||
invalid_success(decode_fetch_interface_response(
|
||||
&request,
|
||||
&response(StatusCode::OK, fetch),
|
||||
limits(),
|
||||
));
|
||||
let mut call = call_json(json!({"scope": "/", "name": "x"}));
|
||||
call["interface"]["scope_ref"] = bad.clone();
|
||||
let body = serde_json::to_vec(&call).unwrap();
|
||||
let error = decode_call_operation_metadata(&body, limits()).unwrap_err();
|
||||
if bad.is_null() {
|
||||
assert!(matches!(error, CodecError::NullOptionalField { .. }));
|
||||
} else {
|
||||
assert!(matches!(error, CodecError::InvalidField { .. }));
|
||||
}
|
||||
assert!(decode_call_operation_request(&body, &descriptor(), limits()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_ref_is_only_allowed_on_fetch_responses_and_call_interface_targets() {
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: reference("/", "x"),
|
||||
};
|
||||
let reference = json!({"scope": "/", "name": "x"});
|
||||
let fetch_request = json!({"interface": reference, "scope_ref": "identity"});
|
||||
assert!(
|
||||
decode_fetch_interface_request(&serde_json::to_vec(&fetch_request).unwrap(), limits())
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let mut call = call_json(reference.clone());
|
||||
call["scope_ref"] = json!("identity");
|
||||
assert!(decode_call_operation_metadata(&serde_json::to_vec(&call).unwrap(), limits()).is_err());
|
||||
let mut call = call_json(reference);
|
||||
call["target"]["scope_ref"] = json!("identity");
|
||||
assert!(decode_call_operation_metadata(&serde_json::to_vec(&call).unwrap(), limits()).is_err());
|
||||
|
||||
let duplicate = br#"{"interface":{"scope":"/","name":"x"},"scope_ref":"identity","scope_ref":"identity","descriptor":{"format":"wip-interface/1","types":[],"operations":[]}}"#;
|
||||
invalid_success(decode_fetch_interface_response(
|
||||
&request,
|
||||
&raw_response(StatusCode::OK, duplicate),
|
||||
limits(),
|
||||
));
|
||||
let duplicate = br#"{"target":{"path":"/"},"interface":{"reference":{"scope":"/","name":"x"},"scope_ref":"identity","scope_ref":"identity"},"operation":"run#::\u64cd\u4f5c","arguments":{}}"#;
|
||||
assert!(matches!(
|
||||
decode_call_operation_metadata(duplicate, limits()).unwrap_err(),
|
||||
CodecError::InvalidJson(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
decode_call_operation_request(duplicate, &descriptor(), limits()).unwrap_err(),
|
||||
CodecError::InvalidJson(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_correspondence_includes_reference_and_scope_ref_but_not_wire_order() {
|
||||
let request = call(reference("/", "x"), Some("identity".into()));
|
||||
let encoded =
|
||||
encode_call_operation_request(&endpoint(), &request, &descriptor(), limits()).unwrap();
|
||||
let metadata = decode_call_operation_metadata(encoded.body(), limits()).unwrap();
|
||||
for (field, changed) in [
|
||||
("scope_ref", json!("other")),
|
||||
("reference", json!({"scope": "/", "name": "other"})),
|
||||
] {
|
||||
let mut body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||
body["interface"][field] = changed;
|
||||
assert!(
|
||||
matches!(metadata.decode_request(&serde_json::to_vec(&body).unwrap(), &descriptor(), limits()).unwrap_err(), CodecError::InvalidField { field, .. } if field == "request")
|
||||
);
|
||||
}
|
||||
let mut body: Json = serde_json::from_slice(encoded.body()).unwrap();
|
||||
body["interface"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("scope_ref");
|
||||
assert!(
|
||||
metadata
|
||||
.decode_request(&serde_json::to_vec(&body).unwrap(), &descriptor(), limits())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_classification_preserves_transport_status_and_error_envelope_semantics() {
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: reference("/", "x"),
|
||||
};
|
||||
let valid = fetch_json(json!({"scope": "/", "name": "x"}));
|
||||
for (status, kind) in [
|
||||
(
|
||||
StatusCode::CREATED,
|
||||
InvalidResponseKind::NonCanonicalSuccessStatus,
|
||||
),
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
InvalidResponseKind::SuccessBodyOnErrorStatus,
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
matches!(decode_fetch_interface_response(&request, &response(status, valid.clone()), limits()).unwrap_err(), ClientResponseError::InvalidResponse { kind: actual, .. } if actual == kind)
|
||||
);
|
||||
}
|
||||
for status in [
|
||||
StatusCode::UNAUTHORIZED,
|
||||
StatusCode::FORBIDDEN,
|
||||
StatusCode::BAD_GATEWAY,
|
||||
] {
|
||||
let malformed = response(status, fetch_json(json!("legacy")));
|
||||
assert!(
|
||||
matches!(decode_fetch_interface_response(&request, &malformed, limits()).unwrap_err(), ClientResponseError::TransportBinding(failure) if failure.status == status)
|
||||
);
|
||||
}
|
||||
let error = ProtocolError {
|
||||
code: ProtocolErrorCode::InterfaceNotFound,
|
||||
message: "unpublished".into(),
|
||||
};
|
||||
let mut encoded =
|
||||
encode_protocol_error_response(ProtocolInteraction::FetchInterface, &error, limits())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
decode_fetch_interface_response(&request, &encoded, limits()).unwrap(),
|
||||
DecodedResponse::ProtocolFailure {
|
||||
error: error.clone(),
|
||||
status_mismatch: None
|
||||
}
|
||||
);
|
||||
*encoded.status_mut() = StatusCode::BAD_GATEWAY;
|
||||
assert!(
|
||||
matches!(decode_fetch_interface_response(&request, &encoded, limits()).unwrap(), DecodedResponse::ProtocolFailure { error: actual, status_mismatch: Some(_) } if actual == error)
|
||||
);
|
||||
*encoded.status_mut() = StatusCode::OK;
|
||||
assert!(matches!(
|
||||
decode_fetch_interface_response(&request, &encoded, limits()).unwrap_err(),
|
||||
ClientResponseError::InvalidResponse {
|
||||
kind: InvalidResponseKind::ErrorEnvelopeOnSuccessStatus,
|
||||
..
|
||||
}
|
||||
));
|
||||
let disallowed = response(
|
||||
StatusCode::CONFLICT,
|
||||
json!({"error": {"code": "interface_mismatch", "message": "wrong interaction"}}),
|
||||
);
|
||||
assert!(matches!(
|
||||
decode_fetch_interface_response(&request, &disallowed, limits()).unwrap_err(),
|
||||
ClientResponseError::InvalidResponse {
|
||||
kind: InvalidResponseKind::DisallowedProtocolCode,
|
||||
..
|
||||
}
|
||||
));
|
||||
let mut coded = response(StatusCode::OK, valid);
|
||||
coded
|
||||
.headers_mut()
|
||||
.insert("content-encoding", "gzip".parse().unwrap());
|
||||
assert!(
|
||||
matches!(decode_fetch_interface_response(&request, &coded, limits()).unwrap_err(), ClientResponseError::TransportBinding(failure) if failure.kind == TransportFailureKind::UnsupportedContentCoding)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_interface_mismatch_envelope_remains_protocol_failure_with_structured_metadata() {
|
||||
let request = call(reference("/unrelated", "x"), Some("stale identity".into()));
|
||||
let error = ProtocolError {
|
||||
code: ProtocolErrorCode::InterfaceMismatch,
|
||||
message: "interface precondition".into(),
|
||||
};
|
||||
let encoded =
|
||||
encode_protocol_error_response(ProtocolInteraction::CallOperation, &error, limits())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
decode_call_operation_response(&request, &descriptor(), &encoded, limits()).unwrap(),
|
||||
DecodedResponse::ProtocolFailure {
|
||||
error,
|
||||
status_mismatch: None
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,15 @@ This crate defines the canonical Object and Interface Descriptor models, the thr
|
||||
wip-protocol = "0.1.0"
|
||||
```
|
||||
|
||||
Interface identity is an exact `InterfaceReference { scope, name }` pair.
|
||||
`scope` is a canonical absolute Worldspace path and `name` is nonempty Unicode.
|
||||
Use `validate()` for shape, `validate_for_path()` for ancestor-or-self placement,
|
||||
and Object/interaction validation at response boundaries. Fetching needs no
|
||||
target Object path. Fetch responses and interface call targets carry optional
|
||||
opaque string `scope_ref` metadata, not a third identity field. Descriptors do
|
||||
not embed identity. See the repository's [migration guide](../../MIGRATION.md)
|
||||
for the breaking Rust and HTTP wire changes and deferred consumer semantics.
|
||||
|
||||
See the [API documentation](https://docs.rs/wip-protocol) for the complete model and validation rules.
|
||||
|
||||
Licensed under either of Apache-2.0 or MIT at your option.
|
||||
|
||||
@@ -143,8 +143,17 @@ pub enum ValidationErrorKind {
|
||||
},
|
||||
/// An object's ordered interface set contains a duplicate.
|
||||
DuplicateInterface {
|
||||
/// Duplicate opaque interface reference.
|
||||
reference: String,
|
||||
/// Duplicate structured interface reference.
|
||||
reference: crate::InterfaceReference,
|
||||
},
|
||||
/// The local interface name is empty.
|
||||
EmptyInterfaceName,
|
||||
/// A valid scope is not an ancestor-or-self of the target path.
|
||||
InterfaceScopeMismatch {
|
||||
/// Selected scope path.
|
||||
scope: String,
|
||||
/// Target Object path.
|
||||
path: String,
|
||||
},
|
||||
/// A string is not a canonical absolute Worldspace path.
|
||||
InvalidPath {
|
||||
@@ -175,10 +184,10 @@ pub enum ValidationErrorKind {
|
||||
},
|
||||
/// A response refers to a different interface than its request.
|
||||
InterfaceResponseMismatch {
|
||||
/// Requested opaque reference.
|
||||
expected: String,
|
||||
/// Returned opaque reference.
|
||||
actual: String,
|
||||
/// Requested structured reference (boxed to keep validation errors small).
|
||||
expected: Box<crate::InterfaceReference>,
|
||||
/// Returned structured reference (boxed to keep validation errors small).
|
||||
actual: Box<crate::InterfaceReference>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -226,7 +235,7 @@ impl Error for ValidationError {}
|
||||
pub enum ProtocolInteraction {
|
||||
/// Observe one object and its indexable descendants to a requested depth.
|
||||
Observe,
|
||||
/// Fetch one interface descriptor by its opaque reference.
|
||||
/// Fetch one interface descriptor by its structured reference.
|
||||
FetchInterface,
|
||||
/// Invoke one operation on a path-targeted object.
|
||||
CallOperation,
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
//! guarantee; Clients must not automatically retry it.
|
||||
//!
|
||||
//! Paths, object refs, interface refs, and validators remain separate concepts.
|
||||
//! They are represented by ordinary protocol strings/bytes in the corresponding
|
||||
//! Interface refs use [`InterfaceReference`]; other metadata use strings/bytes in their
|
||||
//! fields. In particular, object refs are optional, operation targeting uses a
|
||||
//! path, and this crate defines no interaction that fetches an object by ref.
|
||||
//!
|
||||
@@ -52,9 +52,10 @@ pub use error::{
|
||||
};
|
||||
pub use model::{
|
||||
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
|
||||
FetchInterfaceResponse, FieldDeclaration, InterfaceDescriptor, InterfaceTarget, Object,
|
||||
ObjectObservation, ObserveRequest, ObserveResponse, OperationDeclaration, ParameterDeclaration,
|
||||
ProtocolResult, ReturnDeclaration, Target, TypeDeclaration, TypeExpr, UnionCase, Value,
|
||||
FetchInterfaceResponse, FieldDeclaration, InterfaceDescriptor, InterfaceReference,
|
||||
InterfaceTarget, Object, ObjectObservation, ObserveRequest, ObserveResponse,
|
||||
OperationDeclaration, ParameterDeclaration, ProtocolResult, ReturnDeclaration, Target,
|
||||
TypeDeclaration, TypeExpr, UnionCase, Value,
|
||||
};
|
||||
|
||||
/// Validates a canonical absolute Worldspace path.
|
||||
|
||||
@@ -11,9 +11,49 @@ pub struct Documentation {
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
/// An interface identified by an exact `(scope, name)` pair in one Worldspace.
|
||||
///
|
||||
/// Neither field is normalized, case-folded, URL-decoded, or split on display
|
||||
/// delimiters. Descriptor identity is external to the descriptor itself.
|
||||
///
|
||||
/// ```
|
||||
/// use wip_protocol::{FetchInterfaceRequest, InterfaceReference};
|
||||
/// let reference = InterfaceReference {
|
||||
/// scope: "/世::界/#".into(),
|
||||
/// name: "read::書く#λ".into(),
|
||||
/// };
|
||||
/// reference.validate_for_path("/世::界/#/item")?;
|
||||
/// // Fetching is independent of an Object target path.
|
||||
/// FetchInterfaceRequest { interface: reference }.validate()?;
|
||||
/// # Ok::<(), wip_protocol::ValidationError>(())
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct InterfaceReference {
|
||||
/// Canonical absolute Worldspace path of the interface's scope Object.
|
||||
pub scope: String,
|
||||
/// Nonempty Unicode local name, with no delimiter restrictions.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl InterfaceReference {
|
||||
/// Validates reference shape without requiring a target Object path.
|
||||
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||
crate::validation::validate_interface_reference(self)
|
||||
}
|
||||
|
||||
/// Validates shape and that scope is an ancestor-or-self of `path`.
|
||||
///
|
||||
/// This is independent of indexability. A Host maps a valid reference's
|
||||
/// scope mismatch to `InterfaceMismatch` at the interface-selection stage,
|
||||
/// not to the earlier shape-validation `InvalidRequest` stage.
|
||||
pub fn validate_for_path(&self, path: &str) -> Result<(), ValidationError> {
|
||||
crate::validation::validate_interface_scope(self, path)
|
||||
}
|
||||
}
|
||||
|
||||
/// The protocol projection currently published at one Worldspace path.
|
||||
///
|
||||
/// `name` is a path segment, `interfaces` is an ordered set of opaque interface
|
||||
/// `name` is a path segment, `interfaces` is an ordered set of structured interface
|
||||
/// references, `r#ref` is optional object identity, and `validator` is an
|
||||
/// independent optional observation precondition. In particular, neither the
|
||||
/// path nor `r#ref` is a mandatory object identifier.
|
||||
@@ -23,8 +63,8 @@ pub struct Object {
|
||||
pub name: String,
|
||||
/// Short public description of the object.
|
||||
pub description: Option<String>,
|
||||
/// Opaque interface references in host publication order.
|
||||
pub interfaces: Vec<String>,
|
||||
/// Structured interface references in host publication order.
|
||||
pub interfaces: Vec<InterfaceReference>,
|
||||
/// Optional opaque identity within one client-facing Worldspace.
|
||||
pub r#ref: Option<String>,
|
||||
/// Optional opaque validator for the object's observed state.
|
||||
@@ -239,18 +279,20 @@ pub struct ObjectObservation {
|
||||
/// Successful `observe` response.
|
||||
pub type ObserveResponse = ObjectObservation;
|
||||
|
||||
/// Request for the current descriptor of one opaque interface reference.
|
||||
/// Request for the current descriptor of one structured interface reference.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FetchInterfaceRequest {
|
||||
/// Opaque interface reference obtained from an object.
|
||||
pub interface: String,
|
||||
/// Structured interface reference obtained from an object.
|
||||
pub interface: InterfaceReference,
|
||||
}
|
||||
|
||||
/// Successful `fetch_interface` response.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FetchInterfaceResponse {
|
||||
/// Opaque interface reference corresponding to the request.
|
||||
pub interface: String,
|
||||
/// Structured interface reference corresponding to the request.
|
||||
pub interface: InterfaceReference,
|
||||
/// Optional opaque ref of the scope Object, not part of interface identity.
|
||||
pub scope_ref: Option<String>,
|
||||
/// Current validated descriptor.
|
||||
pub descriptor: InterfaceDescriptor,
|
||||
/// Optional opaque validator for this descriptor observation.
|
||||
@@ -269,8 +311,10 @@ pub struct Target {
|
||||
/// Interface portion of an operation target.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InterfaceTarget {
|
||||
/// Opaque interface reference selected from the object's interface set.
|
||||
pub reference: String,
|
||||
/// Structured reference selected from the object's interface set.
|
||||
pub reference: InterfaceReference,
|
||||
/// Optional scope Object ref copied from the same interface observation.
|
||||
pub scope_ref: Option<String>,
|
||||
/// Optional validator copied from the interface observation.
|
||||
pub validator: Option<Vec<u8>>,
|
||||
}
|
||||
@@ -369,6 +413,13 @@ impl ObserveRequest {
|
||||
}
|
||||
}
|
||||
|
||||
impl FetchInterfaceRequest {
|
||||
/// Validates reference shape; fetching an interface needs no target path.
|
||||
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||
self.interface.validate()
|
||||
}
|
||||
}
|
||||
|
||||
impl FetchInterfaceResponse {
|
||||
/// Validates request correspondence and descriptor semantics.
|
||||
///
|
||||
@@ -382,7 +433,16 @@ impl FetchInterfaceResponse {
|
||||
}
|
||||
|
||||
impl CallOperationRequest {
|
||||
/// Validates this call against the descriptor selected by `interface`.
|
||||
/// Validates descriptor-independent request shape only.
|
||||
///
|
||||
/// Scope compatibility is deliberately deferred to interface selection so
|
||||
/// that `NotFound` precedes `InterfaceMismatch` at the Host boundary.
|
||||
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||
crate::validation::validate_call_shape(self)
|
||||
}
|
||||
|
||||
/// Validates this call's shape and descriptor-bound operation/arguments.
|
||||
/// Scope compatibility and membership require Host publication context.
|
||||
pub fn validate_with(&self, descriptor: &InterfaceDescriptor) -> Result<(), ValidationError> {
|
||||
descriptor.validate_call(self)
|
||||
}
|
||||
|
||||
@@ -2,17 +2,22 @@ use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use crate::{
|
||||
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,
|
||||
InterfaceReference, 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> {
|
||||
validate_object_name(&object.name, true)?;
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
for interface in &object.interfaces {
|
||||
if !seen.insert(interface.as_str()) {
|
||||
for (index, interface) in object.interfaces.iter().enumerate() {
|
||||
interface.validate().map_err(|error| {
|
||||
error
|
||||
.prepend(PathSegment::Index(index))
|
||||
.prepend(PathSegment::Field("interfaces".into()))
|
||||
})?;
|
||||
if !seen.insert(interface) {
|
||||
return Err(
|
||||
ValidationError::new(ValidationErrorKind::DuplicateInterface {
|
||||
reference: interface.clone(),
|
||||
@@ -51,6 +56,14 @@ pub(crate) fn validate_object_at_path(object: &Object, path: &str) -> Result<(),
|
||||
.next()
|
||||
.expect("non-root path has a segment")
|
||||
};
|
||||
for (index, interface) in object.interfaces.iter().enumerate() {
|
||||
interface.validate_for_path(path).map_err(|error| {
|
||||
error
|
||||
.prepend(PathSegment::Index(index))
|
||||
.prepend(PathSegment::Field("interfaces".into()))
|
||||
.prepend(PathSegment::Object(path.to_owned()))
|
||||
})?;
|
||||
}
|
||||
if object.name == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -106,6 +119,54 @@ pub(crate) fn validate_path(path: &str) -> Result<(), ValidationError> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_interface_reference(
|
||||
reference: &InterfaceReference,
|
||||
) -> Result<(), ValidationError> {
|
||||
validate_path(&reference.scope)
|
||||
.map_err(|error| error.prepend(PathSegment::Field("scope".into())))?;
|
||||
if reference.name.is_empty() {
|
||||
return Err(
|
||||
ValidationError::new(ValidationErrorKind::EmptyInterfaceName)
|
||||
.prepend(PathSegment::Field("name".into())),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_interface_scope(
|
||||
reference: &InterfaceReference,
|
||||
path: &str,
|
||||
) -> Result<(), ValidationError> {
|
||||
reference.validate()?;
|
||||
validate_path(path)?;
|
||||
let scope = reference.scope.as_str();
|
||||
if scope == "/"
|
||||
|| path == scope
|
||||
|| path
|
||||
.strip_prefix(scope)
|
||||
.is_some_and(|rest| rest.starts_with('/'))
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ValidationError::new(
|
||||
ValidationErrorKind::InterfaceScopeMismatch {
|
||||
scope: reference.scope.clone(),
|
||||
path: path.to_owned(),
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_call_shape(request: &CallOperationRequest) -> Result<(), ValidationError> {
|
||||
validate_path(&request.target.path)
|
||||
.map_err(|error| error.prepend(PathSegment::Field("target".into())))?;
|
||||
request.interface.reference.validate().map_err(|error| {
|
||||
error
|
||||
.prepend(PathSegment::Field("reference".into()))
|
||||
.prepend(PathSegment::Field("interface".into()))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn validate_descriptor(descriptor: &InterfaceDescriptor) -> Result<(), ValidationError> {
|
||||
let declarations = declaration_map(descriptor)?;
|
||||
|
||||
@@ -582,8 +643,7 @@ pub(crate) fn validate_call(
|
||||
descriptor: &InterfaceDescriptor,
|
||||
request: &CallOperationRequest,
|
||||
) -> Result<(), ValidationError> {
|
||||
validate_path(&request.target.path)
|
||||
.map_err(|error| error.prepend(PathSegment::Field("target".into())))?;
|
||||
request.validate()?;
|
||||
validate_arguments(descriptor, &request.operation, &request.arguments)
|
||||
}
|
||||
|
||||
@@ -660,11 +720,13 @@ pub(crate) fn validate_fetch_interface_response(
|
||||
response: &FetchInterfaceResponse,
|
||||
request: &FetchInterfaceRequest,
|
||||
) -> Result<(), ValidationError> {
|
||||
request.validate()?;
|
||||
response.interface.validate()?;
|
||||
if response.interface != request.interface {
|
||||
return Err(
|
||||
ValidationError::new(ValidationErrorKind::InterfaceResponseMismatch {
|
||||
expected: request.interface.clone(),
|
||||
actual: response.interface.clone(),
|
||||
expected: Box::new(request.interface.clone()),
|
||||
actual: Box::new(response.interface.clone()),
|
||||
})
|
||||
.prepend(PathSegment::Field("interface".into())),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
use std::collections::BTreeMap;
|
||||
use wip_protocol::{
|
||||
CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, InterfaceDescriptor,
|
||||
InterfaceReference, InterfaceTarget, Object, ObjectObservation, ObserveRequest,
|
||||
OperationDeclaration, ReturnDeclaration, Target, TypeExpr, ValidationErrorKind,
|
||||
};
|
||||
|
||||
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||
InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn object(path: &str, interfaces: Vec<InterfaceReference>) -> Object {
|
||||
Object {
|
||||
name: path.rsplit('/').next().unwrap().into(),
|
||||
description: None,
|
||||
interfaces,
|
||||
r#ref: None,
|
||||
validator: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn descriptor() -> InterfaceDescriptor {
|
||||
InterfaceDescriptor {
|
||||
format: wip_protocol::INTERFACE_FORMAT_V1.into(),
|
||||
documentation: None,
|
||||
types: vec![],
|
||||
operations: vec![OperationDeclaration {
|
||||
name: "run".into(),
|
||||
documentation: None,
|
||||
parameters: vec![],
|
||||
returns: ReturnDeclaration {
|
||||
documentation: None,
|
||||
r#type: TypeExpr::Unit,
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_is_segment_based_ancestor_or_self_not_indexability() {
|
||||
for (scope, path) in [
|
||||
("/", "/"),
|
||||
("/", "/a/b"),
|
||||
("/a", "/a"),
|
||||
("/a", "/a/b"),
|
||||
("/a/b", "/a/b/c"),
|
||||
] {
|
||||
reference(scope, "read").validate_for_path(path).unwrap();
|
||||
object(path, vec![reference(scope, "read")])
|
||||
.validate_at_path(path)
|
||||
.unwrap();
|
||||
}
|
||||
for (scope, path) in [
|
||||
("/a", "/ab"),
|
||||
("/a", "/ab/c"),
|
||||
("/a/b", "/a/c"),
|
||||
("/a/b", "/a"),
|
||||
("/a", "/"),
|
||||
] {
|
||||
assert!(matches!(
|
||||
reference(scope, "read")
|
||||
.validate_for_path(path)
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ValidationErrorKind::InterfaceScopeMismatch { .. }
|
||||
));
|
||||
// Without the placement context, the Object/reference shape is valid.
|
||||
let object = object(path, vec![reference(scope, "read")]);
|
||||
object.validate().unwrap();
|
||||
assert!(matches!(
|
||||
object.validate_at_path(path).unwrap_err().kind,
|
||||
ValidationErrorKind::InterfaceScopeMismatch { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_validation_does_not_normalize_or_parse_fields() {
|
||||
for scope in ["", "relative", "//a", "/a/", "/a//b", "/a/./b", "/a/../b"] {
|
||||
let reference = reference(scope, "read");
|
||||
assert!(matches!(
|
||||
reference.validate().unwrap_err().kind,
|
||||
ValidationErrorKind::InvalidPath { .. }
|
||||
));
|
||||
assert!(
|
||||
FetchInterfaceRequest {
|
||||
interface: reference.clone()
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
assert!(object("/a", vec![reference]).validate().is_err());
|
||||
}
|
||||
assert_eq!(
|
||||
reference("/", "").validate().unwrap_err().kind,
|
||||
ValidationErrorKind::EmptyInterfaceName
|
||||
);
|
||||
for name in [".", "..", "a/b", "::", "#", "読み::書き#λ", "e\u{301}"] {
|
||||
let reference = reference("/世::界/#/%2F", name);
|
||||
reference.validate().unwrap();
|
||||
assert_eq!(reference.name, name);
|
||||
assert_eq!(reference.scope, "/世::界/#/%2F");
|
||||
}
|
||||
assert_ne!(reference("/", "é"), reference("/", "e\u{301}"));
|
||||
assert_ne!(reference("/A", "read"), reference("/a", "read"));
|
||||
assert_ne!(reference("/%61", "read"), reference("/a", "read"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_membership_and_correlation_use_complete_pair() {
|
||||
let root = reference("/", "read");
|
||||
let scoped = reference("/a", "read");
|
||||
let mut object = object("/a", vec![root.clone(), scoped.clone()]);
|
||||
object.validate_at_path("/a").unwrap();
|
||||
assert!(object.interfaces.contains(&scoped));
|
||||
assert!(!object.interfaces.contains(&reference("/b", "read")));
|
||||
object.interfaces.push(root.clone());
|
||||
assert_eq!(
|
||||
object.validate().unwrap_err().kind,
|
||||
ValidationErrorKind::DuplicateInterface {
|
||||
reference: root.clone()
|
||||
}
|
||||
);
|
||||
let request = FetchInterfaceRequest { interface: root };
|
||||
let mut response = FetchInterfaceResponse {
|
||||
interface: scoped,
|
||||
scope_ref: None,
|
||||
descriptor: descriptor(),
|
||||
validator: None,
|
||||
};
|
||||
assert!(matches!(
|
||||
response.validate_for(&request).unwrap_err().kind,
|
||||
ValidationErrorKind::InterfaceResponseMismatch { .. }
|
||||
));
|
||||
response.interface = request.interface.clone();
|
||||
for scope_ref in [None, Some(String::new()), Some("opaque::世界#/%".into())] {
|
||||
response.scope_ref = scope_ref;
|
||||
response.validate_for(&request).unwrap();
|
||||
}
|
||||
response.interface.name.clear();
|
||||
assert_eq!(
|
||||
response.validate_for(&request).unwrap_err().kind,
|
||||
ValidationErrorKind::EmptyInterfaceName
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_checks_scope_at_every_node_including_depth_boundary() {
|
||||
let request = ObserveRequest {
|
||||
path: "/a".into(),
|
||||
depth: 1,
|
||||
};
|
||||
let mut response = ObjectObservation {
|
||||
object: object("/a", vec![reference("/a", "read")]),
|
||||
children: Some(vec![ObjectObservation {
|
||||
object: object("/a/b", vec![reference("/a/b", "read")]),
|
||||
children: None,
|
||||
}]),
|
||||
};
|
||||
request.validate_response(&response).unwrap();
|
||||
response.children.as_mut().unwrap()[0].object.interfaces[0].scope = "/a/c".into();
|
||||
assert!(matches!(
|
||||
request.validate_response(&response).unwrap_err().kind,
|
||||
ValidationErrorKind::InterfaceScopeMismatch { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_needs_no_target_path_and_call_separates_shape_from_scope_selection() {
|
||||
let reference = reference("/some/unrelated/scope", "read");
|
||||
let fetch = FetchInterfaceRequest {
|
||||
interface: reference.clone(),
|
||||
};
|
||||
fetch.validate().unwrap();
|
||||
FetchInterfaceResponse {
|
||||
interface: reference.clone(),
|
||||
scope_ref: None,
|
||||
descriptor: descriptor(),
|
||||
validator: None,
|
||||
}
|
||||
.validate_for(&fetch)
|
||||
.unwrap();
|
||||
let mut call = CallOperationRequest {
|
||||
target: Target {
|
||||
path: "/a".into(),
|
||||
validator: None,
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference,
|
||||
scope_ref: Some("opaque".into()),
|
||||
validator: None,
|
||||
},
|
||||
operation: "run".into(),
|
||||
arguments: BTreeMap::new(),
|
||||
};
|
||||
call.validate().unwrap();
|
||||
call.validate_with(&descriptor()).unwrap();
|
||||
// This stage is Host InterfaceMismatch, after NotFound, not shape InvalidRequest.
|
||||
assert!(matches!(
|
||||
call.interface
|
||||
.reference
|
||||
.validate_for_path(&call.target.path)
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ValidationErrorKind::InterfaceScopeMismatch { .. }
|
||||
));
|
||||
call.interface.reference.name.clear();
|
||||
assert_eq!(
|
||||
call.validate().unwrap_err().kind,
|
||||
ValidationErrorKind::EmptyInterfaceName
|
||||
);
|
||||
assert_eq!(
|
||||
call.validate_with(&descriptor()).unwrap_err().kind,
|
||||
ValidationErrorKind::EmptyInterfaceName
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,19 @@ use std::collections::BTreeMap;
|
||||
use wip_protocol::{
|
||||
CallOperationRequest, CallOperationResponse, EnumCase, FetchInterfaceRequest,
|
||||
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,
|
||||
InterfaceReference, InterfaceTarget, MAX_SAFE_INTEGER, NameNamespace, Object,
|
||||
ObjectObservation, ObserveRequest, OperationDeclaration, ParameterDeclaration, PathSegment,
|
||||
ProtocolError, ProtocolErrorCode, ProtocolInteraction, ProtocolResult, ReturnDeclaration,
|
||||
Target, TypeDeclaration, TypeExpr, UnionCase, ValidationErrorKind, Value, ValueKind,
|
||||
};
|
||||
|
||||
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||
InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn record(entries: impl IntoIterator<Item = (&'static str, Value)>) -> Value {
|
||||
Value::Record(
|
||||
entries
|
||||
@@ -135,7 +142,7 @@ fn object(name: &str) -> Object {
|
||||
Object {
|
||||
name: name.into(),
|
||||
description: Some(format!("Object {name}")),
|
||||
interfaces: vec!["catalog-v1".into(), "search-v2".into()],
|
||||
interfaces: vec![reference("/", "catalog-v1"), reference("/", "search-v2")],
|
||||
r#ref: Some("opaque-object-ref".into()),
|
||||
validator: Some(vec![1, 2, 3]),
|
||||
}
|
||||
@@ -146,16 +153,19 @@ fn object_fields_have_canonical_identity_and_ordered_set_semantics() {
|
||||
let object = object("article");
|
||||
object.validate().unwrap();
|
||||
object.validate_at_path("/articles/article").unwrap();
|
||||
assert_eq!(object.interfaces, ["catalog-v1", "search-v2"]);
|
||||
assert_eq!(
|
||||
object.interfaces,
|
||||
[reference("/", "catalog-v1"), reference("/", "search-v2")]
|
||||
);
|
||||
|
||||
let duplicate = Object {
|
||||
interfaces: vec!["catalog-v1".into(), "catalog-v1".into()],
|
||||
interfaces: vec![reference("/", "catalog-v1"), reference("/", "catalog-v1")],
|
||||
..object.clone()
|
||||
};
|
||||
assert_eq!(
|
||||
duplicate.validate().unwrap_err().kind,
|
||||
ValidationErrorKind::DuplicateInterface {
|
||||
reference: "catalog-v1".into()
|
||||
reference: reference("/", "catalog-v1")
|
||||
}
|
||||
);
|
||||
|
||||
@@ -574,7 +584,8 @@ fn calls_validate_canonical_paths_named_arguments_and_unit_results() {
|
||||
validator: Some(vec![1]),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: "catalog-v1".into(),
|
||||
scope_ref: None,
|
||||
reference: reference("/", "catalog-v1"),
|
||||
validator: Some(vec![2]),
|
||||
},
|
||||
operation: "publish".into(),
|
||||
@@ -659,17 +670,18 @@ fn observe_and_fetch_interface_dtos_validate_request_response_correspondence() {
|
||||
}
|
||||
|
||||
let request = FetchInterfaceRequest {
|
||||
interface: "catalog-v1".into(),
|
||||
interface: reference("/", "catalog-v1"),
|
||||
};
|
||||
let response = FetchInterfaceResponse {
|
||||
interface: "catalog-v1".into(),
|
||||
scope_ref: None,
|
||||
interface: reference("/", "catalog-v1"),
|
||||
descriptor: descriptor(),
|
||||
validator: Some(vec![9]),
|
||||
};
|
||||
response.validate_for(&request).unwrap();
|
||||
assert!(matches!(
|
||||
FetchInterfaceResponse {
|
||||
interface: "other".into(),
|
||||
interface: reference("/", "other"),
|
||||
..response
|
||||
}
|
||||
.validate_for(&request)
|
||||
@@ -847,7 +859,7 @@ fn invalid_success_payloads_remain_client_local_validation_failures() {
|
||||
));
|
||||
|
||||
let interface_request = FetchInterfaceRequest {
|
||||
interface: "catalog-v1".into(),
|
||||
interface: reference("/", "catalog-v1"),
|
||||
};
|
||||
let mut invalid_descriptor = descriptor();
|
||||
invalid_descriptor
|
||||
@@ -856,6 +868,7 @@ fn invalid_success_payloads_remain_client_local_validation_failures() {
|
||||
assert!(matches!(
|
||||
FetchInterfaceResponse {
|
||||
interface: interface_request.interface.clone(),
|
||||
scope_ref: None,
|
||||
descriptor: invalid_descriptor,
|
||||
validator: None,
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
//!
|
||||
//! The canonical API targets operations by `target.path` rather than a mandatory
|
||||
//! object identifier. Object `ref` and object/interface validators are separate,
|
||||
//! optional observation fields. Interface references are opaque values observed
|
||||
//! optional observation fields. Interface references are structured scope/name values observed
|
||||
//! separately from descriptors, and descriptors contain neither a Rust trait
|
||||
//! name nor an opaque-reference `name` field. Application records, enums,
|
||||
//! unions, entry paths, and JSON all encode through the schema-neutral [`Value`]
|
||||
@@ -46,7 +46,7 @@ use wip_host::{
|
||||
};
|
||||
use wip_interface as wip;
|
||||
use wip_interface::WipType;
|
||||
use wip_protocol::{ProtocolError, ProtocolErrorCode, Value};
|
||||
use wip_protocol::{InterfaceReference, ProtocolError, ProtocolErrorCode, Value};
|
||||
|
||||
const OBJECT_PATH: &str = "/article-service";
|
||||
const OBJECT_VALIDATOR: &[u8] = b"object-v1";
|
||||
@@ -134,12 +134,12 @@ pub struct DemoReport {
|
||||
pub object_reference: Option<String>,
|
||||
/// Optional object-state validator.
|
||||
pub object_validator: Option<Vec<u8>>,
|
||||
/// Opaque interface references published by the object, in exposure order.
|
||||
pub published_interfaces: Vec<String>,
|
||||
/// Opaque reference assigned to the catalog descriptor.
|
||||
pub catalog_interface_reference: String,
|
||||
/// Opaque reference assigned to the audit descriptor.
|
||||
pub audit_interface_reference: String,
|
||||
/// Structured interface references published by the object, in exposure order.
|
||||
pub published_interfaces: Vec<InterfaceReference>,
|
||||
/// Root-scoped reference assigned to the catalog descriptor.
|
||||
pub catalog_interface_reference: InterfaceReference,
|
||||
/// Root-scoped reference assigned to the audit descriptor.
|
||||
pub audit_interface_reference: InterfaceReference,
|
||||
/// Descriptor format observed separately for the catalog reference.
|
||||
pub catalog_descriptor_format: String,
|
||||
/// Descriptor format observed separately for the audit reference.
|
||||
@@ -296,7 +296,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
validator: Some(b"stale-object".to_vec()),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: diagnostics.reference().as_str().to_owned(),
|
||||
scope_ref: None,
|
||||
reference: diagnostics.reference().protocol_reference(),
|
||||
validator: Some(b"stale-interface".to_vec()),
|
||||
},
|
||||
operation: "missing".into(),
|
||||
@@ -319,7 +320,10 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
.code;
|
||||
|
||||
let interface_not_found = registry
|
||||
.fetch_interface("missing-interface")
|
||||
.fetch_interface(&InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: "missing-interface".into(),
|
||||
})
|
||||
.expect_err("unknown interface references must not resolve")
|
||||
.code;
|
||||
|
||||
@@ -330,7 +334,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
validator: Some(b"stale-object".to_vec()),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: diagnostics.reference().as_str().to_owned(),
|
||||
scope_ref: None,
|
||||
reference: diagnostics.reference().protocol_reference(),
|
||||
validator: Some(b"stale-interface".to_vec()),
|
||||
},
|
||||
operation: "missing".into(),
|
||||
@@ -345,8 +350,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
object_reference: object.r#ref,
|
||||
object_validator: object.validator,
|
||||
published_interfaces: object.interfaces,
|
||||
catalog_interface_reference: catalog.reference().as_str().to_owned(),
|
||||
audit_interface_reference: audit.reference().as_str().to_owned(),
|
||||
catalog_interface_reference: catalog.reference().protocol_reference(),
|
||||
audit_interface_reference: audit.reference().protocol_reference(),
|
||||
catalog_descriptor_format: catalog_response.descriptor.format,
|
||||
audit_descriptor_format: audit_response.descriptor.format,
|
||||
published,
|
||||
@@ -412,7 +417,8 @@ fn request(
|
||||
validator: Some(OBJECT_VALIDATOR.to_vec()),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: interface.as_str().to_owned(),
|
||||
scope_ref: None,
|
||||
reference: interface.protocol_reference(),
|
||||
validator: interface_validator.map(<[u8]>::to_vec),
|
||||
},
|
||||
operation: operation.into(),
|
||||
|
||||
@@ -97,7 +97,7 @@ fn object_fields_keep_path_context_and_optional_ref_separate() {
|
||||
let object = Object {
|
||||
name: "article".into(),
|
||||
description: None,
|
||||
interfaces: vec!["catalog-ref".into(), "audit-ref".into()],
|
||||
interfaces: vec![root_reference("catalog-ref"), root_reference("audit-ref")],
|
||||
r#ref: None,
|
||||
validator: None,
|
||||
};
|
||||
@@ -105,7 +105,10 @@ fn object_fields_keep_path_context_and_optional_ref_separate() {
|
||||
object.validate_at_path("/library/article").unwrap();
|
||||
assert_eq!(object.name, "article");
|
||||
assert_eq!(object.r#ref, None);
|
||||
assert_eq!(object.interfaces, ["catalog-ref", "audit-ref"]);
|
||||
assert_eq!(
|
||||
object.interfaces,
|
||||
[root_reference("catalog-ref"), root_reference("audit-ref")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -244,3 +247,10 @@ fn assert_schema_neutral(value: &Value) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||
wip_protocol::InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,8 +96,10 @@ a second explicit Enter (or `e`) to execute once. There is no operation auto-ret
|
||||
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)`. Descriptor lookahead follows fresh visible Objects, shares the
|
||||
- **Interfaces / Operations** identifies every operation by `(interface scope,
|
||||
interface name, operation name)` and explicitly displays `scope=…` and `name=…`.
|
||||
Structured references remain intact in selection, acquisition, and call state;
|
||||
display labels are never parsed back into references. 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.
|
||||
|
||||
+71
-16
@@ -3,7 +3,7 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use wip_client::{InterfaceObservation, ObjectObservation, ObservationState, TreeObservation};
|
||||
use wip_protocol::{InterfaceDescriptor, Object, Value};
|
||||
use wip_protocol::{InterfaceDescriptor, InterfaceReference, Object, Value};
|
||||
|
||||
use crate::editor::ArgumentEditor;
|
||||
|
||||
@@ -139,8 +139,8 @@ impl EntryView {
|
||||
/// Cached interface descriptor state used by presentation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InterfaceView {
|
||||
/// Opaque interface reference.
|
||||
pub reference: String,
|
||||
/// Structured interface reference.
|
||||
pub reference: InterfaceReference,
|
||||
/// Descriptor when accepted by the client runtime.
|
||||
pub descriptor: Option<InterfaceDescriptor>,
|
||||
/// Descriptor observation state.
|
||||
@@ -187,13 +187,13 @@ pub enum InFlightKind {
|
||||
/// Tree expansion or refresh.
|
||||
Tree(String),
|
||||
/// Descriptor fetch.
|
||||
Interface(String),
|
||||
Interface(InterfaceReference),
|
||||
/// Explicit operation call.
|
||||
Operation {
|
||||
/// Target path.
|
||||
path: String,
|
||||
/// Interface reference.
|
||||
interface: String,
|
||||
interface: InterfaceReference,
|
||||
/// Operation name.
|
||||
operation: String,
|
||||
/// Whether the transport dispatch boundary was crossed.
|
||||
@@ -241,13 +241,13 @@ pub enum Action {
|
||||
path: String,
|
||||
},
|
||||
/// Retrieve one interface descriptor.
|
||||
FetchInterface(String),
|
||||
FetchInterface(InterfaceReference),
|
||||
/// Execute only after explicit confirmation.
|
||||
Execute {
|
||||
/// Entry target.
|
||||
path: String,
|
||||
/// Interface reference.
|
||||
interface: String,
|
||||
interface: InterfaceReference,
|
||||
/// Operation name.
|
||||
operation: String,
|
||||
/// Descriptor-validated arguments.
|
||||
@@ -276,8 +276,8 @@ pub struct App {
|
||||
pub selected_path: String,
|
||||
/// Focused pane.
|
||||
pub focus: Focus,
|
||||
/// Interface descriptor views by opaque reference.
|
||||
pub interfaces: BTreeMap<String, InterfaceView>,
|
||||
/// Interface descriptor views by structured reference.
|
||||
pub interfaces: BTreeMap<InterfaceReference, InterfaceView>,
|
||||
/// Selected row in the flattened interface/operation view.
|
||||
pub operation_cursor: usize,
|
||||
/// Selected recent-history row.
|
||||
@@ -615,7 +615,7 @@ impl App {
|
||||
/// 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> {
|
||||
pub fn visible_interface_references(&self) -> Vec<InterfaceReference> {
|
||||
let mut references = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
for path in self.visible_paths() {
|
||||
@@ -716,7 +716,7 @@ impl App {
|
||||
.or_insert_with(|| EntryView::placeholder(path))
|
||||
}
|
||||
|
||||
fn ensure_interface(&mut self, reference: &str) -> &mut InterfaceView {
|
||||
fn ensure_interface(&mut self, reference: &InterfaceReference) -> &mut InterfaceView {
|
||||
if !self.interfaces.contains_key(reference)
|
||||
&& self.interfaces.len() >= MAX_PRESENTATION_INTERFACES
|
||||
{
|
||||
@@ -757,7 +757,7 @@ impl App {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OperationRow {
|
||||
/// Interface reference, which participates in operation identity.
|
||||
pub interface: String,
|
||||
pub interface: InterfaceReference,
|
||||
/// Operation name, or `None` for the interface heading.
|
||||
pub operation: Option<String>,
|
||||
}
|
||||
@@ -773,6 +773,12 @@ pub fn parent_path(path: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||
wip_protocol::InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
use super::*;
|
||||
use wip_protocol::{INTERFACE_FORMAT_V1, OperationDeclaration, ReturnDeclaration, TypeExpr};
|
||||
|
||||
@@ -780,7 +786,10 @@ mod tests {
|
||||
Object {
|
||||
name: name.into(),
|
||||
description: Some(format!("{name} description")),
|
||||
interfaces: interfaces.iter().map(|value| (*value).into()).collect(),
|
||||
interfaces: interfaces
|
||||
.iter()
|
||||
.map(|value| root_reference(value))
|
||||
.collect(),
|
||||
r#ref: None,
|
||||
validator: None,
|
||||
}
|
||||
@@ -846,7 +855,51 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
app.visible_interface_references(),
|
||||
["root", "shared", "child"]
|
||||
[
|
||||
root_reference("root"),
|
||||
root_reference("shared"),
|
||||
root_reference("child")
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presentation_retains_equal_names_in_distinct_scopes() {
|
||||
let mut app = App::new("https://example.test/wip");
|
||||
let root = root_reference("shared#::操作");
|
||||
let local = InterfaceReference {
|
||||
scope: "/a".into(),
|
||||
name: root.name.clone(),
|
||||
};
|
||||
let mut value = object("a", &[]);
|
||||
value.interfaces = vec![root.clone(), local.clone()];
|
||||
app.navigate_entry("/a");
|
||||
app.apply_object(ObjectObservation {
|
||||
path: "/a".into(),
|
||||
object: Some(value),
|
||||
validator: None,
|
||||
state: ObservationState::Fresh,
|
||||
});
|
||||
for reference in [&root, &local] {
|
||||
app.apply_interface(InterfaceObservation {
|
||||
reference: reference.clone(),
|
||||
descriptor: None,
|
||||
validator: None,
|
||||
scope_ref: None,
|
||||
state: ObservationState::Fresh,
|
||||
});
|
||||
}
|
||||
assert_eq!(app.interfaces.len(), 2);
|
||||
assert_eq!(
|
||||
app.visible_interface_references(),
|
||||
vec![root.clone(), local.clone()]
|
||||
);
|
||||
assert_eq!(
|
||||
app.operation_rows()
|
||||
.iter()
|
||||
.map(|row| row.interface.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![root, local]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -874,7 +927,8 @@ mod tests {
|
||||
}],
|
||||
};
|
||||
app.apply_interface(InterfaceObservation {
|
||||
reference: "iface".into(),
|
||||
scope_ref: None,
|
||||
reference: root_reference("iface"),
|
||||
descriptor: Some(descriptor),
|
||||
validator: None,
|
||||
state: ObservationState::Fresh,
|
||||
@@ -903,7 +957,8 @@ mod tests {
|
||||
|
||||
for index in 0..(MAX_PRESENTATION_INTERFACES + 50) {
|
||||
app.apply_interface(InterfaceObservation {
|
||||
reference: format!("interface-{index}"),
|
||||
scope_ref: None,
|
||||
reference: root_reference(&format!("interface-{index}")),
|
||||
descriptor: None,
|
||||
validator: None,
|
||||
state: ObservationState::Fresh,
|
||||
|
||||
@@ -4,7 +4,9 @@ use std::collections::BTreeMap;
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use wip_protocol::{InterfaceDescriptor, OperationDeclaration, TypeExpr, Value};
|
||||
use wip_protocol::{
|
||||
InterfaceDescriptor, InterfaceReference, OperationDeclaration, TypeExpr, Value,
|
||||
};
|
||||
|
||||
const MAX_FIELD_INPUT_BYTES: usize = 256 * 1024;
|
||||
|
||||
@@ -30,8 +32,8 @@ pub struct InvalidArguments;
|
||||
/// Pure, terminal-independent editor built from an operation descriptor.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ArgumentEditor {
|
||||
/// Opaque interface reference selected for the call.
|
||||
pub interface: String,
|
||||
/// Structured interface reference selected for the call.
|
||||
pub interface: InterfaceReference,
|
||||
/// Selected operation declaration.
|
||||
pub operation: OperationDeclaration,
|
||||
/// Complete descriptor used to resolve named types.
|
||||
@@ -46,7 +48,7 @@ impl ArgumentEditor {
|
||||
/// Builds an editor whose fields preserve descriptor order.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
interface: impl Into<String>,
|
||||
interface: InterfaceReference,
|
||||
descriptor: InterfaceDescriptor,
|
||||
operation: OperationDeclaration,
|
||||
) -> Self {
|
||||
@@ -66,7 +68,7 @@ impl ArgumentEditor {
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
interface: interface.into(),
|
||||
interface,
|
||||
operation,
|
||||
descriptor,
|
||||
fields,
|
||||
@@ -466,6 +468,12 @@ fn json_to_untyped(json: &serde_json::Value, field: &str) -> Result<Value, Strin
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||
wip_protocol::InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
use super::*;
|
||||
use wip_protocol::{
|
||||
EnumCase, FieldDeclaration, INTERFACE_FORMAT_V1, ParameterDeclaration, ReturnDeclaration,
|
||||
@@ -560,7 +568,7 @@ mod tests {
|
||||
}],
|
||||
);
|
||||
let operation = descriptor.operations[0].clone();
|
||||
let mut editor = ArgumentEditor::new("test", descriptor, operation);
|
||||
let mut editor = ArgumentEditor::new(root_reference("test"), descriptor, operation);
|
||||
let inputs = [
|
||||
"null",
|
||||
"true",
|
||||
@@ -595,7 +603,7 @@ mod tests {
|
||||
vec![],
|
||||
);
|
||||
let operation = descriptor.operations[0].clone();
|
||||
let mut editor = ArgumentEditor::new("test", descriptor, operation);
|
||||
let mut editor = ArgumentEditor::new(root_reference("test"), descriptor, operation);
|
||||
editor.fields[1].input = "not-a-bool".into();
|
||||
assert_eq!(editor.validate(), Err(InvalidArguments));
|
||||
assert!(
|
||||
|
||||
@@ -11,6 +11,14 @@ use crate::editor::type_name;
|
||||
|
||||
const MAX_RENDER_TEXT: usize = 512;
|
||||
|
||||
fn interface_label(reference: &wip_protocol::InterfaceReference) -> String {
|
||||
format!(
|
||||
"scope={}, name={}",
|
||||
sanitize(&reference.scope),
|
||||
sanitize(&reference.name)
|
||||
)
|
||||
}
|
||||
|
||||
/// Draws the complete application without mutating it.
|
||||
pub fn draw(frame: &mut Frame<'_>, app: &App) {
|
||||
let area = frame.area();
|
||||
@@ -162,7 +170,7 @@ fn render_selected(frame: &mut Frame<'_>, area: Rect, app: &App) {
|
||||
object
|
||||
.interfaces
|
||||
.iter()
|
||||
.map(|value| sanitize(value))
|
||||
.map(interface_label)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
@@ -224,7 +232,7 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
|
||||
.find(|operation| operation.name == *name)
|
||||
});
|
||||
let signature = declaration.map_or_else(
|
||||
|| format!(" ({}, {})", row.interface, name),
|
||||
|| format!(" ({}, {})", interface_label(&row.interface), name),
|
||||
|operation| {
|
||||
let parameters = operation
|
||||
.parameters
|
||||
@@ -246,7 +254,7 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
" ({}, {}) ({parameters}) -> {}{}",
|
||||
row.interface,
|
||||
interface_label(&row.interface),
|
||||
name,
|
||||
type_name(&operation.returns.r#type),
|
||||
documentation
|
||||
@@ -266,7 +274,7 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
|
||||
(
|
||||
Line::from(format!(
|
||||
"Interface {} [{}]{}",
|
||||
sanitize(&row.interface),
|
||||
interface_label(&row.interface),
|
||||
state,
|
||||
documentation
|
||||
)),
|
||||
@@ -355,7 +363,7 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
|
||||
let mut lines = vec![Line::from(format!(
|
||||
"Target: {} Operation: ({}, {})",
|
||||
sanitize(&app.selected_path),
|
||||
sanitize(&editor.interface),
|
||||
interface_label(&editor.interface),
|
||||
sanitize(&editor.operation.name)
|
||||
))];
|
||||
if editor.fields.is_empty() {
|
||||
@@ -405,7 +413,7 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Line::from(format!("Entry: {}", sanitize(&app.selected_path))),
|
||||
Line::from(format!("Interface: {}", sanitize(&editor.interface))),
|
||||
Line::from(format!("Interface: {}", interface_label(&editor.interface))),
|
||||
Line::from(format!("Operation: {}", sanitize(&editor.operation.name))),
|
||||
Line::from("Input values:"),
|
||||
];
|
||||
@@ -579,6 +587,12 @@ fn bounded_value_at(value: &wip_protocol::Value, depth: usize) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||
wip_protocol::InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
@@ -664,7 +678,7 @@ mod tests {
|
||||
object: Some(Object {
|
||||
name: String::new(),
|
||||
description: Some("public root description".into()),
|
||||
interfaces: vec!["demo-interface".into()],
|
||||
interfaces: vec![root_reference("demo-interface")],
|
||||
r#ref: None,
|
||||
validator: Some(b"must-not-render".to_vec()),
|
||||
}),
|
||||
@@ -691,7 +705,8 @@ mod tests {
|
||||
}],
|
||||
};
|
||||
app.apply_interface(InterfaceObservation {
|
||||
reference: "demo-interface".into(),
|
||||
scope_ref: None,
|
||||
reference: root_reference("demo-interface"),
|
||||
descriptor: Some(descriptor.clone()),
|
||||
validator: Some(b"must-not-render".to_vec()),
|
||||
state: ObservationState::Fresh,
|
||||
@@ -711,7 +726,7 @@ mod tests {
|
||||
}
|
||||
let snapshot = rendered(&app, 140, 40);
|
||||
assert!(snapshot.contains("public root description"));
|
||||
assert!(snapshot.contains("(demo-interface, run)"));
|
||||
assert!(snapshot.contains("(scope=/, name=demo-interface, run)"));
|
||||
assert!(snapshot.contains("SUCCESS"));
|
||||
assert!(snapshot.contains("PROTOCOL ERROR"));
|
||||
assert!(snapshot.contains("TRANSPORT FAILURE"));
|
||||
@@ -719,7 +734,7 @@ mod tests {
|
||||
assert!(!snapshot.contains("must-not-render"));
|
||||
|
||||
let mut editor = ArgumentEditor::new(
|
||||
"demo-interface",
|
||||
root_reference("demo-interface"),
|
||||
descriptor.clone(),
|
||||
descriptor.operations[0].clone(),
|
||||
);
|
||||
@@ -774,7 +789,7 @@ mod tests {
|
||||
object: Some(Object {
|
||||
name: String::new(),
|
||||
description: None,
|
||||
interfaces: vec!["many".into()],
|
||||
interfaces: vec![root_reference("many")],
|
||||
r#ref: None,
|
||||
validator: None,
|
||||
}),
|
||||
@@ -793,7 +808,8 @@ mod tests {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
app.apply_interface(InterfaceObservation {
|
||||
reference: "many".into(),
|
||||
scope_ref: None,
|
||||
reference: root_reference("many"),
|
||||
descriptor: Some(InterfaceDescriptor {
|
||||
format: INTERFACE_FORMAT_V1.into(),
|
||||
documentation: None,
|
||||
@@ -806,7 +822,7 @@ mod tests {
|
||||
app.focus = Focus::Operations;
|
||||
app.operation_cursor = app.operation_rows().len() - 1;
|
||||
let snapshot = rendered(&app, 100, 30);
|
||||
assert!(snapshot.contains("(many, operation-19)"));
|
||||
assert!(snapshot.contains("(scope=/, name=many, operation-19)"));
|
||||
|
||||
for index in 0..20 {
|
||||
app.push_history(HistoryItem {
|
||||
@@ -828,6 +844,19 @@ mod tests {
|
||||
assert!(snapshot.contains("too small"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_labels_show_scope_and_name_without_parsing() {
|
||||
let reference = wip_protocol::InterfaceReference {
|
||||
scope: "/nested".into(),
|
||||
name: "#::操作".into(),
|
||||
};
|
||||
assert_eq!(interface_label(&reference), "scope=/nested, name=#::操作");
|
||||
assert_ne!(
|
||||
interface_label(&reference),
|
||||
interface_label(&root_reference(&reference.name))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_characters_and_large_bytes_are_bounded() {
|
||||
assert_eq!(sanitize("safe\u{1b}[31m"), "safe�[31m");
|
||||
|
||||
@@ -303,7 +303,10 @@ impl<T: Transport> Runtime<T> {
|
||||
Err(error) => self.local_error("subtree refresh", error),
|
||||
},
|
||||
Action::FetchInterface(reference) => {
|
||||
match self.client.prepare_interface(&self.session, &reference) {
|
||||
match self
|
||||
.client
|
||||
.prepare_interface(&self.session, reference.clone())
|
||||
{
|
||||
Ok(request) => {
|
||||
self.dispatch(request, InFlightKind::Interface(reference), false)
|
||||
}
|
||||
@@ -367,7 +370,10 @@ impl<T: Transport> Runtime<T> {
|
||||
}
|
||||
}
|
||||
for reference in references {
|
||||
match self.client.ensure_interface(&self.session, &reference) {
|
||||
match self
|
||||
.client
|
||||
.ensure_interface(&self.session, reference.clone())
|
||||
{
|
||||
Ok(Some(request)) => {
|
||||
self.dispatch(request, InFlightKind::Interface(reference), false);
|
||||
}
|
||||
@@ -599,8 +605,9 @@ impl<T: Transport> Runtime<T> {
|
||||
|
||||
fn push_call_history(&mut self, record: &wip_client::CallRecord) {
|
||||
let key = format!(
|
||||
"({}, {}) on {}",
|
||||
record.context.interface().reference,
|
||||
"(scope={}, name={}, {}) on {}",
|
||||
sanitize(&record.context.interface().reference.scope),
|
||||
sanitize(&record.context.interface().reference.name),
|
||||
record.context.operation().name,
|
||||
record.context.object().path
|
||||
);
|
||||
@@ -683,7 +690,11 @@ impl<T: Transport> Runtime<T> {
|
||||
let title = match kind {
|
||||
Some(InFlightKind::Object(path)) => format!("observe {path}"),
|
||||
Some(InFlightKind::Tree(path)) => format!("tree {path}"),
|
||||
Some(InFlightKind::Interface(reference)) => format!("interface {reference}"),
|
||||
Some(InFlightKind::Interface(reference)) => format!(
|
||||
"interface scope={}, name={}",
|
||||
sanitize(&reference.scope),
|
||||
sanitize(&reference.name)
|
||||
),
|
||||
Some(InFlightKind::Operation { operation, .. }) => operation.clone(),
|
||||
None => "request".into(),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user