#![allow(clippy::result_large_err)]
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde_json::Value;
use trust_tasks_https::status_for_code;
use trust_tasks_rs::{
ErrorPayload, ErrorResponse, RejectReason, TrustTask, TrustTaskCode, TypeUri,
};
use uuid::Uuid;
use crate::error::AppError;
pub(super) const TRANSPORT_TRUST_TASK: &str = "trust-task";
pub(crate) struct TrustTaskOutcome {
pub(crate) status: StatusCode,
pub(crate) body: Vec<u8>,
}
impl IntoResponse for TrustTaskOutcome {
fn into_response(self) -> Response {
(
self.status,
[(axum::http::header::CONTENT_TYPE, "application/json")],
self.body,
)
.into_response()
}
}
pub(super) fn parse_payload<T: serde::de::DeserializeOwned>(
doc: &TrustTask<Value>,
) -> Result<T, TrustTaskOutcome> {
serde_json::from_value::<T>(doc.payload.clone()).map_err(|e| {
reject_with(
doc,
RejectReason::MalformedRequest {
reason: format!("payload parse: {e}"),
},
)
})
}
pub(super) fn app_error_to_reject(doc: &TrustTask<Value>, err: AppError) -> TrustTaskOutcome {
let message = err.to_string();
let reason = match err {
AppError::Authentication(_) | AppError::Unauthorized(_) | AppError::Forbidden(_) => {
RejectReason::PermissionDenied { reason: message }
}
AppError::Validation(_) | AppError::TrustTaskMalformed(_) | AppError::InvalidCursor => {
RejectReason::MalformedRequest { reason: message }
}
AppError::NotFound(_) | AppError::Conflict(_) | AppError::Gone(_) => {
RejectReason::TaskFailed {
reason: message,
details: None,
}
}
AppError::Internal(cause) => {
tracing::error!(cause = %cause, "trust task failed with an internal error");
RejectReason::InternalError {
reason: OPAQUE_INTERNAL_ERROR.to_string(),
}
}
other => {
tracing::error!(cause = %other, "trust task failed with an internal error");
RejectReason::InternalError {
reason: OPAQUE_INTERNAL_ERROR.to_string(),
}
}
};
reject_with(doc, reason)
}
pub(super) const OPAQUE_INTERNAL_ERROR: &str =
"the consumer could not complete this task; the request itself was accepted";
const DETAILS_MAX_JCS_BYTES: usize = 4096;
const DETAILS_MAX_MEMBERS: usize = 16;
fn bound_details(details: Option<Value>) -> Option<Value> {
let details = details?;
let too_many_members = details
.as_object()
.is_some_and(|o| o.len() > DETAILS_MAX_MEMBERS);
let too_large = serde_json_canonicalizer::to_string(&details)
.map(|jcs| jcs.len() > DETAILS_MAX_JCS_BYTES)
.unwrap_or(true);
if too_many_members || too_large {
tracing::warn!(
members = details.as_object().map(serde_json::Map::len),
"error `details` exceeds the framework bound and was dropped; the code still went out"
);
return None;
}
Some(details)
}
pub(super) fn reject_with(doc: &TrustTask<Value>, reason: RejectReason) -> TrustTaskOutcome {
let reason = match reason {
RejectReason::TaskFailed { reason, details } => RejectReason::TaskFailed {
reason,
details: bound_details(details),
},
other => other,
};
let routed = doc.reject_with(format!("urn:uuid:{}", Uuid::new_v4()), reason);
error_response(routed)
}
pub(super) fn reject_with_code(
doc: &TrustTask<Value>,
code: TrustTaskCode,
message: impl Into<String>,
details: Option<Value>,
) -> TrustTaskOutcome {
let mut payload = ErrorPayload::new(code).with_message(message);
if let Some(d) = bound_details(details) {
payload = payload.with_details(d);
}
let routed = doc.reject_with(format!("urn:uuid:{}", Uuid::new_v4()), payload);
error_response(routed)
}
pub(super) fn success_response<R: serde::Serialize>(
doc: &TrustTask<Value>,
payload: R,
) -> TrustTaskOutcome {
let response_doc = doc.respond_with(format!("urn:uuid:{}", Uuid::new_v4()), payload);
let body = match serde_json::to_vec(&response_doc) {
Ok(b) => b,
Err(e) => {
tracing::error!(error = %e, "failed to serialise success response doc");
return reject_with(
doc,
RejectReason::InternalError {
reason: format!("response serialisation: {e}"),
},
);
}
};
TrustTaskOutcome {
status: StatusCode::OK,
body,
}
}
#[allow(dead_code)]
pub(super) fn not_implemented_yet(doc: TrustTask<Value>, reason: &str) -> TrustTaskOutcome {
let reject = RejectReason::TaskFailed {
reason: reason.to_string(),
details: None,
};
let routed = doc.reject_with(format!("urn:uuid:{}", Uuid::new_v4()), reject);
error_response(routed)
}
pub(super) fn method_not_found(doc: TrustTask<Value>, type_uri: &str) -> TrustTaskOutcome {
let reject = RejectReason::UnsupportedType {
type_uri: type_uri.to_string(),
};
let routed = doc.reject_with(format!("urn:uuid:{}", Uuid::new_v4()), reject);
error_response(routed)
}
pub(super) fn error_response(err_doc: ErrorResponse) -> TrustTaskOutcome {
let status = StatusCode::from_u16(status_for_code(&err_doc.payload.code))
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let body = serde_json::to_vec(&err_doc).unwrap_or_else(|_| Vec::new());
TrustTaskOutcome { status, body }
}
fn framework_error_type_uri() -> TypeUri {
"https://trusttasks.org/spec/trust-task-error/0.5"
.parse()
.expect("framework error Type URI parses")
}
pub(super) fn body_parse_error_response(reason: &str) -> TrustTaskOutcome {
let reject = RejectReason::MalformedRequest {
reason: format!("body did not parse as a Trust Task document: {reason}"),
};
let payload: ErrorPayload = reject.into();
let type_uri: TypeUri = framework_error_type_uri();
let err = ErrorResponse {
id: format!("urn:uuid:{}", Uuid::new_v4()),
thread_id: None,
parent_thread_id: None,
type_uri,
issuer: None,
recipient: None,
issued_at: Some(chrono::Utc::now()),
expires_at: None,
payload,
context: None,
ceremony: None,
proof: None,
extra: Default::default(),
};
error_response(err)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn doc() -> TrustTask<Value> {
let uri: TypeUri = vta_sdk::trust_tasks::TASK_WEBVH_DIDS_UPDATE_1_0
.parse()
.expect("update uri");
TrustTask::new("urn:uuid:test", uri, json!({}))
}
fn message_of(outcome: TrustTaskOutcome) -> String {
let doc: Value = serde_json::from_slice(&outcome.body).expect("error doc parses");
doc["payload"]["message"]
.as_str()
.expect("payload carries a message")
.to_string()
}
#[test]
fn an_extended_code_survives_to_the_wire() {
let code: TrustTaskCode = "provision/integration:contextRequired"
.parse()
.expect("a legal extended code");
let outcome = reject_with_code(
&doc(),
code,
"which context?",
Some(json!({ "candidates": ["a", "b"] })),
);
let parsed: Value = serde_json::from_slice(&outcome.body).expect("error doc");
assert_eq!(
parsed["payload"]["code"],
"provision/integration:contextRequired"
);
assert_eq!(parsed["payload"]["details"]["candidates"][1], "b");
}
#[test]
fn an_extended_code_rejection_bounds_its_details_too() {
let code: TrustTaskCode = "provision/integration:contextRequired"
.parse()
.expect("a legal extended code");
let huge = json!({ "explanation": "x".repeat(DETAILS_MAX_JCS_BYTES + 1) });
let outcome = reject_with_code(&doc(), code, "which context?", Some(huge));
let parsed: Value = serde_json::from_slice(&outcome.body).expect("error doc");
assert_eq!(
parsed["payload"]["code"], "provision/integration:contextRequired",
"an oversized annex must never cost the code: {parsed}"
);
assert!(
parsed["payload"]["details"].is_null(),
"the oversized details should have been dropped: {parsed}"
);
}
#[test]
fn an_oversized_details_is_dropped_but_the_code_survives() {
let huge = serde_json::json!({ "explanation": "x".repeat(DETAILS_MAX_JCS_BYTES + 1) });
let outcome = reject_with(
&doc(),
RejectReason::TaskFailed {
reason: "policy denied".into(),
details: Some(huge),
},
);
let parsed: Value = serde_json::from_slice(&outcome.body).expect("error doc");
assert_eq!(
parsed["payload"]["code"], "taskFailed",
"an oversized annex must never cost the code: {parsed}"
);
assert!(
parsed["payload"].get("details").is_none_or(Value::is_null),
"the oversized details must not go out: {parsed}"
);
}
#[test]
fn a_details_with_too_many_members_is_dropped() {
let mut wide = serde_json::Map::new();
for i in 0..=DETAILS_MAX_MEMBERS {
wide.insert(format!("k{i}"), serde_json::json!(1));
}
let outcome = reject_with(
&doc(),
RejectReason::TaskFailed {
reason: "policy denied".into(),
details: Some(Value::Object(wide)),
},
);
let parsed: Value = serde_json::from_slice(&outcome.body).expect("error doc");
assert_eq!(parsed["payload"]["code"], "taskFailed");
assert!(parsed["payload"].get("details").is_none_or(Value::is_null));
}
#[test]
fn a_small_details_still_goes_out() {
let outcome = reject_with(
&doc(),
RejectReason::TaskFailed {
reason: "policy denied".into(),
details: Some(serde_json::json!({ "reason": "auth:consent_required" })),
},
);
let parsed: Value = serde_json::from_slice(&outcome.body).expect("error doc");
assert_eq!(
parsed["payload"]["details"]["reason"], "auth:consent_required",
"{parsed}"
);
}
#[test]
fn an_internal_error_reveals_no_internal_state() {
let secret = "log entry has no update_keys";
let message = message_of(app_error_to_reject(
&doc(),
AppError::Internal(secret.into()),
));
assert!(
!message.contains(secret),
"the cause must reach the operator's log, never the wire: {message}"
);
assert!(
message.contains(OPAQUE_INTERNAL_ERROR),
"the producer still needs to be told the failure was not its \
document's doing: {message}"
);
assert!(
!message.contains("internal error: internal error"),
"{message}"
);
}
#[test]
fn the_catch_all_arm_is_opaque_too() {
let message = message_of(app_error_to_reject(
&doc(),
AppError::SecretStore("vault backend at 10.0.0.7 refused the token".into()),
));
assert!(!message.contains("10.0.0.7"), "{message}");
assert!(!message.contains("vault backend"), "{message}");
assert!(message.contains(OPAQUE_INTERNAL_ERROR), "{message}");
}
#[test]
fn unrouted_and_routed_errors_agree_on_the_type_uri() {
let routed = doc().reject_with(
"urn:uuid:routed",
RejectReason::InternalError {
reason: "probe".into(),
},
);
assert_eq!(
framework_error_type_uri(),
routed.type_uri,
"the unrouted body-parse error names a different document type than \
the framework stamps on a routed rejection"
);
}
#[test]
fn the_body_parse_error_goes_out_as_a_framework_error_document() {
let outcome = body_parse_error_response("not json");
let doc: Value = serde_json::from_slice(&outcome.body).expect("error doc parses");
assert_eq!(
doc["type"].as_str().expect("type present"),
framework_error_type_uri().to_string()
);
}
#[test]
fn a_not_found_keeps_the_cause_the_operator_needs() {
let message = message_of(app_error_to_reject(
&doc(),
AppError::NotFound("SCID QmNope not found".into()),
));
assert!(message.contains("SCID QmNope not found"), "{message}");
}
#[test]
fn a_gone_is_a_task_failure_not_an_internal_error() {
let message = message_of(app_error_to_reject(
&doc(),
AppError::Gone("carve-out has already been used".into()),
));
assert!(
message.contains("carve-out has already been used"),
"{message}"
);
assert!(
!message.starts_with("internal error"),
"a consumed resource must not report as a server fault: {message}"
);
}
}