Files
notfiles/docs/superpowers/plans/2026-06-06-notsecrets-multi-provider.md
Joseph O'Brien 1359def673
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled
feat(notsecrets): add multi-provider secret resolution system
Add a config-driven, strongly-typed secret/env resolution system to
notsecrets with 11 provider backends. The system supports explicit
key bindings with typed SecretRef enums and a priority chain fallback.

Tier 1 providers (implemented): Env, 1Password, dotenvx, SOPS, GSM
Tier 2 providers (stubbed): nuenv, direnv, mise, Bitwarden, Vault, dotenvy

New types: Provider, ProviderConfig, SecretRef, SecretsConfig,
SecretsError, SecretSource, EnumerableSecretSource, SecretResolver

Config lives in notsecrets.toml with typed serde deserialization.
ISP split: EnumerableSecretSource for backends that support bulk
enumeration, SecretSource for single-key lookup only.

Includes conformance test suite, 33 new tests (161 workspace total).
notstrap EnvInjector migration deferred to follow-up.
2026-06-06 09:49:05 -04:00

43 KiB

Plan: notsecrets Multi-Provider Secret Resolution

Goal

Add a config-driven, strongly-typed secret resolution system to notsecrets with 11 provider backends, explicit key bindings, and a priority chain fallback.

Context Map

See docs/superpowers/specs/2026-06-06-notsecrets-multi-provider-design.md for full design. Context map produced at plan time:

  • Modified: ports.rs, error.rs, lib.rs, sources/mod.rs, sources/bitwarden.rs, Cargo.toml, notstrap/src/lib.rs
  • Created: config.rs, resolver.rs, 10 new source files
  • Consumers: notstrap (primary), cross-crate integration tests
  • Risk: all changes additive except notstrap EnvInjector removal (binary crate, safe)

Architecture

  • Crate: notsecrets (library)
  • New types: Provider, ProviderConfig, SecretRef, SecretsConfig, SecretsError, SecretSource, EnumerableSecretSource, SecretResolver
  • New files: config.rs, resolver.rs, 10 source adapter files
  • Data flow: notsecrets.toml -> SecretsConfig -> SecretResolver -> SecretSource impls -> resolved HashMap<String, String>

Tech Stack

  • Rust edition 2024
  • New deps on notsecrets: serde (with derive), toml
  • Tier 1 sources shell out to CLIs: op (1Password), dotenvx, sops, gcloud (GSM). EnvSource uses std::env.

Tasks

Task 1: Add serde/toml deps and SecretsError

Crate: notsecrets File(s): crates/notsecrets/Cargo.toml, crates/notsecrets/src/error.rs Run: cargo check -p notsecrets

  1. Write failing test:

    // crates/notsecrets/src/error.rs
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn secrets_error_display_source_error() {
            let err = SecretsError::SourceError {
                name: "op".to_string(),
                source: anyhow::anyhow!("not found"),
            };
            assert!(err.to_string().contains("[op]"));
            assert!(err.to_string().contains("not found"));
        }
    
        #[test]
        fn secrets_error_display_not_found() {
            let err = SecretsError::NotFound {
                key: "DB_URL".to_string(),
            };
            assert!(err.to_string().contains("DB_URL"));
        }
    
        #[test]
        fn secrets_error_display_config() {
            let err = SecretsError::Config("bad toml".to_string());
            assert!(err.to_string().contains("bad toml"));
        }
    }
    

    Run: cargo nextest run -p notsecrets -- secrets_error Expected: FAIL (SecretsError does not exist)

  2. Add deps to Cargo.toml:

    serde = { workspace = true, features = ["derive"] }
    toml = { workspace = true }
    

    If serde and toml are not in workspace deps, add them to root Cargo.toml [workspace.dependencies] first.

  3. Add SecretsError to error.rs:

    #[derive(Debug, thiserror::Error)]
    pub enum SecretsError {
        #[error("[{name}] {source}")]
        SourceError { name: String, source: anyhow::Error },
        #[error("no provider resolved key: {key}")]
        NotFound { key: String },
        #[error("config error: {0}")]
        Config(String),
    }
    
  4. Add pub use error::SecretsError; to lib.rs.

  5. Verify:

    cargo nextest run -p notsecrets -- secrets_error  -> all green
    cargo clippy -p notsecrets -- -D warnings         -> zero warnings
    
  6. Run: git branch --show-current (verify branch) Commit: feat(notsecrets): add SecretsError and serde/toml deps


Task 2: Provider enum and config types

