feat(notsecrets): add multi-provider secret resolution system
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled

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.
This commit is contained in:
2026-06-06 09:49:05 -04:00
parent 65d1a2dc2e
commit 1359def673
27 changed files with 3079 additions and 39 deletions

View File

@@ -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"] }

View File

@@ -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<String> },
Vault { addr: String, mount: Option<String> },
Dotenvy { path: PathBuf },
}
#[derive(Debug, Deserialize)]
#[serde(tag = "source", rename_all = "lowercase")]
pub enum SecretRef {
Env,
Op { uri: String },
Dotenvx { key: Option<String> },
Sops { key: Option<String> },
Gsm { path: String },
Nuenv { key: Option<String> },
Direnv { key: Option<String> },
Mise { key: Option<String> },
Bitwarden { item: String, field: Option<String> },
Vault { path: String, field: Option<String> },
Dotenvy { key: Option<String> },
}
impl SecretRef {
pub fn provider(&self) -> Provider {
match self {
Self::Env { .. } => Provider::Env,
Self::Op { .. } => Provider::Op,
Self::Dotenvx { .. } => Provider::Dotenvx,
Self::Sops { .. } => Provider::Sops,
Self::Gsm { .. } => Provider::Gsm,
Self::Nuenv { .. } => Provider::Nuenv,
Self::Direnv { .. } => Provider::Direnv,
Self::Mise { .. } => Provider::Mise,
Self::Bitwarden { .. } => Provider::Bitwarden,
Self::Vault { .. } => Provider::Vault,
Self::Dotenvy { .. } => Provider::Dotenvy,
}
}
}
#[derive(Debug, Deserialize)]
pub struct SecretsConfig {
pub providers: Vec<Provider>,
#[serde(default)]
pub provider: HashMap<Provider, ProviderConfig>,
#[serde(default)]
pub secrets: HashMap<String, SecretRef>,
}
pub fn load_config(path: &Path) -> Result<SecretsConfig, SecretsError> {
let content = std::fs::read_to_string(path)
.map_err(|e| SecretsError::Config(format!("cannot read {}: {e}", path.display())))?;
toml::from_str(&content).map_err(|e| SecretsError::Config(format!("parse error: {e}")))
}
#[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<SecretsConfig, _> = toml::from_str(toml_str);
assert!(result.is_err());
}
#[test]
fn load_config_reads_file() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("notsecrets.toml");
std::fs::write(&path, "providers = [\"env\"]\n").unwrap();
let cfg = load_config(&path).unwrap();
assert_eq!(cfg.providers, vec![Provider::Env]);
}
#[test]
fn load_config_missing_file_errors() {
let result = load_config(std::path::Path::new("/nonexistent/notsecrets.toml"));
assert!(result.is_err());
}
}

View File

@@ -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"));
}
}

View File

@@ -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.
///

View File

@@ -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<Box<dyn Identity>, 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<Option<String>, 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<Option<String>, SecretsError> {
self.resolve(key)
}
}
/// Extended port: sources that can enumerate all available secrets.
pub trait EnumerableSecretSource: SecretSource {
fn resolve_all(&self) -> Result<HashMap<String, String>, 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<Option<String>, SecretsError> {
Ok(Some("val".to_string()))
}
}
#[test]
fn default_resolve_ref_delegates_to_resolve() {
let source = FakeSource;
let secret_ref = SecretRef::Env;
let result = source.resolve_ref("KEY", &secret_ref).unwrap();
assert_eq!(result, Some("val".to_string()));
}
}

View File

@@ -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<Box<dyn SecretSource>>,
enumerable: Vec<Box<dyn EnumerableSecretSource>>,
}
impl SecretResolver {
pub fn from_config(config: SecretsConfig) -> Result<Self, SecretsError> {
// Validate: every binding references a provider in the chain
for (key, binding) in &config.secrets {
if !config.providers.contains(&binding.provider()) {
return Err(SecretsError::Config(format!(
"secret '{key}' references provider '{:?}' not in providers list",
binding.provider()
)));
}
}
let mut sources: Vec<Box<dyn SecretSource>> = Vec::new();
let mut enumerable: Vec<Box<dyn EnumerableSecretSource>> = Vec::new();
for provider in &config.providers {
let provider_config = config.provider.get(provider);
match provider {
Provider::Env => {
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<Option<String>, SecretsError> {
// 1. Explicit binding
if let Some(secret_ref) = self.config.secrets.get(key) {
let provider = secret_ref.provider();
if let Some(source) = self.sources.iter().find(|s| s.provider() == provider) {
return source.resolve_ref(key, secret_ref);
}
}
// 2. Priority chain
for source in &self.sources {
if let Some(value) = source.resolve(key)? {
return Ok(Some(value));
}
}
Ok(None)
}
pub fn resolve_all(&self) -> Result<HashMap<String, String>, SecretsError> {
let mut map = HashMap::new();
// 1. Merge enumerable sources (priority order, first wins)
for source in &self.enumerable {
let source_map = source.resolve_all()?;
for (k, v) in source_map {
map.entry(k).or_insert(v);
}
}
// 2. Overlay explicit bindings (always win)
for (key, secret_ref) in &self.config.secrets {
let provider = secret_ref.provider();
if let Some(source) = self.sources.iter().find(|s| s.provider() == provider)
&& 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());
}
}

View File

@@ -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<Option<String>, 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))

View File

@@ -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<Option<String>, 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);
}
}

