Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/tracedecay-cli/src/tool_command/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1646,7 +1646,7 @@ fn application_surface_rejects_invalid_output_formats() {
.expect_err("format outside the schema must fail");
assert!(matches!(
error,
ApplicationSurfaceAdapterError::InvalidSurfaceRequest
ApplicationSurfaceAdapterError::InvalidSurfaceRequest { .. }
));
}
}
134 changes: 80 additions & 54 deletions crates/tracedecay-daemon-protocol/src/application_surface.rs

Large diffs are not rendered by default.

56 changes: 38 additions & 18 deletions crates/tracedecay-daemon-protocol/src/application_surface/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,36 @@ pub(super) fn parse_git_read_surface_request(
operation: ApplicationSurfaceOperation,
value: Value,
) -> Result<GitReadSurfaceRequest, ApplicationSurfaceAdapterError> {
let object = value
.as_object()
.ok_or(ApplicationSurfaceAdapterError::InvalidSurfaceRequest)?;
let object = value.as_object().ok_or_else(|| {
ApplicationSurfaceAdapterError::invalid_request("request must be an object")
})?;
let bounded_u64 = |name: &str, default: u64, maximum: u64| match object.get(name) {
None => Ok(default),
Some(value) => value
.as_u64()
.filter(|value| (1..=maximum).contains(value))
.ok_or(ApplicationSurfaceAdapterError::InvalidSurfaceRequest),
.ok_or_else(|| {
ApplicationSurfaceAdapterError::invalid_request(format!(
"`{name}` must be an integer between 1 and {maximum}"
))
}),
};
let boolean = |name: &str, default: bool| match object.get(name) {
None => Ok(default),
Some(value) => value
.as_bool()
.ok_or(ApplicationSurfaceAdapterError::InvalidSurfaceRequest),
Some(value) => value.as_bool().ok_or_else(|| {
ApplicationSurfaceAdapterError::invalid_request(format!("`{name}` must be a boolean"))
}),
};
let optional_string = |name: &str| match object.get(name) {
None => Ok(None),
Some(value) => value
.as_str()
.map(|value| Some(value.to_owned()))
.ok_or(ApplicationSurfaceAdapterError::InvalidSurfaceRequest),
.ok_or_else(|| {
ApplicationSurfaceAdapterError::invalid_request(format!(
"`{name}` must be a string"
))
}),
};
let max_entries = bounded_u64(
"max_entries",
Expand All @@ -53,13 +61,17 @@ pub(super) fn parse_git_read_surface_request(
.and_then(Value::as_str)
.filter(|value| !value.is_empty() && value.trim() == *value)
.map(str::to_owned)
.ok_or(ApplicationSurfaceAdapterError::InvalidSurfaceRequest)
.ok_or_else(|| {
ApplicationSurfaceAdapterError::invalid_request(format!(
"`{name}` is required and must be a non-empty trimmed string"
))
})
};
let scope_name = match object.get("scope") {
None => "working_tree",
Some(value) => value
.as_str()
.ok_or(ApplicationSurfaceAdapterError::InvalidSurfaceRequest)?,
Some(value) => value.as_str().ok_or_else(|| {
ApplicationSurfaceAdapterError::invalid_request("`scope` must be a string")
})?,
};
let scope = |allow_commit_range: bool| match scope_name {
"working_tree" if !object.contains_key("base") && !object.contains_key("head") => {
Expand All @@ -70,11 +82,13 @@ pub(super) fn parse_git_read_surface_request(
}
"commit_range" if allow_commit_range => Ok(GitDiffScopeV1::CommitRange {
base: GitOidV1::new(string("base")?)
.map_err(|_| ApplicationSurfaceAdapterError::InvalidSurfaceRequest)?,
.map_err(ApplicationSurfaceAdapterError::invalid_request)?,
head: GitOidV1::new(string("head")?)
.map_err(|_| ApplicationSurfaceAdapterError::InvalidSurfaceRequest)?,
.map_err(ApplicationSurfaceAdapterError::invalid_request)?,
}),
_ => Err(ApplicationSurfaceAdapterError::InvalidSurfaceRequest),
_ => Err(ApplicationSurfaceAdapterError::invalid_request(format!(
"`scope` `{scope_name}` is not one of working_tree, staged, or commit_range for this operation, or carries base/head outside commit_range"
))),
};
let request = match operation {
ApplicationSurfaceOperation::GitStatus => GitReadRequestV1::Status,
Expand All @@ -95,7 +109,11 @@ pub(super) fn parse_git_read_surface_request(
scope: scope(false)?,
daemon_binding: None,
},
_ => return Err(ApplicationSurfaceAdapterError::InvalidSurfaceRequest),
_ => {
return Err(ApplicationSurfaceAdapterError::invalid_request(
"operation is not a Git read",
));
}
};
let allowed = match operation {
ApplicationSurfaceOperation::GitStatus => &["max_entries", "max_bytes"][..],
Expand All @@ -116,8 +134,10 @@ pub(super) fn parse_git_read_surface_request(
ApplicationSurfaceOperation::GitHunks => &["scope", "max_entries", "max_bytes"][..],
_ => &[],
};
if object.keys().any(|key| !allowed.contains(&key.as_str())) {
return Err(ApplicationSurfaceAdapterError::InvalidSurfaceRequest);
if let Some(unknown) = object.keys().find(|key| !allowed.contains(&key.as_str())) {
return Err(ApplicationSurfaceAdapterError::invalid_request(format!(
"unknown field `{unknown}`"
)));
}
Ok(GitReadSurfaceRequest {
request,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,11 @@ pub(super) fn configuration_binding_has_schema(
pub(super) fn configuration_invocation_payload(
request: &tracedecay_contracts::ConfigurationWireRequestV1,
) -> Result<Value, ApplicationSurfaceAdapterError> {
let mut wire = serde_json::to_value(request)
.map_err(|_| ApplicationSurfaceAdapterError::InvalidSurfaceRequest)?;
wire.get_mut("request")
.map(Value::take)
.ok_or(ApplicationSurfaceAdapterError::InvalidSurfaceRequest)
let mut wire =
serde_json::to_value(request).map_err(ApplicationSurfaceAdapterError::invalid_request)?;
wire.get_mut("request").map(Value::take).ok_or_else(|| {
ApplicationSurfaceAdapterError::invalid_request("configuration wire request has no body")
})
}

fn payload_decodes<T: DeserializeOwned>(payload: Option<&Value>) -> bool {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ pub fn application_surface_dispatch_input_with_controls(
requested_format: RequestedOutputFormat,
) -> Result<DispatchInput<ApplicationSurfaceRequest>, ApplicationSurfaceAdapterError> {
if !request.matches(operation) {
return Err(ApplicationSurfaceAdapterError::InvalidSurfaceRequest);
return Err(ApplicationSurfaceAdapterError::invalid_request(
"request body does not belong to the addressed operation",
));
}
Ok(DispatchInput {
request_id,
Expand Down Expand Up @@ -106,7 +108,7 @@ pub async fn execute_application_surface(
.ok_or(ApplicationSurfaceAdapterError::UnknownOrNotAuthorized)?;
(
i64::try_from(capability.deadline().maximum_millis())
.map_err(|_| ApplicationSurfaceAdapterError::InvalidSurfaceRequest)?
.map_err(ApplicationSurfaceAdapterError::invalid_request)?
.saturating_mul(1_000),
capability.cancellation().clone(),
capability.terminal_states().clone(),
Expand Down Expand Up @@ -143,7 +145,7 @@ pub async fn execute_application_surface(
ApplicationSurfaceRequest::Feedback(request),
) => Some(
serde_json::to_value(request)
.map_err(|_| ApplicationSurfaceAdapterError::InvalidSurfaceRequest)?,
.map_err(ApplicationSurfaceAdapterError::invalid_request)?,
),
_ => None,
};
Expand Down Expand Up @@ -393,7 +395,11 @@ pub async fn execute_application_surface(
});
let request = request
.with_resolved_scope(resolved_scope)
.map_err(|_| ApplicationSurfaceAdapterError::InvalidSurfaceRequest)?
.map_err(|problem| {
ApplicationSurfaceAdapterError::invalid_request(format!(
"resolved scope was refused: {problem:?}"
))
})?
.with_delivery_route(delivery_route);
let Some(executor) = executor else {
return Ok(ApplicationSurfaceInvocationResult {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ pub(super) fn surface_rejection_metadata(
FeedbackArgumentRejectionClassV1::InvalidShape,
FeedbackOutcomeV1::Rejected,
)),
ApplicationSurfaceAdapterError::InvalidSurfaceRequest => Some((
ApplicationSurfaceAdapterError::InvalidSurfaceRequest { .. } => Some((
FeedbackRejectedArgumentV1::RequestBody,
FeedbackArgumentRejectionClassV1::InvalidShape,
FeedbackOutcomeV1::Rejected,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ pub(super) fn application_contract_error_response(error: ApplicationContractErro
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}

fn invalid_surface_request_problem(message: String) -> ApplicationProblem {
ApplicationProblem::InvalidRequest {
diagnostic: SafeDiagnostic {
code: "application.surface.invalid_request".to_owned(),
message,
},
retry: RetryDirective::Never,
legal_actions: Vec::new(),
}
}

pub(super) fn http_adapter_problem(
contract: ResultContractRef,
request_id: RequestId,
Expand All @@ -62,16 +73,11 @@ pub(super) fn http_adapter_problem(
ApplicationSurfaceAdapterError::UnknownOrNotAuthorized => {
ApplicationProblem::not_found_or_not_authorized(RetryDirective::Never)
}
ApplicationSurfaceAdapterError::InvalidRequestHandle
| ApplicationSurfaceAdapterError::InvalidSurfaceRequest => {
ApplicationProblem::InvalidRequest {
diagnostic: SafeDiagnostic {
code: "application.surface.invalid_request".to_owned(),
message: "The application request is invalid".to_owned(),
},
retry: RetryDirective::Never,
legal_actions: Vec::new(),
}
ApplicationSurfaceAdapterError::InvalidRequestHandle => {
invalid_surface_request_problem("The application request is invalid".to_owned())
}
ApplicationSurfaceAdapterError::InvalidSurfaceRequest { detail } => {
invalid_surface_request_problem(format!("The application request is invalid: {detail}"))
}
// Catalog composition is derived from `const` application specs, so
// these failures are deterministic for the lifetime of the process.
Expand Down Expand Up @@ -139,7 +145,7 @@ pub fn mcp_project_open_reset_refusal(

pub(crate) fn current_micros() -> Result<UtcMicros, ApplicationSurfaceAdapterError> {
tracedecay_contracts::clock::try_now_micros()
.map_err(|_| ApplicationSurfaceAdapterError::InvalidSurfaceRequest)
.map_err(ApplicationSurfaceAdapterError::invalid_request)
}

pub(super) fn invocation_problem(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ pub(super) fn outcome_value(
payload
.map(serde_json::to_value)
.transpose()
.map_err(|_| ApplicationSurfaceAdapterError::InvalidSurfaceRequest)
.map_err(ApplicationSurfaceAdapterError::invalid_request)
}

Ok(match outcome {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -603,7 +603,7 @@ fn git_read_parser_rejects_values_outside_the_catalog_schema() {
] {
assert!(matches!(
parse_application_surface_request(operation, args),
Err(ApplicationSurfaceAdapterError::InvalidSurfaceRequest)
Err(ApplicationSurfaceAdapterError::InvalidSurfaceRequest { .. })
));
}
}
Expand Down Expand Up @@ -853,7 +853,7 @@ fn context_scout_controls_and_claims_preserve_the_exact_address() {
ApplicationSurfaceOperation::ContextScoutPause,
claim_body,
),
Err(ApplicationSurfaceAdapterError::InvalidSurfaceRequest)
Err(ApplicationSurfaceAdapterError::InvalidSurfaceRequest { .. })
));

let work = serde_json::json!({
Expand Down Expand Up @@ -1383,14 +1383,14 @@ fn feedback_cycle_projections_require_the_canonical_handle() {

assert!(matches!(
parse_application_surface_request(operation, serde_json::json!({"node_id": "node"})),
Err(ApplicationSurfaceAdapterError::InvalidSurfaceRequest)
Err(ApplicationSurfaceAdapterError::InvalidSurfaceRequest { .. })
));
assert!(matches!(
parse_application_surface_request(
operation,
serde_json::json!({"files": ["src/lib.rs"]})
),
Err(ApplicationSurfaceAdapterError::InvalidSurfaceRequest)
Err(ApplicationSurfaceAdapterError::InvalidSurfaceRequest { .. })
));
assert!(matches!(
parse_application_surface_request(
Expand Down Expand Up @@ -1423,7 +1423,7 @@ fn explicit_feedback_cycle_accepts_only_a_document_uri() {
"request_handle": "rh.client.selected"
}),
),
Err(ApplicationSurfaceAdapterError::InvalidSurfaceRequest)
Err(ApplicationSurfaceAdapterError::InvalidSurfaceRequest { .. })
));
}

Expand All @@ -1443,7 +1443,7 @@ fn callable_code_page_is_transport_owned() {
);
assert!(matches!(
rejected,
Err(ApplicationSurfaceAdapterError::InvalidSurfaceRequest)
Err(ApplicationSurfaceAdapterError::InvalidSurfaceRequest { .. })
));

let rejected = parse_application_surface_request(
Expand All @@ -1460,7 +1460,7 @@ fn callable_code_page_is_transport_owned() {
);
assert!(matches!(
rejected,
Err(ApplicationSurfaceAdapterError::InvalidSurfaceRequest)
Err(ApplicationSurfaceAdapterError::InvalidSurfaceRequest { .. })
));
}

Expand Down Expand Up @@ -2128,7 +2128,7 @@ async fn sse_resume_after_memory_restart_returns_canonical_expired_problem() {
#[test]
fn surface_rejection_metadata_distinguishes_invalid_input_from_authorization() {
assert_eq!(
surface_rejection_metadata(&ApplicationSurfaceAdapterError::InvalidSurfaceRequest),
surface_rejection_metadata(&ApplicationSurfaceAdapterError::invalid_request("detail")),
Some((
FeedbackRejectedArgumentV1::RequestBody,
FeedbackArgumentRejectionClassV1::InvalidShape,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ fn application_surface_dispatch_error(
AdapterError::UnknownOrNotAuthorized => {
("application_surface_not_found_or_not_authorized", false)
}
AdapterError::InvalidRequestHandle | AdapterError::InvalidSurfaceRequest => {
AdapterError::InvalidRequestHandle | AdapterError::InvalidSurfaceRequest { .. } => {
("application_surface_invalid_request", false)
}
AdapterError::Catalog(_)
Expand Down
Loading