Crate: notsecrets File(s): crates/notsecrets/src/config.rs, crates/notsecrets/src/lib.rs Run: cargo nextest run -p notsecrets -- config

  1. Write failing test:

    // crates/notsecrets/src/config.rs
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn provider_roundtrip_serde() {
            let p = Provider::Op;
            let s = toml::to_string(&p).unwrap();
            let p2: Provider = toml::from_str(&s).unwrap();
            assert_eq!(p, p2);
        }
    
        #[test]
        fn secrets_config_parses_minimal() {
            let toml_str = r#"
                providers = ["env", "op"]
            "#;
            let cfg: SecretsConfig = toml::from_str(toml_str).unwrap();
            assert_eq!(cfg.providers, vec![Provider::Env, Provider::Op]);
            assert!(cfg.provider.is_empty());
            assert!(cfg.secrets.is_empty());
        }
    
        #[test]
        fn secrets_config_parses_full() {
            let toml_str = r#"
                providers = ["env", "op", "dotenvx", "sops", "gsm"]
    
                [provider.op]
                type = "op"
                account = "my.1password.com"
    
                [provider.dotenvx]
                type = "dotenvx"
                env_file = "~/dev/.env"
    
                [provider.sops]
                type = "sops"
                file = "secrets/bootstrap.sops.env"
    
                [provider.gsm]
                type = "gsm"
                project = "my-gcp-project"
    
                [secrets]
                DATABASE_URL = { source = "op", uri = "op://Personal/db/url" }
                ANTHROPIC_API_KEY = { source = "dotenvx" }
                GCP_TOKEN = { source = "gsm", path = "projects/123/secrets/gcp-token/versions/latest" }
            "#;
            let cfg: SecretsConfig = toml::from_str(toml_str).unwrap();
            assert_eq!(cfg.providers.len(), 5);
            assert_eq!(cfg.provider.len(), 4);
            assert_eq!(cfg.secrets.len(), 3);
            assert_eq!(
                cfg.secrets["DATABASE_URL"].provider(),
                Provider::Op
            );
        }
    
        #[test]
        fn secret_ref_provider_derivation() {
            let r = SecretRef::Gsm {
                path: "projects/123/secrets/tok/versions/latest".to_string(),
            };
            assert_eq!(r.provider(), Provider::Gsm);
        }
    
        #[test]
        fn unknown_provider_in_toml_errors() {
            let toml_str = r#"providers = ["env", "redis"]"#;
            let result: Result<SecretsConfig, _> = toml::from_str(toml_str);
            assert!(result.is_err());
        }
    
        #[test]
        fn load_config_reads_file() {
            let dir = tempfile::TempDir::new().unwrap();
            let path = dir.path().join("notsecrets.toml");
            std::fs::write(&path, "providers = [\"env\"]\n").unwrap();
            let cfg = load_config(&path).unwrap();
            assert_eq!(cfg.providers, vec![Provider::Env]);
        }
    
        #[test]
        fn load_config_missing_file_errors() {
            let result = load_config(std::path::Path::new("/nonexistent/notsecrets.toml"));
            assert!(result.is_err());
        }
    }
    

    Run: cargo nextest run -p notsecrets -- config Expected: FAIL (config module does not exist)

  2. Create crates/notsecrets/src/config.rs:

    use crate::error::SecretsError;
    use serde::{Deserialize, Serialize};
    use std::collections::HashMap;
    use std::path::{Path, PathBuf};
    
    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
    #[serde(rename_all = "lowercase")]
    pub enum Provider {
        Env,
        Op,
        Dotenvx,
        Sops,
        Gsm,
        Nuenv,
        Direnv,
        Mise,
        Bitwarden,
        Vault,
        Dotenvy,
    }
    
    #[derive(Debug, Deserialize)]
    #[serde(tag = "type", rename_all = "lowercase")]
    pub enum ProviderConfig {
        Env,
        Op { account: String },
        Dotenvx { env_file: PathBuf },
        Sops { file: PathBuf },
        Gsm { project: String },
        Nuenv,
        Direnv,
        Mise,
        Bitwarden { server_url: Option<String> },
        Vault { addr: String, mount: Option<String> },
        Dotenvy { path: PathBuf },
    }
    
    #[derive(Debug, Deserialize)]
    #[serde(tag = "source", rename_all = "lowercase")]
    pub enum SecretRef {
        Env,
        Op { uri: String },
        Dotenvx { key: Option<String> },
        Sops { key: Option<String> },
        Gsm { path: String },
        Nuenv { key: Option<String> },
        Direnv { key: Option<String> },
        Mise { key: Option<String> },
        Bitwarden { item: String, field: Option<String> },
        Vault { path: String, field: Option<String> },
        Dotenvy { key: Option<String> },
    }
    
    impl SecretRef {
        pub fn provider(&self) -> Provider {
            match self {
                Self::Env { .. } => Provider::Env,
                Self::Op { .. } => Provider::Op,
                Self::Dotenvx { .. } => Provider::Dotenvx,
                Self::Sops { .. } => Provider::Sops,
                Self::Gsm { .. } => Provider::Gsm,
                Self::Nuenv { .. } => Provider::Nuenv,
                Self::Direnv { .. } => Provider::Direnv,
                Self::Mise { .. } => Provider::Mise,
                Self::Bitwarden { .. } => Provider::Bitwarden,
                Self::Vault { .. } => Provider::Vault,
                Self::Dotenvy { .. } => Provider::Dotenvy,
            }
        }
    }
    
    #[derive(Debug, Deserialize)]
    pub struct SecretsConfig {
        pub providers: Vec<Provider>,
        #[serde(default)]
        pub provider: HashMap<Provider, ProviderConfig>,
        #[serde(default)]
        pub secrets: HashMap<String, SecretRef>,
    }
    
    pub fn load_config(path: &Path) -> Result<SecretsConfig, SecretsError> {
        let content = std::fs::read_to_string(path).map_err(|e| {
            SecretsError::Config(format!("cannot read {}: {e}", path.display()))
        })?;
        toml::from_str(&content)
            .map_err(|e| SecretsError::Config(format!("parse error: {e}")))
    }
    
  3. Add pub mod config; to lib.rs. Add re-exports:

    pub use config::{Provider, ProviderConfig, SecretRef, SecretsConfig, load_config};
    
  4. Verify:

    cargo nextest run -p notsecrets -- config  -> all green
    cargo clippy -p notsecrets -- -D warnings  -> zero warnings
    
  5. Commit: feat(notsecrets): add Provider enum and typed config types


Task 3: SecretSource and EnumerableSecretSource traits