View File

@@ -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<HashMap<String, String>, SecretsError> {
let output = Command::new("dotenvx")
.args([
"run",
"--env-file",
&self.env_file.to_string_lossy(),
"--",
"env",
])
.output()
.map_err(|e| SecretsError::SourceError {
name: "dotenvx".to_string(),
source: e.into(),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SecretsError::SourceError {
name: "dotenvx".to_string(),
source: anyhow::anyhow!("dotenvx run failed: {stderr}"),
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut map = HashMap::new();
for line in stdout.lines() {
if let Some((k, v)) = line.split_once('=') {
let k = k.trim();
if !k.is_empty() && !k.starts_with('#') {
map.insert(k.to_string(), v.to_string());
}
}
}
Ok(map)
}
}
impl SecretSource for DotenvxSource {
fn name(&self) -> &str {
"dotenvx"
}
fn provider(&self) -> Provider {
Provider::Dotenvx
}
fn resolve(&self, key: &str) -> Result<Option<String>, SecretsError> {
self.decrypt_all().map(|m| m.get(key).cloned())
}
}
impl EnumerableSecretSource for DotenvxSource {
fn resolve_all(&self) -> Result<HashMap<String, String>, SecretsError> {
self.decrypt_all()
}
}
#[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());
}
}

View File

@@ -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<Option<String>, 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);
}
}

View File

@@ -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<Option<String>, SecretsError> {
Ok(std::env::var(key).ok())
}
}
impl EnumerableSecretSource for EnvSource {
fn resolve_all(&self) -> Result<HashMap<String, String>, SecretsError> {
Ok(std::env::vars().collect())
}
}
#[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);
}
}

View File

@@ -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<Option<String>, SecretsError> {
Ok(None)
}
fn resolve_ref(
&self,
key: &str,
secret_ref: &SecretRef,
) -> Result<Option<String>, SecretsError> {
let SecretRef::Gsm { path } = secret_ref else {
return self.resolve(key);
};
let output = Command::new("gcloud")
.args([
"secrets",
"versions",
"access",
"latest",
"--secret",
path,
"--project",
&self.project,
])
.output()
.map_err(|e| SecretsError::SourceError {
name: "gsm".to_string(),
source: e.into(),
})?;
if output.status.success() {
Ok(Some(
String::from_utf8_lossy(&output.stdout).trim().to_string(),
))
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(SecretsError::SourceError {
name: "gsm".to_string(),
source: anyhow::anyhow!("gcloud secrets failed: {stderr}"),
})
}
}
}
#[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);
}
}

View File

@@ -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<Option<String>, 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);
}
}

View File

@@ -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;

View File

@@ -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<Option<String>, 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);
}
}

View File

@@ -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<Option<String>, SecretsError> {
Ok(None)
}
fn resolve_ref(
&self,
key: &str,
secret_ref: &SecretRef,
) -> Result<Option<String>, SecretsError> {
let SecretRef::Op { uri } = secret_ref else {
return self.resolve(key);
};
let output = Command::new("op")
.args(["read", uri, "--account", &self.account])
.output()
.map_err(|e| SecretsError::SourceError {
name: "op".to_string(),
source: e.into(),
})?;
if output.status.success() {
Ok(Some(
String::from_utf8_lossy(&output.stdout).trim().to_string(),
))
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(SecretsError::SourceError {
name: "op".to_string(),
source: anyhow::anyhow!("op read failed: {stderr}"),
})
}
}
}
#[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);
}
}

View File

@@ -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<HashMap<String, String>, SecretsError> {
let output = Command::new("sops")
.args(["--decrypt", &self.file.to_string_lossy()])
.output()
.map_err(|e| SecretsError::SourceError {
name: "sops".to_string(),
source: e.into(),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SecretsError::SourceError {
name: "sops".to_string(),
source: anyhow::anyhow!("sops decrypt failed: {stderr}"),
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut map = HashMap::new();
for line in stdout.lines() {
if let Some((k, v)) = line.split_once('=') {
let k = k.trim();
if !k.is_empty() && !k.starts_with('#') {
map.insert(k.to_string(), v.to_string());
}
}
}
Ok(map)
}
}
impl SecretSource for SopsSource {
fn name(&self) -> &str {
"sops"
}
fn provider(&self) -> Provider {
Provider::Sops
}
fn resolve(&self, key: &str) -> Result<Option<String>, SecretsError> {
self.decrypt_all().map(|m| m.get(key).cloned())
}
}
impl EnumerableSecretSource for SopsSource {
fn resolve_all(&self) -> Result<HashMap<String, String>, SecretsError> {
self.decrypt_all()
}
}
#[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());
}
}

View File

@@ -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<Option<String>, 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);
}
}

View File

@@ -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"));
}