From fdfaa3a68553c01459be9bd7fa44c42a59e9121c Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 30 Sep 2026 08:53:43 +0900 Subject: [PATCH] feat(host): decode operation arguments in dispatch boundary --- crates/wip-host/src/lib.rs | 143 +++++++++++++++++++++++++++------- crates/wip-host/tests/host.rs | 83 ++++++++++++++++++++ 2 files changed, 198 insertions(+), 28 deletions(-) diff --git a/crates/wip-host/src/lib.rs b/crates/wip-host/src/lib.rs index 81354a0..e440785 100644 --- a/crates/wip-host/src/lib.rs +++ b/crates/wip-host/src/lib.rs @@ -535,11 +535,36 @@ impl PublishedObject { context: &ExecutionContext, request: CallRequest, authorize: impl FnOnce(&T, &InterfaceRef, &str) -> bool, + ) -> OperationAttempt { + let CallRequest { + target, + interface, + operation, + arguments, + } = request; + self.dispatch_resolved_with_decoder( + context, + target, + interface, + operation, + |_| Ok::<_, std::convert::Infallible>(arguments), + authorize, + ) + } + + fn dispatch_resolved_with_decoder( + &self, + context: &ExecutionContext, + target: Target, + interface: InterfaceTarget, + operation: String, + decode_arguments: impl FnOnce(&InterfaceDescriptor) -> Result, E>, + authorize: impl FnOnce(&T, &InterfaceRef, &str) -> bool, ) -> OperationAttempt { let pre_dispatch_failure = |kind, message: &str| OperationAttempt::NotStarted(CallError::new(kind, message)); let current_object_validator = self.implementation.validator(); - if let Some(supplied) = request.target.validator.as_deref() + if let Some(supplied) = target.validator.as_deref() && current_object_validator.map(Validator::as_bytes) != Some(supplied) { return pre_dispatch_failure( @@ -548,9 +573,11 @@ impl PublishedObject { ); } - let Some(bound) = self.interfaces.iter().find(|bound| { - bound.artifact.reference.as_str() == request.interface.reference.as_str() - }) else { + let Some(bound) = self + .interfaces + .iter() + .find(|bound| bound.artifact.reference.as_str() == interface.reference.as_str()) + else { return pre_dispatch_failure( CallErrorKind::InterfaceMismatch, "interface is not a member of the current object publication", @@ -559,7 +586,7 @@ impl PublishedObject { match ( bound.artifact.validator.as_ref(), - request.interface.validator.as_deref(), + interface.validator.as_deref(), ) { (Some(_), None) => { return pre_dispatch_failure( @@ -581,18 +608,18 @@ impl PublishedObject { .descriptor .operations .iter() - .any(|operation| operation.name == request.operation) + .any(|declared| declared.name == operation) { return pre_dispatch_failure( CallErrorKind::OperationNotFound, - &format!("operation `{}` is not declared", request.operation), + &format!("operation `{operation}` is not declared"), ); } - if request.target.validator.is_none() + if target.validator.is_none() && self .implementation - .requires_validator(&bound.artifact.reference, &request.operation) + .requires_validator(&bound.artifact.reference, &operation) { return pre_dispatch_failure( CallErrorKind::ValidatorRequired, @@ -600,19 +627,24 @@ impl PublishedObject { ); } + let arguments = match decode_arguments(&bound.artifact.descriptor) { + Ok(arguments) => arguments, + Err(_) => { + return pre_dispatch_failure( + CallErrorKind::InvalidArguments, + "operation arguments could not be decoded", + ); + } + }; if let Err(error) = bound .artifact .descriptor - .validate_arguments(&request.operation, &request.arguments) + .validate_arguments(&operation, &arguments) { return pre_dispatch_failure(CallErrorKind::InvalidArguments, &error.to_string()); } - if !authorize( - &self.implementation, - &bound.artifact.reference, - &request.operation, - ) { + if !authorize(&self.implementation, &bound.artifact.reference, &operation) { return pre_dispatch_failure( CallErrorKind::PermissionDenied, "operation is not permitted for this caller", @@ -623,23 +655,22 @@ impl PublishedObject { return OperationAttempt::NotStarted(error); } - let value = - match (bound.dispatch)(&self.implementation, &request.operation, request.arguments) { - Ok(value) => value, - Err( - error @ (DispatchError::UnknownOperation(_) - | DispatchError::InvalidArguments(_) - | DispatchError::Decode(_)), - ) => return OperationAttempt::NotStarted(CallError::from_pre_dispatch(error)), - Err(error) => { - return OperationAttempt::Started(Err(OperationFailure::from_dispatch(error))); - } - }; + let value = match (bound.dispatch)(&self.implementation, &operation, arguments) { + Ok(value) => value, + Err( + error @ (DispatchError::UnknownOperation(_) + | DispatchError::InvalidArguments(_) + | DispatchError::Decode(_)), + ) => return OperationAttempt::NotStarted(CallError::from_pre_dispatch(error)), + Err(error) => { + return OperationAttempt::Started(Err(OperationFailure::from_dispatch(error))); + } + }; if bound .artifact .descriptor - .validate_result(&request.operation, &value) + .validate_result(&operation, &value) .is_err() { return OperationAttempt::Started(Err(OperationFailure::internal( @@ -922,6 +953,62 @@ pub trait ObjectProvider { ProviderResult::Failure(error) => Err(CallError::from_host_failure(error)), } } + + /// Resolves one operation target and decodes its arguments only after all + /// descriptor-independent canonical preconditions have succeeded. + /// + /// Transport bindings whose value representation depends on the selected + /// interface descriptor should use this method instead of resolving a + /// descriptor before [`ObjectProvider::call_operation_with_context`]. The + /// callback executes inside the same exact-once provider boundary after + /// object/interface validators, membership, operation lookup, and required + /// object-validator checks. A callback error becomes `InvalidArguments` and + /// typed implementation code is not invoked. + fn call_operation_with_context_and_decoder( + &self, + context: &ExecutionContext, + target: Target, + interface: InterfaceTarget, + operation: String, + decode_arguments: impl FnOnce(&InterfaceDescriptor) -> Result, E>, + ) -> Result { + wip_protocol::validate_path(&target.path) + .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| { + publication.dispatch_resolved_with_decoder( + context, + target, + interface, + operation, + decode_arguments, + |object, interface, operation| { + self.authorize(&path, object, interface, operation) + }, + ) + }) + })); + + let execution = execution.map_err(|_| { + CallError::from_operation_failure(OperationFailure::internal( + EffectGuarantee::Unknown, + "provider execution panicked", + )) + })?; + match execution { + ProviderResult::Found(OperationAttempt::NotStarted(error)) => Err(error), + ProviderResult::Found(OperationAttempt::Started(Ok(result))) => Ok(result), + ProviderResult::Found(OperationAttempt::Started(Err(error))) => { + Err(CallError::from_operation_failure(error)) + } + ProviderResult::NotFound => Err(CallError::new( + CallErrorKind::NotFound, + "target path is not published", + )), + ProviderResult::Failure(error) => Err(CallError::from_host_failure(error)), + } + } } /// Failure to construct an explicit object publication. diff --git a/crates/wip-host/tests/host.rs b/crates/wip-host/tests/host.rs index 31920dc..33bbb78 100644 --- a/crates/wip-host/tests/host.rs +++ b/crates/wip-host/tests/host.rs @@ -741,6 +741,89 @@ fn call_failures_follow_canonical_precedence_without_execution() { assert_eq!(provider.calls.get(), 0); } +#[test] +fn descriptor_guided_decoder_runs_inside_canonical_resolution_boundary() { + let (_, provider, hidden) = registry_and_provider(); + let decodes = Cell::new(0_u32); + let decode_failure = || { + decodes.set(decodes.get() + 1); + Err::, _>(()) + }; + + let missing = provider + .call_operation_with_context_and_decoder( + &ExecutionContext::new(), + Target { + path: "/missing".into(), + validator: Some(b"stale".to_vec()), + }, + InterfaceTarget { + reference: hidden.reference().as_str().into(), + validator: None, + }, + "missing".into(), + |_| decode_failure(), + ) + .unwrap_err(); + assert_eq!(missing.kind(), CallErrorKind::NotFound); + assert_eq!(decodes.get(), 0); + + let stale = provider + .call_operation_with_context_and_decoder( + &ExecutionContext::new(), + Target { + path: "/document".into(), + validator: Some(b"stale".to_vec()), + }, + InterfaceTarget { + reference: hidden.reference().as_str().into(), + validator: None, + }, + "hidden".into(), + |_| decode_failure(), + ) + .unwrap_err(); + assert_eq!(stale.kind(), CallErrorKind::ValidatorMismatch); + assert_eq!(decodes.get(), 0); + + let mismatch = provider + .call_operation_with_context_and_decoder( + &ExecutionContext::new(), + Target { + path: "/document".into(), + validator: Some(b"object-v1".to_vec()), + }, + InterfaceTarget { + reference: hidden.reference().as_str().into(), + validator: None, + }, + "hidden".into(), + |_| decode_failure(), + ) + .unwrap_err(); + assert_eq!(mismatch.kind(), CallErrorKind::InterfaceMismatch); + assert_eq!(decodes.get(), 0); + + let invalid_arguments = provider + .call_operation_with_context_and_decoder( + &ExecutionContext::new(), + Target { + path: "/document".into(), + validator: Some(b"object-v1".to_vec()), + }, + InterfaceTarget { + reference: provider.reader.reference().as_str().into(), + validator: Some(b"schema-v1".to_vec()), + }, + "add".into(), + |_| decode_failure(), + ) + .unwrap_err(); + assert_eq!(invalid_arguments.kind(), CallErrorKind::InvalidArguments); + assert_eq!(decodes.get(), 1); + assert_eq!(provider.calls.get(), 0); +} + #[test] fn immutable_interface_rejects_a_supplied_validator() { let (_, provider, _) = registry_and_provider();