Crate: notsecrets File(s): crates/notsecrets/src/ports.rs, crates/notsecrets/src/lib.rs Run: cargo check -p notsecrets

  1. Write failing test:

    // crates/notsecrets/src/ports.rs
    #[cfg(test)]
    mod tests {
        use super::*;
        use crate::config::{Provider, SecretRef};
        use std::collections::HashMap;
    
        struct FakeSource;
        impl SecretSource for FakeSource {
            fn name(&self) -> &str { "fake" }
            fn provider(&self) -> Provider { Provider::Env }
            fn resolve(&self, _key: &str) -> Result<Option<String>, SecretsError> {
                Ok(Some("val".to_string()))
            }
        }
    
        #[test]
        fn default_resolve_ref_delegates_to_resolve() {
            let source = FakeSource;
            let secret_ref = SecretRef::Env;
            let result = source.resolve_ref("KEY", &secret_ref).unwrap();
            assert_eq!(result, Some("val".to_string()));
        }
    }
    

    Run: cargo nextest run -p notsecrets -- default_resolve_ref Expected: FAIL (SecretSource does not exist)

  2. Add to ports.rs:

    use crate::config::{Provider, SecretRef};
    use crate::error::SecretsError;
    use std::collections::HashMap;
    
    pub trait SecretSource {
        fn name(&self) -> &str;
        fn provider(&self) -> Provider;
        fn resolve(&self, key: &str) -> Result<Option<String>, SecretsError>;
        fn resolve_ref(
            &self,
            key: &str,
            _secret_ref: &SecretRef,
        ) -> Result<Option<String>, SecretsError> {
            self.resolve(key)
        }
    }
    
    pub trait EnumerableSecretSource: SecretSource {
        fn resolve_all(&self) -> Result<HashMap<String, String>, SecretsError>;
    }
    
  3. Add re-exports to lib.rs:

    pub use ports::{SecretSource, EnumerableSecretSource};
    
  4. Verify:

    cargo nextest run -p notsecrets -- default_resolve_ref  -> green
    cargo clippy -p notsecrets -- -D warnings               -> zero warnings
    
  5. Commit: feat(notsecrets): add SecretSource and EnumerableSecretSource traits


Task 4: EnvSource (tier 1)

Crate: notsecrets File(s): crates/notsecrets/src/sources/env.rs, crates/notsecrets/src/sources/mod.rs Run: cargo nextest run -p notsecrets -- env_source

  1. Write failing test:

    // crates/notsecrets/src/sources/env.rs
    #[cfg(test)]
    mod tests {
        use super::*;
        use crate::ports::{SecretSource, EnumerableSecretSource};
    
        #[test]
        fn env_source_resolve_existing_key() {
            // SAFETY: test is single-threaded
            unsafe { std::env::set_var("NOTSECRETS_TEST_KEY", "test_value"); }
            let source = EnvSource;
            let result = source.resolve("NOTSECRETS_TEST_KEY").unwrap();
            assert_eq!(result, Some("test_value".to_string()));
            unsafe { std::env::remove_var("NOTSECRETS_TEST_KEY"); }
        }
    
        #[test]
        fn env_source_resolve_missing_key() {
            let source = EnvSource;
            let result = source.resolve("NOTSECRETS_NONEXISTENT_KEY_12345").unwrap();
            assert_eq!(result, None);
        }
    
        #[test]
        fn env_source_resolve_all_contains_path() {
            let source = EnvSource;
            let map = source.resolve_all().unwrap();
            // PATH is virtually always set
            assert!(map.contains_key("HOME") || map.contains_key("USER"));
        }
    
        #[test]
        fn env_source_name_and_provider() {
            let source = EnvSource;
            assert_eq!(source.name(), "env");
            assert_eq!(source.provider(), Provider::Env);
        }
    }
    

    Expected: FAIL

  2. Create crates/notsecrets/src/sources/env.rs:

    use crate::config::{Provider, SecretRef};
    use crate::error::SecretsError;
    use crate::ports::{EnumerableSecretSource, SecretSource};
    use std::collections::HashMap;
    
    pub struct EnvSource;
    
    impl SecretSource for EnvSource {
        fn name(&self) -> &str {
            "env"
        }
    
        fn provider(&self) -> Provider {
            Provider::Env
        }
    
        fn resolve(&self, key: &str) -> Result<Option<String>, SecretsError> {
            Ok(std::env::var(key).ok())
        }
    }
    
    impl EnumerableSecretSource for EnvSource {
        fn resolve_all(&self) -> Result<HashMap<String, String>, SecretsError> {
            Ok(std::env::vars().collect())
        }
    }
    
  3. Add pub mod env; and pub use env::EnvSource; to sources/mod.rs.

  4. Verify:

    cargo nextest run -p notsecrets -- env_source  -> all green
    cargo clippy -p notsecrets -- -D warnings      -> zero warnings
    
  5. Commit: feat(notsecrets): add EnvSource (tier 1 provider)


Task 5: OpSource (tier 1)

