Skip to main content

vti_common/trust_task/
router.rs

1//! [`TrustTaskRouter`] — Axum `Router` builder that enforces
2//! per-route Trust-Task header validation at attach time.
3//!
4//! ## Why a builder, not a macro
5//!
6//! Plan decision **D9**: explicit registration via a typed builder.
7//! A future reader sees the registered task right next to the handler;
8//! no procedural-macro indirection, no string-prefix matching, no
9//! version-family heuristics. Exact-match is the only correctness
10//! check.
11//!
12//! ## Usage
13//!
14//! ```ignore
15//! use vti_common::trust_task::{TrustTask, TrustTaskRouter};
16//! use axum::routing::{get, post};
17//!
18//! let install_claim = TrustTask::new("https://trusttasks.org/spec/vtc/install/claim/start/0.1")?;
19//!
20//! let router = TrustTaskRouter::new()
21//!     .route_with_task("/v1/install/claim/start", post(claim_start), install_claim)
22//!     .route_exempt("/health", get(health))
23//!     .into_router();
24//! ```
25
26use std::sync::Arc;
27
28use axum::Router;
29use axum::routing::MethodRouter;
30
31use super::TrustTask;
32
33/// Builder that wraps an Axum [`Router`] and enforces Trust-Task
34/// header validation on each registered route.
35pub struct TrustTaskRouter<S = ()> {
36    inner: Router<S>,
37}
38
39impl<S> Default for TrustTaskRouter<S>
40where
41    S: Clone + Send + Sync + 'static,
42{
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48impl<S> TrustTaskRouter<S>
49where
50    S: Clone + Send + Sync + 'static,
51{
52    /// Start a new empty builder.
53    pub fn new() -> Self {
54        Self {
55            inner: Router::new(),
56        }
57    }
58
59    /// Register a route whose incoming `Trust-Task` header must
60    /// **exact-match** `task`. Mismatch → 415
61    /// [`AppError::TrustTaskMismatch`]; missing → 400
62    /// [`AppError::TrustTaskMissing`].
63    ///
64    /// [`AppError::TrustTaskMismatch`]: crate::error::AppError::TrustTaskMismatch
65    /// [`AppError::TrustTaskMissing`]: crate::error::AppError::TrustTaskMissing
66    pub fn route_with_task(
67        mut self,
68        path: &str,
69        method_router: MethodRouter<S>,
70        task: TrustTask,
71    ) -> Self {
72        // `Arc` so each invocation of the cloned closure can cheaply
73        // hand out a reference to the same task value. The middleware
74        // closure must be `Clone + Send + Sync + 'static` per Axum's
75        // `from_fn` bound, which `Arc<TrustTask>` satisfies.
76        let task = Arc::new(task);
77        let layered = method_router.layer(axum::middleware::from_fn(move |request, next| {
78            let task = task.clone();
79            async move { super::extractor::validate_header(&task, request, next).await }
80        }));
81        self.inner = self.inner.route(path, layered);
82        self
83    }
84
85    /// Register a route that bypasses Trust-Task validation. Per spec
86    /// §16.2 this is intended **only for `/health`** — operators set
87    /// up monitoring against the health endpoint without having to
88    /// know about Trust-Task identifiers. Documented as the single
89    /// exempt route; if you find yourself reaching for this for a
90    /// second endpoint, stop and add a Trust Task instead.
91    pub fn route_exempt(mut self, path: &str, method_router: MethodRouter<S>) -> Self {
92        self.inner = self.inner.route(path, method_router);
93        self
94    }
95
96    /// Finalise and yield the underlying [`Router`] ready to be
97    /// merged or nested into a parent router.
98    pub fn into_router(self) -> Router<S> {
99        self.inner
100    }
101}
102
103impl<S> From<TrustTaskRouter<S>> for Router<S>
104where
105    S: Clone + Send + Sync + 'static,
106{
107    fn from(r: TrustTaskRouter<S>) -> Self {
108        r.into_router()
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use axum::body::Body;
116    use axum::http::{Request, StatusCode};
117    use axum::routing::{get, post};
118    use http_body_util::BodyExt;
119    use tower::ServiceExt;
120
121    async fn ok() -> &'static str {
122        "ok"
123    }
124
125    fn make_router() -> Router {
126        let claim = TrustTask::new("https://trusttasks.org/spec/vtc/install/claim/0.1").unwrap();
127        TrustTaskRouter::new()
128            .route_with_task("/v1/install/claim", post(ok), claim)
129            .route_exempt("/health", get(ok))
130            .into_router()
131    }
132
133    #[tokio::test]
134    async fn exact_match_succeeds() {
135        let app = make_router();
136        let resp = app
137            .oneshot(
138                Request::builder()
139                    .method("POST")
140                    .uri("/v1/install/claim")
141                    .header(
142                        HEADER_NAME,
143                        "https://trusttasks.org/spec/vtc/install/claim/0.1",
144                    )
145                    .body(Body::empty())
146                    .unwrap(),
147            )
148            .await
149            .unwrap();
150        assert_eq!(resp.status(), StatusCode::OK);
151    }
152
153    #[tokio::test]
154    async fn missing_header_returns_400() {
155        let app = make_router();
156        let resp = app
157            .oneshot(
158                Request::builder()
159                    .method("POST")
160                    .uri("/v1/install/claim")
161                    .body(Body::empty())
162                    .unwrap(),
163            )
164            .await
165            .unwrap();
166        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
167
168        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
169        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
170        assert_eq!(body["error"], "TrustTaskMissing");
171    }
172
173    #[tokio::test]
174    async fn mismatched_header_returns_415_with_expected_field() {
175        let app = make_router();
176        let resp = app
177            .oneshot(
178                Request::builder()
179                    .method("POST")
180                    .uri("/v1/install/claim")
181                    .header(
182                        HEADER_NAME,
183                        "https://trusttasks.org/spec/vtc/auth/login/0.1",
184                    )
185                    .body(Body::empty())
186                    .unwrap(),
187            )
188            .await
189            .unwrap();
190        assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
191
192        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
193        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
194        assert_eq!(body["error"], "TrustTaskMismatch");
195        assert_eq!(
196            body["expected"],
197            "https://trusttasks.org/spec/vtc/install/claim/0.1"
198        );
199        assert_eq!(
200            body["received"],
201            "https://trusttasks.org/spec/vtc/auth/login/0.1"
202        );
203    }
204
205    #[tokio::test]
206    async fn exact_match_is_byte_strict_not_prefix() {
207        let app = make_router();
208        // Same path family, different version — must not match.
209        let resp = app
210            .oneshot(
211                Request::builder()
212                    .method("POST")
213                    .uri("/v1/install/claim")
214                    .header(
215                        HEADER_NAME,
216                        "https://trusttasks.org/spec/vtc/install/claim/0.2",
217                    )
218                    .body(Body::empty())
219                    .unwrap(),
220            )
221            .await
222            .unwrap();
223        assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
224    }
225
226    #[tokio::test]
227    async fn health_is_exempt() {
228        let app = make_router();
229        // No header — should still 200.
230        let resp = app
231            .oneshot(
232                Request::builder()
233                    .method("GET")
234                    .uri("/health")
235                    .body(Body::empty())
236                    .unwrap(),
237            )
238            .await
239            .unwrap();
240        assert_eq!(resp.status(), StatusCode::OK);
241    }
242
243    use super::super::HEADER_NAME;
244}