feat(host): decode operation arguments in dispatch boundary

This commit is contained in:
2026-09-30 08:53:43 +09:00
parent 96db3dd6a8
commit fdfaa3a685
2 changed files with 198 additions and 28 deletions
+115 -28
View File
@@ -535,11 +535,36 @@ impl<T: WipObject> PublishedObject<T> {
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<E>(
&self,
context: &ExecutionContext,
target: Target,
interface: InterfaceTarget,
operation: String,
decode_arguments: impl FnOnce(&InterfaceDescriptor) -> Result<BTreeMap<String, Value>, 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<T: WipObject> PublishedObject<T> {
);
}
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<T: WipObject> PublishedObject<T> {
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<T: WipObject> PublishedObject<T> {
.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<T: WipObject> PublishedObject<T> {
);
}
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<T: WipObject> PublishedObject<T> {
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<E>(
&self,
context: &ExecutionContext,
target: Target,
interface: InterfaceTarget,
operation: String,
decode_arguments: impl FnOnce(&InterfaceDescriptor) -> Result<BTreeMap<String, Value>, E>,
) -> Result<CallResult, CallError> {
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.
+83
View File
@@ -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::<BTreeMap<String, Value>, _>(())
};
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();