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