use crate::audit::{AuditAction, AuditContext, AuditLogger, AuditOutcome};
use crate::config::{Config, GlobalConfig, Profile, RequireReason, Resolved};
use crate::error::{Result, SecretSpecError};
use crate::provider::Provider as ProviderTrait;
use crate::validation::{ValidatedSecrets, ValidationErrors};
use colored::Colorize;
use secrecy::{ExposeSecret, SecretString};
use std::collections::{HashMap, HashSet};
use std::convert::TryFrom;
use std::env;
use std::io::{self, IsTerminal, Read};
use std::path::{Path, PathBuf};
use std::process::Command;
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 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
);
}
fn find_config_file() -> Result<PathBuf> {
let mut dir = std::env::current_dir()?;
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,
global_config: Option<GlobalConfig>,
provider: Option<String>,
profile: Option<String>,
reason: Option<String>,
require_reason: RequireReason,
audit: Option<AuditLogger>,
}
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()
}
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 normalize_reason(reason: &str) -> Option<String> {
let trimmed = reason.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
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>,
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 {
Self {
config,
global_config,
provider,
profile,
reason: None,
require_reason: RequireReason::Never,
audit: None,
}
}
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 global_config = GlobalConfig::load()?;
let audit = AuditLogger::from_config(
&global_config
.as_ref()
.and_then(|g| g.audit.clone())
.unwrap_or_default(),
);
Ok(Self {
require_reason: project_config.project.require_reason.unwrap_or_default(),
config: project_config,
global_config,
provider: None,
profile: None,
reason: env_reason(),
audit,
})
}
pub fn set_provider(&mut self, provider: impl Into<String>) {
self.provider = Some(provider.into());
}
pub fn set_profile(&mut self, profile: impl Into<String>) {
self.profile = Some(profile.into());
}
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) -> Result<Box<dyn ProviderTrait>> {
let provider = Box::<dyn ProviderTrait>::try_from(spec)?;
provider.set_reason(self.reason.clone());
Ok(provider)
}
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,
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>,
) {
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),
provider_uri,
error_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(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())
.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(&self, profile: Option<&str>) -> Result<Profile> {
let profile_name = profile
.map(str::to_string)
.unwrap_or_else(|| self.resolve_profile_name(None));
let mut profile_config = self.require_profile(&profile_name)?.clone();
if profile_name != "default"
&& let Some(default_profile) = self.config.profiles.get("default").cloned()
{
profile_config.merge_with(default_profile);
}
Ok(profile_config)
}
pub(crate) fn resolve_secret_config(
&self,
name: &str,
profile: Option<&str>,
) -> Option<crate::config::Secret> {
let profile_name = self.resolve_profile_name(profile);
let current_profile = self.config.profiles.get(&profile_name);
let current_secret =
current_profile.and_then(|profile_config| profile_config.secrets.get(name));
let current_defaults =
current_profile.and_then(|profile_config| profile_config.defaults.as_ref());
let default_secret = if profile_name != "default" {
self.config
.profiles
.get("default")
.and_then(|default_profile| default_profile.secrets.get(name))
} else {
None
};
match (current_secret, default_secret) {
(Some(current), Some(default)) => {
Some(crate::config::Secret {
description: current
.description
.clone()
.or_else(|| default.description.clone()),
required: current
.required
.or(default.required)
.or(current_defaults.and_then(|d| d.required)),
default: current
.default
.clone()
.or_else(|| default.default.clone())
.or_else(|| current_defaults.and_then(|d| d.default.clone())),
providers: current
.providers
.clone()
.or_else(|| default.providers.clone())
.or_else(|| current_defaults.and_then(|d| d.providers.clone())),
as_path: current.as_path.or(default.as_path),
secret_type: current
.secret_type
.clone()
.or_else(|| default.secret_type.clone()),
generate: current
.generate
.clone()
.or_else(|| default.generate.clone()),
})
}
(Some(secret), None) | (None, Some(secret)) => {
Some(crate::config::Secret {
description: secret.description.clone(),
required: secret
.required
.or(current_defaults.and_then(|d| d.required)),
default: secret
.default
.clone()
.or_else(|| current_defaults.and_then(|d| d.default.clone())),
providers: secret
.providers
.clone()
.or_else(|| current_defaults.and_then(|d| d.providers.clone())),
as_path: secret.as_path,
secret_type: secret.secret_type.clone(),
generate: secret.generate.clone(),
})
}
(None, None) => None,
}
}
fn provider_alias_sources(&self) -> impl Iterator<Item = &HashMap<String, String>> {
self.config.providers.iter().chain(
self.global_config
.as_ref()
.and_then(|gc| gc.defaults.providers.as_ref()),
)
}
fn lookup_provider_alias(&self, alias: &str) -> Option<String> {
self.provider_alias_sources()
.find_map(|m| m.get(alias))
.cloned()
}
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_provider_aliases(
&self,
provider_aliases: Option<&[String]>,
) -> Result<Option<Vec<String>>> {
let Some(aliases) = provider_aliases else {
return Ok(None);
};
let mut uris = Vec::with_capacity(aliases.len());
for alias in aliases {
match self.lookup_provider_alias(alias) {
Some(uri) => uris.push(uri),
None => {
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.",
alias
)
} else {
format!(
"Provider alias '{}' is not defined. Available aliases: {}",
alias,
known.join(", ")
)
};
return Err(SecretSpecError::ProviderNotFound(msg));
}
}
}
Ok(Some(uris))
}
fn explicit_provider_spec(&self, override_arg: Option<String>) -> Option<String> {
override_arg
.or_else(|| self.provider.clone())
.or_else(|| env::var("SECRETSPEC_PROVIDER").ok())
}
pub(crate) fn resolve_provider_override(&self, override_arg: Option<&str>) -> Option<String> {
let spec = self.explicit_provider_spec(override_arg.map(|s| s.to_string()))?;
Some(self.lookup_provider_alias(&spec).unwrap_or(spec))
}
pub(crate) fn resolve_write_provider(
&self,
secret_config: &crate::config::Secret,
override_arg: Option<&str>,
) -> Result<Box<dyn ProviderTrait>> {
if let Some(uri) = self.resolve_provider_override(override_arg) {
return self.build_provider(uri);
}
if let Some(alias) = secret_config.providers.as_ref().and_then(|p| p.first()) {
let provider_uris = self.resolve_provider_aliases(Some(std::slice::from_ref(alias)))?;
let uri = provider_uris
.and_then(|uris| uris.into_iter().next())
.ok_or_else(|| {
SecretSpecError::ProviderNotFound(format!(
"Provider alias '{}' could not be resolved",
alias
))
})?;
return self.build_provider(uri);
}
self.get_provider(None)
}
pub(crate) fn resolve_read_provider_uris(
&self,
secret_config: &crate::config::Secret,
override_arg: Option<&str>,
) -> Result<Option<Vec<String>>> {
if let Some(uri) = self.resolve_provider_override(override_arg) {
return Ok(Some(vec![uri]));
}
self.resolve_provider_aliases(secret_config.providers.as_deref())
}
pub(crate) fn get_provider(
&self,
provider_arg: Option<String>,
) -> Result<Box<dyn ProviderTrait>> {
let provider_spec = self
.explicit_provider_spec(provider_arg)
.or_else(|| {
self.global_config
.as_ref()
.and_then(|gc| gc.defaults.provider.clone())
})
.map(|spec| self.lookup_provider_alias(&spec).unwrap_or(spec))
.ok_or(SecretSpecError::NoProviderConfigured)?;
let provider = self.build_provider(provider_spec)?;
Ok(provider)
}
fn validation_report_provider_uri(
&self,
override_uri: Option<&str>,
secret_primary_uris: &HashMap<String, Option<String>>,
) -> Result<String> {
if let Some(uri) = override_uri {
return Ok(uri.to_string());
}
if secret_primary_uris.values().any(Option::is_none) {
return self.get_provider(None).map(|provider| provider.uri());
}
let mut provider_uris: Vec<&String> = secret_primary_uris
.values()
.filter_map(Option::as_ref)
.collect();
provider_uris.sort();
if let Some(uri) = provider_uris.first() {
return Ok((*uri).clone());
}
self.get_provider(None).map(|provider| provider.uri())
}
fn get_secret_from_providers(
&self,
project_name: &str,
secret_name: &str,
profile_name: &str,
provider_uris: Option<&[String]>,
default_provider_arg: Option<String>,
) -> Result<(Option<SecretString>, Option<String>)> {
if let Some(uris) = provider_uris {
let mut last_error: Option<SecretSpecError> = None;
let mut any_healthy = false;
let mut last_uri: Option<String> = None;
for uri in uris {
let provider = match self.build_provider(uri.clone()) {
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(project_name, secret_name, profile_name) {
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(default_provider_arg)?;
let uri = backend.uri();
backend
.get(project_name, secret_name, profile_name)
.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 secret_config = match self.resolve_secret_config(name, None) {
Some(sc) => sc,
None => {
let profile = self.resolve_profile(Some(&profile_name))?;
let mut available_secrets = profile
.into_iter()
.map(|(name, _)| name)
.collect::<Vec<_>>();
available_secrets.sort();
let err = SecretSpecError::SecretNotFound(format!(
"Secret '{}' is not defined in profile '{}'. Available secrets: {}",
name,
profile_name,
available_secrets.join(", ")
));
self.record(
AuditAction::Set,
&profile_name,
AuditOutcome::Error,
AuditFields {
key: Some(name),
error_kind: Some(err.kind()),
..Default::default()
},
);
return Err(err);
}
};
let backend = self.resolve_write_provider(&secret_config, None)?;
if !backend.allows_set() {
let err = SecretSpecError::ProviderOperationFailed(format!(
"Provider '{}' is read-only and does not support setting values",
backend.name()
));
self.record(
AuditAction::Set,
&profile_name,
AuditOutcome::Error,
AuditFields {
key: Some(name),
provider_uri: Some(backend.uri()),
error_kind: Some(err.kind()),
..Default::default()
},
);
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(
AuditAction::Set,
&profile_name,
AuditOutcome::Error,
AuditFields {
key: Some(name),
provider_uri: Some(backend.uri()),
error_kind: Some(err.kind()),
..Default::default()
},
);
return Err(err);
}
let result = backend.set(&self.config.project.name, name, &value, &profile_name);
self.audit_write_result(&result, name, &profile_name, Some(backend.uri()));
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 secret_config = match self.resolve_secret_config(name, None) {
Some(config) => config,
None => {
let err = SecretSpecError::SecretNotFound(name.to_string());
self.record(
AuditAction::Get,
&profile_name,
AuditOutcome::Error,
AuditFields {
key: Some(name),
error_kind: Some(err.kind()),
..Default::default()
},
);
return Err(err);
}
};
let default = secret_config.default.clone();
let as_path = secret_config.as_path.unwrap_or(false);
let provider_uris = self.resolve_read_provider_uris(&secret_config, None)?;
let result = self.get_secret_from_providers(
&self.config.project.name,
name,
&profile_name,
provider_uris.as_deref(),
None,
);
match &result {
Ok((Some(_), uri)) => self.record(
AuditAction::Get,
&profile_name,
AuditOutcome::Found,
AuditFields {
key: Some(name),
provider_uri: uri.clone(),
..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(),
..Default::default()
},
),
Ok((None, uri)) => self.record(
AuditAction::Get,
&profile_name,
AuditOutcome::Missing,
AuditFields {
key: Some(name),
provider_uri: uri.clone(),
..Default::default()
},
),
Err(e) => self.record(
AuditAction::Get,
&profile_name,
AuditOutcome::Error,
AuditFields {
key: Some(name),
error_kind: Some(e.kind()),
..Default::default()
},
),
}
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)?;
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 = &validation_errors.missing_required;
let total = missing.len();
let default_backend = self.get_provider(provider_arg.clone())?;
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(secret_config) =
self.resolve_secret_config(secret_name, Some(&profile_display))
{
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
.resolve_write_provider(&secret_config, provider_arg.as_deref())?;
let set_result = backend.set(
&self.config.project.name,
secret_name,
&SecretString::new(value.into()),
&profile_display,
);
self.audit_write_result(
&set_result,
secret_name,
&profile_display,
Some(backend.uri()),
);
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)? {
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 profile = self.resolve_profile(Some(&valid.resolved.profile))?;
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 profile.iter() {
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 profile = self.resolve_profile(Some(&errors.profile))?;
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 &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())?;
source_uri = Some(from_provider_instance.uri());
eprintln!(
"Importing secrets from {} (profile: {})...\n",
from_provider.blue(),
profile_display.cyan()
);
let profile = self.resolve_profile(Some(&profile_display))?;
for (name, config) in profile.into_iter() {
read_names.push(name.clone());
let secret_config = self
.resolve_secret_config(&name, Some(&profile_display))
.expect("Secret should exist since we're iterating over it");
let to_provider = self.resolve_write_provider(&secret_config, None)?;
match from_provider_instance.get(
&self.config.project.name,
&name,
&profile_display,
)? {
Some(value) => {
match to_provider.get(&self.config.project.name, &name, &profile_display)? {
Some(_) => {
eprintln!(
"{} {} - {} {} (→ {})",
"○".yellow(),
name,
config.description.as_deref().unwrap_or("No description"),
"(already exists in target)".yellow(),
to_provider.name().blue()
);
already_exists += 1;
}
None => {
let set_result = to_provider.set(
&self.config.project.name,
&name,
&value,
&profile_display,
);
self.audit_write_result(
&set_result,
&name,
&profile_display,
Some(to_provider.uri()),
);
set_result?;
eprintln!(
"{} {} - {} (→ {})",
"✓".green(),
name,
config.description.as_deref().unwrap_or("No description"),
to_provider.name().blue()
);
imported += 1;
}
}
}
None => {
match to_provider.get(&self.config.project.name, &name, &profile_display)? {
Some(_) => {
eprintln!(
"{} {} - {} {} (→ {})",
"○".blue(),
name,
config.description.as_deref().unwrap_or("No description"),
"(already in target, not in source)".blue(),
to_provider.name().blue()
);
already_exists += 1;
}
None => {
eprintln!(
"{} {} - {} {}",
"✗".red(),
name,
config.description.as_deref().unwrap_or("No description"),
"(not found in source)".red()
);
not_found += 1;
}
}
}
}
}
Ok(())
})();
if let Err(e) = copy_result {
read_names.sort();
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,
);
}
read_names.sort();
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 get_writable_provider_for_secret(
&self,
secret_config: &crate::config::Secret,
) -> Result<Box<dyn ProviderTrait>> {
let backend = self.resolve_write_provider(secret_config, None)?;
if !backend.allows_set() {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"Provider '{}' is read-only and cannot store generated secrets",
backend.name()
)));
}
Ok(backend)
}
fn try_generate_secret(
&self,
name: &str,
secret_config: &crate::config::Secret,
profile_name: &str,
) -> Result<Option<SecretString>> {
let gen_config = match &secret_config.generate {
Some(config) if config.is_enabled() => config,
_ => return Ok(None),
};
let secret_type = match &secret_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 backend = self.get_writable_provider_for_secret(secret_config)?;
let set_result = backend.set(&self.config.project.name, name, &value, profile_name);
self.audit_write_result(&set_result, name, profile_name, Some(backend.uri()));
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)
}
fn validate_audited(
&self,
emit_check: bool,
) -> 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 mut audit_keys: Vec<String> = Vec::new();
let result: Result<std::result::Result<ValidatedSecrets, ValidationErrors>> =
(|| -> Result<std::result::Result<ValidatedSecrets, ValidationErrors>> {
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 profile = self.resolve_profile(Some(&profile_name))?;
let all_secrets: Vec<(String, crate::config::Secret)> =
profile.into_iter().collect();
audit_keys = {
let mut keys: Vec<String> =
all_secrets.iter().map(|(name, _)| name.clone()).collect();
keys.sort();
keys
};
let override_uri = self.resolve_provider_override(None);
let mut provider_groups: HashMap<Option<String>, Vec<String>> = HashMap::new();
let mut secret_primary_uris: HashMap<String, Option<String>> = HashMap::new();
for (name, _) in &all_secrets {
let secret_config = self
.resolve_secret_config(name, Some(&profile_name))
.expect("Secret should exist in config since we're iterating over it");
let provider_uri = match (&override_uri, secret_config.providers.as_deref()) {
(Some(uri), _) => Some(uri.clone()),
(None, Some([first_alias, ..])) => self
.resolve_provider_aliases(Some(std::slice::from_ref(first_alias)))?
.and_then(|uris| uris.into_iter().next()),
_ => None,
};
secret_primary_uris.insert(name.clone(), provider_uri.clone());
provider_groups
.entry(provider_uri)
.or_default()
.push(name.clone());
}
let mut fetched_values: HashMap<String, SecretString> = HashMap::new();
let mut failed_primary_uris: HashMap<Option<String>, SecretSpecError> =
HashMap::new();
for (provider_uri, secret_names) in provider_groups {
let provider_result = if let Some(uri) = provider_uri.clone() {
self.build_provider(uri)
} else {
self.get_provider(None)
};
let provider = match provider_result {
Ok(p) => p,
Err(e) => {
let shown =
provider_uri.as_deref().map(crate::audit::redact_uri_strict);
warn_primary_provider_failure(shown.as_deref(), &e);
failed_primary_uris.insert(provider_uri, e);
continue;
}
};
let keys: Vec<&str> = secret_names.iter().map(|s| s.as_str()).collect();
match provider.get_batch(&self.config.project.name, &keys, &profile_name) {
Ok(batch_results) => fetched_values.extend(batch_results),
Err(e) => {
warn_primary_provider_failure(Some(&provider.uri()), &e);
failed_primary_uris.insert(provider_uri, e);
}
}
}
for (name, _) in all_secrets {
let secret_config = self
.resolve_secret_config(&name, Some(&profile_name))
.expect("Secret should exist in config since we're iterating over it");
let required = secret_config.required.unwrap_or(true);
let default = secret_config.default.clone();
let as_path = secret_config.as_path.unwrap_or(false);
match fetched_values.remove(&name) {
Some(value) => {
self.insert_resolved(
&mut secrets,
&mut temp_files,
name,
value,
as_path,
)?;
}
None => {
let primary_uri = &secret_primary_uris[&name];
let primary_failed = failed_primary_uris.contains_key(primary_uri);
let fallback_value =
match (override_uri.as_ref(), secret_config.providers.as_deref()) {
(None, Some(providers)) if providers.len() > 1 => {
let fallback_uris =
self.resolve_provider_aliases(Some(&providers[1..]))?;
self.get_secret_from_providers(
&self.config.project.name,
&name,
&profile_name,
fallback_uris.as_deref(),
None,
)?
.0
}
_ if primary_failed => {
let err = failed_primary_uris
.remove(primary_uri)
.expect("primary_failed implies entry present");
return Err(err);
}
_ => None,
};
if let Some(value) = fallback_value {
self.insert_resolved(
&mut secrets,
&mut temp_files,
name,
value,
as_path,
)?;
} else if let Some(generated) =
self.try_generate_secret(&name, &secret_config, &profile_name)?
{
self.insert_resolved(
&mut secrets,
&mut temp_files,
name,
generated,
as_path,
)?;
} else if let Some(default_value) = default {
self.insert_resolved(
&mut secrets,
&mut temp_files,
name.clone(),
SecretString::new(default_value.clone().into()),
as_path,
)?;
with_defaults.push((name, default_value));
} else if required {
missing_required.push(name);
} else {
missing_optional.push(name);
}
}
}
}
let report_provider_uri = self.validation_report_provider_uri(
override_uri.as_deref(),
&secret_primary_uris,
)?;
if !missing_required.is_empty() {
Ok(Err(ValidationErrors::new(
missing_required,
missing_optional,
with_defaults,
report_provider_uri,
profile_name.to_string(),
)))
} else {
Ok(Ok(ValidatedSecrets {
resolved: Resolved::new(
secrets,
report_provider_uri,
profile_name.to_string(),
),
missing_optional,
with_defaults,
temp_files,
}))
}
})();
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
}
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 mut env_vars = env::vars().collect::<HashMap<_, _>>();
for (key, secret) in &validation_result.resolved.secrets {
env_vars.insert(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))
}
}
#[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);
}
#[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);
}
}