use std::fmt;
pub const ENV_SNAPSHOT_HMAC_KEY: &str = "TENSOR_WASM_API_SNAPSHOT_HMAC_KEY";
pub const ENV_SNAPSHOT_REQUIRE_SIGNATURE: &str = "TENSOR_WASM_API_SNAPSHOT_REQUIRE_SIGNATURE";
pub const SNAPSHOT_HMAC_KEY_LEN: usize = 32;
#[derive(Clone, Default, PartialEq, Eq)]
pub struct AppConfig {
pub snapshot_hmac_key: Option<[u8; SNAPSHOT_HMAC_KEY_LEN]>,
pub snapshot_require_signature: bool,
}
impl std::fmt::Debug for AppConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AppConfig")
.field(
"snapshot_hmac_key",
&self
.snapshot_hmac_key
.as_ref()
.map(|_| "<REDACTED 32-byte HMAC key>"),
)
.field(
"snapshot_require_signature",
&self.snapshot_require_signature,
)
.finish()
}
}
impl AppConfig {
pub fn from_env() -> Result<Self, ConfigError> {
let snapshot_hmac_key = match std::env::var(ENV_SNAPSHOT_HMAC_KEY) {
Ok(raw) => {
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(parse_hex_key(trimmed)?)
}
}
Err(std::env::VarError::NotPresent) => None,
Err(std::env::VarError::NotUnicode(_)) => {
return Err(ConfigError::NonUnicode {
var: ENV_SNAPSHOT_HMAC_KEY,
});
}
};
let snapshot_require_signature = match std::env::var(ENV_SNAPSHOT_REQUIRE_SIGNATURE) {
Ok(raw) => parse_bool(raw.trim())?,
Err(std::env::VarError::NotPresent) => false,
Err(std::env::VarError::NotUnicode(_)) => {
return Err(ConfigError::NonUnicode {
var: ENV_SNAPSHOT_REQUIRE_SIGNATURE,
});
}
};
if snapshot_require_signature && snapshot_hmac_key.is_none() {
tracing::warn!(
target: "tensor_wasm_api::config",
env_key = ENV_SNAPSHOT_HMAC_KEY,
env_require = ENV_SNAPSHOT_REQUIRE_SIGNATURE,
"{} is true but {} is unset; the /snapshot/* routes will \
return 503 snapshot_signing_not_configured — this is almost \
certainly a misconfiguration",
ENV_SNAPSHOT_REQUIRE_SIGNATURE,
ENV_SNAPSHOT_HMAC_KEY,
);
}
if snapshot_hmac_key.is_some() {
tracing::info!(
target: "tensor_wasm_api::config",
require_signature = snapshot_require_signature,
"snapshot HMAC-SHA256 key configured ({} chars hex)",
SNAPSHOT_HMAC_KEY_LEN * 2,
);
}
Ok(Self {
snapshot_hmac_key,
snapshot_require_signature,
})
}
#[doc(hidden)]
pub fn with_snapshot_hmac_key(mut self, key: [u8; SNAPSHOT_HMAC_KEY_LEN]) -> Self {
self.snapshot_hmac_key = Some(key);
self
}
#[doc(hidden)]
pub fn with_snapshot_require_signature(mut self, require: bool) -> Self {
self.snapshot_require_signature = require;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigError {
NonUnicode {
var: &'static str,
},
InvalidHexKey {
var: &'static str,
reason: HexParseReason,
},
InvalidBool {
var: &'static str,
value: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HexParseReason {
WrongLength {
actual: usize,
},
InvalidCharacter,
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::NonUnicode { var } => {
write!(f, "{var} is set but its value is not valid UTF-8")
}
ConfigError::InvalidHexKey { var, reason } => match reason {
HexParseReason::WrongLength { actual } => write!(
f,
"{var} must be exactly {expected} hex characters \
(32 bytes); got {actual}",
expected = SNAPSHOT_HMAC_KEY_LEN * 2,
),
HexParseReason::InvalidCharacter => {
write!(f, "{var} must contain only hex characters (0-9, a-f, A-F)",)
}
},
ConfigError::InvalidBool { var, value } => write!(
f,
"{var} must be `true` or `false` (case-insensitive); got `{value}`",
),
}
}
}
impl std::error::Error for ConfigError {}
fn parse_hex_key(s: &str) -> Result<[u8; SNAPSHOT_HMAC_KEY_LEN], ConfigError> {
let expected = SNAPSHOT_HMAC_KEY_LEN * 2;
if s.len() != expected {
return Err(ConfigError::InvalidHexKey {
var: ENV_SNAPSHOT_HMAC_KEY,
reason: HexParseReason::WrongLength { actual: s.len() },
});
}
let mut out = [0u8; SNAPSHOT_HMAC_KEY_LEN];
let bytes = s.as_bytes();
for (i, slot) in out.iter_mut().enumerate() {
let hi = hex_nibble(bytes[i * 2])?;
let lo = hex_nibble(bytes[i * 2 + 1])?;
*slot = (hi << 4) | lo;
}
Ok(out)
}
fn hex_nibble(c: u8) -> Result<u8, ConfigError> {
match c {
b'0'..=b'9' => Ok(c - b'0'),
b'a'..=b'f' => Ok(c - b'a' + 10),
b'A'..=b'F' => Ok(c - b'A' + 10),
_ => Err(ConfigError::InvalidHexKey {
var: ENV_SNAPSHOT_HMAC_KEY,
reason: HexParseReason::InvalidCharacter,
}),
}
}
fn parse_bool(s: &str) -> Result<bool, ConfigError> {
if s.eq_ignore_ascii_case("true") {
Ok(true)
} else if s.eq_ignore_ascii_case("false") || s.is_empty() {
Ok(false)
} else {
Err(ConfigError::InvalidBool {
var: ENV_SNAPSHOT_REQUIRE_SIGNATURE,
value: s.to_owned(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_all_none_or_false() {
let cfg = AppConfig::default();
assert!(cfg.snapshot_hmac_key.is_none());
assert!(!cfg.snapshot_require_signature);
}
#[test]
fn parse_hex_key_round_trips_lowercase() {
let s = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
let key = parse_hex_key(s).expect("valid");
assert_eq!(key[0], 0x00);
assert_eq!(key[1], 0x11);
assert_eq!(key[15], 0xff);
assert_eq!(key[31], 0xff);
}
#[test]
fn parse_hex_key_accepts_uppercase() {
let s = "00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDDEEFF";
let key = parse_hex_key(s).expect("valid");
assert_eq!(key[1], 0x11);
assert_eq!(key[15], 0xff);
}
#[test]
fn parse_hex_key_rejects_short() {
let err = parse_hex_key("deadbeef").expect_err("too short");
assert!(matches!(
err,
ConfigError::InvalidHexKey {
reason: HexParseReason::WrongLength { actual: 8 },
..
}
));
}
#[test]
fn parse_hex_key_rejects_non_hex() {
let s = "g0112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
assert_eq!(s.len(), 64);
let err = parse_hex_key(s).expect_err("non-hex character");
assert!(matches!(
err,
ConfigError::InvalidHexKey {
reason: HexParseReason::InvalidCharacter,
..
}
));
}
#[test]
fn parse_bool_accepts_case_insensitive() {
assert!(parse_bool("true").unwrap());
assert!(parse_bool("TRUE").unwrap());
assert!(parse_bool("True").unwrap());
assert!(!parse_bool("false").unwrap());
assert!(!parse_bool("FALSE").unwrap());
assert!(!parse_bool("").unwrap());
}
#[test]
fn parse_bool_rejects_garbage() {
let err = parse_bool("yes").expect_err("not a bool");
assert!(matches!(err, ConfigError::InvalidBool { .. }));
}
#[test]
fn with_snapshot_hmac_key_builder_sets_field() {
let key = [0x42u8; SNAPSHOT_HMAC_KEY_LEN];
let cfg = AppConfig::default().with_snapshot_hmac_key(key);
assert_eq!(cfg.snapshot_hmac_key, Some(key));
}
#[test]
fn with_snapshot_require_signature_builder_sets_field() {
let cfg = AppConfig::default().with_snapshot_require_signature(true);
assert!(cfg.snapshot_require_signature);
}
#[test]
fn config_error_display_messages_are_descriptive() {
let e = ConfigError::InvalidHexKey {
var: ENV_SNAPSHOT_HMAC_KEY,
reason: HexParseReason::WrongLength { actual: 4 },
};
let msg = e.to_string();
assert!(msg.contains(ENV_SNAPSHOT_HMAC_KEY));
assert!(msg.contains("64"));
assert!(msg.contains('4'));
let e = ConfigError::InvalidHexKey {
var: ENV_SNAPSHOT_HMAC_KEY,
reason: HexParseReason::InvalidCharacter,
};
assert!(e.to_string().contains("hex"));
let e = ConfigError::InvalidBool {
var: ENV_SNAPSHOT_REQUIRE_SIGNATURE,
value: "yes".to_owned(),
};
let msg = e.to_string();
assert!(msg.contains(ENV_SNAPSHOT_REQUIRE_SIGNATURE));
assert!(msg.contains("yes"));
}
}