diff --git a/CHANGELOG.md b/CHANGELOG.md index 8572a10..f0b3926 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..501621c --- /dev/null +++ b/MIGRATION.md @@ -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`. +`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` 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. diff --git a/README.md b/README.md index f22b41d..55b5ad6 100644 --- a/README.md +++ b/README.md @@ -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; diff --git a/crates/wip-client/README.md b/crates/wip-client/README.md index 77b6dda..982982c 100644 --- a/crates/wip-client/README.md +++ b/crates/wip-client/README.md @@ -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. diff --git a/crates/wip-client/src/lib.rs b/crates/wip-client/src/lib.rs index f8dc133..a60b9b4 100644 --- a/crates/wip-client/src/lib.rs +++ b/crates/wip-client/src/lib.rs @@ -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, /// Optional interface validator. Absence makes the reference immutable. pub validator: Option>, + /// Optional opaque identity of the interface scope Object. + pub scope_ref: Option, /// Current observation lifecycle. pub state: ObservationState, } @@ -404,7 +406,7 @@ struct Session { objects: BTreeMap, refs: BTreeMap, trees: BTreeMap, - interfaces: BTreeMap, + interfaces: BTreeMap, calls: VecDeque, diagnostics: VecDeque, 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, + reference: InterfaceReference, ) -> Result, 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, + reference: InterfaceReference, ) -> Result { 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, arguments: BTreeMap, ) -> Result { @@ -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 { diff --git a/crates/wip-client/tests/adapter.rs b/crates/wip-client/tests/adapter.rs index 72bc4bf..14440a8 100644 --- a/crates/wip-client/tests/adapter.rs +++ b/crates/wip-client/tests/adapter.rs @@ -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))]), ) diff --git a/crates/wip-client/tests/runtime.rs b/crates/wip-client/tests/runtime.rs index 789b863..089a9d7 100644 --- a/crates/wip-client/tests/runtime.rs +++ b/crates/wip-client/tests/runtime.rs @@ -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); +} diff --git a/crates/wip-fs-server/README.md b/crates/wip-fs-server/README.md index 19461ea..5614830 100644 --- a/crates/wip-fs-server/README.md +++ b/crates/wip-fs-server/README.md @@ -195,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":""}' + --data '{"interface":{"scope":"/","name":""}}' ``` Then call a bounded range read: @@ -211,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":""}, - "interface":{"reference":""}, + "interface":{"reference":{"scope":"/","name":""}}, "operation":"read", "arguments":{"offset":0,"length":4096} }' @@ -225,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":""}, - "interface":{"reference":""}, + "interface":{"reference":{"scope":"/","name":""}}, "operation":"replace", "arguments":{"contents":"dXBkYXRlZA=="} }' diff --git a/crates/wip-fs-server/src/projection.rs b/crates/wip-fs-server/src/projection.rs index a618848..74f698f 100644 --- a/crates/wip-fs-server/src/projection.rs +++ b/crates/wip-fs-server/src/projection.rs @@ -827,7 +827,7 @@ mod tests { projection: &DefaultProjection, path: &str, validator: Option>, - interface: String, + interface: wip_protocol::InterfaceReference, operation: &str, arguments: BTreeMap, ) -> Result { @@ -837,6 +837,7 @@ mod tests { validator, }, interface: InterfaceTarget { + scope_ref: None, reference: interface, validator: None, }, diff --git a/crates/wip-fs-server/tests/end_to_end.rs b/crates/wip-fs-server/tests/end_to_end.rs index f67486e..62624bd 100644 --- a/crates/wip-fs-server/tests/end_to_end.rs +++ b/crates/wip-fs-server/tests/end_to_end.rs @@ -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, }, diff --git a/crates/wip-fs-server/tests/extensions.rs b/crates/wip-fs-server/tests/extensions.rs index 4b8271b..8b6e61e 100644 --- a/crates/wip-fs-server/tests/extensions.rs +++ b/crates/wip-fs-server/tests/extensions.rs @@ -576,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, @@ -616,8 +616,8 @@ type Engine = Projection; struct Fixture { engine: Engine, shared: Arc, - custom: String, - read: String, + custom: wip_protocol::InterfaceReference, + read: wip_protocol::InterfaceReference, limits: ResourceLimits, } impl Fixture { @@ -696,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, } } @@ -707,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)) @@ -717,7 +721,7 @@ impl Fixture { fn request( &self, path: &str, - reference: &str, + reference: &wip_protocol::InterfaceReference, operation: &str, validator: Option>, ) -> CallOperationRequest { @@ -727,7 +731,8 @@ impl Fixture { validator, }, interface: InterfaceTarget { - reference: reference.into(), + scope_ref: None, + reference: reference.clone(), validator: None, }, operation: operation.into(), @@ -866,9 +871,9 @@ fn observe_request(path: &str, depth: u32) -> ObserveRequest { depth, } } -fn fetch_request(reference: &str) -> FetchInterfaceRequest { +fn fetch_request(reference: &wip_protocol::InterfaceReference) -> FetchInterfaceRequest { FetchInterfaceRequest { - interface: reference.into(), + interface: reference.clone(), } } fn success(response: DecodedResponse) -> T { @@ -1804,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) diff --git a/crates/wip-host/README.md b/crates/wip-host/README.md index b786252..9de0d58 100644 --- a/crates/wip-host/README.md +++ b/crates/wip-host/README.md @@ -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. diff --git a/crates/wip-host/src/lib.rs b/crates/wip-host/src/lib.rs index 5823cdf..ff41ed1 100644 --- a/crates/wip-host/src/lib.rs +++ b/crates/wip-host/src/lib.rs @@ -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 { 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 { - let reference = InterfaceRef(reference.to_owned()); + pub fn resolve(&self, reference: &InterfaceReference) -> Option { + 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 { - 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 { + 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 PublishedObject { 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 PublishedObject { ); } + 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 { 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 { 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| { diff --git a/crates/wip-host/tests/host.rs b/crates/wip-host/tests/host.rs index bb71cc6..a44a9a9 100644 --- a/crates/wip-host/tests/host.rs +++ b/crates/wip-host/tests/host.rs @@ -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, ()> { panic!("must not decode") }, + ) + .unwrap_err() + .kind(), + CallErrorKind::InterfaceMismatch + ); + } + assert_eq!(provider.calls.get(), 0); + assert_eq!(provider.authorization_checks.get(), 0); +} diff --git a/crates/wip-http/README.md b/crates/wip-http/README.md index ac266a8..6f15409 100644 --- a/crates/wip-http/README.md +++ b/crates/wip-http/README.md @@ -26,6 +26,27 @@ fn main() -> Result<(), Box> { } ``` +## 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. diff --git a/crates/wip-http/src/codec.rs b/crates/wip-http/src/codec.rs index 354c220..7ae5908 100644 --- a/crates/wip-http/src/codec.rs +++ b/crates/wip-http/src/codec.rs @@ -296,11 +296,14 @@ pub fn decode_observe_request(body: &[u8], limits: Limits) -> Result Result>, 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, diff --git a/crates/wip-http/src/json.rs b/crates/wip-http/src/json.rs index 3dfa922..5d711f0 100644 --- a/crates/wip-http/src/json.rs +++ b/crates/wip-http/src/json.rs @@ -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 Json { + serde_json::json!({"scope": reference.scope, "name": reference.name}) +} + +fn interface_reference_from_json( + value: &Json, + field: &str, +) -> Result { + 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 { 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, _>>()?; 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 { - 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 { 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 { - 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()?, diff --git a/crates/wip-http/src/lib.rs b/crates/wip-http/src/lib.rs index d092fe0..c9dc711 100644 --- a/crates/wip-http/src/lib.rs +++ b/crates/wip-http/src/lib.rs @@ -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; diff --git a/crates/wip-http/tests/codecs.rs b/crates/wip-http/tests/codecs.rs index c064ebb..48408af 100644 --- a/crates/wip-http/tests/codecs.rs +++ b/crates/wip-http/tests/codecs.rs @@ -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() diff --git a/crates/wip-http/tests/references.rs b/crates/wip-http/tests/references.rs new file mode 100644 index 0000000..951835e --- /dev/null +++ b/crates/wip-http/tests/references.rs @@ -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) -> 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) -> 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> { + raw_response(status, &serde_json::to_vec(&value).unwrap()) +} + +fn raw_response(status: StatusCode, body: &[u8]) -> Response> { + 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(result: Result) { + 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::(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 + } + ); +} diff --git a/crates/wip-protocol/README.md b/crates/wip-protocol/README.md index 8f23418..8a0026d 100644 --- a/crates/wip-protocol/README.md +++ b/crates/wip-protocol/README.md @@ -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. diff --git a/crates/wip-protocol/src/error.rs b/crates/wip-protocol/src/error.rs index 92ee9fe..a214fb7 100644 --- a/crates/wip-protocol/src/error.rs +++ b/crates/wip-protocol/src/error.rs @@ -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, + /// Returned structured reference (boxed to keep validation errors small). + actual: Box, }, } @@ -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, diff --git a/crates/wip-protocol/src/lib.rs b/crates/wip-protocol/src/lib.rs index de8b313..42d196e 100644 --- a/crates/wip-protocol/src/lib.rs +++ b/crates/wip-protocol/src/lib.rs @@ -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. diff --git a/crates/wip-protocol/src/model.rs b/crates/wip-protocol/src/model.rs index d96603a..10143e8 100644 --- a/crates/wip-protocol/src/model.rs +++ b/crates/wip-protocol/src/model.rs @@ -11,9 +11,49 @@ pub struct Documentation { pub details: Option, } +/// 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, - /// Opaque interface references in host publication order. - pub interfaces: Vec, + /// Structured interface references in host publication order. + pub interfaces: Vec, /// Optional opaque identity within one client-facing Worldspace. pub r#ref: Option, /// 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, /// 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, /// Optional validator copied from the interface observation. pub validator: Option>, } @@ -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) } diff --git a/crates/wip-protocol/src/validation.rs b/crates/wip-protocol/src/validation.rs index 39615f0..79a6ea8 100644 --- a/crates/wip-protocol/src/validation.rs +++ b/crates/wip-protocol/src/validation.rs @@ -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())), ); diff --git a/crates/wip-protocol/tests/interface_references.rs b/crates/wip-protocol/tests/interface_references.rs new file mode 100644 index 0000000..fe7e827 --- /dev/null +++ b/crates/wip-protocol/tests/interface_references.rs @@ -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) -> 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 + ); +} diff --git a/crates/wip-protocol/tests/validation.rs b/crates/wip-protocol/tests/validation.rs index e1d3f9b..b2b9242 100644 --- a/crates/wip-protocol/tests/validation.rs +++ b/crates/wip-protocol/tests/validation.rs @@ -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) -> 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, } diff --git a/crates/wip-public-api-example/src/lib.rs b/crates/wip-public-api-example/src/lib.rs index 649522d..0c92c34 100644 --- a/crates/wip-public-api-example/src/lib.rs +++ b/crates/wip-public-api-example/src/lib.rs @@ -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, /// Optional object-state validator. pub object_validator: Option>, - /// Opaque interface references published by the object, in exposure order. - pub published_interfaces: Vec, - /// 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, + /// 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> { 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> { .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> { 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> { 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(), diff --git a/crates/wip-public-api-example/tests/end_to_end.rs b/crates/wip-public-api-example/tests/end_to_end.rs index 9935605..7e0a458 100644 --- a/crates/wip-public-api-example/tests/end_to_end.rs +++ b/crates/wip-public-api-example/tests/end_to_end.rs @@ -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(), + } +} diff --git a/crates/wip-tui/README.md b/crates/wip-tui/README.md index b60e4b5..2629456 100644 --- a/crates/wip-tui/README.md +++ b/crates/wip-tui/README.md @@ -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. diff --git a/crates/wip-tui/src/app.rs b/crates/wip-tui/src/app.rs index 5c80d96..176ca59 100644 --- a/crates/wip-tui/src/app.rs +++ b/crates/wip-tui/src/app.rs @@ -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, /// 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, + /// Interface descriptor views by structured reference. + pub interfaces: BTreeMap, /// 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 { + pub fn visible_interface_references(&self) -> Vec { 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, } @@ -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![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, diff --git a/crates/wip-tui/src/editor.rs b/crates/wip-tui/src/editor.rs index d34b7b9..a313d8c 100644 --- a/crates/wip-tui/src/editor.rs +++ b/crates/wip-tui/src/editor.rs @@ -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, + 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 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!( diff --git a/crates/wip-tui/src/render.rs b/crates/wip-tui/src/render.rs index 2bc0fee..1ad44cf 100644 --- a/crates/wip-tui/src/render.rs +++ b/crates/wip-tui/src/render.rs @@ -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::>() .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::>(); 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"); diff --git a/crates/wip-tui/src/runtime.rs b/crates/wip-tui/src/runtime.rs index c85b963..885a05e 100644 --- a/crates/wip-tui/src/runtime.rs +++ b/crates/wip-tui/src/runtime.rs @@ -303,7 +303,10 @@ impl Runtime { 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 Runtime { } } 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 Runtime { 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 Runtime { 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(), };