Crate: notsecrets File(s): crates/notsecrets/src/sources/op.rs, crates/notsecrets/src/sources/mod.rs Run: cargo nextest run -p notsecrets -- op_source

  1. Write failing test:

    // crates/notsecrets/src/sources/op.rs
    #[cfg(test)]
    mod tests {
        use super::*;
        use crate::config::SecretRef;
        use crate::ports::SecretSource;
    
        #[test]
        fn op_source_name_and_provider() {
            let source = OpSource::new("my.1password.com".to_string());
            assert_eq!(source.name(), "op");
            assert_eq!(source.provider(), Provider::Op);
        }
    
        #[test]
        fn op_source_resolve_without_ref_returns_none() {
            // Without a ref, OpSource cannot look up by key name alone
            let source = OpSource::new("my.1password.com".to_string());
            let result = source.resolve("SOME_KEY").unwrap();
            assert_eq!(result, None);
        }
    
        #[test]
        fn op_source_resolve_ref_wrong_variant_delegates() {
            let source = OpSource::new("my.1password.com".to_string());
            let wrong_ref = SecretRef::Env;
            let result = source.resolve_ref("KEY", &wrong_ref).unwrap();
            assert_eq!(result, None);
        }
    }
    

    Expected: FAIL

  2. Create crates/notsecrets/src/sources/op.rs:

    use crate::config::{Provider, SecretRef};
    use crate::error::SecretsError;
    use crate::ports::SecretSource;
    use std::process::Command;
    
    pub struct OpSource {
        account: String,
    }
    
    impl OpSource {
        pub fn new(account: String) -> Self {
            Self { account }
        }
    }
    
    impl SecretSource for OpSource {
        fn name(&self) -> &str {
            "op"
        }
    
        fn provider(&self) -> Provider {
            Provider::Op
        }
    
        /// OpSource cannot resolve by key name alone -- requires a ref.
        fn resolve(&self, _key: &str) -> Result<Option<String>, SecretsError> {
            Ok(None)
        }
    
        fn resolve_ref(
            &self,
            _key: &str,
            secret_ref: &SecretRef,
        ) -> Result<Option<String>, SecretsError> {
            let SecretRef::Op { uri } = secret_ref else {
                return self.resolve(_key);
            };
            let output = Command::new("op")
                .args(["read", uri, "--account", &self.account])
                .output()
                .map_err(|e| SecretsError::SourceError {
                    name: "op".to_string(),
                    source: e.into(),
                })?;
            if output.status.success() {
                Ok(Some(String::from_utf8_lossy(&output.stdout).trim().to_string()))
            } else {
                let stderr = String::from_utf8_lossy(&output.stderr);
                Err(SecretsError::SourceError {
                    name: "op".to_string(),
                    source: anyhow::anyhow!("op read failed: {stderr}"),
                })
            }
        }
    }
    
  3. Add to sources/mod.rs: pub mod op; and pub use op::OpSource;

  4. Verify:

    cargo nextest run -p notsecrets -- op_source  -> all green
    cargo clippy -p notsecrets -- -D warnings     -> zero warnings
    
  5. Commit: feat(notsecrets): add OpSource (tier 1 provider)


Task 6: DotenvxSource (tier 1)

Crate: notsecrets File(s): crates/notsecrets/src/sources/dotenvx.rs, crates/notsecrets/src/sources/mod.rs Run: cargo nextest run -p notsecrets -- dotenvx_source

  1. Write failing test:

    #[cfg(test)]
    mod tests {
        use super::*;
        use crate::ports::{SecretSource, EnumerableSecretSource};
    
        #[test]
        fn dotenvx_source_name_and_provider() {
            let source = DotenvxSource::new("/tmp/test.env".into());
            assert_eq!(source.name(), "dotenvx");
            assert_eq!(source.provider(), Provider::Dotenvx);
        }
    
        #[test]
        fn dotenvx_source_resolve_missing_file_returns_error() {
            let source = DotenvxSource::new("/nonexistent/.env".into());
            let result = source.resolve_all();
            assert!(result.is_err());
        }
    }
    

    Expected: FAIL

  2. Create crates/notsecrets/src/sources/dotenvx.rs:

    use crate::config::{Provider, SecretRef};
    use crate::error::SecretsError;
    use crate::ports::{EnumerableSecretSource, SecretSource};
    use std::collections::HashMap;
    use std::path::PathBuf;
    use std::process::Command;
    
    pub struct DotenvxSource {
        env_file: PathBuf,
    }
    
    impl DotenvxSource {
        pub fn new(env_file: PathBuf) -> Self {
            Self { env_file }
        }
    
        fn decrypt_all(&self) -> Result<HashMap<String, String>, SecretsError> {
            let output = Command::new("dotenvx")
                .args(["run", "--env-file", &self.env_file.to_string_lossy(), "--", "env"])
                .output()
                .map_err(|e| SecretsError::SourceError {
                    name: "dotenvx".to_string(),
                    source: e.into(),
                })?;
            if !output.status.success() {
                let stderr = String::from_utf8_lossy(&output.stderr);
                return Err(SecretsError::SourceError {
                    name: "dotenvx".to_string(),
                    source: anyhow::anyhow!("dotenvx run failed: {stderr}"),
                });
            }
            let stdout = String::from_utf8_lossy(&output.stdout);
            let mut map = HashMap::new();
            for line in stdout.lines() {
                if let Some((k, v)) = line.split_once('=') {
                    let k = k.trim();
                    if !k.is_empty() && !k.starts_with('#') {
                        map.insert(k.to_string(), v.to_string());
                    }
                }
            }
            Ok(map)
        }
    }
    
    impl SecretSource for DotenvxSource {
        fn name(&self) -> &str { "dotenvx" }
        fn provider(&self) -> Provider { Provider::Dotenvx }
    
        fn resolve(&self, key: &str) -> Result<Option<String>, SecretsError> {
            self.decrypt_all().map(|m| m.get(key).cloned())
        }
    }
    
    impl EnumerableSecretSource for DotenvxSource {
        fn resolve_all(&self) -> Result<HashMap<String, String>, SecretsError> {
            self.decrypt_all()
        }
    }
    
  3. Add to sources/mod.rs.

  4. Verify, commit: feat(notsecrets): add DotenvxSource (tier 1 provider)


