Skip to main content

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}