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. 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}