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}