From 1359def673350e88f0fa6a3da068595247f92282 Mon Sep 17 00:00:00 2001 From: Joseph O'Brien Date: Sat, 6 Jun 2026 09:49:05 -0400 Subject: [PATCH] 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. --- .ctx/HANDOFF.md | 11 +- .ctx/HANDOFF.notfiles.notfiles.yaml | 26 +- .../72758-1776397235/state/server-info | 1 - .../72758-1776397235/state/server.log | 1 + Cargo.lock | 2 + crates/notsecrets/Cargo.toml | 2 + crates/notsecrets/src/config.rs | 170 ++ crates/notsecrets/src/error.rs | 39 + crates/notsecrets/src/lib.rs | 12 +- crates/notsecrets/src/ports.rs | 52 +- crates/notsecrets/src/resolver.rs | 222 +++ crates/notsecrets/src/sources/bitwarden.rs | 43 +- crates/notsecrets/src/sources/direnv.rs | 35 + crates/notsecrets/src/sources/dotenvx.rs | 89 + crates/notsecrets/src/sources/dotenvy.rs | 35 + crates/notsecrets/src/sources/env.rs | 62 + crates/notsecrets/src/sources/gsm.rs | 92 ++ crates/notsecrets/src/sources/mise.rs | 35 + crates/notsecrets/src/sources/mod.rs | 20 + crates/notsecrets/src/sources/nuenv.rs | 37 + crates/notsecrets/src/sources/op.rs | 84 + crates/notsecrets/src/sources/sops.rs | 83 + crates/notsecrets/src/sources/vault.rs | 35 + .../tests/conformance_secret_source.rs | 82 + .../2026-06-06-notsecrets-multi-provider.md | 1445 +++++++++++++++++ ...-06-06-notsecrets-multi-provider-design.md | 383 +++++ tests/integration/tests/cross_crate.rs | 20 + 27 files changed, 3079 insertions(+), 39 deletions(-) delete mode 100644 .superpowers/brainstorm/72758-1776397235/state/server-info create mode 100644 crates/notsecrets/src/config.rs create mode 100644 crates/notsecrets/src/resolver.rs create mode 100644 crates/notsecrets/src/sources/direnv.rs create mode 100644 crates/notsecrets/src/sources/dotenvx.rs create mode 100644 crates/notsecrets/src/sources/dotenvy.rs create mode 100644 crates/notsecrets/src/sources/env.rs create mode 100644 crates/notsecrets/src/sources/gsm.rs create mode 100644 crates/notsecrets/src/sources/mise.rs create mode 100644 crates/notsecrets/src/sources/nuenv.rs create mode 100644 crates/notsecrets/src/sources/op.rs create mode 100644 crates/notsecrets/src/sources/sops.rs create mode 100644 crates/notsecrets/src/sources/vault.rs create mode 100644 crates/notsecrets/tests/conformance_secret_source.rs create mode 100644 docs/superpowers/plans/2026-06-06-notsecrets-multi-provider.md create mode 100644 docs/superpowers/specs/2026-06-06-notsecrets-multi-provider-design.md diff --git a/.ctx/HANDOFF.md b/.ctx/HANDOFF.md index 3ea22b3..4adaeb6 100644 --- a/.ctx/HANDOFF.md +++ b/.ctx/HANDOFF.md @@ -1,17 +1,14 @@ -# Handoff — notfiles (2026-04-17) +# Handoff — notfiles (2026-06-06) -**Branch:** main | **Build:** clean | **Tests:** passing -Added notnet crate with YubikeySource and threaded Tailscale into notstrap; Tailscale -integration design spec written. +**Branch:** main | **Build:** unknown | **Tests:** unknown ## Items | ID | P | Status | Title | |---|---|---|---| -| - | - | - | No open local handoff items | ## Log -- 2026-04-17: Added notnet crate with YubikeySource and threaded Tailscale into notstrap; design spec complete [c7c074d, dfdd9ca] -- 2026-04-15: Hardened bootstrap and hook-state failure handling, made notfiles FileStore usage real, and fixed copy-drift safety coverage [67f20ee] +- 2026-04-17: Added notnet crate with YubikeySource and threaded Tailscale into notstrap; design spec complete +- 2026-04-15: Hardened bootstrap and hook-state failure handling, made notfiles FileStore usage real, and fixed copy-drift safety coverage - 2026-04-15: Pruned completed items from handoff; GitHub issues plus doob are now the source of truth for active work diff --git a/.ctx/HANDOFF.notfiles.notfiles.yaml b/.ctx/HANDOFF.notfiles.notfiles.yaml index d9943da..4948220 100644 --- a/.ctx/HANDOFF.notfiles.notfiles.yaml +++ b/.ctx/HANDOFF.notfiles.notfiles.yaml @@ -1,21 +1,19 @@ project: notfiles id: notfile -updated: 2026-04-17 +updated: 2026-06-06 +items: [] +log: +- date: 2026-04-17 + summary: Added notnet crate with YubikeySource and threaded Tailscale into notstrap; design spec complete + commits: [] +- date: 2026-04-15 + summary: Hardened bootstrap and hook-state failure handling, made notfiles FileStore usage real, and fixed copy-drift safety coverage + commits: [] +- date: 2026-04-15 + summary: Pruned completed items from handoff; GitHub issues plus doob are now the source of truth for active work + commits: [] branch: main state: tests: passing build: clean notes: Handoff is now ephemeral; track active work via GitHub issues synced through doob. -items: [] -log: -- date: 2026-04-17 - summary: Added notnet crate with YubikeySource and threaded Tailscale into notstrap; design spec complete - commits: - - c7c074d - - dfdd9ca -- date: 2026-04-15 - summary: Hardened bootstrap and hook-state failure handling, made notfiles FileStore usage real, and fixed copy-drift safety coverage - commits: - - 67f20ee -- date: 2026-04-15 - summary: Pruned completed items from handoff; GitHub issues plus doob are now the source of truth for active work diff --git a/.superpowers/brainstorm/72758-1776397235/state/server-info b/.superpowers/brainstorm/72758-1776397235/state/server-info deleted file mode 100644 index 613d21c..0000000 --- a/.superpowers/brainstorm/72758-1776397235/state/server-info +++ /dev/null @@ -1 +0,0 @@ -{"type":"server-started","port":63245,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:63245","screen_dir":"/Users/joe/dev/notfiles/.superpowers/brainstorm/72758-1776397235/content","state_dir":"/Users/joe/dev/notfiles/.superpowers/brainstorm/72758-1776397235/state"} diff --git a/.superpowers/brainstorm/72758-1776397235/state/server.log b/.superpowers/brainstorm/72758-1776397235/state/server.log index 31e0e26..584bbb4 100644 --- a/.superpowers/brainstorm/72758-1776397235/state/server.log +++ b/.superpowers/brainstorm/72758-1776397235/state/server.log @@ -1,2 +1,3 @@ {"type":"server-started","port":63245,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:63245","screen_dir":"/Users/joe/dev/notfiles/.superpowers/brainstorm/72758-1776397235/content","state_dir":"/Users/joe/dev/notfiles/.superpowers/brainstorm/72758-1776397235/state"} {"type":"screen-added","file":"/Users/joe/dev/notfiles/.superpowers/brainstorm/72758-1776397235/content/architecture.html"} +{"type":"server-stopped","reason":"idle timeout"} diff --git a/Cargo.lock b/Cargo.lock index a14ba29..4d94c6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -967,9 +967,11 @@ dependencies = [ "rpassword", "rsa", "scrypt", + "serde", "sha2", "tempfile", "thiserror 2.0.18", + "toml", "x25519-dalek", "yubikey", "zeroize", diff --git a/crates/notsecrets/Cargo.toml b/crates/notsecrets/Cargo.toml index 32befa6..9c1abbf 100644 --- a/crates/notsecrets/Cargo.toml +++ b/crates/notsecrets/Cargo.toml @@ -10,6 +10,8 @@ anyhow = { workspace = true } thiserror = { workspace = true } rpassword = { workspace = true } dirs = { workspace = true } +serde = { workspace = true } +toml = { workspace = true } x25519-dalek = { version = "2", features = ["static_secrets"] } ed25519-dalek = "2" rsa = { version = "0.9", features = ["sha2"] } diff --git a/crates/notsecrets/src/config.rs b/crates/notsecrets/src/config.rs new file mode 100644 index 0000000..cd685b6 --- /dev/null +++ b/crates/notsecrets/src/config.rs @@ -0,0 +1,170 @@ +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 }, + Vault { addr: String, mount: Option }, + Dotenvy { path: PathBuf }, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "source", rename_all = "lowercase")] +pub enum SecretRef { + Env, + Op { uri: String }, + Dotenvx { key: Option }, + Sops { key: Option }, + Gsm { path: String }, + Nuenv { key: Option }, + Direnv { key: Option }, + Mise { key: Option }, + Bitwarden { item: String, field: Option }, + Vault { path: String, field: Option }, + Dotenvy { key: Option }, +} + +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, + #[serde(default)] + pub provider: HashMap, + #[serde(default)] + pub secrets: HashMap, +} + +pub fn load_config(path: &Path) -> Result { + 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}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_deserializes_from_string() { + let p: Provider = toml::Value::String("op".to_string()).try_into().unwrap(); + assert_eq!(p, Provider::Op); + } + + #[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 = 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()); + } +} diff --git a/crates/notsecrets/src/error.rs b/crates/notsecrets/src/error.rs index d52a604..15712dc 100644 --- a/crates/notsecrets/src/error.rs +++ b/crates/notsecrets/src/error.rs @@ -13,3 +13,42 @@ pub enum AgeError { #[error("identity source failed ({name}): {source}")] SourceError { name: String, source: anyhow::Error }, } + +#[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), +} + +#[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")); + } +} diff --git a/crates/notsecrets/src/lib.rs b/crates/notsecrets/src/lib.rs index c31630c..2fe288e 100644 --- a/crates/notsecrets/src/lib.rs +++ b/crates/notsecrets/src/lib.rs @@ -1,3 +1,4 @@ +pub mod config; pub mod decrypt; pub mod encrypt; pub mod error; @@ -5,20 +6,27 @@ pub mod format; pub mod identities; pub mod ports; pub mod recipients; +pub mod resolver; pub mod sources; +pub use config::{Provider, ProviderConfig, SecretRef, SecretsConfig, load_config}; pub use decrypt::Decryptor; pub use encrypt::Encryptor; pub use error::AgeError; +pub use error::SecretsError; pub use identities::{ EncryptedIdentity, FileKey, Header, Identity, ScryptIdentity, SshEd25519Identity, SshRsaIdentity, Stanza, X25519Identity, }; -pub use ports::IdentitySource; +pub use ports::{EnumerableSecretSource, IdentitySource, SecretSource}; pub use recipients::{ Recipient, ScryptRecipient, SshEd25519Recipient, SshRsaRecipient, X25519Recipient, }; -pub use sources::{BitwardenSource, FileSource, PromptSource, YubikeySource}; +pub use resolver::SecretResolver; +pub use sources::{ + BitwardenSource, DirenvSource, DotenvxSource, DotenvySource, EnvSource, FileSource, GsmSource, + MiseSource, NuenvSource, OpSource, PromptSource, SopsSource, VaultSource, YubikeySource, +}; /// Try each `IdentitySource` in order; collect all identities that load successfully. /// diff --git a/crates/notsecrets/src/ports.rs b/crates/notsecrets/src/ports.rs index a1e6be0..95f3c7f 100644 --- a/crates/notsecrets/src/ports.rs +++ b/crates/notsecrets/src/ports.rs @@ -1,5 +1,7 @@ -use crate::error::AgeError; +use crate::config::{Provider, SecretRef}; +use crate::error::{AgeError, SecretsError}; use crate::identities::Identity; +use std::collections::HashMap; /// Infra boundary trait: an identity resolver that loads key material from an /// external source and returns a concrete Identity. @@ -10,3 +12,51 @@ pub trait IdentitySource { /// Load and return a concrete identity from the source. fn load(&self) -> Result, AgeError>; } + +/// Port: resolve a single secret by key name from an external provider. +pub trait SecretSource { + fn name(&self) -> &str; + fn provider(&self) -> Provider; + fn resolve(&self, key: &str) -> Result, SecretsError>; + + /// Resolve using a provider-specific typed reference. + /// Default: ignores ref, falls back to resolve(key). + fn resolve_ref( + &self, + key: &str, + _secret_ref: &SecretRef, + ) -> Result, SecretsError> { + self.resolve(key) + } +} + +/// Extended port: sources that can enumerate all available secrets. +pub trait EnumerableSecretSource: SecretSource { + fn resolve_all(&self) -> Result, SecretsError>; +} + +#[cfg(test)] +mod tests { + use super::*; + + struct FakeSource; + impl SecretSource for FakeSource { + fn name(&self) -> &str { + "fake" + } + fn provider(&self) -> Provider { + Provider::Env + } + fn resolve(&self, _key: &str) -> Result, 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())); + } +} diff --git a/crates/notsecrets/src/resolver.rs b/crates/notsecrets/src/resolver.rs new file mode 100644 index 0000000..6ac9505 --- /dev/null +++ b/crates/notsecrets/src/resolver.rs @@ -0,0 +1,222 @@ +use crate::config::{Provider, ProviderConfig, SecretsConfig}; +use crate::error::SecretsError; +use crate::ports::{EnumerableSecretSource, SecretSource}; +use crate::sources::*; +use std::collections::HashMap; + +pub struct SecretResolver { + config: SecretsConfig, + sources: Vec>, + enumerable: Vec>, +} + +impl SecretResolver { + pub fn from_config(config: SecretsConfig) -> Result { + // 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> = Vec::new(); + let mut enumerable: Vec> = Vec::new(); + + for provider in &config.providers { + let provider_config = config.provider.get(provider); + match provider { + Provider::Env => { + enumerable.push(Box::new(EnvSource)); + sources.push(Box::new(EnvSource)); + } + 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(), + )); + } + }; + enumerable.push(Box::new(DotenvxSource::new(env_file.clone()))); + sources.push(Box::new(DotenvxSource::new(env_file))); + } + 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(), + )); + } + }; + enumerable.push(Box::new(SopsSource::new(file.clone()))); + sources.push(Box::new(SopsSource::new(file))); + } + 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))); + } + 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 { .. }) => "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, 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, 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) + && let Some(value) = source.resolve_ref(key, secret_ref)? + { + map.insert(key.clone(), value); + } + } + + Ok(map) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Provider, SecretRef, SecretsConfig}; + + 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(); + 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()); + } +} diff --git a/crates/notsecrets/src/sources/bitwarden.rs b/crates/notsecrets/src/sources/bitwarden.rs index 83dad17..b3b095a 100644 --- a/crates/notsecrets/src/sources/bitwarden.rs +++ b/crates/notsecrets/src/sources/bitwarden.rs @@ -1,7 +1,8 @@ -use crate::error::AgeError; +use crate::config::Provider; +use crate::error::{AgeError, SecretsError}; use crate::identities::Identity; use crate::identities::x25519::X25519Identity; -use crate::ports::IdentitySource; +use crate::ports::{IdentitySource, SecretSource}; use std::io::Write; use std::process::{Command, Stdio}; @@ -24,7 +25,7 @@ impl BitwardenSource { .unwrap_or(false); if !bw_ok { return Err(AgeError::SourceError { - name: self.name().to_string(), + name: "bitwarden".to_string(), source: anyhow::anyhow!("bw CLI not found in PATH"), }); } @@ -34,7 +35,7 @@ impl BitwardenSource { let password = rpassword::prompt_password("Bitwarden master password: ").map_err(|e| { AgeError::SourceError { - name: self.name().to_string(), + name: "bitwarden".to_string(), source: anyhow::anyhow!("could not read password: {e}"), } })?; @@ -46,26 +47,26 @@ impl BitwardenSource { .stderr(Stdio::piped()) .spawn() .map_err(|e| AgeError::SourceError { - name: self.name().to_string(), + name: "bitwarden".to_string(), source: anyhow::anyhow!("bw unlock spawn: {e}"), })?; if let Some(mut stdin) = child.stdin.take() { stdin .write_all(password.as_bytes()) .map_err(|e| AgeError::SourceError { - name: self.name().to_string(), + name: "bitwarden".to_string(), source: anyhow::anyhow!("bw unlock stdin write: {e}"), })?; } let output = child .wait_with_output() .map_err(|e| AgeError::SourceError { - name: self.name().to_string(), + name: "bitwarden".to_string(), source: anyhow::anyhow!("bw unlock wait: {e}"), })?; if !output.status.success() { return Err(AgeError::SourceError { - name: self.name().to_string(), + name: "bitwarden".to_string(), source: anyhow::anyhow!( "bw unlock failed: {}", String::from_utf8_lossy(&output.stderr) @@ -74,7 +75,7 @@ impl BitwardenSource { } String::from_utf8(output.stdout) .map_err(|e| AgeError::SourceError { - name: self.name().to_string(), + name: "bitwarden".to_string(), source: anyhow::anyhow!("bw unlock output UTF-8: {e}"), })? .trim() @@ -90,12 +91,12 @@ impl BitwardenSource { .args(["get", "notes", &self.item_name, "--session", &session]) .output() .map_err(|e| AgeError::SourceError { - name: self.name().to_string(), + name: "bitwarden".to_string(), source: anyhow::anyhow!("bw get spawn: {e}"), })?; if !output.status.success() { return Err(AgeError::SourceError { - name: self.name().to_string(), + name: "bitwarden".to_string(), source: anyhow::anyhow!( "bw get notes '{}' failed: {}", self.item_name, @@ -105,14 +106,14 @@ impl BitwardenSource { } let key = String::from_utf8(output.stdout) .map_err(|e| AgeError::SourceError { - name: self.name().to_string(), + name: "bitwarden".to_string(), source: anyhow::anyhow!("bw output UTF-8: {e}"), })? .trim() .to_string(); if key.is_empty() { return Err(AgeError::SourceError { - name: self.name().to_string(), + name: "bitwarden".to_string(), source: anyhow::anyhow!("Bitwarden item '{}' has empty notes", self.item_name), }); } @@ -120,6 +121,20 @@ impl BitwardenSource { } } +impl SecretSource for BitwardenSource { + fn name(&self) -> &str { + "bitwarden" + } + + fn provider(&self) -> Provider { + Provider::Bitwarden + } + + fn resolve(&self, _key: &str) -> Result, SecretsError> { + Ok(None) + } +} + impl IdentitySource for BitwardenSource { fn name(&self) -> &str { "bitwarden" @@ -129,7 +144,7 @@ impl IdentitySource for BitwardenSource { let key = self.retrieve_key()?; let identity = X25519Identity::from_bech32(key.trim()).map_err(|e| AgeError::SourceError { - name: self.name().to_string(), + name: "bitwarden".to_string(), source: anyhow::anyhow!("key is not a valid AGE-SECRET-KEY-1: {e}"), })?; Ok(Box::new(identity)) diff --git a/crates/notsecrets/src/sources/direnv.rs b/crates/notsecrets/src/sources/direnv.rs new file mode 100644 index 0000000..429ac53 --- /dev/null +++ b/crates/notsecrets/src/sources/direnv.rs @@ -0,0 +1,35 @@ +use crate::config::Provider; +use crate::error::SecretsError; +use crate::ports::SecretSource; + +pub struct DirenvSource; + +impl SecretSource for DirenvSource { + fn name(&self) -> &str { + "direnv" + } + + fn provider(&self) -> Provider { + Provider::Direnv + } + + fn resolve(&self, _key: &str) -> Result, SecretsError> { + Ok(None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn direnv_stub_resolve_returns_none() { + assert_eq!(DirenvSource.resolve("ANY").unwrap(), None); + } + + #[test] + fn direnv_stub_name_and_provider() { + assert_eq!(DirenvSource.name(), "direnv"); + assert_eq!(DirenvSource.provider(), Provider::Direnv); + } +} diff --git a/crates/notsecrets/src/sources/dotenvx.rs b/crates/notsecrets/src/sources/dotenvx.rs new file mode 100644 index 0000000..d2330a3 --- /dev/null +++ b/crates/notsecrets/src/sources/dotenvx.rs @@ -0,0 +1,89 @@ +use crate::config::Provider; +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, 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, SecretsError> { + self.decrypt_all().map(|m| m.get(key).cloned()) + } +} + +impl EnumerableSecretSource for DotenvxSource { + fn resolve_all(&self) -> Result, SecretsError> { + self.decrypt_all() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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()); + } +} diff --git a/crates/notsecrets/src/sources/dotenvy.rs b/crates/notsecrets/src/sources/dotenvy.rs new file mode 100644 index 0000000..55448ea --- /dev/null +++ b/crates/notsecrets/src/sources/dotenvy.rs @@ -0,0 +1,35 @@ +use crate::config::Provider; +use crate::error::SecretsError; +use crate::ports::SecretSource; + +pub struct DotenvySource; + +impl SecretSource for DotenvySource { + fn name(&self) -> &str { + "dotenvy" + } + + fn provider(&self) -> Provider { + Provider::Dotenvy + } + + fn resolve(&self, _key: &str) -> Result, SecretsError> { + Ok(None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dotenvy_stub_resolve_returns_none() { + assert_eq!(DotenvySource.resolve("ANY").unwrap(), None); + } + + #[test] + fn dotenvy_stub_name_and_provider() { + assert_eq!(DotenvySource.name(), "dotenvy"); + assert_eq!(DotenvySource.provider(), Provider::Dotenvy); + } +} diff --git a/crates/notsecrets/src/sources/env.rs b/crates/notsecrets/src/sources/env.rs new file mode 100644 index 0000000..63f7e2c --- /dev/null +++ b/crates/notsecrets/src/sources/env.rs @@ -0,0 +1,62 @@ +use crate::config::Provider; +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, SecretsError> { + Ok(std::env::var(key).ok()) + } +} + +impl EnumerableSecretSource for EnvSource { + fn resolve_all(&self) -> Result, SecretsError> { + Ok(std::env::vars().collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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_home_or_user() { + let source = EnvSource; + let map = source.resolve_all().unwrap(); + 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); + } +} diff --git a/crates/notsecrets/src/sources/gsm.rs b/crates/notsecrets/src/sources/gsm.rs new file mode 100644 index 0000000..cd2884c --- /dev/null +++ b/crates/notsecrets/src/sources/gsm.rs @@ -0,0 +1,92 @@ +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, SecretsError> { + Ok(None) + } + + fn resolve_ref( + &self, + key: &str, + secret_ref: &SecretRef, + ) -> Result, 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}"), + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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); + } +} diff --git a/crates/notsecrets/src/sources/mise.rs b/crates/notsecrets/src/sources/mise.rs new file mode 100644 index 0000000..9f8e678 --- /dev/null +++ b/crates/notsecrets/src/sources/mise.rs @@ -0,0 +1,35 @@ +use crate::config::Provider; +use crate::error::SecretsError; +use crate::ports::SecretSource; + +pub struct MiseSource; + +impl SecretSource for MiseSource { + fn name(&self) -> &str { + "mise" + } + + fn provider(&self) -> Provider { + Provider::Mise + } + + fn resolve(&self, _key: &str) -> Result, SecretsError> { + Ok(None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mise_stub_resolve_returns_none() { + assert_eq!(MiseSource.resolve("ANY").unwrap(), None); + } + + #[test] + fn mise_stub_name_and_provider() { + assert_eq!(MiseSource.name(), "mise"); + assert_eq!(MiseSource.provider(), Provider::Mise); + } +} diff --git a/crates/notsecrets/src/sources/mod.rs b/crates/notsecrets/src/sources/mod.rs index 721a4f9..e4a1d97 100644 --- a/crates/notsecrets/src/sources/mod.rs +++ b/crates/notsecrets/src/sources/mod.rs @@ -1,10 +1,30 @@ pub mod bitwarden; +pub mod direnv; +pub mod dotenvx; +pub mod dotenvy; +pub mod env; pub mod file; +pub mod gsm; +pub mod mise; +pub mod nuenv; +pub mod op; pub mod prompt; +pub mod sops; +pub mod vault; pub mod yubikey; pub use crate::ports::IdentitySource; pub use bitwarden::BitwardenSource; +pub use direnv::DirenvSource; +pub use dotenvx::DotenvxSource; +pub use dotenvy::DotenvySource; +pub use env::EnvSource; pub use file::FileSource; +pub use gsm::GsmSource; +pub use mise::MiseSource; +pub use nuenv::NuenvSource; +pub use op::OpSource; pub use prompt::PromptSource; +pub use sops::SopsSource; +pub use vault::VaultSource; pub use yubikey::YubikeySource; diff --git a/crates/notsecrets/src/sources/nuenv.rs b/crates/notsecrets/src/sources/nuenv.rs new file mode 100644 index 0000000..01f6737 --- /dev/null +++ b/crates/notsecrets/src/sources/nuenv.rs @@ -0,0 +1,37 @@ +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, SecretsError> { + Ok(None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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); + } +} diff --git a/crates/notsecrets/src/sources/op.rs b/crates/notsecrets/src/sources/op.rs new file mode 100644 index 0000000..0f66903 --- /dev/null +++ b/crates/notsecrets/src/sources/op.rs @@ -0,0 +1,84 @@ +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, SecretsError> { + Ok(None) + } + + fn resolve_ref( + &self, + key: &str, + secret_ref: &SecretRef, + ) -> Result, 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}"), + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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() { + 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); + } +} diff --git a/crates/notsecrets/src/sources/sops.rs b/crates/notsecrets/src/sources/sops.rs new file mode 100644 index 0000000..9797c2b --- /dev/null +++ b/crates/notsecrets/src/sources/sops.rs @@ -0,0 +1,83 @@ +use crate::config::Provider; +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, 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, SecretsError> { + self.decrypt_all().map(|m| m.get(key).cloned()) + } +} + +impl EnumerableSecretSource for SopsSource { + fn resolve_all(&self) -> Result, SecretsError> { + self.decrypt_all() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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()); + } +} diff --git a/crates/notsecrets/src/sources/vault.rs b/crates/notsecrets/src/sources/vault.rs new file mode 100644 index 0000000..844d25a --- /dev/null +++ b/crates/notsecrets/src/sources/vault.rs @@ -0,0 +1,35 @@ +use crate::config::Provider; +use crate::error::SecretsError; +use crate::ports::SecretSource; + +pub struct VaultSource; + +impl SecretSource for VaultSource { + fn name(&self) -> &str { + "vault" + } + + fn provider(&self) -> Provider { + Provider::Vault + } + + fn resolve(&self, _key: &str) -> Result, SecretsError> { + Ok(None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vault_stub_resolve_returns_none() { + assert_eq!(VaultSource.resolve("ANY").unwrap(), None); + } + + #[test] + fn vault_stub_name_and_provider() { + assert_eq!(VaultSource.name(), "vault"); + assert_eq!(VaultSource.provider(), Provider::Vault); + } +} diff --git a/crates/notsecrets/tests/conformance_secret_source.rs b/crates/notsecrets/tests/conformance_secret_source.rs new file mode 100644 index 0000000..7f6fca3 --- /dev/null +++ b/crates/notsecrets/tests/conformance_secret_source.rs @@ -0,0 +1,82 @@ +use notsecrets::ports::{EnumerableSecretSource, SecretSource}; +use notsecrets::sources::*; + +fn assert_secret_source_contract(source: &dyn SecretSource) { + assert!( + !source.name().is_empty(), + "{:?}: name() must be non-empty", + source.provider() + ); + + // 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); + + let map = source.resolve_all(); + assert!( + map.is_ok(), + "{}: resolve_all() should be Ok, got {:?}", + source.name(), + map, + ); +} + +#[test] +fn conformance_env_source() { + assert_enumerable_contract(&EnvSource); +} + +#[test] +fn conformance_op_source() { + assert_secret_source_contract(&OpSource::new("test.1password.com".to_string())); +} + +#[test] +fn conformance_gsm_source() { + assert_secret_source_contract(&GsmSource::new("test-project".to_string())); +} + +#[test] +fn conformance_nuenv_stub() { + assert_secret_source_contract(&NuenvSource); +} + +#[test] +fn conformance_direnv_stub() { + assert_secret_source_contract(&DirenvSource); +} + +#[test] +fn conformance_mise_stub() { + assert_secret_source_contract(&MiseSource); +} + +#[test] +fn conformance_vault_stub() { + assert_secret_source_contract(&VaultSource); +} + +#[test] +fn conformance_dotenvy_stub() { + assert_secret_source_contract(&DotenvySource); +} + +#[test] +fn conformance_bitwarden_stub() { + assert_secret_source_contract(&BitwardenSource::new("test-item")); +} diff --git a/docs/superpowers/plans/2026-06-06-notsecrets-multi-provider.md b/docs/superpowers/plans/2026-06-06-notsecrets-multi-provider.md new file mode 100644 index 0000000..ff62c2d --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-notsecrets-multi-provider.md @@ -0,0 +1,1445 @@ +# 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` + +## 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: + + ```rust + // 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`: + + ```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`: + + ```rust + #[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: + + ```rust + // 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 = 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`: + + ```rust + 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 }, + Vault { addr: String, mount: Option }, + Dotenvy { path: PathBuf }, + } + + #[derive(Debug, Deserialize)] + #[serde(tag = "source", rename_all = "lowercase")] + pub enum SecretRef { + Env, + Op { uri: String }, + Dotenvx { key: Option }, + Sops { key: Option }, + Gsm { path: String }, + Nuenv { key: Option }, + Direnv { key: Option }, + Mise { key: Option }, + Bitwarden { item: String, field: Option }, + Vault { path: String, field: Option }, + Dotenvy { key: Option }, + } + + 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, + #[serde(default)] + pub provider: HashMap, + #[serde(default)] + pub secrets: HashMap, + } + + pub fn load_config(path: &Path) -> Result { + 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: + + ```rust + 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: + + ```rust + // 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, 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`: + + ```rust + 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, SecretsError>; + fn resolve_ref( + &self, + key: &str, + _secret_ref: &SecretRef, + ) -> Result, SecretsError> { + self.resolve(key) + } + } + + pub trait EnumerableSecretSource: SecretSource { + fn resolve_all(&self) -> Result, SecretsError>; + } + ``` + +3. Add re-exports to `lib.rs`: + + ```rust + 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: + + ```rust + // 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`: + + ```rust + 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, SecretsError> { + Ok(std::env::var(key).ok()) + } + } + + impl EnumerableSecretSource for EnvSource { + fn resolve_all(&self) -> Result, 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: + + ```rust + // 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`: + + ```rust + 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, SecretsError> { + Ok(None) + } + + fn resolve_ref( + &self, + _key: &str, + secret_ref: &SecretRef, + ) -> Result, 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: + + ```rust + #[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`: + + ```rust + 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, 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, SecretsError> { + self.decrypt_all().map(|m| m.get(key).cloned()) + } + } + + impl EnumerableSecretSource for DotenvxSource { + fn resolve_all(&self) -> Result, 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: + + ```rust + #[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`: + + ```rust + 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, 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, SecretsError> { + self.decrypt_all().map(|m| m.get(key).cloned()) + } + } + + impl EnumerableSecretSource for SopsSource { + fn resolve_all(&self) -> Result, 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: + + ```rust + #[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`: + + ```rust + 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, SecretsError> { + Ok(None) + } + + fn resolve_ref( + &self, + _key: &str, + secret_ref: &SecretRef, + ) -> Result, 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): + + ```rust + // 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): + + ```rust + 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, 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`: + + ```rust + impl SecretSource for BitwardenSource { + fn name(&self) -> &str { "bitwarden" } + fn provider(&self) -> Provider { Provider::Bitwarden } + fn resolve(&self, _key: &str) -> Result, 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: + + ```rust + // 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`: + + ```rust + 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>, + enumerable: Vec>, + } + + impl SecretResolver { + pub fn from_config(config: SecretsConfig) -> Result { + // 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> = Vec::new(); + let mut enumerable: Vec> = 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, 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, 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: + + ```rust + 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: + + ```rust + 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: + + ```rust + #[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` diff --git a/docs/superpowers/specs/2026-06-06-notsecrets-multi-provider-design.md b/docs/superpowers/specs/2026-06-06-notsecrets-multi-provider-design.md new file mode 100644 index 0000000..b541506 --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-notsecrets-multi-provider-design.md @@ -0,0 +1,383 @@ +# notsecrets Multi-Provider Secret Resolution + +**Date:** 2026-06-06 +**Crate:** `notsecrets` +**Status:** Approved design, pending implementation plan + +## Goal + +Extend notsecrets with a general-purpose secret/env resolution system alongside +the existing age identity system. Multiple backends resolve arbitrary secrets +(API keys, tokens, env vars) via a typed provider chain with explicit bindings. + +## Non-Goals + +- Replacing the existing `IdentitySource` / age identity system +- Runtime hot-reloading of config +- Secret rotation or lease management +- Encrypting secrets (write path) -- read-only resolution + +## Architecture + +### Two independent port systems in one crate + +``` +notsecrets + +-- Age identity system (existing, untouched) + | IdentitySource trait -> BitwardenSource, FileSource, PromptSource, YubikeySource + | resolve_identities() -> Vec> + | + +-- Secret resolution system (new) + SecretSource trait -> 11 provider adapters + SecretResolver -> config-driven resolution with bindings +``` + +### Core Traits + +```rust +/// Port: resolve a single secret by key name. +pub trait SecretSource { + fn name(&self) -> &str; + fn provider(&self) -> Provider; + fn resolve(&self, key: &str) -> Result, SecretsError>; + + /// Resolve using a provider-specific typed reference. + /// Default: ignores ref, falls back to resolve(key). + fn resolve_ref( + &self, + key: &str, + secret_ref: &SecretRef, + ) -> Result, SecretsError> { + let _ = secret_ref; + self.resolve(key) + } +} + +/// Extended port: sources that can enumerate all available secrets. +pub trait EnumerableSecretSource: SecretSource { + fn resolve_all(&self) -> Result, SecretsError>; +} +``` + +ISP rationale: not every backend supports enumeration. `OpSource` and `GsmSource` +require explicit refs for lookup -- they cannot enumerate a vault without extra +config. Forcing `resolve_all()` on every impl would produce incomplete maps. + +### Type System + +#### Provider enum + +```rust +#[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, +} +``` + +#### Provider config (tagged enum) + +```rust +#[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 }, + Vault { addr: String, mount: Option }, + Dotenvy { path: PathBuf }, +} +``` + +#### Secret references (tagged enum) + +```rust +#[derive(Debug, Deserialize)] +#[serde(tag = "source", rename_all = "lowercase")] +pub enum SecretRef { + Env, + Op { uri: String }, + Dotenvx { key: Option }, + Sops { key: Option }, + Gsm { path: String }, + Nuenv { key: Option }, + Direnv { key: Option }, + Mise { key: Option }, + Bitwarden { item: String, field: Option }, + Vault { path: String, field: Option }, + Dotenvy { key: Option }, +} + +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, + } + } +} +``` + +#### Config root + +```rust +#[derive(Debug, Deserialize)] +pub struct SecretsConfig { + pub providers: Vec, + #[serde(default)] + pub provider: HashMap, + #[serde(default)] + pub secrets: HashMap, +} +``` + +### Compile-Time Guarantees + +- Misspelled provider name in TOML -> serde deserialization error +- Binding with wrong fields for a provider -> serde error +- `from_config` validates every binding references a provider in the chain + (enum comparison, not string matching) +- `resolve_ref` receives typed variant; compiler warns on missing arms + +### Domain Error + +```rust +#[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), +} +``` + +Adapters map infra errors (reqwest, tonic, serde, io) into +`SecretsError::SourceError`. Domain logic never sees infra error types. + +### SecretResolver (domain logic) + +```rust +pub struct SecretResolver { + config: SecretsConfig, + sources: Vec>, + enumerable: Vec>, +} + +impl SecretResolver { + pub fn from_config(config: SecretsConfig) -> Result; + pub fn resolve(&self, key: &str) -> Result, SecretsError>; + pub fn resolve_all(&self) -> Result, SecretsError>; +} +``` + +#### resolve(key) flow + +1. Check explicit bindings (`config.secrets`). If key has a `SecretRef`, + route to the named source via `source.resolve_ref(key, &secret_ref)`. +2. Otherwise, walk the priority chain (`sources` in order). + First `Some` wins. + +#### resolve_all() flow + +1. Iterate enumerable sources in priority order. Merge maps -- earlier + source wins on key collision. +2. Overlay explicit bindings: resolve each bound key individually via + `resolve_ref`. Bindings always override chain results. + +### No inject_env() + +SRP: env injection (`set_var`) is a side effect belonging to the consumer +(notstrap), not the resolver. `SecretResolver` returns data; the caller +decides what to do with it. + +## Config File + +Separate `notsecrets.toml` at the dotfiles root. Rationale: + +- notsecrets is consumed by notstrap, notfiles, and potentially standalone +- Provider chain config is complex enough to warrant its own file +- Follows existing pattern (each crate has its own state file) +- Enables standalone CLI usage: `notsecrets resolve DATABASE_URL` + +### Example + +```toml +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" } +``` + +## File Layout + +New and modified files in `crates/notsecrets/src/`: + +``` +src/ + lib.rs # add re-exports for new public API + ports.rs # add SecretSource, EnumerableSecretSource + config.rs # NEW -- SecretsConfig, ProviderConfig, SecretRef, Provider, load_config() + resolver.rs # NEW -- SecretResolver + error.rs # add SecretsError alongside existing AgeError + sources/ + mod.rs # add new source re-exports + env.rs # NEW tier 1 -- SecretSource + EnumerableSecretSource + op.rs # NEW tier 1 -- SecretSource only + dotenvx.rs # NEW tier 1 -- SecretSource + EnumerableSecretSource + sops.rs # NEW tier 1 -- SecretSource + EnumerableSecretSource + gsm.rs # NEW tier 1 -- SecretSource only + nuenv.rs # NEW tier 2 stub + direnv.rs # NEW tier 2 stub + mise.rs # NEW tier 2 stub + vault.rs # NEW tier 2 stub + dotenvy.rs # NEW tier 2 stub + bitwarden.rs # existing -- add tier 2 SecretSource stub + file.rs # existing -- untouched + prompt.rs # existing -- untouched + yubikey.rs # existing -- untouched + identities/ # untouched + recipients/ # untouched + decrypt.rs # untouched + encrypt.rs # untouched + format.rs # untouched +``` + +## Consumer Changes + +### notstrap + +Current hardcoded source chain (`lib.rs:135-143`) and `EnvInjector` closure +replaced by: + +```rust +let secrets_config = notsecrets::config::load_config( + &dotfiles_dir.join("notsecrets.toml") +)?; +let resolver = SecretResolver::from_config(secrets_config)?; +let env_map = resolver.resolve_all()?; + +for (k, v) in &env_map { + if let Some((k, v)) = parse_env_line(&format!("{k}={v}"))? { + unsafe { std::env::set_var(&k, &v); } + } +} +report.add("secrets", StepStatus::Ok); +``` + +`EnvInjector` type alias and `env_injector` field on `BootstrapOptions` become +unnecessary and are removed. + +## Testing Strategy + +| Dimension | Scope | When | +| ------------- | --------------------------------------------------- | ----------------------------- | +| Unit | Resolver routing (binding vs chain), config parsing | Every new function | +| Conformance | Shared suite for SecretSource trait, all impls | Every new impl | +| Property | Config deserialization: arbitrary TOML never panics | After config types defined | +| Integration | Resolver + real EnvSource + fake sources | After resolver + 2+ sources | +| Regression | One test per bug | Ongoing | +| Fuzz | Deferred -- no raw byte parsing or unsafe | Revisit if SOPS moves in-crate | +| Model check | Deferred -- no arithmetic invariants | N/A | + +### Conformance suite + +```rust +fn assert_secret_source_contract(source: &dyn SecretSource) { + // 1. name() returns non-empty string + // 2. provider() matches expected variant + // 3. resolve() for unknown key returns Ok(None), not Err + // 4. resolve_ref() with mismatched variant returns Ok(None) or + // delegates to resolve() +} + +fn assert_enumerable_contract(source: &dyn EnumerableSecretSource) { + assert_secret_source_contract(source); + // 5. resolve_all() returns Ok(map) + // 6. For every key in resolve_all() map, + // resolve(key) returns Ok(Some(same_value)) +} +``` + +## Implementation Tiers + +### Tier 1 (first milestone) + +- Types: `Provider`, `ProviderConfig`, `SecretRef`, `SecretsConfig`, `SecretsError` +- Traits: `SecretSource`, `EnumerableSecretSource` +- Resolver: `SecretResolver` with `from_config`, `resolve`, `resolve_all` +- Sources: `EnvSource`, `OpSource`, `DotenvxSource`, `SopsSource`, `GsmSource` +- Config: `load_config()`, `notsecrets.toml` parsing +- Consumer: notstrap integration +- Tests: unit + conformance + integration + +### Tier 2 (subsequent) + +- Sources: `NuenvSource`, `DirenvSource`, `MiseSource`, `BitwardenSource` + (SecretSource impl), `VaultSource`, `DotenvySource` +- Each: trait impl returning `Ok(None)` / empty map, passing conformance suite +- Implemented as needed when the backend is required + +## Decisions Log + +| Decision | Why | Alternative rejected | +| --------------------------------- | ------------------------------------------------------ | ---------------------------------- | +| Separate `notsecrets.toml` | Decouples from notfiles/notstrap config | Subsection in notfiles.toml | +| Two traits (ISP split) | Not all backends can enumerate | Single trait with `resolve_all()` | +| Typed enums over string maps | Compile-time validation, exhaustive matching | `HashMap` | +| No `inject_env()` on resolver | SRP -- side effects belong to consumer | Method on SecretResolver | +| `resolve_ref` default impl | Sources that don't use refs delegate to `resolve(key)` | Per-binding source instances | +| Explicit bindings override chain | User intent is unambiguous when they bind a key | Chain-only, no bindings | +| `SecretsError` separate from `AgeError` | Different domains, different consumers | Unified error enum | + +## Out of Scope + +- Secret write/rotation path +- RBAC or access control on secrets +- Caching/TTL for resolved secrets +- Async trait (all providers are sync CLI/file-based today) +- Migration tooling from old notstrap config format diff --git a/tests/integration/tests/cross_crate.rs b/tests/integration/tests/cross_crate.rs index c8c6c71..1b768f5 100644 --- a/tests/integration/tests/cross_crate.rs +++ b/tests/integration/tests/cross_crate.rs @@ -5,6 +5,8 @@ use tempfile::TempDir; use notcore::{HookPhase, HookSpec}; use notfiles::{LinkOptions, link}; use nothooks::{HookResult, HookRunner}; +use notsecrets::config::{Provider, SecretsConfig}; +use notsecrets::resolver::SecretResolver; use notsecrets::sources::IdentitySource; use notsecrets::{FileSource, resolve_identities}; @@ -46,6 +48,24 @@ fn test_nothooks_notsecrets_independent() { ); } +#[test] +fn secret_resolver_resolves_env_var_cross_crate() { + 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") }; +} + /// Test that notfiles ignores .notfiles-state.toml and .nothooks-state.toml /// by default — they must not be symlinked into the target directory and must /// not appear in the returned State.