feat(notsecrets): add multi-provider secret resolution system
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled
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:
@@ -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))
|
||||
|
||||
35
crates/notsecrets/src/sources/direnv.rs
Normal file
35
crates/notsecrets/src/sources/direnv.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
89
crates/notsecrets/src/sources/dotenvx.rs
Normal file
89
crates/notsecrets/src/sources/dotenvx.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
35
crates/notsecrets/src/sources/dotenvy.rs
Normal file
35
crates/notsecrets/src/sources/dotenvy.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
62
crates/notsecrets/src/sources/env.rs
Normal file
62
crates/notsecrets/src/sources/env.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
92
crates/notsecrets/src/sources/gsm.rs
Normal file
92
crates/notsecrets/src/sources/gsm.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
35
crates/notsecrets/src/sources/mise.rs
Normal file
35
crates/notsecrets/src/sources/mise.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
37
crates/notsecrets/src/sources/nuenv.rs
Normal file
37
crates/notsecrets/src/sources/nuenv.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
84
crates/notsecrets/src/sources/op.rs
Normal file
84
crates/notsecrets/src/sources/op.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
83
crates/notsecrets/src/sources/sops.rs
Normal file
83
crates/notsecrets/src/sources/sops.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
35
crates/notsecrets/src/sources/vault.rs
Normal file
35
crates/notsecrets/src/sources/vault.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user