]> quire.kejadlen.dev Git - quire.git/commitdiff
Add SecretString type with lazy file resolver
authorAlpha Chen <alpha@kejadlen.dev>
Sun, 26 Apr 2026 01:30:11 +0000 (01:30 +0000)
committerAlpha Chen <alpha@kejadlen.dev>
Sun, 26 Apr 2026 03:02:06 +0000 (03:02 +0000)
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
Cargo.lock
Cargo.toml
src/error.rs
src/lib.rs
src/secret.rs [new file with mode: 0644]

index 57016a51fbce630b4a4cadb5251fa0c459139fe1..ca721a7e92f64e86c83ec40951a6edea650b823a 100644 (file)
@@ -884,6 +884,7 @@ dependencies = [
  "predicates",
  "regex",
  "serde",
+ "serde_json",
  "shell-words",
  "tempfile",
  "thiserror",
index 114eebebc8ff5d0595c81ce711a99a4a3ce7a021..4b24b2de422faabf5ea6e3a9ab32b97efb47b3ee 100644 (file)
@@ -26,3 +26,4 @@ tracing-subscriber = { version = "*", features = ["env-filter"] }
 assert_cmd = "*"
 predicates = "*"
 tempfile = "*"
+serde_json = "*"
index de0493db44827b434c96cb9cdc7db19b0931e0fe..480a7bc91600c7cea27d774a5ca9c4cc193717a6 100644 (file)
@@ -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<T> = std::result::Result<T, Error>;
index 983ec2dc71ec247714be195f5e8fb3b505277ccb..08f4448ee500d19315bcdfdb8adbc49597adf62d 100644 (file)
@@ -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 (file)
index 0000000..f3d9f5c
--- /dev/null
@@ -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<std::result::Result<String, String>>,
+}
+
+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(&"<redacted>").finish()
+    }
+}
+
+impl<'de> serde::Deserialize<'de> for SecretString {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    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(\"<redacted>\")");
+        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");
+    }
+}