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;
26pub mod router;
27
28pub use extractor::TrustTaskHeader;
29pub use router::TrustTaskRouter;
30
31use crate::error::AppError;
32
33/// Canonical HTTP header name carrying the Trust-Task identifier on
34/// REST requests. The workspace pins this literal so a future audit
35/// can grep for header consumers without ambiguity.
36pub const HEADER_NAME: &str = "Trust-Task";
37
38/// A validated Trust-Task identifier.
39///
40/// The workspace treats Trust-Task URLs as opaque — we don't enforce
41/// the full `https://trusttasks.org/{org}/{path}/{maj}.{min}` shape
42/// because the registry's canonical format is still evolving (spec
43/// §17 Q10). What we **do** enforce:
44///
45/// - non-empty
46/// - starts with `https://`
47/// - no CR/LF characters (prevents header-injection attacks via a
48/// round-tripped Trust-Task value)
49///
50/// Exact-match against a handler's registered task is the only
51/// correctness check at request time — see
52/// [`TrustTaskRouter::route_with_task`].
53#[derive(Debug, Clone, PartialEq, Eq, Hash)]
54pub struct TrustTask(String);
55
56impl TrustTask {
57 /// Parse and validate a Trust-Task identifier. Returns
58 /// [`AppError::TrustTaskMalformed`] for empty, non-HTTPS, or
59 /// control-character-containing values.
60 pub fn new(s: impl Into<String>) -> Result<Self, AppError> {
61 let s = s.into();
62 if s.is_empty() {
63 return Err(AppError::TrustTaskMalformed("<empty>".into()));
64 }
65 if !s.starts_with("https://") {
66 return Err(AppError::TrustTaskMalformed(s));
67 }
68 if s.chars().any(|c| c == '\r' || c == '\n' || c == '\0') {
69 return Err(AppError::TrustTaskMalformed(s));
70 }
71 Ok(Self(s))
72 }
73
74 /// The validated identifier as a `&str`.
75 pub fn as_str(&self) -> &str {
76 &self.0
77 }
78}
79
80impl AsRef<str> for TrustTask {
81 fn as_ref(&self) -> &str {
82 &self.0
83 }
84}
85
86impl std::fmt::Display for TrustTask {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 f.write_str(&self.0)
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn accepts_well_formed_https_url() {
98 let t = TrustTask::new("https://trusttasks.org/openvtc/vtc/install/claim/1.0").unwrap();
99 assert_eq!(
100 t.as_str(),
101 "https://trusttasks.org/openvtc/vtc/install/claim/1.0"
102 );
103 }
104
105 #[test]
106 fn rejects_empty_string() {
107 let err = TrustTask::new("").expect_err("empty");
108 assert!(matches!(err, AppError::TrustTaskMalformed(_)));
109 }
110
111 #[test]
112 fn rejects_non_https() {
113 for s in [
114 "http://trusttasks.org/openvtc/vtc/install/claim/1.0",
115 "urn:openvtc:vtc:install:claim:1.0",
116 "trusttasks.org/openvtc/vtc/install/claim/1.0",
117 ] {
118 let err = TrustTask::new(s).expect_err("non-https");
119 assert!(
120 matches!(err, AppError::TrustTaskMalformed(_)),
121 "{s} should be rejected"
122 );
123 }
124 }
125
126 #[test]
127 fn rejects_header_injection_attempts() {
128 for s in [
129 "https://trusttasks.org/x\r\nInjected: yes",
130 "https://trusttasks.org/x\nInjected: yes",
131 "https://trusttasks.org/x\0",
132 ] {
133 let err = TrustTask::new(s).expect_err("control chars");
134 assert!(
135 matches!(err, AppError::TrustTaskMalformed(_)),
136 "{s:?} should be rejected"
137 );
138 }
139 }
140
141 #[test]
142 fn display_returns_full_url() {
143 let t = TrustTask::new("https://trusttasks.org/x/1.0").unwrap();
144 assert_eq!(format!("{t}"), "https://trusttasks.org/x/1.0");
145 }
146}