From: Alpha Chen Date: Sun, 26 Apr 2026 01:30:11 +0000 (+0000) Subject: Add SecretString type with lazy file resolver X-Git-Url: http://quire.kejadlen.dev/?a=commitdiff_plain;h=5a3b90527b0206b52a836b9810fe2fd63705961a;p=quire.git Add SecretString type with lazy file resolver Untagged serde enum accepts a plain string or a file path table ({:file "/run/secrets/name"}). File contents are resolved lazily on first call to reveal() and cached via OnceLock. Debug impl redacts the value. Missing files produce a typed SecretResolve error. Assisted-by: GLM-5.1 via pi --- diff --git a/Cargo.lock b/Cargo.lock index 57016a5..ca721a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -884,6 +884,7 @@ dependencies = [ "predicates", "regex", "serde", + "serde_json", "shell-words", "tempfile", "thiserror", diff --git a/Cargo.toml b/Cargo.toml index 114eebe..4b24b2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,3 +26,4 @@ tracing-subscriber = { version = "*", features = ["env-filter"] } assert_cmd = "*" predicates = "*" tempfile = "*" +serde_json = "*" diff --git a/src/error.rs b/src/error.rs index de0493d..480a7bc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,6 +5,9 @@ pub enum Error { #[error("io error: {0}")] Io(#[from] std::io::Error), + + #[error("secret resolution failed: {0}")] + SecretResolve(String), } pub type Result = std::result::Result; diff --git a/src/lib.rs b/src/lib.rs index 983ec2d..08f4448 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ mod config; mod error; pub mod fennel; pub mod quire; +pub mod secret; pub use config::Config; pub use error::Error; diff --git a/src/secret.rs b/src/secret.rs new file mode 100644 index 0000000..f3d9f5c --- /dev/null +++ b/src/secret.rs @@ -0,0 +1,249 @@ +use std::path::PathBuf; +use std::sync::OnceLock; + +/// A string value that deserializes from either a plain literal or a file path. +/// +/// Fennel config can provide a secret as: +/// - A plain string: `"s3cret"` +/// - A file reference: `{:file "/run/secrets/my_token"}` +/// +/// File contents are resolved lazily on first access to [`SecretString::reveal`] +/// and cached for the lifetime of the instance. Trailing newlines are stripped +/// from file contents (Docker secrets convention). +/// +/// The [`std::fmt::Debug`] impl redacts the value. +pub struct SecretString { + source: SecretSource, + resolved: OnceLock>, +} + +impl Clone for SecretString { + fn clone(&self) -> Self { + Self { + source: self.source.clone(), + resolved: OnceLock::new(), + } + } +} + +#[derive(Clone)] +enum SecretSource { + Plain(String), + File(PathBuf), +} + +impl SecretString { + /// The resolved secret value. + /// + /// For the file variant, reads from disk on first call and caches the + /// result. Returns a typed error if the file is missing or unreadable. + /// Errors are also cached — subsequent calls return the same error. + pub fn reveal(&self) -> crate::Result<&str> { + self.resolved + .get_or_init(|| match &self.source { + SecretSource::Plain(s) => Ok(s.clone()), + SecretSource::File(path) => fs_err::read_to_string(path) + .map(|s| s.trim_end_matches('\n').to_string()) + .map_err(|e| format!("{}: {e}", path.display())), + }) + .as_ref() + .map(|s| s.as_str()) + .map_err(|msg| crate::Error::SecretResolve(msg.clone())) + } +} + +impl std::fmt::Debug for SecretString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("SecretString").field(&"").finish() + } +} + +impl<'de> serde::Deserialize<'de> for SecretString { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(serde::Deserialize)] + #[serde(untagged)] + enum Raw { + Plain(String), + File { file: PathBuf }, + } + + let raw = Raw::deserialize(deserializer)?; + let source = match raw { + Raw::Plain(s) => SecretSource::Plain(s), + Raw::File { file } => SecretSource::File(file), + }; + + Ok(Self { + source, + resolved: OnceLock::new(), + }) + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::*; + + impl SecretString { + fn from_plain(value: &str) -> Self { + Self { + source: SecretSource::Plain(value.to_string()), + resolved: OnceLock::new(), + } + } + + fn from_file(path: &Path) -> Self { + Self { + source: SecretSource::File(path.to_path_buf()), + resolved: OnceLock::new(), + } + } + } + + #[test] + fn debug_redacts_value() { + let secret = SecretString::from_plain("super_secret_password"); + let debug_output = format!("{secret:?}"); + assert_eq!(debug_output, "SecretString(\"\")"); + assert!( + !debug_output.contains("super_secret_password"), + "Debug must not leak the secret value" + ); + } + + #[test] + fn reveal_returns_plain_value() { + let secret = SecretString::from_plain("plain_value"); + assert_eq!(secret.reveal().unwrap(), "plain_value"); + } + + #[test] + fn reveal_caches_file_value() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("token"); + fs_err::write(&path, "initial\n").expect("write"); + + let secret = SecretString::from_file(&path); + assert_eq!(secret.reveal().unwrap(), "initial"); + + // Overwrite the file — cached value should not change. + fs_err::write(&path, "changed\n").expect("overwrite"); + assert_eq!(secret.reveal().unwrap(), "initial"); + } + + #[test] + fn reveal_strips_trailing_newline() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("secret"); + fs_err::write(&path, "line1\nline2\n").expect("write"); + + let secret = SecretString::from_file(&path); + assert_eq!(secret.reveal().unwrap(), "line1\nline2"); + } + + #[test] + fn reveal_errors_on_missing_file() { + let secret = SecretString::from_file(PathBuf::from("/no/such/file/ever").as_path()); + let err = secret.reveal().unwrap_err(); + assert!( + matches!(err, crate::Error::SecretResolve(_)), + "expected SecretResolve error, got {err:?}" + ); + } + + #[test] + fn clone_resolves_independently() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("pw"); + fs_err::write(&path, "secret\n").expect("write"); + + let original = SecretString::from_file(&path); + assert_eq!(original.reveal().unwrap(), "secret"); + + // Clone gets a fresh OnceLock — it re-reads from disk. + let cloned = original.clone(); + assert_eq!(cloned.reveal().unwrap(), "secret"); + } + + #[test] + fn deserialize_plain_string() { + #[derive(serde::Deserialize)] + struct Wrapper { + token: SecretString, + } + + let json = r#"{"token": "s3cret"}"#; + let w: Wrapper = serde_json::from_str(json).expect("deserialize plain string"); + assert_eq!(w.token.reveal().unwrap(), "s3cret"); + } + + #[test] + fn deserialize_file_does_not_touch_disk() { + #[derive(serde::Deserialize)] + struct Wrapper { + token: SecretString, + } + + let json = r#"{"token": {"file": "/no/such/file/ever"}}"#; + let w: Wrapper = serde_json::from_str(json).expect("deserialize should not read file"); + // Deserialization succeeded without touching disk. + assert!(w.token.reveal().is_err()); + } + + #[test] + fn deserialize_file_resolves_on_reveal() { + #[derive(serde::Deserialize)] + struct Wrapper { + token: SecretString, + } + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("token"); + fs_err::write(&path, "from_file\n").expect("write"); + + let json = serde_json::json!({ + "token": {"file": path.display().to_string()} + }); + let w: Wrapper = serde_json::from_value(json).expect("deserialize"); + assert_eq!(w.token.reveal().unwrap(), "from_file"); + } + + #[test] + fn fennel_round_trip_plain_string() { + #[derive(serde::Deserialize)] + struct Config { + token: SecretString, + } + + let fennel = crate::fennel::Fennel::new().expect("fennel"); + let config: Config = fennel + .load_string(r#"{:token "hunter2"}"#, "test.fnl") + .expect("deserialize from fennel"); + assert_eq!(config.token.reveal().unwrap(), "hunter2"); + } + + #[test] + fn fennel_round_trip_file_ref() { + #[derive(serde::Deserialize)] + struct Config { + token: SecretString, + } + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("pw"); + fs_err::write(&path, "secret_from_file\n").expect("write"); + + let fennel = crate::fennel::Fennel::new().expect("fennel"); + // Fennel table syntax: {:token {:file "/path"}} + let source = format!("{{:token {{:file \"{}\"}}}}", path.display(),); + let config: Config = fennel + .load_string(&source, "test.fnl") + .expect("deserialize file ref from fennel"); + assert_eq!(config.token.reveal().unwrap(), "secret_from_file"); + } +}