cratestack_axum/schema_fingerprint.rs
1//! Drift-detection middleware for the `x-cratestack-schema-sha` header
2//! (issue #178). Every generated client stamps its own `SCHEMA_SHA256`
3//! constant (`SHA-256` of the `.cstack` source it was compiled against)
4//! onto every request; this middleware compares that value against the
5//! server's own constant and `tracing::warn!`s on a mismatch — nothing
6//! more. It never rejects a request: a missing header (a client not yet
7//! regenerated) is not itself a warning, and a present-but-different value
8//! only ever produces a log line, never an error response. Applies to
9//! every transport (`rest`/`rpc` alike), since nothing about schema drift
10//! is transport-specific.
11//!
12//! Deliberately a plain [`axum::middleware::from_fn_with_state`] function,
13//! not a hand-rolled `tower::Layer`/`Service` pair like
14//! [`crate::idempotency::IdempotencyLayer`] or
15//! [`crate::ratelimit::RateLimitLayer`] — those exist because they need
16//! async state lookups (a store) and per-request state threading that
17//! justifies the extra structure. This check is "compare a header to a
18//! known string and maybe log," which `from_fn_with_state` covers in a
19//! fraction of the code with no loss of correctness.
20
21use axum::extract::{Request, State};
22use axum::http::HeaderName;
23use axum::middleware::Next;
24use axum::response::Response;
25
26/// `x-cratestack-schema-sha` — lowercase per HTTP/2 header-name convention;
27/// `axum`/`http` normalize header name lookups case-insensitively either
28/// way, but the constant form is what generated clients literally send.
29pub const SCHEMA_SHA_HEADER: HeaderName = HeaderName::from_static("x-cratestack-schema-sha");
30
31/// Wraps a router with the drift-detection check. `expected_sha` is the
32/// server's own `SCHEMA_SHA256` constant (`'static`, baked in at macro-
33/// expansion time — see `crates/cratestack-macros/src/include/server.rs`).
34pub async fn warn_on_schema_mismatch(
35 State(expected_sha): State<&'static str>,
36 request: Request,
37 next: Next,
38) -> Response {
39 if let Some(received) = request
40 .headers()
41 .get(&SCHEMA_SHA_HEADER)
42 .and_then(|value| value.to_str().ok())
43 && received != expected_sha
44 {
45 tracing::warn!(
46 expected_schema_sha = expected_sha,
47 received_schema_sha = received,
48 "client and server schema SHA-256 differ — one side may be compiled against a \
49 stale copy of the `.cstack` schema"
50 );
51 }
52 next.run(request).await
53}
54
55#[cfg(test)]
56mod tests {
57 use axum::Router;
58 use axum::body::Body;
59 use axum::http::{Request as HttpRequest, StatusCode};
60 use axum::middleware::from_fn_with_state;
61 use axum::routing::get;
62 use tower::ServiceExt;
63
64 use super::{SCHEMA_SHA_HEADER, warn_on_schema_mismatch};
65
66 fn app() -> Router {
67 Router::new()
68 .route("/", get(|| async { "ok" }))
69 .layer(from_fn_with_state("expected-sha", warn_on_schema_mismatch))
70 }
71
72 #[tokio::test]
73 async fn matching_header_passes_through_with_200() {
74 let response = app()
75 .oneshot(
76 HttpRequest::builder()
77 .uri("/")
78 .header(SCHEMA_SHA_HEADER, "expected-sha")
79 .body(Body::empty())
80 .unwrap(),
81 )
82 .await
83 .unwrap();
84 assert_eq!(response.status(), StatusCode::OK);
85 }
86
87 #[tokio::test]
88 async fn mismatched_header_still_passes_through_with_200() {
89 // The whole point: a mismatch warns, it never rejects.
90 let response = app()
91 .oneshot(
92 HttpRequest::builder()
93 .uri("/")
94 .header(SCHEMA_SHA_HEADER, "different-sha")
95 .body(Body::empty())
96 .unwrap(),
97 )
98 .await
99 .unwrap();
100 assert_eq!(response.status(), StatusCode::OK);
101 }
102
103 #[tokio::test]
104 async fn missing_header_passes_through_with_200() {
105 let response = app()
106 .oneshot(HttpRequest::builder().uri("/").body(Body::empty()).unwrap())
107 .await
108 .unwrap();
109 assert_eq!(response.status(), StatusCode::OK);
110 }
111}