Task 7: SopsSource (tier 1)

Crate: notsecrets File(s): crates/notsecrets/src/sources/sops.rs, crates/notsecrets/src/sources/mod.rs Run: cargo nextest run -p notsecrets -- sops_source

  1. Write failing test:

    #[cfg(test)]
    mod tests {
        use super::*;
        use crate::ports::SecretSource;
    
        #[test]
        fn sops_source_name_and_provider() {
            let source = SopsSource::new("/tmp/secrets.sops.env".into());
            assert_eq!(source.name(), "sops");
            assert_eq!(source.provider(), Provider::Sops);
        }
    
        #[test]
        fn sops_source_resolve_missing_file_returns_error() {
            let source = SopsSource::new("/nonexistent/secrets.sops.env".into());
            let result = source.resolve("KEY");
            assert!(result.is_err());
        }
    }
    

    Expected: FAIL

  2. Create crates/notsecrets/src/sources/sops.rs:

    use crate::config::{Provider, SecretRef};
    use crate::error::SecretsError;
    use crate::ports::{EnumerableSecretSource, SecretSource};
    use std::collections::HashMap;
    use std::path::PathBuf;
    use std::process::Command;
    
    pub struct SopsSource {
        file: PathBuf,
    }
    
    impl SopsSource {
        pub fn new(file: PathBuf) -> Self {
            Self { file }
        }
    
        fn decrypt_all(&self) -> Result<HashMap<String, String>, SecretsError> {
            let output = Command::new("sops")
                .args(["--decrypt", &self.file.to_string_lossy()])
                .output()
                .map_err(|e| SecretsError::SourceError {
                    name: "sops".to_string(),
                    source: e.into(),
                })?;
            if !output.status.success() {
                let stderr = String::from_utf8_lossy(&output.stderr);
                return Err(SecretsError::SourceError {
                    name: "sops".to_string(),
                    source: anyhow::anyhow!("sops decrypt failed: {stderr}"),
                });
            }
            let stdout = String::from_utf8_lossy(&output.stdout);
            let mut map = HashMap::new();
            for line in stdout.lines() {
                if let Some((k, v)) = line.split_once('=') {
                    let k = k.trim();
                    if !k.is_empty() && !k.starts_with('#') {
                        map.insert(k.to_string(), v.to_string());
                    }
                }
            }
            Ok(map)
        }
    }
    
    impl SecretSource for SopsSource {
        fn name(&self) -> &str { "sops" }
        fn provider(&self) -> Provider { Provider::Sops }
    
        fn resolve(&self, key: &str) -> Result<Option<String>, SecretsError> {
            self.decrypt_all().map(|m| m.get(key).cloned())
        }
    }
    
    impl EnumerableSecretSource for SopsSource {
        fn resolve_all(&self) -> Result<HashMap<String, String>, SecretsError> {
            self.decrypt_all()
        }
    }
    
  3. Add to sources/mod.rs.

  4. Verify, commit: feat(notsecrets): add SopsSource (tier 1 provider)


Task 8: GsmSource (tier 1)

Crate: notsecrets File(s): crates/notsecrets/src/sources/gsm.rs, crates/notsecrets/src/sources/mod.rs Run: cargo nextest run -p notsecrets -- gsm_source

  1. Write failing test:

    #[cfg(test)]
    mod tests {
        use super::*;
        use crate::config::SecretRef;
        use crate::ports::SecretSource;
    
        #[test]
        fn gsm_source_name_and_provider() {
            let source = GsmSource::new("my-project".to_string());
            assert_eq!(source.name(), "gsm");
            assert_eq!(source.provider(), Provider::Gsm);
        }
    
        #[test]
        fn gsm_source_resolve_without_ref_returns_none() {
            let source = GsmSource::new("my-project".to_string());
            let result = source.resolve("KEY").unwrap();
            assert_eq!(result, None);
        }
    
        #[test]
        fn gsm_source_resolve_ref_wrong_variant_delegates() {
            let source = GsmSource::new("my-project".to_string());
            let wrong_ref = SecretRef::Env;
            let result = source.resolve_ref("KEY", &wrong_ref).unwrap();
            assert_eq!(result, None);
        }
    }
    

    Expected: FAIL

  2. Create crates/notsecrets/src/sources/gsm.rs:

    use crate::config::{Provider, SecretRef};
    use crate::error::SecretsError;
    use crate::ports::SecretSource;
    use std::process::Command;
    
    pub struct GsmSource {
        project: String,
    }
    
    impl GsmSource {
        pub fn new(project: String) -> Self {
            Self { project }
        }
    }
    
    impl SecretSource for GsmSource {
        fn name(&self) -> &str { "gsm" }
        fn provider(&self) -> Provider { Provider::Gsm }
    
        fn resolve(&self, _key: &str) -> Result<Option<String>, SecretsError> {
            Ok(None)
        }
    
        fn resolve_ref(
            &self,
            _key: &str,
            secret_ref: &SecretRef,
        ) -> Result<Option<String>, SecretsError> {
            let SecretRef::Gsm { path } = secret_ref else {
                return self.resolve(_key);
            };
            let output = Command::new("gcloud")
                .args([
                    "secrets", "versions", "access", "latest",
                    "--secret", path,
                    "--project", &self.project,
                ])
                .output()
                .map_err(|e| SecretsError::SourceError {
                    name: "gsm".to_string(),
                    source: e.into(),
                })?;
            if output.status.success() {
                Ok(Some(String::from_utf8_lossy(&output.stdout).trim().to_string()))
            } else {
                let stderr = String::from_utf8_lossy(&output.stderr);
                Err(SecretsError::SourceError {
                    name: "gsm".to_string(),
                    source: anyhow::anyhow!("gcloud secrets failed: {stderr}"),
                })
            }
        }
    }
    
  3. Add to sources/mod.rs.

  4. Verify, commit: feat(notsecrets): add GsmSource (tier 1 provider)


