Skip to main content

tensor_wasm_api/
config.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! Top-level application config knobs that don't fit naturally into any
5//! single middleware-scoped config struct.
6//!
7//! The historical `tensor-wasm-api` pattern co-locates configuration with
8//! the middleware it parametrises (see
9//! [`crate::middleware::AuthConfig`], [`crate::middleware::TenantConfig`],
10//! [`crate::rate_limit::RateLimitConfig`], [`crate::audit::AuditConfig`]).
11//! That works fine for knobs that flow into exactly one layer, but breaks
12//! down for cross-cutting concerns whose consumer spans more than one
13//! route — e.g. the snapshot HMAC key, which is parsed at server startup
14//! and consumed by the `/snapshot/save` and `/snapshot/restore` route
15//! handlers.
16//!
17//! This module hosts that small set of cross-cutting knobs as
18//! [`AppConfig`], keeping the env-var schema in one place.
19//!
20//! ## Env-var schema
21//!
22//! **Both variables below are LIVE (M5).** They are parsed and validated
23//! at startup by [`AppConfig::from_env`], which
24//! [`crate::server::build_router`] now calls and threads onto the shared
25//! [`crate::routes::AppState`] (via `AppState::with_app_config`). The
26//! `/snapshot/save` and `/snapshot/restore` routes consume the resulting
27//! [`AppConfig`] to sign and verify snapshot blobs.
28//!
29//! | Variable                                       | Format         | Default | Meaning                                                                                                            |
30//! |------------------------------------------------|----------------|---------|--------------------------------------------------------------------------------------------------------------------|
31//! | `TENSOR_WASM_API_SNAPSHOT_HMAC_KEY`            | hex (64 chars) | unset   | `/snapshot/save` HMAC-SHA256-signs the returned blob and `/snapshot/restore` verifies it. Unset ⇒ both routes return `503 snapshot_signing_not_configured`. Malformed values are a hard startup error. |
32//! | `TENSOR_WASM_API_SNAPSHOT_REQUIRE_SIGNATURE`   | `true`/`false` | `false` | Strict-restore posture: refuse unsigned (v2) snapshots. `/snapshot/restore` enforces signature verification unconditionally as its hardened default; this knob is the operator surface for that posture. |
33//!
34//! ## Status
35//!
36//! The `/snapshot/save` and `/snapshot/restore` HTTP routes are wired into
37//! [`crate::server::build_router`], which reads
38//! [`AppConfig::from_env`] at startup and installs it on the
39//! [`crate::routes::AppState`]. The key is therefore a **live knob** at
40//! runtime (closes finding M5): with it set, save returns a signed blob
41//! and restore verifies the HMAC; with it unset, both routes report
42//! `503 snapshot_signing_not_configured`. A malformed key / toggle is a
43//! hard startup failure (`build_router` panics) — the gateway refuses to
44//! come up serving snapshot routes under a misconfigured signing key
45//! rather than silently downgrading restore integrity.
46
47use std::fmt;
48
49/// Environment variable carrying the hex-encoded HMAC-SHA256 key used for
50/// signing and verifying snapshot blobs.
51///
52/// **LIVE (M5).** Consumed by the `/snapshot/save` and `/snapshot/restore`
53/// routes: [`AppConfig::from_env`] is read by
54/// [`crate::server::build_router`] and threaded onto the
55/// [`crate::routes::AppState`]. With the key set, save returns an
56/// HMAC-SHA256-signed blob and restore verifies the signature; with it
57/// unset both routes return `503 snapshot_signing_not_configured`.
58///
59/// 64 lowercase or uppercase hex characters (32 bytes). Any other length
60/// or non-hex character is a hard parse error from
61/// [`AppConfig::from_env`] (and a startup panic from `build_router`).
62pub const ENV_SNAPSHOT_HMAC_KEY: &str = "TENSOR_WASM_API_SNAPSHOT_HMAC_KEY";
63
64/// Environment variable selecting the strict-restore posture: when set to
65/// `true` (case-insensitive) snapshot restore refuses v2 (unsigned)
66/// snapshots. Defaults to `false`.
67///
68/// **LIVE (M5).** Carried on the [`crate::routes::AppState`] alongside
69/// [`ENV_SNAPSHOT_HMAC_KEY`]. The `/snapshot/restore` route enforces
70/// signature verification unconditionally as its hardened default (the
71/// gateway only ever writes signed blobs), so this knob is the documented
72/// operator surface for that posture; an operator who sets it gets a
73/// confirming log line on restore. A one-shot startup warning still fires
74/// when it is `true` with no key set.
75pub const ENV_SNAPSHOT_REQUIRE_SIGNATURE: &str = "TENSOR_WASM_API_SNAPSHOT_REQUIRE_SIGNATURE";
76
77/// Byte length of the HMAC-SHA256 key. Fixed by the algorithm.
78pub const SNAPSHOT_HMAC_KEY_LEN: usize = 32;
79
80/// Top-level configuration knobs read from the process environment at
81/// server startup.
82///
83/// Today this only carries snapshot-related knobs that don't have a
84/// natural home in any of the middleware-scoped config structs. As the
85/// API surface grows, additional cross-cutting knobs land here so the
86/// env-var schema stays discoverable.
87///
88/// The `snapshot_hmac_key` field is a raw `[u8; 32]` rather than a
89/// `tensor_wasm_snapshot::HmacKey` (or similar) so this crate does NOT
90/// take a hard dependency on `tensor-wasm-snapshot`. When the snapshot
91/// route handlers land they can hand the bytes to
92/// `SnapshotWriter::with_hmac_sha256_key(key)` /
93/// `SnapshotReader::with_hmac_sha256_key(key)` directly.
94///
95/// `Debug` is implemented manually so that `snapshot_hmac_key` renders as
96/// a redacted placeholder. A derived `Debug` would print all 32 key bytes
97/// any time a caller writes `tracing::debug!(?cfg)` or similar.
98#[derive(Clone, Default, PartialEq, Eq)]
99pub struct AppConfig {
100    /// Optional HMAC-SHA256 signing key for snapshot save/restore.
101    ///
102    /// `None` means snapshots are written in the v2 (unsigned) format and
103    /// restore accepts both signed and unsigned blobs. `Some(key)` means
104    /// snapshots are written in the signed v3 format and restore verifies
105    /// any signature present (rejecting tampered blobs).
106    ///
107    /// See [`Self::snapshot_require_signature`] for the strict-restore
108    /// knob that additionally rejects unsigned v2 blobs.
109    pub snapshot_hmac_key: Option<[u8; SNAPSHOT_HMAC_KEY_LEN]>,
110
111    /// When `true`, snapshot restore refuses v2 (unsigned) blobs even if
112    /// a key is configured. Defaults to `false` so existing v2 archives
113    /// keep working through the migration window.
114    pub snapshot_require_signature: bool,
115}
116
117impl std::fmt::Debug for AppConfig {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        f.debug_struct("AppConfig")
120            .field(
121                "snapshot_hmac_key",
122                &self
123                    .snapshot_hmac_key
124                    .as_ref()
125                    .map(|_| "<REDACTED 32-byte HMAC key>"),
126            )
127            .field(
128                "snapshot_require_signature",
129                &self.snapshot_require_signature,
130            )
131            .finish()
132    }
133}
134
135impl AppConfig {
136    /// Load from the process environment.
137    ///
138    /// Reads [`ENV_SNAPSHOT_HMAC_KEY`] (hex, 64 chars) and
139    /// [`ENV_SNAPSHOT_REQUIRE_SIGNATURE`] (`true`/`false`,
140    /// case-insensitive).
141    ///
142    /// Returns [`ConfigError`] when:
143    ///
144    /// * `TENSOR_WASM_API_SNAPSHOT_HMAC_KEY` is set but is not exactly 64
145    ///   hex characters (each character must be `0-9`, `a-f`, or `A-F`).
146    /// * `TENSOR_WASM_API_SNAPSHOT_REQUIRE_SIGNATURE` is set to a value
147    ///   that is neither `true` nor `false` (case-insensitive).
148    ///
149    /// An unset variable in either case is *not* an error: the resulting
150    /// `AppConfig` carries `None` / `false` for the corresponding fields.
151    /// This matches the silent-default behaviour every other
152    /// `*Config::from_env` in this crate exhibits for unset variables —
153    /// we deliberately diverge from that pattern only for *malformed*
154    /// values, where a typo in a secret would otherwise be silently
155    /// swallowed.
156    pub fn from_env() -> Result<Self, ConfigError> {
157        let snapshot_hmac_key = match std::env::var(ENV_SNAPSHOT_HMAC_KEY) {
158            Ok(raw) => {
159                let trimmed = raw.trim();
160                if trimmed.is_empty() {
161                    None
162                } else {
163                    Some(parse_hex_key(trimmed)?)
164                }
165            }
166            Err(std::env::VarError::NotPresent) => None,
167            Err(std::env::VarError::NotUnicode(_)) => {
168                return Err(ConfigError::NonUnicode {
169                    var: ENV_SNAPSHOT_HMAC_KEY,
170                });
171            }
172        };
173
174        let snapshot_require_signature = match std::env::var(ENV_SNAPSHOT_REQUIRE_SIGNATURE) {
175            Ok(raw) => parse_bool(raw.trim())?,
176            Err(std::env::VarError::NotPresent) => false,
177            Err(std::env::VarError::NotUnicode(_)) => {
178                return Err(ConfigError::NonUnicode {
179                    var: ENV_SNAPSHOT_REQUIRE_SIGNATURE,
180                });
181            }
182        };
183
184        if snapshot_require_signature && snapshot_hmac_key.is_none() {
185            tracing::warn!(
186                target: "tensor_wasm_api::config",
187                env_key = ENV_SNAPSHOT_HMAC_KEY,
188                env_require = ENV_SNAPSHOT_REQUIRE_SIGNATURE,
189                "{} is true but {} is unset; the /snapshot/* routes will \
190                 return 503 snapshot_signing_not_configured — this is almost \
191                 certainly a misconfiguration",
192                ENV_SNAPSHOT_REQUIRE_SIGNATURE,
193                ENV_SNAPSHOT_HMAC_KEY,
194            );
195        }
196
197        if snapshot_hmac_key.is_some() {
198            tracing::info!(
199                target: "tensor_wasm_api::config",
200                require_signature = snapshot_require_signature,
201                "snapshot HMAC-SHA256 key configured ({} chars hex)",
202                SNAPSHOT_HMAC_KEY_LEN * 2,
203            );
204        }
205
206        Ok(Self {
207            snapshot_hmac_key,
208            snapshot_require_signature,
209        })
210    }
211
212    /// Test-only builder that installs an explicit HMAC key without
213    /// touching the process environment.
214    ///
215    /// Hidden from rustdoc because production code paths should always
216    /// flow through [`Self::from_env`] — this exists so tests of the
217    /// future snapshot route handlers can drive the config directly
218    /// without env-var poisoning.
219    #[doc(hidden)]
220    pub fn with_snapshot_hmac_key(mut self, key: [u8; SNAPSHOT_HMAC_KEY_LEN]) -> Self {
221        self.snapshot_hmac_key = Some(key);
222        self
223    }
224
225    /// Test-only builder for the require-signature toggle. See
226    /// [`Self::with_snapshot_hmac_key`] for the rationale.
227    #[doc(hidden)]
228    pub fn with_snapshot_require_signature(mut self, require: bool) -> Self {
229        self.snapshot_require_signature = require;
230        self
231    }
232}
233
234/// Errors returned by [`AppConfig::from_env`] for malformed values.
235///
236/// Unset variables are NOT errors — see [`AppConfig::from_env`] for the
237/// silent-default behaviour.
238#[derive(Debug, Clone, PartialEq, Eq)]
239pub enum ConfigError {
240    /// The variable held bytes that were not valid UTF-8.
241    NonUnicode {
242        /// Name of the offending environment variable.
243        var: &'static str,
244    },
245    /// The variable was set but did not parse as a 64-character lowercase
246    /// or uppercase hex string.
247    InvalidHexKey {
248        /// Name of the offending environment variable.
249        var: &'static str,
250        /// Human-readable reason (length / non-hex character).
251        reason: HexParseReason,
252    },
253    /// The variable was set but is not `true` or `false` (case-insensitive).
254    InvalidBool {
255        /// Name of the offending environment variable.
256        var: &'static str,
257        /// The raw value that failed to parse (trimmed).
258        value: String,
259    },
260}
261
262/// Specific reason a hex-encoded key failed to parse.
263#[derive(Debug, Clone, PartialEq, Eq)]
264pub enum HexParseReason {
265    /// The string had the wrong number of characters. The expected length
266    /// is always `SNAPSHOT_HMAC_KEY_LEN * 2`.
267    WrongLength {
268        /// The number of characters actually present.
269        actual: usize,
270    },
271    /// The string contained a character that is not `0-9`, `a-f`, or `A-F`.
272    InvalidCharacter,
273}
274
275impl fmt::Display for ConfigError {
276    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
277        match self {
278            ConfigError::NonUnicode { var } => {
279                write!(f, "{var} is set but its value is not valid UTF-8")
280            }
281            ConfigError::InvalidHexKey { var, reason } => match reason {
282                HexParseReason::WrongLength { actual } => write!(
283                    f,
284                    "{var} must be exactly {expected} hex characters \
285                     (32 bytes); got {actual}",
286                    expected = SNAPSHOT_HMAC_KEY_LEN * 2,
287                ),
288                HexParseReason::InvalidCharacter => {
289                    write!(f, "{var} must contain only hex characters (0-9, a-f, A-F)",)
290                }
291            },
292            ConfigError::InvalidBool { var, value } => write!(
293                f,
294                "{var} must be `true` or `false` (case-insensitive); got `{value}`",
295            ),
296        }
297    }
298}
299
300impl std::error::Error for ConfigError {}
301
302/// Parse a hex-encoded `[u8; SNAPSHOT_HMAC_KEY_LEN]` from `s`.
303///
304/// `s` must be already-trimmed (the caller is responsible for
305/// whitespace handling so the error message reports the right length).
306fn parse_hex_key(s: &str) -> Result<[u8; SNAPSHOT_HMAC_KEY_LEN], ConfigError> {
307    let expected = SNAPSHOT_HMAC_KEY_LEN * 2;
308    if s.len() != expected {
309        return Err(ConfigError::InvalidHexKey {
310            var: ENV_SNAPSHOT_HMAC_KEY,
311            reason: HexParseReason::WrongLength { actual: s.len() },
312        });
313    }
314
315    let mut out = [0u8; SNAPSHOT_HMAC_KEY_LEN];
316    let bytes = s.as_bytes();
317    for (i, slot) in out.iter_mut().enumerate() {
318        let hi = hex_nibble(bytes[i * 2])?;
319        let lo = hex_nibble(bytes[i * 2 + 1])?;
320        *slot = (hi << 4) | lo;
321    }
322    Ok(out)
323}
324
325fn hex_nibble(c: u8) -> Result<u8, ConfigError> {
326    match c {
327        b'0'..=b'9' => Ok(c - b'0'),
328        b'a'..=b'f' => Ok(c - b'a' + 10),
329        b'A'..=b'F' => Ok(c - b'A' + 10),
330        _ => Err(ConfigError::InvalidHexKey {
331            var: ENV_SNAPSHOT_HMAC_KEY,
332            reason: HexParseReason::InvalidCharacter,
333        }),
334    }
335}
336
337fn parse_bool(s: &str) -> Result<bool, ConfigError> {
338    if s.eq_ignore_ascii_case("true") {
339        Ok(true)
340    } else if s.eq_ignore_ascii_case("false") || s.is_empty() {
341        // Treat empty / whitespace as `false` (matches the documented
342        // default for unset). Callers that care about "set but empty"
343        // can read the raw env var themselves.
344        Ok(false)
345    } else {
346        Err(ConfigError::InvalidBool {
347            var: ENV_SNAPSHOT_REQUIRE_SIGNATURE,
348            value: s.to_owned(),
349        })
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn default_is_all_none_or_false() {
359        let cfg = AppConfig::default();
360        assert!(cfg.snapshot_hmac_key.is_none());
361        assert!(!cfg.snapshot_require_signature);
362    }
363
364    #[test]
365    fn parse_hex_key_round_trips_lowercase() {
366        let s = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
367        let key = parse_hex_key(s).expect("valid");
368        assert_eq!(key[0], 0x00);
369        assert_eq!(key[1], 0x11);
370        assert_eq!(key[15], 0xff);
371        assert_eq!(key[31], 0xff);
372    }
373
374    #[test]
375    fn parse_hex_key_accepts_uppercase() {
376        let s = "00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDDEEFF";
377        let key = parse_hex_key(s).expect("valid");
378        assert_eq!(key[1], 0x11);
379        assert_eq!(key[15], 0xff);
380    }
381
382    #[test]
383    fn parse_hex_key_rejects_short() {
384        let err = parse_hex_key("deadbeef").expect_err("too short");
385        assert!(matches!(
386            err,
387            ConfigError::InvalidHexKey {
388                reason: HexParseReason::WrongLength { actual: 8 },
389                ..
390            }
391        ));
392    }
393
394    #[test]
395    fn parse_hex_key_rejects_non_hex() {
396        // 64 chars, but with a 'g'.
397        let s = "g0112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
398        assert_eq!(s.len(), 64);
399        let err = parse_hex_key(s).expect_err("non-hex character");
400        assert!(matches!(
401            err,
402            ConfigError::InvalidHexKey {
403                reason: HexParseReason::InvalidCharacter,
404                ..
405            }
406        ));
407    }
408
409    #[test]
410    fn parse_bool_accepts_case_insensitive() {
411        assert!(parse_bool("true").unwrap());
412        assert!(parse_bool("TRUE").unwrap());
413        assert!(parse_bool("True").unwrap());
414        assert!(!parse_bool("false").unwrap());
415        assert!(!parse_bool("FALSE").unwrap());
416        assert!(!parse_bool("").unwrap());
417    }
418
419    #[test]
420    fn parse_bool_rejects_garbage() {
421        let err = parse_bool("yes").expect_err("not a bool");
422        assert!(matches!(err, ConfigError::InvalidBool { .. }));
423    }
424
425    #[test]
426    fn with_snapshot_hmac_key_builder_sets_field() {
427        let key = [0x42u8; SNAPSHOT_HMAC_KEY_LEN];
428        let cfg = AppConfig::default().with_snapshot_hmac_key(key);
429        assert_eq!(cfg.snapshot_hmac_key, Some(key));
430    }
431
432    #[test]
433    fn with_snapshot_require_signature_builder_sets_field() {
434        let cfg = AppConfig::default().with_snapshot_require_signature(true);
435        assert!(cfg.snapshot_require_signature);
436    }
437
438    #[test]
439    fn config_error_display_messages_are_descriptive() {
440        // Spot-check each variant renders something humans can act on.
441        let e = ConfigError::InvalidHexKey {
442            var: ENV_SNAPSHOT_HMAC_KEY,
443            reason: HexParseReason::WrongLength { actual: 4 },
444        };
445        let msg = e.to_string();
446        assert!(msg.contains(ENV_SNAPSHOT_HMAC_KEY));
447        assert!(msg.contains("64"));
448        assert!(msg.contains('4'));
449
450        let e = ConfigError::InvalidHexKey {
451            var: ENV_SNAPSHOT_HMAC_KEY,
452            reason: HexParseReason::InvalidCharacter,
453        };
454        assert!(e.to_string().contains("hex"));
455
456        let e = ConfigError::InvalidBool {
457            var: ENV_SNAPSHOT_REQUIRE_SIGNATURE,
458            value: "yes".to_owned(),
459        };
460        let msg = e.to_string();
461        assert!(msg.contains(ENV_SNAPSHOT_REQUIRE_SIGNATURE));
462        assert!(msg.contains("yes"));
463    }
464}