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,17 +1,14 @@
|
|||||||
# Handoff — notfiles (2026-04-17)
|
# Handoff — notfiles (2026-06-06)
|
||||||
|
|
||||||
**Branch:** main | **Build:** clean | **Tests:** passing
|
**Branch:** main | **Build:** unknown | **Tests:** unknown
|
||||||
Added notnet crate with YubikeySource and threaded Tailscale into notstrap; Tailscale
|
|
||||||
integration design spec written.
|
|
||||||
|
|
||||||
## Items
|
## Items
|
||||||
|
|
||||||
| ID | P | Status | Title |
|
| ID | P | Status | Title |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| - | - | - | No open local handoff items |
|
|
||||||
|
|
||||||
## Log
|
## Log
|
||||||
|
|
||||||
- 2026-04-17: Added notnet crate with YubikeySource and threaded Tailscale into notstrap; design spec complete [c7c074d, dfdd9ca]
|
- 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 [67f20ee]
|
- 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
|
- 2026-04-15: Pruned completed items from handoff; GitHub issues plus doob are now the source of truth for active work
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
project: notfiles
|
project: notfiles
|
||||||
id: notfile
|
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
|
branch: main
|
||||||
state:
|
state:
|
||||||
tests: passing
|
tests: passing
|
||||||
build: clean
|
build: clean
|
||||||
notes: Handoff is now ephemeral; track active work via GitHub issues synced through doob.
|
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
|
|
||||||
|
|||||||
@@ -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"}
|
|
||||||
@@ -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":"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":"screen-added","file":"/Users/joe/dev/notfiles/.superpowers/brainstorm/72758-1776397235/content/architecture.html"}
|
||||||
|
{"type":"server-stopped","reason":"idle timeout"}
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -967,9 +967,11 @@ dependencies = [
|
|||||||
"rpassword",
|
"rpassword",
|
||||||
"rsa",
|
"rsa",
|
||||||
"scrypt",
|
"scrypt",
|
||||||
|
"serde",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"toml",
|
||||||
"x25519-dalek",
|
"x25519-dalek",
|
||||||
"yubikey",
|
"yubikey",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ anyhow = { workspace = true }
|
|||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
rpassword = { workspace = true }
|
rpassword = { workspace = true }
|
||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
toml = { workspace = true }
|
||||||
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||||
ed25519-dalek = "2"
|
ed25519-dalek = "2"
|
||||||
rsa = { version = "0.9", features = ["sha2"] }
|
rsa = { version = "0.9", features = ["sha2"] }
|
||||||
|
|||||||
170
crates/notsecrets/src/config.rs
Normal file
170
crates/notsecrets/src/config.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,3 +13,42 @@ pub enum AgeError {
|
|||||||
#[error("identity source failed ({name}): {source}")]
|
#[error("identity source failed ({name}): {source}")]
|
||||||
SourceError { name: String, source: anyhow::Error },
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod config;
|
||||||
pub mod decrypt;
|
pub mod decrypt;
|
||||||
pub mod encrypt;
|
pub mod encrypt;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
@@ -5,20 +6,27 @@ pub mod format;
|
|||||||
pub mod identities;
|
pub mod identities;
|
||||||
pub mod ports;
|
pub mod ports;
|
||||||
pub mod recipients;
|
pub mod recipients;
|
||||||
|
pub mod resolver;
|
||||||
pub mod sources;
|
pub mod sources;
|
||||||
|
|
||||||
|
pub use config::{Provider, ProviderConfig, SecretRef, SecretsConfig, load_config};
|
||||||
pub use decrypt::Decryptor;
|
pub use decrypt::Decryptor;
|
||||||
pub use encrypt::Encryptor;
|
pub use encrypt::Encryptor;
|
||||||
pub use error::AgeError;
|
pub use error::AgeError;
|
||||||
|
pub use error::SecretsError;
|
||||||
pub use identities::{
|
pub use identities::{
|
||||||
EncryptedIdentity, FileKey, Header, Identity, ScryptIdentity, SshEd25519Identity,
|
EncryptedIdentity, FileKey, Header, Identity, ScryptIdentity, SshEd25519Identity,
|
||||||
SshRsaIdentity, Stanza, X25519Identity,
|
SshRsaIdentity, Stanza, X25519Identity,
|
||||||
};
|
};
|
||||||
pub use ports::IdentitySource;
|
pub use ports::{EnumerableSecretSource, IdentitySource, SecretSource};
|
||||||
pub use recipients::{
|
pub use recipients::{
|
||||||
Recipient, ScryptRecipient, SshEd25519Recipient, SshRsaRecipient, X25519Recipient,
|
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.
|
/// Try each `IdentitySource` in order; collect all identities that load successfully.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use crate::error::AgeError;
|
use crate::config::{Provider, SecretRef};
|
||||||
|
use crate::error::{AgeError, SecretsError};
|
||||||
use crate::identities::Identity;
|
use crate::identities::Identity;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// Infra boundary trait: an identity resolver that loads key material from an
|
/// Infra boundary trait: an identity resolver that loads key material from an
|
||||||
/// external source and returns a concrete Identity.
|
/// external source and returns a concrete Identity.
|
||||||
@@ -10,3 +12,51 @@ pub trait IdentitySource {
|
|||||||
/// Load and return a concrete identity from the source.
|
/// Load and return a concrete identity from the source.
|
||||||
fn load(&self) -> Result<Box<dyn Identity>, AgeError>;
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
222
crates/notsecrets/src/resolver.rs
Normal file
222
crates/notsecrets/src/resolver.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
use crate::error::AgeError;
|
use crate::config::Provider;
|
||||||
|
use crate::error::{AgeError, SecretsError};
|
||||||
use crate::identities::Identity;
|
use crate::identities::Identity;
|
||||||
use crate::identities::x25519::X25519Identity;
|
use crate::identities::x25519::X25519Identity;
|
||||||
use crate::ports::IdentitySource;
|
use crate::ports::{IdentitySource, SecretSource};
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ impl BitwardenSource {
|
|||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if !bw_ok {
|
if !bw_ok {
|
||||||
return Err(AgeError::SourceError {
|
return Err(AgeError::SourceError {
|
||||||
name: self.name().to_string(),
|
name: "bitwarden".to_string(),
|
||||||
source: anyhow::anyhow!("bw CLI not found in PATH"),
|
source: anyhow::anyhow!("bw CLI not found in PATH"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -34,7 +35,7 @@ impl BitwardenSource {
|
|||||||
let password =
|
let password =
|
||||||
rpassword::prompt_password("Bitwarden master password: ").map_err(|e| {
|
rpassword::prompt_password("Bitwarden master password: ").map_err(|e| {
|
||||||
AgeError::SourceError {
|
AgeError::SourceError {
|
||||||
name: self.name().to_string(),
|
name: "bitwarden".to_string(),
|
||||||
source: anyhow::anyhow!("could not read password: {e}"),
|
source: anyhow::anyhow!("could not read password: {e}"),
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
@@ -46,26 +47,26 @@ impl BitwardenSource {
|
|||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| AgeError::SourceError {
|
.map_err(|e| AgeError::SourceError {
|
||||||
name: self.name().to_string(),
|
name: "bitwarden".to_string(),
|
||||||
source: anyhow::anyhow!("bw unlock spawn: {e}"),
|
source: anyhow::anyhow!("bw unlock spawn: {e}"),
|
||||||
})?;
|
})?;
|
||||||
if let Some(mut stdin) = child.stdin.take() {
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
stdin
|
stdin
|
||||||
.write_all(password.as_bytes())
|
.write_all(password.as_bytes())
|
||||||
.map_err(|e| AgeError::SourceError {
|
.map_err(|e| AgeError::SourceError {
|
||||||
name: self.name().to_string(),
|
name: "bitwarden".to_string(),
|
||||||
source: anyhow::anyhow!("bw unlock stdin write: {e}"),
|
source: anyhow::anyhow!("bw unlock stdin write: {e}"),
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
let output = child
|
let output = child
|
||||||
.wait_with_output()
|
.wait_with_output()
|
||||||
.map_err(|e| AgeError::SourceError {
|
.map_err(|e| AgeError::SourceError {
|
||||||
name: self.name().to_string(),
|
name: "bitwarden".to_string(),
|
||||||
source: anyhow::anyhow!("bw unlock wait: {e}"),
|
source: anyhow::anyhow!("bw unlock wait: {e}"),
|
||||||
})?;
|
})?;
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
return Err(AgeError::SourceError {
|
return Err(AgeError::SourceError {
|
||||||
name: self.name().to_string(),
|
name: "bitwarden".to_string(),
|
||||||
source: anyhow::anyhow!(
|
source: anyhow::anyhow!(
|
||||||
"bw unlock failed: {}",
|
"bw unlock failed: {}",
|
||||||
String::from_utf8_lossy(&output.stderr)
|
String::from_utf8_lossy(&output.stderr)
|
||||||
@@ -74,7 +75,7 @@ impl BitwardenSource {
|
|||||||
}
|
}
|
||||||
String::from_utf8(output.stdout)
|
String::from_utf8(output.stdout)
|
||||||
.map_err(|e| AgeError::SourceError {
|
.map_err(|e| AgeError::SourceError {
|
||||||
name: self.name().to_string(),
|
name: "bitwarden".to_string(),
|
||||||
source: anyhow::anyhow!("bw unlock output UTF-8: {e}"),
|
source: anyhow::anyhow!("bw unlock output UTF-8: {e}"),
|
||||||
})?
|
})?
|
||||||
.trim()
|
.trim()
|
||||||
@@ -90,12 +91,12 @@ impl BitwardenSource {
|
|||||||
.args(["get", "notes", &self.item_name, "--session", &session])
|
.args(["get", "notes", &self.item_name, "--session", &session])
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| AgeError::SourceError {
|
.map_err(|e| AgeError::SourceError {
|
||||||
name: self.name().to_string(),
|
name: "bitwarden".to_string(),
|
||||||
source: anyhow::anyhow!("bw get spawn: {e}"),
|
source: anyhow::anyhow!("bw get spawn: {e}"),
|
||||||
})?;
|
})?;
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
return Err(AgeError::SourceError {
|
return Err(AgeError::SourceError {
|
||||||
name: self.name().to_string(),
|
name: "bitwarden".to_string(),
|
||||||
source: anyhow::anyhow!(
|
source: anyhow::anyhow!(
|
||||||
"bw get notes '{}' failed: {}",
|
"bw get notes '{}' failed: {}",
|
||||||
self.item_name,
|
self.item_name,
|
||||||
@@ -105,14 +106,14 @@ impl BitwardenSource {
|
|||||||
}
|
}
|
||||||
let key = String::from_utf8(output.stdout)
|
let key = String::from_utf8(output.stdout)
|
||||||
.map_err(|e| AgeError::SourceError {
|
.map_err(|e| AgeError::SourceError {
|
||||||
name: self.name().to_string(),
|
name: "bitwarden".to_string(),
|
||||||
source: anyhow::anyhow!("bw output UTF-8: {e}"),
|
source: anyhow::anyhow!("bw output UTF-8: {e}"),
|
||||||
})?
|
})?
|
||||||
.trim()
|
.trim()
|
||||||
.to_string();
|
.to_string();
|
||||||
if key.is_empty() {
|
if key.is_empty() {
|
||||||
return Err(AgeError::SourceError {
|
return Err(AgeError::SourceError {
|
||||||
name: self.name().to_string(),
|
name: "bitwarden".to_string(),
|
||||||
source: anyhow::anyhow!("Bitwarden item '{}' has empty notes", self.item_name),
|
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 {
|
impl IdentitySource for BitwardenSource {
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
"bitwarden"
|
"bitwarden"
|
||||||
@@ -129,7 +144,7 @@ impl IdentitySource for BitwardenSource {
|
|||||||
let key = self.retrieve_key()?;
|
let key = self.retrieve_key()?;
|
||||||
let identity =
|
let identity =
|
||||||
X25519Identity::from_bech32(key.trim()).map_err(|e| AgeError::SourceError {
|
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}"),
|
source: anyhow::anyhow!("key is not a valid AGE-SECRET-KEY-1: {e}"),
|
||||||
})?;
|
})?;
|
||||||
Ok(Box::new(identity))
|
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 bitwarden;
|
||||||
|
pub mod direnv;
|
||||||
|
pub mod dotenvx;
|
||||||
|
pub mod dotenvy;
|
||||||
|
pub mod env;
|
||||||
pub mod file;
|
pub mod file;
|
||||||
|
pub mod gsm;
|
||||||
|
pub mod mise;
|
||||||
|
pub mod nuenv;
|
||||||
|
pub mod op;
|
||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
|
pub mod sops;
|
||||||
|
pub mod vault;
|
||||||
pub mod yubikey;
|
pub mod yubikey;
|
||||||
|
|
||||||
pub use crate::ports::IdentitySource;
|
pub use crate::ports::IdentitySource;
|
||||||
pub use bitwarden::BitwardenSource;
|
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 file::FileSource;
|
||||||
|
pub use gsm::GsmSource;
|
||||||
|
pub use mise::MiseSource;
|
||||||
|
pub use nuenv::NuenvSource;
|
||||||
|
pub use op::OpSource;
|
||||||
pub use prompt::PromptSource;
|
pub use prompt::PromptSource;
|
||||||
|
pub use sops::SopsSource;
|
||||||
|
pub use vault::VaultSource;
|
||||||
pub use yubikey::YubikeySource;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
crates/notsecrets/tests/conformance_secret_source.rs
Normal file
82
crates/notsecrets/tests/conformance_secret_source.rs
Normal 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"));
|
||||||
|
}
|
||||||
1445
docs/superpowers/plans/2026-06-06-notsecrets-multi-provider.md
Normal file
1445
docs/superpowers/plans/2026-06-06-notsecrets-multi-provider.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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<Box<dyn Identity>>
|
||||||
|
|
|
||||||
|
+-- 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<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> {
|
||||||
|
let _ = secret_ref;
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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<String> },
|
||||||
|
Vault { addr: String, mount: Option<String> },
|
||||||
|
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<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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Config root
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SecretsConfig {
|
||||||
|
pub providers: Vec<Provider>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub provider: HashMap<Provider, ProviderConfig>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub secrets: HashMap<String, SecretRef>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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<Box<dyn SecretSource>>,
|
||||||
|
enumerable: Vec<Box<dyn EnumerableSecretSource>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretResolver {
|
||||||
|
pub fn from_config(config: SecretsConfig) -> Result<Self, SecretsError>;
|
||||||
|
pub fn resolve(&self, key: &str) -> Result<Option<String>, SecretsError>;
|
||||||
|
pub fn resolve_all(&self) -> Result<HashMap<String, String>, 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<String, toml::Value>` |
|
||||||
|
| 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
|
||||||
@@ -5,6 +5,8 @@ use tempfile::TempDir;
|
|||||||
use notcore::{HookPhase, HookSpec};
|
use notcore::{HookPhase, HookSpec};
|
||||||
use notfiles::{LinkOptions, link};
|
use notfiles::{LinkOptions, link};
|
||||||
use nothooks::{HookResult, HookRunner};
|
use nothooks::{HookResult, HookRunner};
|
||||||
|
use notsecrets::config::{Provider, SecretsConfig};
|
||||||
|
use notsecrets::resolver::SecretResolver;
|
||||||
use notsecrets::sources::IdentitySource;
|
use notsecrets::sources::IdentitySource;
|
||||||
use notsecrets::{FileSource, resolve_identities};
|
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
|
/// Test that notfiles ignores .notfiles-state.toml and .nothooks-state.toml
|
||||||
/// by default — they must not be symlinked into the target directory and must
|
/// by default — they must not be symlinked into the target directory and must
|
||||||
/// not appear in the returned State.
|
/// not appear in the returned State.
|
||||||
|
|||||||
Reference in New Issue
Block a user