Task 9: Tier 2 stubs (6 sources)

Crate: notsecrets File(s): crates/notsecrets/src/sources/{nuenv,direnv,mise,vault,dotenvy}.rs, crates/notsecrets/src/sources/bitwarden.rs (add SecretSource impl), crates/notsecrets/src/sources/mod.rs Run: cargo nextest run -p notsecrets -- stub

  1. Write failing test (one representative, same pattern for all):

    // crates/notsecrets/src/sources/nuenv.rs
    #[cfg(test)]
    mod tests {
        use super::*;
        use crate::ports::SecretSource;
    
        #[test]
        fn nuenv_stub_resolve_returns_none() {
            let source = NuenvSource;
            assert_eq!(source.resolve("ANY").unwrap(), None);
        }
    
        #[test]
        fn nuenv_stub_name_and_provider() {
            let source = NuenvSource;
            assert_eq!(source.name(), "nuenv");
            assert_eq!(source.provider(), Provider::Nuenv);
        }
    }
    

    Expected: FAIL

  2. Create each stub file. Template (using nuenv as example):

    use crate::config::Provider;
    use crate::error::SecretsError;
    use crate::ports::SecretSource;
    
    pub struct NuenvSource;
    
    impl SecretSource for NuenvSource {
        fn name(&self) -> &str { "nuenv" }
        fn provider(&self) -> Provider { Provider::Nuenv }
        fn resolve(&self, _key: &str) -> Result<Option<String>, SecretsError> {
            Ok(None)
        }
    }
    

    Repeat for: DirenvSource (Provider::Direnv), MiseSource (Provider::Mise), VaultSource (Provider::Vault), DotenvySource (Provider::Dotenvy).

  3. Add SecretSource stub impl to existing bitwarden.rs:

    impl SecretSource for BitwardenSource {
        fn name(&self) -> &str { "bitwarden" }
        fn provider(&self) -> Provider { Provider::Bitwarden }
        fn resolve(&self, _key: &str) -> Result<Option<String>, SecretsError> {
            Ok(None)
        }
    }
    
  4. Update sources/mod.rs with all new modules and re-exports.

  5. Verify:

    cargo nextest run -p notsecrets -- stub  -> all green
    cargo clippy -p notsecrets -- -D warnings -> zero warnings
    
  6. Commit: feat(notsecrets): add tier 2 SecretSource stubs


Task 10: SecretResolver

