vti_common/trust_task/mod.rs
1//! Trust-Task primitive — every wire op in the workspace binds to a
2//! versioned Trust Task identifier published on
3//! [`trusttasks.org`](https://trusttasks.org). See spec §3-L and §16 of
4//! `docs/05-design-notes/vtc-mvp.md` for the full design rationale.
5//!
6//! This module ships the workspace-wide foundation:
7//!
8//! - [`TrustTask`] — a validated newtype around the Trust-Task
9//! identifier (a URL the workspace treats as opaque).
10//! - [`HEADER_NAME`] — the canonical HTTP header name (`Trust-Task`).
11//! - [`extractor::TrustTaskHeader`] — Axum extractor for handlers that
12//! want to read the header value directly.
13//! - [`router::TrustTaskRouter`] — builder that wraps Axum `Router`
14//! and enforces exact-match Trust-Task header validation **at route
15//! attach time** (no string-prefix tricks, no version-family
16//! matching — see spec §9.4).
17//!
18//! ## Design call
19//!
20//! The router builder is explicit and macro-free per the M0.1.1 plan
21//! decision **D9**. A future-reader sees the registered task right
22//! next to the handler in source, and `cargo doc` surfaces it on the
23//! route without any procedural-macro indirection.
24
25pub mod extractor;
26#[cfg(feature = "openapi")]
27pub mod openapi;
28pub mod router;
29
30pub use extractor::TrustTaskHeader;
31#[cfg(feature = "openapi")]
32pub use openapi::{task_layer, task_routes};
33pub use router::TrustTaskRouter;
34
35use crate::error::AppError;
36
37/// Canonical HTTP header name carrying the Trust-Task identifier on
38/// REST requests. The workspace pins this literal so a future audit
39/// can grep for header consumers without ambiguity.
40pub const HEADER_NAME: &str = "Trust-Task";
41
42/// A validated Trust-Task identifier.
43///
44/// The workspace treats Trust-Task URLs as opaque — we don't enforce
45/// the full `https://trusttasks.org/{org}/{path}/{maj}.{min}` shape
46/// because the registry's canonical format is still evolving (spec
47/// §17 Q10). What we **do** enforce:
48///
49/// - non-empty
50/// - starts with `https://`
51/// - no CR/LF characters (prevents header-injection attacks via a
52/// round-tripped Trust-Task value)
53///
54/// Exact-match against a handler's registered task is the only
55/// correctness check at request time — see
56/// [`TrustTaskRouter::route_with_task`].
57#[derive(Debug, Clone, PartialEq, Eq, Hash)]
58pub struct TrustTask(String);
59
60impl TrustTask {
61 /// Parse and validate a Trust-Task identifier. Returns
62 /// [`AppError::TrustTaskMalformed`] for empty, non-HTTPS, or
63 /// control-character-containing values.
64 pub fn new(s: impl Into<String>) -> Result<Self, AppError> {
65 let s = s.into();
66 if s.is_empty() {
67 return Err(AppError::TrustTaskMalformed("<empty>".into()));
68 }
69 if !s.starts_with("https://") {
70 return Err(AppError::TrustTaskMalformed(s));
71 }
72 if s.chars().any(|c| c == '\r' || c == '\n' || c == '\0') {
73 return Err(AppError::TrustTaskMalformed(s));
74 }
75 Ok(Self(s))
76 }
77
78 /// The validated identifier as a `&str`.
79 pub fn as_str(&self) -> &str {
80 &self.0
81 }
82}
83
84impl AsRef<str> for TrustTask {
85 fn as_ref(&self) -> &str {
86 &self.0
87 }
88}
89
90impl std::fmt::Display for TrustTask {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 f.write_str(&self.0)
93 }
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99
100 #[test]
101 fn accepts_well_formed_https_url() {
102 let t = TrustTask::new("https://trusttasks.org/openvtc/vtc/install/claim/1.0").unwrap();
103 assert_eq!(
104 t.as_str(),
105 "https://trusttasks.org/openvtc/vtc/install/claim/1.0"
106 );
107 }
108
109 #[test]
110 fn rejects_empty_string() {
111 let err = TrustTask::new("").expect_err("empty");
112 assert!(matches!(err, AppError::TrustTaskMalformed(_)));
113 }
114
115 #[test]
116 fn rejects_non_https() {
117 for s in [
118 "http://trusttasks.org/openvtc/vtc/install/claim/1.0",
119 "urn:openvtc:vtc:install:claim:1.0",
120 "trusttasks.org/openvtc/vtc/install/claim/1.0",
121 ] {
122 let err = TrustTask::new(s).expect_err("non-https");
123 assert!(
124 matches!(err, AppError::TrustTaskMalformed(_)),
125 "{s} should be rejected"
126 );
127 }
128 }
129
130 #[test]
131 fn rejects_header_injection_attempts() {
132 for s in [
133 "https://trusttasks.org/x\r\nInjected: yes",
134 "https://trusttasks.org/x\nInjected: yes",
135 "https://trusttasks.org/x\0",
136 ] {
137 let err = TrustTask::new(s).expect_err("control chars");
138 assert!(
139 matches!(err, AppError::TrustTaskMalformed(_)),
140 "{s:?} should be rejected"
141 );
142 }
143 }
144
145 #[test]
146 fn display_returns_full_url() {
147 let t = TrustTask::new("https://trusttasks.org/x/1.0").unwrap();
148 assert_eq!(format!("{t}"), "https://trusttasks.org/x/1.0");
149 }
150}