use crate::audit::{AuditAction, AuditContext, AuditLogger, AuditOutcome};
use crate::config::{
Config, CredentialSource, GlobalConfig, NativeAddress, Profile, ProviderAlias, RequireReason,
Resolved,
};
use crate::error::{Result, SecretSpecError};
use crate::manifest::{CompiledManifest, MissingPolicy};
use crate::plan::{PlannedSecret, ResolutionPlan, Route};
use crate::provider::{Address, Provider as ProviderTrait, ProviderCredentials};
use crate::report::{ResolutionReport, ResolutionStatus, SecretResolution};
use crate::resolve::{RESOLVE_SCHEMA_VERSION, ResolveResponse, ResolvedSecret, ResolvedSource};
use crate::validation::{ValidatedSecrets, ValidationErrors};
use colored::Colorize;
use secrecy::{ExposeSecret, SecretString};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::convert::TryFrom;
use std::env;
use std::io::{self, IsTerminal, Read};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, Mutex};
fn warn_provider_failure(display_uri: &str, secret_name: &str, err: &SecretSpecError) {
eprintln!(
"{} provider {} failed for {}: {}; trying next provider in chain",
"warning:".yellow(),
display_uri.bold(),
secret_name.bold(),
err
);
}
fn credential_missing_error(name: &str, alias_spec: &str, location: &str) -> SecretSpecError {
SecretSpecError::ProviderOperationFailed(format!(
"credential '{name}' for provider '{alias_spec}' was not found in {location}; \
store it there with `secretspec config provider login {alias_spec}`"
))
}
fn sorted_credential_entries(
credentials: &HashMap<String, CredentialSource>,
) -> Vec<(&String, &CredentialSource)> {
let mut entries: Vec<(&String, &CredentialSource)> = credentials.iter().collect();
entries.sort_by_key(|(name, _)| name.as_str());
entries
}
const PROVIDER_CREDENTIAL_SCOPE: &str = "_provider";
impl CredentialSource {
pub(crate) fn display_provider(&self) -> String {
crate::audit::redact_uri_strict(&self.provider)
}
fn address<'a>(&'a self, project: &'a str, name: &'a str) -> Address<'a> {
match &self.reference {
Some(reference) => Address::Native(reference),
None => Address::convention(project, PROVIDER_CREDENTIAL_SCOPE, name),
}
}
fn location(&self, project: &str, name: &str) -> String {
let provider = self.display_provider();
match &self.reference {
Some(reference) => format!("{provider} at {}", reference.render()),
None => format!("{provider} at {project}/{PROVIDER_CREDENTIAL_SCOPE}/{name}"),
}
}
}
type ProviderCredentialsKey = (String, String);
type ProviderCredentialsSlot = Arc<Mutex<Option<ProviderCredentials>>>;
type GroupFetch<'a> = (
Option<&'a str>,
Vec<&'a PlannedSecret>,
Box<dyn ProviderTrait>,
);
#[derive(Default)]
struct ProviderCredentialsCache {
entries: Mutex<HashMap<ProviderCredentialsKey, ProviderCredentialsSlot>>,
}
impl ProviderCredentialsCache {
fn get_or_try_init<F>(
&self,
key: ProviderCredentialsKey,
resolve: F,
) -> Result<ProviderCredentials>
where
F: FnOnce() -> Result<ProviderCredentials>,
{
let slot = {
let mut entries = self.entries.lock().unwrap();
Arc::clone(
entries
.entry(key.clone())
.or_insert_with(|| Arc::new(Mutex::new(None))),
)
};
let mut cached = slot.lock().unwrap();
if let Some(credentials) = cached.as_ref() {
return Ok(credentials.clone());
}
match resolve() {
Ok(credentials) => {
*cached = Some(credentials.clone());
Ok(credentials)
}
Err(err) => {
drop(cached);
let mut entries = self.entries.lock().unwrap();
if entries
.get(&key)
.is_some_and(|current| Arc::ptr_eq(current, &slot))
{
entries.remove(&key);
}
Err(err)
}
}
}
#[cfg(any(feature = "cli", test))]
fn clear(&self) {
self.entries.lock().unwrap().clear();
}
}
fn warn_primary_provider_failure(display_uri: Option<&str>, err: &SecretSpecError) {
eprintln!(
"{} primary provider {} failed: {}; will try fallback chain for affected secrets",
"warning:".yellow(),
display_uri.unwrap_or("<default>").bold(),
err
);
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Materialize {
Values,
None,
}
fn find_config_file() -> Result<PathBuf> {
find_config_file_from(std::env::current_dir()?)
}
fn find_config_file_from(start: PathBuf) -> Result<PathBuf> {
let mut dir = start;
loop {
let candidate = dir.join("secretspec.toml");
if candidate.exists() {
return Ok(candidate);
}
if !dir.pop() {
return Err(SecretSpecError::NoManifest);
}
}
}
pub struct Secrets {
config: Config,
pub(crate) manifest: CompiledManifest,
config_dir: PathBuf,
global_config: Option<GlobalConfig>,
provider: Option<String>,
profile: Option<String>,
reason: Option<String>,
require_reason: RequireReason,
audit: Option<AuditLogger>,
provider_credentials_cache: ProviderCredentialsCache,
}
const AGENT_OPT_IN_ENV: &str = "SECRETSPEC_AGENT";
fn utf8_env() -> std::collections::HashMap<String, String> {
utf8_env_from(std::env::vars_os())
}
fn utf8_env_from<I>(vars: I) -> std::collections::HashMap<String, String>
where
I: IntoIterator<Item = (std::ffi::OsString, std::ffi::OsString)>,
{
vars.into_iter()
.filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?)))
.collect()
}
fn child_env_from<I, S>(
vars: I,
secrets: S,
) -> std::collections::HashMap<std::ffi::OsString, std::ffi::OsString>
where
I: IntoIterator<Item = (std::ffi::OsString, std::ffi::OsString)>,
S: IntoIterator<Item = (String, String)>,
{
let mut env: std::collections::HashMap<std::ffi::OsString, std::ffi::OsString> =
vars.into_iter().collect();
env.extend(secrets.into_iter().map(|(k, v)| (k.into(), v.into())));
env
}
pub(crate) fn detect_agent_id() -> Option<&'static str> {
detect_coding_agent::detect_with_env(utf8_env()).map(|a| a.id)
}
pub(crate) fn running_as_agent() -> bool {
std::env::var_os(AGENT_OPT_IN_ENV).is_some_and(|v| !v.is_empty())
|| detect_coding_agent::detect_with_env(utf8_env())
.is_some_and(|a| a.is_agent() || a.is_hybrid())
}
fn policy_requires_reason(mode: RequireReason, is_agent: bool) -> bool {
match mode {
RequireReason::Never => false,
RequireReason::Always => true,
RequireReason::Agents => is_agent,
}
}
const REASON_ENV: &str = "SECRETSPEC_REASON";
pub(crate) fn non_blank(value: &str) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
pub(crate) fn normalize_reason(reason: &str) -> Option<String> {
non_blank(reason)
}
fn env_reason() -> Option<String> {
std::env::var(REASON_ENV)
.ok()
.as_deref()
.and_then(normalize_reason)
}
#[derive(Default)]
struct AuditFields<'a> {
key: Option<&'a str>,
keys: &'a [String],
command: Option<&'a str>,
provider_uri: Option<String>,
reference: Option<&'a NativeAddress>,
error_kind: Option<&'a str>,
}
impl Secrets {
#[cfg(test)]
pub(crate) fn new(
config: Config,
global_config: Option<GlobalConfig>,
provider: Option<String>,
profile: Option<String>,
) -> Self {
let manifest = CompiledManifest::compile(&config);
Self {
config,
manifest,
config_dir: PathBuf::from("."),
global_config,
provider,
profile,
reason: None,
require_reason: RequireReason::Never,
audit: None,
provider_credentials_cache: ProviderCredentialsCache::default(),
}
}
pub fn load() -> Result<Self> {
let config_path = find_config_file()?;
Self::load_from(&config_path)
}
pub fn load_from(path: &Path) -> Result<Self> {
let project_config = Config::try_from(path)?;
let manifest = project_config.validate_and_compile()?;
let global_config = GlobalConfig::load()?;
let audit = AuditLogger::from_config(
&global_config
.as_ref()
.and_then(|g| g.audit.clone())
.unwrap_or_default(),
);
let config_dir = path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
Ok(Self {
require_reason: project_config.project.require_reason.unwrap_or_default(),
config: project_config,
manifest,
config_dir,
global_config,
provider: None,
profile: None,
reason: env_reason(),
audit,
provider_credentials_cache: ProviderCredentialsCache::default(),
})
}
pub fn set_provider(&mut self, provider: impl Into<String>) {
if let Some(provider) = non_blank(&provider.into()) {
self.provider = Some(provider);
}
}
pub fn set_profile(&mut self, profile: impl Into<String>) {
if let Some(profile) = non_blank(&profile.into()) {
self.profile = Some(profile);
}
}
pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
if let Some(reason) = normalize_reason(&reason.into()) {
self.reason = Some(reason);
}
self
}
fn ensure_reason(&self) -> Result<()> {
if self.reason.is_some() {
return Ok(());
}
let is_agent = self.require_reason == RequireReason::Agents && running_as_agent();
if policy_requires_reason(self.require_reason, is_agent) {
return Err(SecretSpecError::ReasonRequired);
}
Ok(())
}
fn build_provider(
&self,
spec: String,
profile: Option<&str>,
) -> Result<Box<dyn ProviderTrait>> {
let profile = self.resolve_profile_name(profile);
let key = (profile.clone(), spec.clone());
let credentials = self
.provider_credentials_cache
.get_or_try_init(key, || self.resolve_provider_credentials(&spec, &profile))?;
self.build_provider_with_credentials(&spec, credentials)
}
fn build_source_provider(&self, spec: &str) -> Result<Box<dyn ProviderTrait>> {
self.build_provider_with_credentials(spec, ProviderCredentials::new())
}
fn build_provider_with_credentials(
&self,
spec: &str,
credentials: ProviderCredentials,
) -> Result<Box<dyn ProviderTrait>> {
let resolved = self.resolve_provider_spec(spec.to_string());
let mut provider = crate::provider::provider_from_spec(resolved.as_str(), credentials)
.map_err(|err| self.explain_unknown_provider(err, &resolved))?;
provider.with_base_dir(&self.config_dir);
provider.set_reason(self.reason.clone());
Ok(provider)
}
pub(crate) fn resolve_provider_credentials(
&self,
spec: &str,
profile: &str,
) -> Result<ProviderCredentials> {
let mut credentials = ProviderCredentials::new();
let Some(declared) = self
.lookup_provider_alias_entry(spec)
.map(|alias| &alias.credentials)
.filter(|credentials| !credentials.is_empty())
else {
return Ok(credentials);
};
self.validate_credential_sources(spec)?;
let project = self.config.project.name.clone();
let mut sources: HashMap<String, Box<dyn ProviderTrait>> = HashMap::new();
for (name, source) in sorted_credential_entries(declared) {
let source_provider = match sources.entry(source.provider.clone()) {
std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(),
std::collections::hash_map::Entry::Vacant(entry) => {
entry.insert(self.build_source_provider(&source.provider)?)
}
};
let fetched = source_provider.get(source.address(&project, name));
let (outcome, error_kind) = match &fetched {
Ok(Some(_)) => (AuditOutcome::Found, None),
Ok(None) => (AuditOutcome::Missing, None),
Err(e) => (AuditOutcome::Error, Some(e.kind())),
};
self.record(
AuditAction::Get,
profile,
outcome,
AuditFields {
key: Some(name),
command: Some("credential"),
provider_uri: Some(source_provider.uri()),
reference: source.reference.as_ref(),
error_kind,
..Default::default()
},
);
match fetched? {
Some(value) => {
credentials.insert(name.clone(), value);
}
None => {
return Err(credential_missing_error(
name,
spec,
&source.location(&project, name),
));
}
}
}
Ok(credentials)
}
#[cfg(any(feature = "cli", test))]
pub(crate) fn declared_provider_credentials(
&self,
alias: &str,
) -> Result<Vec<(String, CredentialSource)>> {
self.validate_credential_sources(alias)?;
let entry = self
.lookup_provider_alias_entry(alias)
.ok_or_else(|| SecretSpecError::ProviderNotFound(alias.to_string()))?;
Ok(sorted_credential_entries(&entry.credentials)
.into_iter()
.map(|(name, source)| (name.clone(), source.clone()))
.collect())
}
#[cfg(any(feature = "cli", test))]
pub(crate) fn store_provider_credential(
&self,
source: &CredentialSource,
name: &str,
value: &SecretString,
) -> Result<String> {
self.ensure_reason_for(AuditAction::Set, Some(name))?;
let provider = self.build_source_provider(&source.provider)?;
let profile = self.resolve_profile_name(None);
let project = self.config.project.name.clone();
let address = source.address(&project, name);
let result = provider
.check_writable(address)
.and_then(|()| provider.set(address, value));
self.audit_write_result(
&result,
name,
&profile,
Some(provider.uri()),
source.reference.as_ref(),
Some("credential"),
);
result?;
self.provider_credentials_cache.clear();
Ok(source.location(&project, name))
}
pub(crate) fn validate_credential_sources(&self, spec: &str) -> Result<()> {
let Some(alias) = self.lookup_provider_alias_entry(spec) else {
return Ok(());
};
let resolved_target = self.resolve_provider_spec(spec.to_string());
let supported = crate::provider::credential_names_for_spec(&resolved_target);
let provider_name = crate::provider::provider_display_name_for_spec(&resolved_target);
for (name, source) in sorted_credential_entries(&alias.credentials) {
if !supported.contains(&name.as_str()) {
let supported_display = if supported.is_empty() {
"none".to_string()
} else {
supported.join(", ")
};
return Err(SecretSpecError::ProviderOperationFailed(format!(
"credential '{name}' is not supported by provider '{provider_name}' \
for alias '{spec}' (supported credentials: {supported_display})"
)));
}
let context = |err: SecretSpecError| {
SecretSpecError::ProviderOperationFailed(format!(
"credential source for '{name}' in provider alias '{spec}': {err}"
))
};
let resolved = self
.resolve_one_provider(&source.provider)
.map_err(context)?;
let known = crate::provider::spec_names_known_provider(&resolved).map_err(context)?;
if !known {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"credential source for '{name}' in provider alias '{spec}' names an unknown \
provider '{}'",
crate::audit::redact_uri_strict(&source.provider)
)));
}
if let Some(source_alias) = self.lookup_provider_alias_entry(&source.provider)
&& !source_alias.credentials.is_empty()
{
return Err(SecretSpecError::ProviderOperationFailed(format!(
"provider alias '{}' cannot be a credential source for '{spec}' because it \
declares its own credentials; credential chains are limited to one hop",
source.provider
)));
}
}
Ok(())
}
fn explain_unknown_provider(&self, err: SecretSpecError, spec: &str) -> SecretSpecError {
match err {
SecretSpecError::ProviderNotFound(_) if !spec.contains(':') => {
let known = self.known_provider_aliases();
if known.is_empty() {
return err;
}
SecretSpecError::ProviderNotFound(format!(
"{} (not a known provider or alias; available aliases: {})",
spec,
known.join(", ")
))
}
_ => err,
}
}
fn record(
&self,
action: AuditAction,
profile: &str,
outcome: AuditOutcome,
fields: AuditFields<'_>,
) {
if let Some(logger) = &self.audit {
logger.record(
action,
AuditContext {
project: &self.config.project.name,
profile,
key: fields.key,
keys: fields.keys,
command: fields.command,
provider_uri: fields.provider_uri,
reference: fields.reference.map(NativeAddress::render),
outcome,
error_kind: fields.error_kind,
reason: self.reason.as_deref(),
},
);
}
}
fn audit_write_result(
&self,
result: &Result<()>,
key: &str,
profile: &str,
provider_uri: Option<String>,
reference: Option<&NativeAddress>,
command: Option<&str>,
) {
let (outcome, error_kind) = match result {
Ok(()) => (AuditOutcome::Written, None),
Err(e) => (AuditOutcome::Error, Some(e.kind())),
};
self.record(
AuditAction::Set,
profile,
outcome,
AuditFields {
key: Some(key),
command,
provider_uri,
reference,
error_kind,
..Default::default()
},
);
}
fn record_key_error(
&self,
action: AuditAction,
profile: &str,
key: &str,
provider_uri: Option<String>,
reference: Option<&NativeAddress>,
err: &SecretSpecError,
) {
self.record(
action,
profile,
AuditOutcome::Error,
AuditFields {
key: Some(key),
provider_uri,
reference,
error_kind: Some(err.kind()),
..Default::default()
},
);
}
fn ensure_reason_for(&self, action: AuditAction, key: Option<&str>) -> Result<()> {
if let Err(e) = self.ensure_reason() {
let profile = self.resolve_profile_name(None);
self.record(
action,
&profile,
AuditOutcome::Error,
AuditFields {
key,
error_kind: Some(e.kind()),
..Default::default()
},
);
return Err(e);
}
Ok(())
}
fn insert_resolved(
&self,
secrets: &mut HashMap<String, SecretString>,
temp_files: &mut Vec<tempfile::NamedTempFile>,
name: String,
value: SecretString,
as_path: bool,
) -> Result<()> {
if as_path {
let (temp_file, path_str) = self.write_secret_to_temp_file(&value)?;
temp_files.push(temp_file);
secrets.insert(name, SecretString::new(path_str.into()));
} else {
secrets.insert(name, value);
}
Ok(())
}
#[cfg(any(feature = "cli", test))]
pub(crate) fn config(&self) -> &Config {
&self.config
}
#[cfg(test)]
pub(crate) fn global_config(&self) -> &Option<GlobalConfig> {
&self.global_config
}
#[cfg(test)]
pub(crate) fn set_audit_for_test(&mut self, logger: crate::audit::AuditLogger) {
self.audit = Some(logger);
}
#[cfg(test)]
pub(crate) fn set_require_reason(&mut self, policy: RequireReason) {
self.require_reason = policy;
}
pub(crate) fn resolve_profile_name(&self, profile: Option<&str>) -> String {
profile
.map(|p| p.to_string())
.or_else(|| self.profile.clone())
.or_else(|| {
env::var("SECRETSPEC_PROFILE")
.ok()
.as_deref()
.and_then(non_blank)
})
.or_else(|| {
self.global_config
.as_ref()
.and_then(|gc| gc.defaults.profile.clone())
})
.unwrap_or_else(|| "default".to_string())
}
fn require_profile(&self, profile_name: &str) -> Result<&Profile> {
self.config.profiles.get(profile_name).ok_or_else(|| {
let mut available: Vec<&str> =
self.config.profiles.keys().map(String::as_str).collect();
available.sort();
SecretSpecError::InvalidProfile(format!(
"'{}' is not defined in secretspec.toml. Available profiles: {}",
profile_name,
available.join(", ")
))
})
}
pub(crate) fn resolve_profile_secret_names(
&self,
profile: Option<&str>,
) -> Result<Vec<String>> {
let profile_name = profile
.map(str::to_string)
.unwrap_or_else(|| self.resolve_profile_name(None));
self.require_profile(&profile_name)?;
let compiled = self
.manifest
.profile(&profile_name)
.expect("raw and compiled profile sets stay identical");
Ok(compiled.secrets.keys().cloned().collect())
}
pub(crate) fn resolve_secret_config(
&self,
name: &str,
profile: Option<&str>,
) -> Option<crate::config::Secret> {
let profile_name = self.resolve_profile_name(profile);
self.manifest
.profile(&profile_name)
.and_then(|profile| profile.secrets.get(name))
.map(|secret| secret.config.clone())
}
fn effective_secrets(&self, profile_name: &str) -> Vec<(String, crate::config::Secret)> {
self.manifest
.profile(profile_name)
.into_iter()
.flat_map(|profile| &profile.secrets)
.map(|(name, secret)| (name.clone(), secret.config.clone()))
.collect()
}
fn provider_alias_sources(&self) -> impl Iterator<Item = &HashMap<String, ProviderAlias>> {
self.config.providers.iter().chain(
self.global_config
.as_ref()
.and_then(|gc| gc.defaults.providers.as_ref()),
)
}
fn lookup_provider_alias_entry(&self, alias: &str) -> Option<&ProviderAlias> {
self.provider_alias_sources().find_map(|m| m.get(alias))
}
fn lookup_provider_alias(&self, alias: &str) -> Option<String> {
self.lookup_provider_alias_entry(alias)
.map(|alias| alias.uri.clone())
}
pub(crate) fn resolve_provider_spec(&self, spec: String) -> String {
self.lookup_provider_alias(&spec).unwrap_or(spec)
}
fn known_provider_aliases(&self) -> Vec<String> {
let mut names: Vec<String> = self
.provider_alias_sources()
.flat_map(|m| m.keys().cloned())
.collect::<HashSet<_>>()
.into_iter()
.collect();
names.sort();
names
}
pub(crate) fn resolve_one_provider(&self, spec: &str) -> Result<String> {
if spec.contains("://") {
return Ok(spec.to_string());
}
if let Some(uri) = self.lookup_provider_alias(spec) {
return Ok(uri);
}
if crate::provider::spec_names_known_provider(spec)? {
return Ok(spec.to_string());
}
let known = self.known_provider_aliases();
let msg = if known.is_empty() {
format!(
"Provider alias '{}' is not defined. Declare it in [providers] in secretspec.toml or in the global config.",
spec
)
} else {
format!(
"Provider alias '{}' is not defined. Available aliases: {}",
spec,
known.join(", ")
)
};
Err(SecretSpecError::ProviderNotFound(msg))
}
pub(crate) fn explicit_provider_spec(&self, override_arg: Option<&str>) -> Option<String> {
override_arg
.map(|spec| spec.to_string())
.or_else(|| self.provider.clone())
.or_else(|| {
env::var("SECRETSPEC_PROVIDER")
.ok()
.as_deref()
.and_then(non_blank)
})
}
fn fetch_group(
provider: &dyn ProviderTrait,
group: &[&PlannedSecret],
project: &str,
profile: &str,
) -> Result<HashMap<String, SecretString>> {
let requests: Vec<(&str, Address<'_>)> = group
.iter()
.map(|planned| (planned.name.as_str(), planned.as_address(project, profile)))
.collect();
provider.get_many(&requests)
}
fn write_provider_for_route(
&self,
route: &Route,
profile: Option<&str>,
) -> Result<Box<dyn ProviderTrait>> {
self.get_provider(route.group_key(), profile)
}
pub(crate) fn get_provider(
&self,
provider_arg: Option<&str>,
profile: Option<&str>,
) -> Result<Box<dyn ProviderTrait>> {
let provider_spec = self.default_provider_spec(provider_arg)?;
let provider = self.build_provider(provider_spec, profile)?;
Ok(provider)
}
fn default_provider_spec(&self, provider_arg: Option<&str>) -> Result<String> {
self.explicit_provider_spec(provider_arg)
.or_else(|| {
self.global_config
.as_ref()
.and_then(|gc| gc.defaults.provider.clone())
})
.ok_or(SecretSpecError::NoProviderConfigured)
}
fn validation_report_provider_uri<'a>(
&self,
override_uri: Option<&str>,
primary_uris: impl Iterator<Item = Option<&'a str>>,
profile: Option<&str>,
) -> Result<String> {
if let Some(uri) = override_uri {
return Ok(crate::audit::redact_uri_strict(uri));
}
let provider_uris: Option<Vec<&str>> = primary_uris.collect();
match provider_uris.and_then(|uris| uris.into_iter().min()) {
Some(uri) => Ok(crate::audit::redact_uri_strict(uri)),
None => self
.get_provider(None, profile)
.map(|provider| provider.uri()),
}
}
fn get_secret_from_providers(
&self,
secret_name: &str,
addr: Address<'_>,
provider_specs: Option<&[String]>,
profile: Option<&str>,
) -> Result<(Option<SecretString>, Option<String>)> {
if let Some(specs) = provider_specs {
let mut last_error: Option<SecretSpecError> = None;
let mut any_healthy = false;
let mut last_uri: Option<String> = None;
for spec in specs {
let uri = match self.resolve_one_provider(spec) {
Ok(uri) => uri,
Err(e) => {
warn_provider_failure(
&crate::audit::redact_uri_strict(spec),
secret_name,
&e,
);
last_error = Some(e);
continue;
}
};
let provider = match self.build_provider(spec.clone(), profile) {
Ok(p) => p,
Err(e) => {
warn_provider_failure(
&crate::audit::redact_uri_strict(&uri),
secret_name,
&e,
);
last_error = Some(e);
continue;
}
};
let provider_uri = provider.uri();
last_uri = Some(provider_uri.clone());
match provider.get(addr) {
Ok(Some(value)) => return Ok((Some(value), Some(provider_uri))),
Ok(None) => {
any_healthy = true;
continue;
}
Err(e) => {
warn_provider_failure(&provider_uri, secret_name, &e);
last_error = Some(e);
continue;
}
}
}
match last_error {
Some(e) if !any_healthy => Err(e),
_ => Ok((None, last_uri)),
}
} else {
let backend = self.get_provider(None, profile)?;
let uri = backend.uri();
backend.get(addr).map(|opt| (opt, Some(uri)))
}
}
pub fn set(&self, name: &str, value: Option<String>) -> Result<()> {
self.ensure_reason_for(AuditAction::Set, Some(name))?;
let profile_name = self.resolve_profile_name(None);
self.require_profile(&profile_name)?;
let planned = match self.plan_secret(name, &profile_name, None) {
Ok(Some(planned)) => planned,
Err(err) => {
self.record_key_error(AuditAction::Set, &profile_name, name, None, None, &err);
return Err(err);
}
Ok(None) => {
let available_secrets = self.resolve_profile_secret_names(Some(&profile_name))?;
let err = SecretSpecError::SecretNotFound(format!(
"Secret '{}' is not defined in profile '{}'. Available secrets: {}",
name,
profile_name,
available_secrets.join(", ")
));
self.record_key_error(AuditAction::Set, &profile_name, name, None, None, &err);
return Err(err);
}
};
let Some(route) = &planned.route else {
let err = SecretSpecError::ComposedSecretReadOnly(name.to_string());
self.record_key_error(AuditAction::Set, &profile_name, name, None, None, &err);
return Err(err);
};
let backend = match self.write_provider_for_route(route, Some(&profile_name)) {
Ok(backend) => backend,
Err(err) => {
self.record_key_error(AuditAction::Set, &profile_name, name, None, None, &err);
return Err(err);
}
};
let addr = planned.as_address(&self.config.project.name, &profile_name);
if let Err(err) = backend.check_writable(addr) {
self.record_key_error(
AuditAction::Set,
&profile_name,
name,
Some(backend.uri()),
None,
&err,
);
return Err(err);
}
let value = if let Some(v) = value {
SecretString::new(v.into())
} else if io::stdin().is_terminal() {
let secret = inquire::Password::new(&format!(
"Enter value for {name} (profile: {profile_name}):"
))
.without_confirmation()
.prompt()?;
SecretString::new(secret.into())
} else {
let mut buffer = String::new();
io::stdin().read_to_string(&mut buffer)?;
SecretString::new(buffer.trim().to_string().into())
};
if value.expose_secret().is_empty() {
let err = SecretSpecError::ProviderOperationFailed(
"Secret value cannot be empty".to_string(),
);
self.record_key_error(
AuditAction::Set,
&profile_name,
name,
Some(backend.uri()),
None,
&err,
);
return Err(err);
}
let result = backend.set(addr, &value);
self.audit_write_result(
&result,
name,
&profile_name,
Some(backend.uri()),
planned.reference(),
None,
);
result?;
eprintln!(
"{} Secret '{}' saved to {} (profile: {})",
"✓".green(),
name,
backend.name(),
profile_name
);
Ok(())
}
pub fn get(&self, name: &str) -> Result<()> {
self.ensure_reason_for(AuditAction::Get, Some(name))?;
let profile_name = self.resolve_profile_name(None);
let planned = match self.plan_secret(name, &profile_name, None) {
Ok(Some(planned)) => planned,
Err(err) => {
self.record_key_error(AuditAction::Get, &profile_name, name, None, None, &err);
return Err(err);
}
Ok(None) => {
let err = SecretSpecError::SecretNotFound(name.to_string());
self.record_key_error(AuditAction::Get, &profile_name, name, None, None, &err);
return Err(err);
}
};
let Some(route) = &planned.route else {
let names = self.composed_dependency_names(name, &profile_name);
let plan = self.build_plan_from_names(profile_name.clone(), names)?;
return match self.execute_plan(&plan, Materialize::Values)? {
Ok(mut validated) => {
if !validated.resolved.secrets.contains_key(name) {
let err = SecretSpecError::SecretNotFound(name.to_string());
self.record_key_error(
AuditAction::Get,
&profile_name,
name,
None,
None,
&err,
);
return Err(err);
}
validated.keep_temp_files()?;
let value = &validated.resolved.secrets[name];
self.record(
AuditAction::Get,
&profile_name,
AuditOutcome::Found,
AuditFields {
key: Some(name),
..Default::default()
},
);
println!("{}", value.expose_secret());
Ok(())
}
Err(errors) => {
let err =
SecretSpecError::RequiredSecretMissing(errors.missing_required.join(", "));
self.record_key_error(AuditAction::Get, &profile_name, name, None, None, &err);
Err(err)
}
};
};
let default = planned.config().default.clone();
let as_path = planned.as_path();
let read_specs = route.specs();
let result = self.get_secret_from_providers(
name,
planned.as_address(&self.config.project.name, &profile_name),
read_specs.as_deref(),
Some(&profile_name),
);
let reference = planned.reference();
match &result {
Ok((Some(_), uri)) => self.record(
AuditAction::Get,
&profile_name,
AuditOutcome::Found,
AuditFields {
key: Some(name),
provider_uri: uri.clone(),
reference,
..Default::default()
},
),
Ok((None, uri)) if default.is_some() => self.record(
AuditAction::Get,
&profile_name,
AuditOutcome::Default,
AuditFields {
key: Some(name),
provider_uri: uri.clone(),
reference,
..Default::default()
},
),
Ok((None, uri)) => self.record(
AuditAction::Get,
&profile_name,
AuditOutcome::Missing,
AuditFields {
key: Some(name),
provider_uri: uri.clone(),
reference,
..Default::default()
},
),
Err(e) => {
self.record_key_error(AuditAction::Get, &profile_name, name, None, reference, e)
}
}
match result?.0 {
Some(value) => {
if as_path {
let (temp_file, _path_str) = self.write_secret_to_temp_file(&value)?;
let temp_path = temp_file.into_temp_path();
let persisted_path = temp_path.keep().map_err(|e| {
SecretSpecError::Io(io::Error::other(format!(
"Failed to persist temporary file: {}",
e
)))
})?;
println!("{}", persisted_path.display());
} else {
println!("{}", value.expose_secret());
}
Ok(())
}
None => {
if let Some(default_value) = default {
if as_path {
let (temp_file, _) = self
.write_secret_to_temp_file(&SecretString::new(default_value.into()))?;
let temp_path = temp_file.into_temp_path();
let persisted_path = temp_path.keep().map_err(|e| {
SecretSpecError::Io(io::Error::other(format!(
"Failed to persist temporary file: {}",
e
)))
})?;
println!("{}", persisted_path.display());
} else {
println!("{}", default_value);
}
Ok(())
} else {
Err(SecretSpecError::SecretNotFound(name.to_string()))
}
}
}
}
pub fn ensure_secrets(
&self,
provider_arg: Option<String>,
profile: Option<String>,
interactive: bool,
) -> Result<ValidatedSecrets> {
let profile_display = self.resolve_profile_name(profile.as_deref());
let validation_result = self.validate_audited(false, Materialize::Values)?;
match validation_result {
Ok(valid_secrets) => Ok(valid_secrets),
Err(validation_errors) => {
if interactive && !validation_errors.missing_required.is_empty() {
if !io::stdin().is_terminal() {
return Err(SecretSpecError::RequiredSecretMissing(
validation_errors.missing_required.join(", "),
));
}
let missing =
self.promptable_missing_names(&validation_errors, &profile_display);
if missing.is_empty() {
return Err(SecretSpecError::RequiredSecretMissing(
validation_errors.missing_required.join(", "),
));
}
let total = missing.len();
let default_backend_name = crate::provider::provider_display_name_for_spec(
&self.resolve_provider_spec(
self.default_provider_spec(provider_arg.as_deref())?,
),
);
eprintln!(
"\n{} required {} missing in profile {} with provider {}:\n",
total,
if total == 1 {
"secret is"
} else {
"secrets are"
},
profile_display.bold(),
default_backend_name.bold(),
);
for secret_name in &missing {
let description = self
.resolve_secret_config(secret_name, Some(&profile_display))
.and_then(|c| c.description)
.unwrap_or_default();
if description.is_empty() {
eprintln!(" {} {}", "-".dimmed(), secret_name.bold());
} else {
eprintln!(
" {} {} - {}",
"-".dimmed(),
secret_name.bold(),
description
);
}
}
eprintln!();
for (i, secret_name) in missing.iter().enumerate() {
if let Some(planned) = self.plan_secret(
secret_name,
&profile_display,
provider_arg.as_deref(),
)? {
let prompt_msg =
format!("[{}/{}] Enter value for {}:", i + 1, total, secret_name,);
let prompt = inquire::Password::new(&prompt_msg).without_confirmation();
let value = prompt.prompt()?;
let backend = self.write_provider_for_route(
planned
.route
.as_ref()
.expect("prompted names are provider-backed leaves"),
Some(&profile_display),
)?;
let set_result = backend.set(
planned.as_address(&self.config.project.name, &profile_display),
&SecretString::new(value.into()),
);
self.audit_write_result(
&set_result,
secret_name,
&profile_display,
Some(backend.uri()),
planned.reference(),
None,
);
set_result?;
eprintln!(
"{} Secret '{}' saved to {} (profile: {})",
"✓".green(),
secret_name,
backend.name(),
profile_display
);
}
}
eprintln!("\nAll required secrets have been set.");
match self.validate_audited(false, Materialize::Values)? {
Ok(valid_secrets) => Ok(valid_secrets),
Err(still_errors) => Err(SecretSpecError::RequiredSecretMissing(
still_errors.missing_required.join(", "),
)),
}
} else {
Err(SecretSpecError::RequiredSecretMissing(
validation_errors.missing_required.join(", "),
))
}
}
}
}
pub fn check(&self, no_prompt: bool) -> Result<ValidatedSecrets> {
self.ensure_reason_for(AuditAction::Check, None)?;
let profile_display = self.resolve_profile_name(None);
eprintln!(
"Checking secrets in {} (profile: {})...\n",
self.config.project.name.bold(),
profile_display.cyan()
);
match self.validate()? {
Ok(valid) => {
self.display_validation_success(&valid)?;
Ok(valid)
}
Err(errors) => {
self.display_validation_errors(&errors)?;
self.ensure_secrets(None, None, !no_prompt)
}
}
}
fn display_validation_success(&self, valid: &ValidatedSecrets) -> Result<()> {
let mut found_count = 0;
let mut optional_count = 0;
let default_names = valid
.with_defaults
.iter()
.map(|(name, _)| name)
.collect::<HashSet<_>>();
let missing_optional: HashSet<&String> = valid.missing_optional.iter().collect();
for (name, config) in &self.effective_secrets(&valid.resolved.profile) {
if missing_optional.contains(&name) {
optional_count += 1;
eprintln!(
"{} {} - {} {}",
"â—‹".blue(),
name,
config.description.as_deref().unwrap_or("No description"),
"(optional)".blue()
);
} else if config.default.is_some() && default_names.contains(&name) {
found_count += 1;
eprintln!(
"{} {} - {} {}",
"â—‹".yellow(),
name,
config.description.as_deref().unwrap_or("No description"),
"(has default)".yellow()
);
} else {
found_count += 1;
eprintln!(
"{} {} - {}",
"✓".green(),
name,
config.description.as_deref().unwrap_or("No description")
);
}
}
eprintln!("\n{}", Self::format_summary(found_count, 0, optional_count));
Ok(())
}
fn display_validation_errors(&self, errors: &ValidationErrors) -> Result<()> {
let mut found_count = 0;
let mut missing_count = 0;
let mut optional_count = 0;
let default_names = errors
.with_defaults
.iter()
.map(|(name, _)| name)
.collect::<HashSet<_>>();
for (name, config) in &self.effective_secrets(&errors.profile) {
if errors.missing_required.contains(name) {
missing_count += 1;
eprintln!(
"{} {} - {} {}",
"✗".red(),
name,
config.description.as_deref().unwrap_or("No description"),
"(required)".red()
);
} else if errors.missing_optional.contains(name) {
optional_count += 1;
eprintln!(
"{} {} - {} {}",
"â—‹".blue(),
name,
config.description.as_deref().unwrap_or("No description"),
"(optional)".blue()
);
} else {
found_count += 1;
if default_names.contains(name) {
eprintln!(
"{} {} - {} {}",
"â—‹".yellow(),
name,
config.description.as_deref().unwrap_or("No description"),
"(has default)".yellow()
);
} else {
eprintln!(
"{} {} - {}",
"✓".green(),
name,
config.description.as_deref().unwrap_or("No description")
);
}
}
}
eprintln!(
"\n{}",
Self::format_summary(found_count, missing_count, optional_count)
);
Ok(())
}
pub(crate) fn format_summary(found: usize, missing: usize, optional: usize) -> String {
if optional > 0 {
format!(
"Summary: {} found, {} missing, {} optional",
found.to_string().green(),
missing.to_string().red(),
optional.to_string().blue()
)
} else {
format!(
"Summary: {} found, {} missing",
found.to_string().green(),
missing.to_string().red()
)
}
}
pub fn import(&self, from_provider: &str) -> Result<()> {
self.ensure_reason_for(AuditAction::Import, None)?;
let profile_display = self.resolve_profile_name(None);
let mut imported = 0;
let mut already_exists = 0;
let mut not_found = 0;
let mut read_names: Vec<String> = Vec::new();
let mut source_uri: Option<String> = None;
let copy_result = (|| -> Result<()> {
let from_provider_instance =
self.build_provider(from_provider.to_string(), Some(&profile_display))?;
source_uri = Some(from_provider_instance.uri());
eprintln!(
"Importing secrets from {} (profile: {})...\n",
from_provider.blue(),
profile_display.cyan()
);
let import_names = self.resolve_profile_secret_names(Some(&profile_display))?;
for name in import_names {
let planned = self
.plan_secret(&name, &profile_display, None)?
.expect("Secret should exist since we're iterating over it");
let Some(route) = &planned.route else {
continue;
};
read_names.push(name.clone());
let description = planned.config().description.as_deref();
let to_provider = self.write_provider_for_route(route, Some(&profile_display))?;
let addr = planned.as_address(&self.config.project.name, &profile_display);
match from_provider_instance.get(addr)? {
Some(value) => {
match to_provider.get(addr)? {
Some(_) => {
eprintln!(
"{} {} - {} {} (→ {})",
"â—‹".yellow(),
name,
description.unwrap_or("No description"),
"(already exists in target)".yellow(),
to_provider.name().blue()
);
already_exists += 1;
}
None => {
let set_result = to_provider.set(addr, &value);
self.audit_write_result(
&set_result,
&name,
&profile_display,
Some(to_provider.uri()),
planned.reference(),
None,
);
set_result?;
eprintln!(
"{} {} - {} (→ {})",
"✓".green(),
name,
description.unwrap_or("No description"),
to_provider.name().blue()
);
imported += 1;
}
}
}
None => {
match to_provider.get(addr)? {
Some(_) => {
eprintln!(
"{} {} - {} {} (→ {})",
"â—‹".blue(),
name,
description.unwrap_or("No description"),
"(already in target, not in source)".blue(),
to_provider.name().blue()
);
already_exists += 1;
}
None => {
eprintln!(
"{} {} - {} {}",
"✗".red(),
name,
description.unwrap_or("No description"),
"(not found in source)".red()
);
not_found += 1;
}
}
}
}
}
Ok(())
})();
if let Err(e) = copy_result {
self.record(
AuditAction::Import,
&profile_display,
AuditOutcome::Error,
AuditFields {
keys: &read_names,
provider_uri: source_uri,
error_kind: Some(e.kind()),
..Default::default()
},
);
return Err(e);
}
eprintln!(
"\nSummary: {} imported, {} already exists, {} not found in source",
imported.to_string().green(),
already_exists.to_string().yellow(),
not_found.to_string().red()
);
if imported > 0 {
eprintln!(
"\n{} Successfully imported {} secrets from {}",
"✓".green(),
imported,
from_provider,
);
}
let outcome = if imported > 0 {
AuditOutcome::Written
} else if already_exists > 0 {
AuditOutcome::Found
} else {
AuditOutcome::Missing
};
self.record(
AuditAction::Import,
&profile_display,
outcome,
AuditFields {
keys: &read_names,
provider_uri: source_uri,
..Default::default()
},
);
Ok(())
}
fn try_generate_secret(
&self,
planned: &PlannedSecret,
profile_name: &str,
) -> Result<Option<SecretString>> {
let name = planned.name.as_str();
let gen_config = match &planned.config().generate {
Some(config) if config.is_enabled() => config,
_ => return Ok(None),
};
let secret_type = match &planned.config().secret_type {
Some(t) => t.as_str(),
None => {
return Err(SecretSpecError::GenerationFailed(format!(
"Secret '{}' has generate config but no type",
name
)));
}
};
let value = crate::generator::generate(secret_type, gen_config)?;
let addr = planned.as_address(&self.config.project.name, profile_name);
let backend = self.write_provider_for_route(
planned
.route
.as_ref()
.expect("a generating secret is provider-backed"),
Some(profile_name),
)?;
backend.check_writable(addr)?;
let set_result = backend.set(addr, &value);
self.audit_write_result(
&set_result,
name,
profile_name,
Some(backend.uri()),
planned.reference(),
None,
);
set_result?;
eprintln!(
"{} {} - generated and saved to {} (profile: {})",
"✓".green(),
name,
backend.name(),
profile_name
);
Ok(Some(value))
}
fn write_secret_to_temp_file(
&self,
secret: &SecretString,
) -> Result<(tempfile::NamedTempFile, String)> {
use std::io::Write;
let mut temp_file = tempfile::NamedTempFile::new().map_err(SecretSpecError::Io)?;
temp_file
.write_all(secret.expose_secret().as_bytes())
.map_err(SecretSpecError::Io)?;
temp_file.flush().map_err(SecretSpecError::Io)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = temp_file
.as_file()
.metadata()
.map_err(SecretSpecError::Io)?
.permissions();
perms.set_mode(0o400);
temp_file
.as_file()
.set_permissions(perms)
.map_err(SecretSpecError::Io)?;
}
let path_str = temp_file
.path()
.to_str()
.ok_or_else(|| {
SecretSpecError::Io(io::Error::new(
io::ErrorKind::InvalidData,
"Temporary file path is not valid UTF-8",
))
})?
.to_string();
Ok((temp_file, path_str))
}
pub fn validate(&self) -> Result<std::result::Result<ValidatedSecrets, ValidationErrors>> {
self.validate_audited(true, Materialize::Values)
}
pub fn resolve(&self) -> Result<ResolveResponse> {
self.resolve_impl(true)
}
pub fn resolve_without_values(&self) -> Result<ResolveResponse> {
self.resolve_impl(false)
}
fn resolve_impl(&self, include_values: bool) -> Result<ResolveResponse> {
let materialize = if include_values {
Materialize::Values
} else {
Materialize::None
};
match self.validate_audited(true, materialize)? {
Ok(mut validated) => {
if include_values {
validated.keep_temp_files()?;
}
let mut secrets = BTreeMap::new();
for entry in &validated.resolution {
if entry.status != ResolutionStatus::Resolved {
continue;
}
let source = if entry.generated {
ResolvedSource::Generated
} else if entry.default_applied {
ResolvedSource::Default
} else if entry.composed {
ResolvedSource::Composed
} else {
ResolvedSource::Provider
};
let (value, path) = if !include_values {
(None, None)
} else {
let raw = validated
.resolved
.secrets
.get(&entry.name)
.expect("a Resolved entry always has a value")
.expose_secret()
.to_string();
if entry.as_path {
(None, Some(raw))
} else {
(Some(raw), None)
}
};
secrets.insert(
entry.name.clone(),
ResolvedSecret {
value,
path,
as_path: entry.as_path,
source,
source_provider: entry.source_provider.clone(),
},
);
}
let mut missing_optional = validated.missing_optional.clone();
missing_optional.sort();
Ok(ResolveResponse {
schema_version: RESOLVE_SCHEMA_VERSION,
provider: validated.resolved.provider.clone(),
profile: validated.resolved.profile.clone(),
secrets,
missing_required: Vec::new(),
missing_optional,
})
}
Err(errors) => {
let mut missing_required = errors.missing_required.clone();
missing_required.sort();
let mut missing_optional = errors.missing_optional.clone();
missing_optional.sort();
Ok(ResolveResponse {
schema_version: RESOLVE_SCHEMA_VERSION,
provider: errors.provider.clone(),
profile: errors.profile.clone(),
secrets: BTreeMap::new(),
missing_required,
missing_optional,
})
}
}
}
pub fn report(&self) -> Result<ResolutionReport> {
Ok(match self.validate_audited(true, Materialize::None)? {
Ok(validated) => validated.report(),
Err(errors) => errors.report(),
})
}
fn validate_audited(
&self,
emit_check: bool,
materialize: Materialize,
) -> Result<std::result::Result<ValidatedSecrets, ValidationErrors>> {
if emit_check {
self.ensure_reason_for(AuditAction::Check, None)?;
} else {
self.ensure_reason()?;
}
let profile_name = self.resolve_profile_name(None);
let names_result = self.resolve_profile_secret_names(Some(&profile_name));
let audit_keys: Vec<String> = names_result.as_ref().ok().cloned().unwrap_or_default();
let result: Result<std::result::Result<ValidatedSecrets, ValidationErrors>> = names_result
.and_then(|_| self.build_plan_from_names(profile_name.clone(), audit_keys.clone()))
.and_then(|plan| self.execute_plan(&plan, materialize));
if emit_check {
let (outcome, error_kind) = match &result {
Ok(Ok(_)) => (AuditOutcome::Found, None),
Ok(Err(_)) => (AuditOutcome::Missing, None),
Err(e) => (AuditOutcome::Error, Some(e.kind())),
};
self.record(
AuditAction::Check,
&profile_name,
outcome,
AuditFields {
keys: &audit_keys,
error_kind,
..Default::default()
},
);
}
result
}
fn composed_dependency_names(&self, target: &str, profile_name: &str) -> Vec<String> {
fn visit(
name: &str,
profile: &crate::manifest::CompiledProfile,
names: &mut HashSet<String>,
) {
if !names.insert(name.to_string()) {
return;
}
if let Some(template) = &profile.secrets[name].composition {
for dependency in template.dependencies() {
visit(dependency, profile, names);
}
}
}
let profile = self
.manifest
.profile(profile_name)
.expect("profile is validated before dependency planning");
let mut names = HashSet::new();
visit(target, profile, &mut names);
let mut names: Vec<String> = names.into_iter().collect();
names.sort();
names
}
fn promptable_missing_names(
&self,
errors: &ValidationErrors,
profile_name: &str,
) -> Vec<String> {
let statuses: HashMap<&str, &ResolutionStatus> = errors
.resolution
.iter()
.map(|entry| (entry.name.as_str(), &entry.status))
.collect();
let profile = self
.manifest
.profile(profile_name)
.expect("profile is validated before prompting");
fn visit(
name: &str,
profile: &crate::manifest::CompiledProfile,
statuses: &HashMap<&str, &ResolutionStatus>,
promptable: &mut HashSet<String>,
) {
let Some(template) = &profile.secrets[name].composition else {
promptable.insert(name.to_string());
return;
};
for dependency in template.dependencies() {
if statuses.get(dependency.as_str()).copied() != Some(&ResolutionStatus::Resolved) {
visit(dependency, profile, statuses, promptable);
}
}
}
let mut promptable = HashSet::new();
for name in &errors.missing_required {
visit(name, profile, &statuses, &mut promptable);
}
let mut promptable: Vec<String> = promptable.into_iter().collect();
promptable.sort();
promptable
}
fn check_single_store_ref_coords(
group: &[&PlannedSecret],
provider: &dyn ProviderTrait,
) -> Result<()> {
for planned in group {
let Some(route) = &planned.route else {
continue;
};
if route.fallback_specs().is_some() {
continue;
}
if let Some(native) = planned.reference() {
provider.resolve_coords(Address::Native(native))?;
}
}
Ok(())
}
fn execute_plan(
&self,
plan: &ResolutionPlan,
materialize: Materialize,
) -> Result<std::result::Result<ValidatedSecrets, ValidationErrors>> {
let project = self.config.project.name.as_str();
let profile = plan.profile.as_str();
let mut secrets: HashMap<String, SecretString> = HashMap::new();
let mut missing_required = Vec::new();
let mut missing_optional = Vec::new();
let mut with_defaults = Vec::new();
let mut temp_files = Vec::new();
let mut resolution: Vec<SecretResolution> = Vec::new();
let mut group_uris: HashMap<Option<&str>, String> = HashMap::new();
let mut fetched_values: HashMap<String, SecretString> = HashMap::new();
let mut failed_primary_uris: HashMap<Option<&str>, SecretSpecError> = HashMap::new();
let mut group_fetches: Vec<GroupFetch<'_>> = Vec::new();
for (provider_uri, group) in plan.groups() {
match self.get_provider(provider_uri, Some(&plan.profile)) {
Ok(provider) => {
group_uris.insert(provider_uri, provider.uri());
group_fetches.push((provider_uri, group, provider));
}
Err(e) => {
let shown = provider_uri.map(crate::audit::redact_uri_strict);
warn_primary_provider_failure(shown.as_deref(), &e);
failed_primary_uris.insert(provider_uri, e);
}
}
}
for (_, group, provider) in &group_fetches {
Self::check_single_store_ref_coords(group, provider.as_ref())?;
}
fn fetch_group<'a>(
(provider_uri, group, provider): GroupFetch<'a>,
project: &str,
profile: &str,
) -> (Option<&'a str>, Result<HashMap<String, SecretString>>) {
let result = Secrets::fetch_group(&*provider, &group, project, profile);
(provider_uri, result)
}
let fetch_results: Vec<(Option<&str>, Result<_>)> = if group_fetches.len() <= 1 {
group_fetches
.into_iter()
.map(|group| fetch_group(group, project, profile))
.collect()
} else {
std::thread::scope(|scope| {
let handles: Vec<_> = group_fetches
.into_iter()
.map(|group| scope.spawn(|| fetch_group(group, project, profile)))
.collect();
handles
.into_iter()
.map(|handle| handle.join().expect("group fetch thread panicked"))
.collect()
})
};
for (provider_uri, result) in fetch_results {
match result {
Ok(batch_results) => fetched_values.extend(batch_results),
Err(e) => {
let display_uri = group_uris.get(&provider_uri).map(String::as_str);
warn_primary_provider_failure(display_uri, &e);
failed_primary_uris.insert(provider_uri, e);
}
}
}
for planned in &plan.secrets {
let Some(route) = &planned.route else {
continue;
};
let name = &planned.name;
let required = planned.required();
let as_path = planned.as_path();
let primary_uri = route.group_key();
let status;
let mut source_provider = None;
let mut default_applied = false;
let mut generated = false;
match fetched_values.remove(name.as_str()) {
Some(value) => {
source_provider = group_uris.get(&primary_uri).cloned();
if materialize == Materialize::Values {
self.insert_resolved(
&mut secrets,
&mut temp_files,
name.clone(),
value,
as_path,
)?;
}
status = ResolutionStatus::Resolved;
}
None => {
let primary_failed = failed_primary_uris.contains_key(&primary_uri);
let (fallback_value, fallback_uri) = match route.fallback_specs() {
Some(fallback) => {
let resolved = self.get_secret_from_providers(
name,
planned.as_address(project, profile),
Some(fallback),
Some(profile),
)?;
if resolved.0.is_none() && primary_failed {
let err = failed_primary_uris
.remove(&primary_uri)
.expect("primary_failed implies entry present");
return Err(err);
}
resolved
}
None if primary_failed => {
let err = failed_primary_uris
.remove(&primary_uri)
.expect("primary_failed implies entry present");
return Err(err);
}
None => (None, None),
};
if let Some(value) = fallback_value {
source_provider = fallback_uri;
if materialize == Materialize::Values {
self.insert_resolved(
&mut secrets,
&mut temp_files,
name.clone(),
value,
as_path,
)?;
}
status = ResolutionStatus::Resolved;
} else {
match planned.secret.missing {
MissingPolicy::Generate => {
generated = true;
if materialize == Materialize::Values {
let generated_value = self
.try_generate_secret(planned, profile)?
.expect("compiled Generate policy has a generator");
self.insert_resolved(
&mut secrets,
&mut temp_files,
name.clone(),
generated_value,
as_path,
)?;
}
status = ResolutionStatus::Resolved;
}
MissingPolicy::UseDefault => {
let default_value = planned
.config()
.default
.as_ref()
.expect("compiled UseDefault policy has a default");
default_applied = true;
if materialize == Materialize::Values {
self.insert_resolved(
&mut secrets,
&mut temp_files,
name.clone(),
SecretString::new(default_value.clone().into()),
as_path,
)?;
with_defaults.push((name.clone(), default_value.clone()));
}
status = ResolutionStatus::Resolved;
}
MissingPolicy::Error => {
missing_required.push(name.clone());
status = ResolutionStatus::MissingRequired;
}
MissingPolicy::Omit => {
missing_optional.push(name.clone());
status = ResolutionStatus::MissingOptional;
}
}
}
}
}
resolution.push(SecretResolution {
name: name.clone(),
status,
required,
source_provider,
default_applied,
generated,
composed: false,
as_path,
});
}
fn composition_order<'a>(
planned: &'a PlannedSecret,
composed: &HashMap<&str, &'a PlannedSecret>,
visited: &mut HashSet<&'a str>,
ordered: &mut Vec<&'a PlannedSecret>,
) {
if !visited.insert(planned.name.as_str()) {
return;
}
let template = planned
.composition()
.expect("only composed nodes are ordered");
for dependency in template.dependencies() {
if let Some(dependency) = composed.get(dependency.as_str()) {
composition_order(dependency, composed, visited, ordered);
}
}
ordered.push(planned);
}
let composed: HashMap<&str, &PlannedSecret> = plan
.secrets
.iter()
.filter(|secret| secret.is_composed())
.map(|secret| (secret.name.as_str(), secret))
.collect();
let mut ordered = Vec::with_capacity(composed.len());
let mut visited = HashSet::new();
for planned in plan.secrets.iter().filter(|secret| secret.is_composed()) {
composition_order(planned, &composed, &mut visited, &mut ordered);
}
if !ordered.is_empty() {
let mut statuses: HashMap<String, ResolutionStatus> = resolution
.iter()
.map(|entry| (entry.name.clone(), entry.status.clone()))
.collect();
for planned in ordered {
let template = planned
.composition()
.expect("only composed nodes are ordered");
let dependencies_resolved = template.dependencies().iter().all(|dependency| {
statuses.get(dependency) == Some(&ResolutionStatus::Resolved)
});
let status = if dependencies_resolved {
if materialize == Materialize::Values {
let rendered = template
.render(|dependency| {
secrets.get(dependency).map(|value| value.expose_secret())
})
.map_err(SecretSpecError::CompositionFailed)?;
self.insert_resolved(
&mut secrets,
&mut temp_files,
planned.name.clone(),
SecretString::new(rendered.into()),
planned.as_path(),
)?;
}
ResolutionStatus::Resolved
} else {
match planned.secret.missing {
MissingPolicy::Error => {
missing_required.push(planned.name.clone());
ResolutionStatus::MissingRequired
}
MissingPolicy::Omit => {
missing_optional.push(planned.name.clone());
ResolutionStatus::MissingOptional
}
MissingPolicy::Generate | MissingPolicy::UseDefault => {
unreachable!("composed source conflicts are rejected at load time")
}
}
};
statuses.insert(planned.name.clone(), status.clone());
resolution.push(SecretResolution {
name: planned.name.clone(),
status,
required: planned.required(),
source_provider: None,
default_applied: false,
generated: false,
composed: true,
as_path: planned.as_path(),
});
}
}
let report_provider_uri = self.validation_report_provider_uri(
plan.override_uri.as_deref(),
plan.secrets
.iter()
.filter_map(|secret| secret.route.as_ref())
.map(|route| route.primary()),
Some(&plan.profile),
)?;
if !missing_required.is_empty() {
let mut errors = ValidationErrors::new(
missing_required,
missing_optional,
with_defaults,
report_provider_uri,
profile.to_string(),
);
errors.resolution = resolution;
Ok(Err(errors))
} else {
Ok(Ok(ValidatedSecrets {
resolved: Resolved::new(secrets, report_provider_uri, profile.to_string()),
missing_optional,
with_defaults,
resolution,
temp_files,
}))
}
}
pub fn run(&self, command: Vec<String>) -> Result<()> {
self.ensure_reason_for(AuditAction::Run, None)?;
let exit_code = self.run_command(command)?;
std::process::exit(exit_code);
}
pub(crate) fn run_command(&self, command: Vec<String>) -> Result<i32> {
if command.is_empty() {
return Err(SecretSpecError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
"No command specified. Usage: secretspec run -- <command> [args...]",
)));
}
let validation_result = match self.ensure_secrets(None, None, false) {
Ok(v) => v,
Err(e) => {
self.record(
AuditAction::Run,
&self.resolve_profile_name(None),
AuditOutcome::Error,
AuditFields {
command: Some(&command[0]),
error_kind: Some(e.kind()),
..Default::default()
},
);
return Err(e);
}
};
let env_vars = child_env_from(
env::vars_os(),
validation_result
.resolved
.secrets
.iter()
.map(|(key, secret)| (key.clone(), secret.expose_secret().to_string())),
);
let keys: Vec<String> = if self.audit.is_some() {
let mut keys: Vec<String> =
validation_result.resolved.secrets.keys().cloned().collect();
keys.sort();
keys
} else {
Vec::new()
};
let mut cmd = Command::new(&command[0]);
cmd.args(&command[1..]);
cmd.envs(&env_vars);
let child = cmd.spawn();
let (outcome, error_kind) = match &child {
Ok(_) => (AuditOutcome::Started, None),
Err(_) => (AuditOutcome::Error, Some("io")),
};
self.record(
AuditAction::Run,
&validation_result.resolved.profile,
outcome,
AuditFields {
keys: &keys,
command: Some(&command[0]),
error_kind,
..Default::default()
},
);
let status = child?.wait()?;
Ok(status.code().unwrap_or(1))
}
pub fn export(&self, format: ExportFormat, out: &mut dyn io::Write) -> Result<()> {
self.ensure_reason_for(AuditAction::Export, None)?;
let profile = self.resolve_profile_name(None);
let mut validated = match self.ensure_secrets(None, None, false) {
Ok(v) => v,
Err(e) => {
self.record(
AuditAction::Export,
&profile,
AuditOutcome::Error,
AuditFields {
error_kind: Some(e.kind()),
..Default::default()
},
);
return Err(e);
}
};
if let Err(e) = validated.keep_temp_files() {
let err = SecretSpecError::Io(e);
self.record(
AuditAction::Export,
&validated.resolved.profile,
AuditOutcome::Error,
AuditFields {
error_kind: Some(err.kind()),
..Default::default()
},
);
return Err(err);
}
let mut entries: Vec<(&str, &str)> = validated
.resolved
.secrets
.iter()
.map(|(key, value)| (key.as_str(), value.expose_secret()))
.collect();
entries.sort_by(|(a, _), (b, _)| a.cmp(b));
let keys: Vec<String> = if self.audit.is_some() {
entries.iter().map(|(key, _)| key.to_string()).collect()
} else {
Vec::new()
};
let result = write_export(format, &entries, out);
self.record(
AuditAction::Export,
&validated.resolved.profile,
if result.is_ok() {
AuditOutcome::Found
} else {
AuditOutcome::Error
},
AuditFields {
keys: &keys,
error_kind: result.as_ref().err().map(|e| e.kind()),
..Default::default()
},
);
result?;
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum ExportFormat {
#[default]
Shell,
Dotenv,
Json,
Gha,
}
fn write_export(
format: ExportFormat,
entries: &[(&str, &str)],
out: &mut dyn io::Write,
) -> Result<()> {
match format {
ExportFormat::Shell => {
let mut buf = String::new();
for (key, value) in entries {
buf.push_str("export ");
buf.push_str(key);
buf.push('=');
buf.push_str(&shell_single_quote(value));
buf.push('\n');
}
out.write_all(buf.as_bytes()).map_err(SecretSpecError::Io)?;
}
ExportFormat::Dotenv => {
let content = crate::provider::dotenv::serialize_dotenv_pairs(
entries.iter().map(|(key, value)| (*key, *value)),
);
out.write_all(content.as_bytes())
.map_err(SecretSpecError::Io)?;
}
ExportFormat::Json => {
let map: BTreeMap<&str, &str> = entries.iter().copied().collect();
let json = serde_json::to_string(&map)
.map_err(|e| SecretSpecError::Io(io::Error::other(e)))?;
out.write_all(json.as_bytes())
.and_then(|()| out.write_all(b"\n"))
.map_err(SecretSpecError::Io)?;
}
ExportFormat::Gha => write_gha(entries, out)?,
}
Ok(())
}
fn shell_single_quote(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('\'');
for ch in value.chars() {
if ch == '\'' {
out.push_str("'\\''");
} else {
out.push(ch);
}
}
out.push('\'');
out
}
fn write_gha(entries: &[(&str, &str)], out: &mut dyn io::Write) -> Result<()> {
use std::io::Write;
let github_env = env::var("GITHUB_ENV").map_err(|_| {
SecretSpecError::Io(io::Error::new(
io::ErrorKind::NotFound,
"GITHUB_ENV is not set; `--format gha` only works inside a GitHub/Forgejo Actions runner",
))
})?;
let mut masks = String::new();
for (_, value) in entries {
for line in value.split('\n') {
if !line.is_empty() {
masks.push_str("::add-mask::");
masks.push_str(&gha_escape_data(line));
masks.push('\n');
}
}
}
out.write_all(masks.as_bytes())
.map_err(SecretSpecError::Io)?;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&github_env)
.map_err(SecretSpecError::Io)?;
let mut block = String::new();
for (key, value) in entries {
if value.contains('\n') {
let delimiter = gha_heredoc_delimiter(value);
block.push_str(key);
block.push_str("<<");
block.push_str(&delimiter);
block.push('\n');
block.push_str(value);
block.push('\n');
block.push_str(&delimiter);
block.push('\n');
} else {
block.push_str(key);
block.push('=');
block.push_str(value);
block.push('\n');
}
}
let start_len = file.metadata().map_err(SecretSpecError::Io)?.len();
if let Err(e) = file.write_all(block.as_bytes()) {
let _ = file.set_len(start_len);
return Err(SecretSpecError::Io(e));
}
Ok(())
}
fn gha_escape_data(value: &str) -> String {
value
.replace('%', "%25")
.replace('\r', "%0D")
.replace('\n', "%0A")
}
fn gha_heredoc_delimiter(value: &str) -> String {
loop {
let delimiter = format!("ghadelimiter_{}", uuid::Uuid::new_v4().simple());
if !value.lines().any(|line| line == delimiter) {
return delimiter;
}
}
}
#[cfg(test)]
mod export_tests {
use super::*;
#[cfg(unix)]
#[test]
fn shell_single_quote_round_trips_through_sh() {
let cases = ["abc'123", "a b c", "pa$$word", "he said \"hi\"", "", "'"];
for value in cases {
let script = format!("export K={}; printf '%s' \"$K\"", shell_single_quote(value));
let output = std::process::Command::new("sh")
.arg("-c")
.arg(&script)
.output()
.expect("sh should be available in the test environment");
assert!(
output.status.success(),
"sh failed for {value:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
let read_back = String::from_utf8(output.stdout).expect("sh stdout is utf-8");
assert_eq!(read_back, value, "round-trip mismatch for {value:?}");
}
}
fn rendered(format: ExportFormat, entries: &[(&str, &str)]) -> String {
let mut buf = Vec::new();
write_export(format, entries, &mut buf).expect("write_export should succeed");
String::from_utf8(buf).expect("export output is utf-8")
}
#[test]
fn shell_format_quotes_each_value() {
let out = rendered(ExportFormat::Shell, &[("A", "x y"), ("B", "a'b")]);
assert_eq!(out, "export A='x y'\nexport B='a'\\''b'\n");
}
#[test]
fn json_format_is_compact() {
let out = rendered(ExportFormat::Json, &[("A", "1"), ("B", "2")]);
assert_eq!(out, "{\"A\":\"1\",\"B\":\"2\"}\n");
}
#[test]
fn dotenv_format_double_quotes_and_escapes() {
let out = rendered(ExportFormat::Dotenv, &[("A", "pa$$"), ("B", "x")]);
assert_eq!(out, "A=\"pa\\$\\$\"\nB=\"x\"\n");
}
#[test]
fn gha_escape_data_encodes_percent_cr_and_lf() {
assert_eq!(gha_escape_data("plain"), "plain");
assert_eq!(gha_escape_data("a%b"), "a%25b");
assert_eq!(gha_escape_data("a\rb"), "a%0Db");
assert_eq!(gha_escape_data("a\nb"), "a%0Ab");
assert_eq!(gha_escape_data("a%0Ab"), "a%250Ab");
}
}
#[cfg(test)]
mod policy_tests {
use super::*;
#[test]
fn policy_decision_matrix() {
use RequireReason::*;
assert!(!policy_requires_reason(Never, true));
assert!(!policy_requires_reason(Never, false));
assert!(policy_requires_reason(Always, false));
assert!(policy_requires_reason(Always, true));
assert!(policy_requires_reason(Agents, true));
assert!(!policy_requires_reason(Agents, false));
}
#[test]
fn normalize_reason_trims_and_blanks_to_none() {
assert_eq!(
normalize_reason(" deploy web "),
Some("deploy web".to_string())
);
assert_eq!(normalize_reason("deploy"), Some("deploy".to_string()));
assert_eq!(normalize_reason(""), None);
assert_eq!(normalize_reason(" "), None);
assert_eq!(normalize_reason("\t\n"), None);
}
#[test]
fn non_blank_trims_and_blanks_to_none() {
assert_eq!(non_blank("production\n"), Some("production".to_string()));
assert_eq!(non_blank(" keyring "), Some("keyring".to_string()));
assert_eq!(non_blank(""), None);
assert_eq!(non_blank(" "), None);
assert_eq!(non_blank("\t\n"), None);
}
#[cfg(unix)]
#[test]
fn utf8_env_drops_non_utf8_entries_without_panicking() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let bad_key = OsString::from_vec(vec![0x66, 0x6f, 0xff]); let bad_val = OsString::from_vec(vec![0xfe, 0xfe]);
let vars = vec![
(OsString::from("CLEAN_KEY"), OsString::from("clean_value")),
(bad_key, OsString::from("value_for_bad_key")),
(OsString::from("KEY_WITH_BAD_VALUE"), bad_val),
];
let env = utf8_env_from(vars);
assert_eq!(
env.get("CLEAN_KEY").map(String::as_str),
Some("clean_value")
);
assert_eq!(env.len(), 1);
}
#[cfg(unix)]
#[test]
fn child_env_passes_through_non_utf8_and_overlays_secrets() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let bad_val = OsString::from_vec(vec![0x64, 0x61, 0x63, 0xa3]); let vars = vec![
(OsString::from("CLEAN_KEY"), OsString::from("clean_value")),
(OsString::from("BAD"), bad_val.clone()),
(OsString::from("OVERRIDDEN"), OsString::from("parent_value")),
];
let secrets = vec![
("SECRET_KEY".to_string(), "secret_value".to_string()),
("OVERRIDDEN".to_string(), "secret_wins".to_string()),
];
let env = child_env_from(vars, secrets);
assert_eq!(env.get(&OsString::from("BAD")), Some(&bad_val));
assert_eq!(
env.get(&OsString::from("CLEAN_KEY")),
Some(&OsString::from("clean_value"))
);
assert_eq!(
env.get(&OsString::from("SECRET_KEY")),
Some(&OsString::from("secret_value"))
);
assert_eq!(
env.get(&OsString::from("OVERRIDDEN")),
Some(&OsString::from("secret_wins"))
);
assert_eq!(env.len(), 4);
}
}
#[cfg(test)]
mod provider_credentials_cache_tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Barrier};
use std::thread;
use std::time::Duration;
#[test]
fn concurrent_population_for_one_key_is_single_flight() {
const CALLERS: usize = 8;
let cache = Arc::new(ProviderCredentialsCache::default());
let start = Arc::new(Barrier::new(CALLERS));
let fetches = Arc::new(AtomicUsize::new(0));
let threads: Vec<_> = (0..CALLERS)
.map(|_| {
let cache = Arc::clone(&cache);
let start = Arc::clone(&start);
let fetches = Arc::clone(&fetches);
thread::spawn(move || {
start.wait();
cache
.get_or_try_init(("default".into(), "target".into()), || {
fetches.fetch_add(1, Ordering::SeqCst);
thread::sleep(Duration::from_millis(50));
let mut credentials = ProviderCredentials::new();
credentials.insert("token".into(), SecretString::new("value".into()));
Ok(credentials)
})
.unwrap()
})
})
.collect();
for thread in threads {
let credentials = thread.join().unwrap();
assert_eq!(
credentials.get("token").map(|value| value.expose_secret()),
Some("value")
);
}
assert_eq!(fetches.load(Ordering::SeqCst), 1);
}
}
#[cfg(test)]
mod provider_credential_scope_tests {
use super::*;
use crate::config::{CredentialSource, Profile, ProviderAlias, Secret};
use crate::tests::{resolve_test_config, scrub_resolution_env};
use tempfile::TempDir;
#[test]
fn provider_credentials_resolve_under_any_profile() {
let _env = scrub_resolution_env();
let _cwd = crate::secrets::lock_cwd();
let _store = TempDir::new().unwrap();
let providers = HashMap::from([(
"bws".to_string(),
ProviderAlias {
uri: "bws://proj".to_string(),
credentials: HashMap::from([(
"access_token".to_string(),
CredentialSource::from("memtest://"),
)]),
},
)]);
let mut config =
resolve_test_config(HashMap::from([("API_KEY".to_string(), Secret::default())]));
config.profiles.insert(
"production".to_string(),
Profile {
defaults: None,
secrets: HashMap::new(),
},
);
config.providers = Some(providers);
let logged_in = Secrets::new(config.clone(), None, None, None);
let source = logged_in
.declared_provider_credentials("bws")
.unwrap()
.into_iter()
.next()
.expect("alias declares one credential")
.1;
logged_in
.store_provider_credential(
&source,
"access_token",
&SecretString::new("tok-123".into()),
)
.unwrap();
let resolver = Secrets::new(config, None, None, Some("production".to_string()));
let resolved = resolver
.resolve_provider_credentials("bws", "production")
.expect("a stored provider credential must resolve under any profile");
assert_eq!(
resolved
.get("access_token")
.map(|value| value.expose_secret()),
Some("tok-123"),
);
}
}
#[cfg(test)]
pub(crate) static CWD_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
pub(crate) fn lock_cwd() -> std::sync::MutexGuard<'static, ()> {
CWD_GUARD.lock().unwrap_or_else(|e| e.into_inner())
}
#[cfg(test)]
mod config_discovery_tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn find_config_file_walks_up_to_nearest_ancestor() {
let root = TempDir::new().unwrap();
let manifest = root.path().join("secretspec.toml");
fs::write(&manifest, "[project]\nname=\"x\"\nrevision=\"1.0\"\n").unwrap();
let nested = root.path().join("a").join("b").join("c");
fs::create_dir_all(&nested).unwrap();
let found = find_config_file_from(nested).unwrap();
assert_eq!(
found.canonicalize().unwrap(),
manifest.canonicalize().unwrap()
);
}
#[test]
fn find_config_file_reports_missing_manifest() {
let empty = TempDir::new().unwrap();
assert!(matches!(
find_config_file_from(empty.path().to_path_buf()),
Err(SecretSpecError::NoManifest)
));
}
#[test]
fn try_from_resolves_relative_paths_against_cwd() {
let _cwd = lock_cwd();
let root = TempDir::new().unwrap();
fs::write(
root.path().join("secretspec.toml"),
"[project]\nname=\"x\"\nrevision=\"1.0\"\n\n[profiles.default]\n",
)
.unwrap();
let sub = root.path().join("sub");
fs::create_dir_all(&sub).unwrap();
let original = env::current_dir().unwrap();
env::set_current_dir(root.path()).unwrap();
let from_cwd = Config::try_from(Path::new("secretspec.toml"));
env::set_current_dir(&sub).unwrap();
let from_parent = Config::try_from(Path::new("../secretspec.toml"));
env::set_current_dir(&original).unwrap();
assert!(from_cwd.is_ok(), "bare filename: {:?}", from_cwd.err());
assert!(
from_parent.is_ok(),
"../ relative path: {:?}",
from_parent.err()
);
}
}
#[cfg(test)]
mod report_provider_tests {
use super::*;
#[test]
fn report_provider_uri_redacts_credentials() {
let spec = Secrets::new(
Config {
project: crate::config::Project {
name: "redact-test".to_string(),
..Default::default()
},
profiles: HashMap::new(),
providers: None,
},
None,
None,
None,
);
let got = spec
.validation_report_provider_uri(
Some("vault+token:s3cr3t@host/db?token=abc"),
std::iter::empty(),
None,
)
.unwrap();
assert_eq!(got, "vault+token:host/db");
assert!(!got.contains("s3cr3t") && !got.contains("abc"));
let got = spec
.validation_report_provider_uri(
None,
[Some("vault://host?token=zzz")].into_iter(),
None,
)
.unwrap();
assert_eq!(got, "vault://host");
assert!(!got.contains("zzz"));
}
}
#[cfg(test)]
mod reference_routing_tests {
use super::*;
use crate::config::Secret;
fn spec_with_provider(provider: Option<&str>) -> Secrets {
Secrets::new(
Config {
project: crate::config::Project {
name: "ref-test".to_string(),
..Default::default()
},
profiles: HashMap::new(),
providers: None,
},
None,
provider.map(String::from),
None,
)
}
fn ref_secret(providers: Option<Vec<&str>>) -> Secret {
Secret {
description: Some("Sentry DSN".to_string()),
reference: Some(crate::config::NativeAddress {
item: "shared".to_string(),
field: Some("SENTRY_DSN".to_string()),
..Default::default()
}),
providers: providers.map(|p| p.into_iter().map(String::from).collect()),
..Default::default()
}
}
fn read_uris(
spec: &Secrets,
config: &Secret,
override_arg: Option<&str>,
) -> Option<Vec<String>> {
let override_spec = spec.explicit_provider_spec(override_arg);
spec.route_for(config, &override_spec).unwrap().specs()
}
#[test]
fn reference_does_not_affect_read_routing() {
let _env = crate::tests::scrub_resolution_env();
let spec = spec_with_provider(None);
let uris = read_uris(&spec, &ref_secret(None), None);
assert_eq!(uris, None, "no routing configured, default store applies");
}
#[test]
fn override_redirects_reference() {
let _env = crate::tests::scrub_resolution_env();
let spec = spec_with_provider(Some("keyring"));
let uris = read_uris(&spec, &ref_secret(None), Some("dotenv://.env.mock"));
assert_eq!(uris, Some(vec!["dotenv://.env.mock".to_string()]));
}
#[test]
fn reference_routes_through_providers_chain() {
let _env = crate::tests::scrub_resolution_env();
let spec = spec_with_provider(None);
let uris = read_uris(
&spec,
&ref_secret(Some(vec!["onepassword://Production", "keyring://"])),
None,
);
assert_eq!(
uris,
Some(vec![
"onepassword://Production".to_string(),
"keyring://".to_string()
])
);
}
#[test]
fn write_provider_follows_routing() {
let _env = crate::tests::scrub_resolution_env();
let spec = spec_with_provider(None);
let write_provider = |override_arg: Option<&str>| {
let override_spec = spec.explicit_provider_spec(override_arg);
let route = spec
.route_for(
&ref_secret(Some(vec!["onepassword://Production"])),
&override_spec,
)
.unwrap();
spec.write_provider_for_route(&route, None).unwrap()
};
assert_eq!(write_provider(None).name(), "onepassword");
assert_eq!(write_provider(Some("dotenv://.env.mock")).name(), "dotenv");
}
fn check_ref_coords_of(secret: Secret) -> Result<()> {
let mut secrets = HashMap::new();
secrets.insert("SECRET".to_string(), secret);
let spec = Secrets::new(crate::tests::resolve_test_config(secrets), None, None, None);
let plan = spec.build_plan(None).unwrap();
for (primary, group) in plan.groups() {
let provider = spec.get_provider(primary, None).unwrap();
Secrets::check_single_store_ref_coords(&group, provider.as_ref())?;
}
Ok(())
}
#[test]
fn single_store_ref_with_unsupported_coord_is_rejected() {
let _env = crate::tests::scrub_resolution_env();
assert!(
check_ref_coords_of(ref_secret(Some(vec!["dotenv:///tmp/x"]))).is_err(),
"a single-store ref with an unsupported coordinate must be rejected"
);
}
#[test]
fn multi_store_ref_defers_coord_validation() {
let _env = crate::tests::scrub_resolution_env();
assert!(
check_ref_coords_of(ref_secret(Some(vec!["dotenv:///tmp/a", "dotenv:///tmp/b"])))
.is_ok(),
"a multi-store ref must defer coordinate checking to read time"
);
}
}