Crate: notsecrets File(s): crates/notsecrets/src/resolver.rs, crates/notsecrets/src/lib.rs Run: cargo nextest run -p notsecrets -- resolver

  1. Write failing tests:

    // crates/notsecrets/src/resolver.rs
    #[cfg(test)]
    mod tests {
        use super::*;
        use crate::config::{Provider, SecretRef, SecretsConfig};
        use std::collections::HashMap;
    
        fn config_with_env_only() -> SecretsConfig {
            SecretsConfig {
                providers: vec![Provider::Env],
                provider: HashMap::new(),
                secrets: HashMap::new(),
            }
        }
    
        #[test]
        fn resolver_from_config_env_only() {
            let resolver = SecretResolver::from_config(config_with_env_only());
            assert!(resolver.is_ok());
        }
    
        #[test]
        fn resolver_resolve_env_var() {
            unsafe { std::env::set_var("NOTSECRETS_RESOLVER_TEST", "hello"); }
            let resolver = SecretResolver::from_config(config_with_env_only()).unwrap();
            let val = resolver.resolve("NOTSECRETS_RESOLVER_TEST").unwrap();
            assert_eq!(val, Some("hello".to_string()));
            unsafe { std::env::remove_var("NOTSECRETS_RESOLVER_TEST"); }
        }
    
        #[test]
        fn resolver_resolve_missing_returns_none() {
            let resolver = SecretResolver::from_config(config_with_env_only()).unwrap();
            let val = resolver.resolve("NOTSECRETS_NONEXISTENT_99999").unwrap();
            assert_eq!(val, None);
        }
    
        #[test]
        fn resolver_binding_overrides_chain() {
            unsafe { std::env::set_var("NOTSECRETS_BOUND_TEST", "from_env"); }
            let mut config = config_with_env_only();
            // Binding to env with no special ref -- should still resolve via env
            config.secrets.insert(
                "NOTSECRETS_BOUND_TEST".to_string(),
                SecretRef::Env,
            );
            let resolver = SecretResolver::from_config(config).unwrap();
            let val = resolver.resolve("NOTSECRETS_BOUND_TEST").unwrap();
            assert_eq!(val, Some("from_env".to_string()));
            unsafe { std::env::remove_var("NOTSECRETS_BOUND_TEST"); }
        }
    
        #[test]
        fn resolver_resolve_all_includes_env() {
            unsafe { std::env::set_var("NOTSECRETS_ALL_TEST", "present"); }
            let resolver = SecretResolver::from_config(config_with_env_only()).unwrap();
            let map = resolver.resolve_all().unwrap();
            assert_eq!(map.get("NOTSECRETS_ALL_TEST").map(|s| s.as_str()), Some("present"));
            unsafe { std::env::remove_var("NOTSECRETS_ALL_TEST"); }
        }
    
        #[test]
        fn resolver_binding_refs_unknown_provider_errors() {
            let config = SecretsConfig {
                providers: vec![Provider::Env],
                provider: HashMap::new(),
                secrets: HashMap::from([(
                    "KEY".to_string(),
                    SecretRef::Op { uri: "op://x/y/z".to_string() },
                )]),
            };
            let result = SecretResolver::from_config(config);
            assert!(result.is_err());
        }
    }
    

    Expected: FAIL

  2. Create crates/notsecrets/src/resolver.rs:

    use crate::config::{Provider, ProviderConfig, SecretRef, SecretsConfig};
    use crate::error::SecretsError;
    use crate::ports::{EnumerableSecretSource, SecretSource};
    use crate::sources::*;
    use std::collections::HashMap;
    
    pub struct SecretResolver {
        config: SecretsConfig,
        sources: Vec<Box<dyn SecretSource>>,
        enumerable: Vec<Box<dyn EnumerableSecretSource>>,
    }
    
    impl SecretResolver {
        pub fn from_config(config: SecretsConfig) -> Result<Self, SecretsError> {
            // Validate: every binding references a provider in the chain
            for (key, binding) in &config.secrets {
                if !config.providers.contains(&binding.provider()) {
                    return Err(SecretsError::Config(format!(
                        "secret '{key}' references provider '{:?}' not in providers list",
                        binding.provider()
                    )));
                }
            }
    
            let mut sources: Vec<Box<dyn SecretSource>> = Vec::new();
            let mut enumerable: Vec<Box<dyn EnumerableSecretSource>> = Vec::new();
    
            for provider in &config.providers {
                let provider_config = config.provider.get(provider);
                match provider {
                    Provider::Env => {
                        let s = EnvSource;
                        enumerable.push(Box::new(EnvSource));
                        sources.push(Box::new(s));
                    }
                    Provider::Op => {
                        let account = match provider_config {
                            Some(ProviderConfig::Op { account }) => account.clone(),
                            _ => return Err(SecretsError::Config(
                                "op provider requires [provider.op] with account".to_string(),
                            )),
                        };
                        sources.push(Box::new(OpSource::new(account)));
                    }
                    Provider::Dotenvx => {
                        let env_file = match provider_config {
                            Some(ProviderConfig::Dotenvx { env_file }) => env_file.clone(),
                            _ => return Err(SecretsError::Config(
                                "dotenvx provider requires [provider.dotenvx] with env_file".to_string(),
                            )),
                        };
                        let s = DotenvxSource::new(env_file.clone());
                        enumerable.push(Box::new(DotenvxSource::new(env_file)));
                        sources.push(Box::new(s));
                    }
                    Provider::Sops => {
                        let file = match provider_config {
                            Some(ProviderConfig::Sops { file }) => file.clone(),
                            _ => return Err(SecretsError::Config(
                                "sops provider requires [provider.sops] with file".to_string(),
                            )),
                        };
                        let s = SopsSource::new(file.clone());
                        enumerable.push(Box::new(SopsSource::new(file)));
                        sources.push(Box::new(s));
                    }
                    Provider::Gsm => {
                        let project = match provider_config {
                            Some(ProviderConfig::Gsm { project }) => project.clone(),
                            _ => return Err(SecretsError::Config(
                                "gsm provider requires [provider.gsm] with project".to_string(),
                            )),
                        };
                        sources.push(Box::new(GsmSource::new(project)));
                    }
                    // Tier 2: no config required
                    Provider::Nuenv => {
                        sources.push(Box::new(NuenvSource));
                    }
                    Provider::Direnv => {
                        sources.push(Box::new(DirenvSource));
                    }
                    Provider::Mise => {
                        sources.push(Box::new(MiseSource));
                    }
                    Provider::Bitwarden => {
                        let item_name = match provider_config {
                            Some(ProviderConfig::Bitwarden { server_url: _ }) => {
                                "default".to_string()
                            }
                            _ => "default".to_string(),
                        };
                        sources.push(Box::new(BitwardenSource::new(item_name)));
                    }
                    Provider::Vault => {
                        sources.push(Box::new(VaultSource));
                    }
                    Provider::Dotenvy => {
                        sources.push(Box::new(DotenvySource));
                    }
                }
            }
    
            Ok(Self { config, sources, enumerable })
        }
    
        pub fn resolve(&self, key: &str) -> Result<Option<String>, SecretsError> {
            // 1. Explicit binding
            if let Some(secret_ref) = self.config.secrets.get(key) {
                let provider = secret_ref.provider();
                if let Some(source) = self.sources.iter().find(|s| s.provider() == provider) {
                    return source.resolve_ref(key, secret_ref);
                }
            }
    
            // 2. Priority chain
            for source in &self.sources {
                if let Some(value) = source.resolve(key)? {
                    return Ok(Some(value));
                }
            }
            Ok(None)
        }
    
        pub fn resolve_all(&self) -> Result<HashMap<String, String>, SecretsError> {
            let mut map = HashMap::new();
    
            // 1. Merge enumerable sources (priority order, first wins)
            for source in &self.enumerable {
                let source_map = source.resolve_all()?;
                for (k, v) in source_map {
                    map.entry(k).or_insert(v);
                }
            }
    
            // 2. Overlay explicit bindings (always win)
            for (key, secret_ref) in &self.config.secrets {
                let provider = secret_ref.provider();
                if let Some(source) = self.sources.iter().find(|s| s.provider() == provider) {
                    if let Some(value) = source.resolve_ref(key, secret_ref)? {
                        map.insert(key.clone(), value);
                    }
                }
            }
    
            Ok(map)
        }
    
        pub fn source_by_provider(&self, provider: Provider) -> Option<&dyn SecretSource> {
            self.sources.iter().find(|s| s.provider() == provider).map(|s| s.as_ref())
        }
    }
    
  3. Add pub mod resolver; to lib.rs. Re-export:

    pub use resolver::SecretResolver;
    
  4. Verify:

    cargo nextest run -p notsecrets -- resolver  -> all green
    cargo clippy -p notsecrets -- -D warnings    -> zero warnings
    
  5. Commit: feat(notsecrets): add SecretResolver with binding + chain resolution


Task 11: Conformance test suite

Crate: notsecrets File(s): crates/notsecrets/tests/conformance_secret_source.rs Run: cargo nextest run -p notsecrets -- conformance

  1. Create conformance test:

    use notsecrets::ports::{SecretSource, EnumerableSecretSource};
    use notsecrets::sources::*;
    
    fn assert_secret_source_contract(source: &dyn SecretSource) {
        // name is non-empty
        assert!(!source.name().is_empty(), "{}: name() must be non-empty", source.name());
    
        // resolve for unknown key returns Ok(None)
        let result = source.resolve("__CONFORMANCE_NONEXISTENT_KEY_99999__");
        assert!(
            result.is_ok(),
            "{}: resolve(unknown) should be Ok, got {:?}",
            source.name(),
            result,
        );
        assert_eq!(
            result.unwrap(),
            None,
            "{}: resolve(unknown) should be None",
            source.name(),
        );
    }
    
    fn assert_enumerable_contract(source: &dyn EnumerableSecretSource) {
        assert_secret_source_contract(source);
    
        // resolve_all returns Ok
        let map = source.resolve_all();
        assert!(
            map.is_ok(),
            "{}: resolve_all() should be Ok, got {:?}",
            source.name(),
            map,
        );
    }
    
    #[test]
    fn conformance_env_source() {
        let s = EnvSource;
        assert_enumerable_contract(&s);
    }
    
    #[test]
    fn conformance_op_source() {
        let s = OpSource::new("test.1password.com".to_string());
        assert_secret_source_contract(&s);
    }
    
    #[test]
    fn conformance_gsm_source() {
        let s = GsmSource::new("test-project".to_string());
        assert_secret_source_contract(&s);
    }
    
    #[test]
    fn conformance_nuenv_stub() {
        let s = NuenvSource;
        assert_secret_source_contract(&s);
    }
    
    #[test]
    fn conformance_direnv_stub() {
        let s = DirenvSource;
        assert_secret_source_contract(&s);
    }
    
    #[test]
    fn conformance_mise_stub() {
        let s = MiseSource;
        assert_secret_source_contract(&s);
    }
    
    #[test]
    fn conformance_vault_stub() {
        let s = VaultSource;
        assert_secret_source_contract(&s);
    }
    
    #[test]
    fn conformance_dotenvy_stub() {
        let s = DotenvySource;
        assert_secret_source_contract(&s);
    }
    

    Note: DotenvxSource and SopsSource are excluded from conformance because resolve(unknown) triggers a CLI call that may fail without the tool installed. They get unit-tested individually.

  2. Verify:

    cargo nextest run -p notsecrets -- conformance  -> all green
    
  3. Commit: test(notsecrets): add SecretSource conformance test suite


Task 12: notstrap integration

Crate: notstrap File(s): crates/notstrap/src/lib.rs Run: cargo nextest run -p notstrap

  1. Replace EnvInjector type alias and env_injector field with SecretResolver usage. See design doc Section 3 for exact code.

  2. Remove env_injector from BootstrapOptions.

  3. Update any tests in notstrap that construct BootstrapOptions to remove the env_injector field.

  4. Verify:

    cargo nextest run -p notstrap           -> all green
    cargo nextest run --workspace           -> all green
    cargo clippy --workspace -- -D warnings -> zero warnings
    
  5. Commit: refactor(notstrap): use SecretResolver instead of EnvInjector


Task 13: Workspace integration test

Crate: integration tests File(s): tests/integration/tests/cross_crate.rs Run: cargo nextest run -p integration

  1. Add a test that constructs a SecretResolver from a minimal config and resolves an env var end-to-end:

    #[test]
    fn secret_resolver_resolves_env_var_cross_crate() {
        use notsecrets::config::{Provider, SecretsConfig};
        use notsecrets::resolver::SecretResolver;
        use std::collections::HashMap;
    
        unsafe { std::env::set_var("NOTFILES_CROSS_CRATE_TEST", "works"); }
    
        let config = SecretsConfig {
            providers: vec![Provider::Env],
            provider: HashMap::new(),
            secrets: HashMap::new(),
        };
        let resolver = SecretResolver::from_config(config).unwrap();
        let val = resolver.resolve("NOTFILES_CROSS_CRATE_TEST").unwrap();
        assert_eq!(val, Some("works".to_string()));
    
        unsafe { std::env::remove_var("NOTFILES_CROSS_CRATE_TEST"); }
    }
    
  2. Verify:

    cargo nextest run --workspace  -> all green
    
  3. Commit: test(integration): add SecretResolver cross-crate test