use std::collections::HashMap;
use std::collections::HashSet;
use std::convert::TryFrom;
use std::env;
use std::io::IsTerminal;
use std::io::Read;
use std::io::{self};
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use colored::Colorize;
use secrecy::ExposeSecret;
use secrecy::SecretString;
use crate::config::Config;
use crate::config::GlobalConfig;
use crate::config::Manifest;
use crate::config::Profile;
use crate::config::ProviderDependency;
use crate::config::ProviderRef;
use crate::config::RequireReason;
use crate::config::Resolved;
use crate::config::SecretRequest;
use crate::error::MonosecretError;
use crate::error::Result;
use crate::provider::Provider as ProviderTrait;
use crate::provider::provider_from_spec_with_dependencies;
use crate::validation::ValidatedSecrets;
use crate::validation::ValidationErrors;
fn redact_provider_uri(uri: &str) -> String {
let Ok(mut parsed) = url::Url::parse(uri) else {
return uri.to_string();
};
if !parsed.username().is_empty() {
let _ = parsed.set_username("<redacted>");
}
if parsed.password().is_some() {
let _ = parsed.set_password(Some("<redacted>"));
}
parsed.to_string()
}
fn warn_provider_failure(uri: &str, secret_name: &str, err: &MonosecretError) {
let uri = redact_provider_uri(uri);
tracing::warn!(
provider = %uri,
secret = %secret_name,
error = %err,
"provider failed while resolving secret"
);
eprintln!(
"{} provider {} failed for {}: {}; trying next provider in chain",
"warning:".yellow(),
uri.bold(),
secret_name.bold(),
err
);
}
fn warn_primary_provider_failure(uri: Option<&str>, err: &MonosecretError) {
let uri = uri.map_or_else(|| "<default>".to_string(), redact_provider_uri);
tracing::warn!(provider = %uri, error = %err, "primary batch provider failed");
eprintln!(
"{} primary provider {} failed: {}; will try fallback chain for affected secrets",
"warning:".yellow(),
uri.bold(),
err
);
}
fn find_config_file() -> Result<PathBuf> {
let mut dir = env::current_dir()?;
loop {
let candidate = dir.join("monosecret.toml");
if candidate.exists() {
return Ok(candidate);
}
let legacy_candidate = dir.join("secretspec.toml");
if legacy_candidate.exists() {
return Ok(legacy_candidate);
}
if !dir.pop() {
return Err(MonosecretError::NoManifest);
}
}
}
fn env_var_with_legacy(name: &str, legacy_name: &str) -> Option<String> {
env::var(name).ok().or_else(|| env::var(legacy_name).ok())
}
const AGENT_OPT_IN_ENV: &str = "MONOSECRET_AGENT";
const LEGACY_AGENT_OPT_IN_ENV: &str = "SECRETSPEC_AGENT";
pub(crate) fn detect_agent_id() -> Option<&'static str> {
detect_coding_agent::detect().map(|agent| agent.id)
}
pub(crate) fn running_as_agent() -> bool {
env::var_os(AGENT_OPT_IN_ENV).is_some_and(|v| !v.is_empty())
|| env::var_os(LEGACY_AGENT_OPT_IN_ENV).is_some_and(|v| !v.is_empty())
|| detect_coding_agent::is_agent()
}
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 = "MONOSECRET_REASON";
const LEGACY_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> {
env_var_with_legacy(REASON_ENV, LEGACY_REASON_ENV)
.as_deref()
.and_then(normalize_reason)
}
pub struct Secrets {
config: Config,
global_config: Option<GlobalConfig>,
provider: Option<String>,
profile: Option<String>,
reason: Option<String>,
require_reason: RequireReason,
}
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,
}
}
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()?;
Ok(Self {
require_reason: project_config.project.require_reason.unwrap_or_default(),
config: project_config,
global_config,
provider: None,
profile: None,
reason: env_reason(),
})
}
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 manifest(&self) -> Manifest {
self.config.to_manifest()
}
#[must_use]
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(MonosecretError::ReasonRequired);
}
Ok(())
}
fn build_provider(&self, spec: &str) -> Result<Box<dyn ProviderTrait>> {
let provider = Box::<dyn ProviderTrait>::try_from(spec.to_string())?;
provider.set_reason(self.reason.clone());
Ok(provider)
}
#[cfg(test)]
pub(crate) fn config(&self) -> &Config {
&self.config
}
#[cfg(test)]
pub(crate) fn global_config(&self) -> Option<&GlobalConfig> {
self.global_config.as_ref()
}
pub(crate) fn resolve_profile_name(&self, profile: Option<&str>) -> String {
profile
.map(ToString::to_string)
.or_else(|| self.profile.clone())
.or_else(|| env_var_with_legacy("MONOSECRET_PROFILE", "SECRETSPEC_PROFILE"))
.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_unstable();
MonosecretError::InvalidProfile(format!(
"'{}' is not defined in monosecret.toml. Available profiles: {}",
profile_name,
available.join(", ")
))
})
}
pub(crate) fn resolve_profile(&self, profile: Option<&str>) -> Result<Profile> {
let profile_name = profile.map_or_else(|| self.resolve_profile_name(None), str::to_string);
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" {
None
} else {
self.config
.profiles
.get("default")
.and_then(|default_profile| default_profile.secrets.get(name))
};
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())),
groups: current.groups.clone().or_else(|| default.groups.clone()),
providers: current
.providers
.clone()
.or_else(|| default.providers.clone())
.or_else(|| {
current_defaults.and_then(|d| {
d.providers
.clone()
.map(|v| v.into_iter().map(ProviderRef::from).collect())
})
}),
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())),
groups: secret.groups.clone(),
providers: secret.providers.clone().or_else(|| {
current_defaults.and_then(|d| {
d.providers
.clone()
.map(|v| v.into_iter().map(ProviderRef::from).collect())
})
}),
as_path: secret.as_path,
secret_type: secret.secret_type.clone(),
generate: secret.generate.clone(),
})
}
(None, None) => None,
}
}
fn lookup_provider_alias(&self, alias: &str) -> Option<String> {
if let Some(config) = self.config.providers.as_ref().and_then(|m| m.get(alias)) {
return Some(config.uri().to_string());
}
self.global_config
.as_ref()
.and_then(|gc| gc.defaults.providers.as_ref())
.and_then(|m| m.get(alias))
.cloned()
}
fn alias_for_provider_uri(&self, uri: &str) -> Option<String> {
self.config.providers.as_ref().and_then(|providers| {
providers
.iter()
.find(|(_, config)| config.uri() == uri)
.map(|(alias, _)| alias.clone())
})
}
fn provider_from_alias(
&self,
alias: &str,
uri: &str,
profile_name: &str,
) -> Result<Box<dyn ProviderTrait>> {
let dependencies = self.resolve_provider_requirements(alias, profile_name)?;
let dependencies = dependencies
.into_iter()
.map(|(dep, value)| (dep.effective_as().to_string(), value))
.collect::<Vec<_>>();
let provider = provider_from_spec_with_dependencies(uri, &dependencies)?;
provider.set_reason(self.reason.clone());
Ok(provider)
}
fn provider_from_uri(&self, uri: &str, profile_name: &str) -> Result<Box<dyn ProviderTrait>> {
match self.alias_for_provider_uri(uri) {
Some(alias) => self.provider_from_alias(&alias, uri, profile_name),
None => self.build_provider(uri),
}
}
pub(crate) fn resolve_provider_requirements(
&self,
alias: &str,
profile_name: &str,
) -> Result<Vec<(ProviderDependency, SecretString)>> {
let config = self.config.providers.as_ref().and_then(|m| m.get(alias));
let dependencies = match config {
Some(crate::config::ProviderConfig::Structured(s)) => &s.depends_on,
_ => return Ok(Vec::new()),
};
if dependencies.is_empty() {
return Ok(Vec::new());
}
let mut resolved = Vec::new();
for dep in dependencies {
let secret_name = &dep.secret;
let secret_config = self
.resolve_secret_config(secret_name, Some(profile_name))
.ok_or_else(|| {
MonosecretError::SecretNotFound(format!(
"Provider '{alias}' requires secret '{secret_name}' but it is not defined in monosecret.toml"
))
})?;
let provider_entries = self.resolve_read_provider_uris(&secret_config, None)?;
let project_name = &self.config.project.name;
let value = match provider_entries {
Some(entries) if !entries.is_empty() => {
match self.get_secret_from_providers(
project_name,
secret_name,
profile_name,
Some(&entries),
None,
)? {
Some(v) => v,
None => {
return Err(MonosecretError::ProviderOperationFailed(format!(
"Provider '{alias}' requires secret '{secret_name}' but it was not found"
)));
}
}
}
_ => {
let provider = self.get_provider(None)?;
match provider.get(project_name, secret_name, profile_name)? {
Some(v) => v,
None => {
return Err(MonosecretError::ProviderOperationFailed(format!(
"Provider '{alias}' requires secret '{secret_name}' but it was not found"
)));
}
}
}
};
resolved.push((dep.clone(), value));
}
Ok(resolved)
}
fn known_provider_aliases(&self) -> Vec<String> {
let mut names: Vec<String> = self
.config
.providers
.iter()
.flat_map(|m| m.keys())
.chain(
self.global_config
.as_ref()
.and_then(|gc| gc.defaults.providers.as_ref())
.into_iter()
.flat_map(|m| m.keys()),
)
.cloned()
.collect::<HashSet<_>>()
.into_iter()
.collect();
names.sort();
names
}
pub(crate) fn resolve_provider_ref_uris(
&self,
provider_refs: Option<&[ProviderRef]>,
) -> Result<Option<Vec<(String, SecretRequest)>>> {
let Some(refs) = provider_refs else {
return Ok(None);
};
let mut entries = Vec::with_capacity(refs.len());
for r in refs {
let alias = r.provider_alias();
if let Some(uri) = self.lookup_provider_alias(alias) {
let request = SecretRequest::from_provider_ref(r);
tracing::debug!(
alias = %alias,
provider = %redact_provider_uri(&uri),
path = ?request.path,
key = ?request.key,
"resolved provider reference"
);
entries.push((uri, request));
} else {
let known = self.known_provider_aliases();
let msg = if known.is_empty() {
format!(
"Provider alias '{alias}' is not defined. Declare it in [providers] in monosecret.toml or in the global config."
)
} else {
format!(
"Provider alias '{}' is not defined. Available aliases: {}",
alias,
known.join(", ")
)
};
return Err(MonosecretError::ProviderNotFound(msg));
}
}
Ok(Some(entries))
}
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 {
if let Some(uri) = self.lookup_provider_alias(alias) {
uris.push(uri);
} else {
let known = self.known_provider_aliases();
let msg = if known.is_empty() {
format!(
"Provider alias '{alias}' is not defined. Declare it in [providers] in monosecret.toml or in the global config."
)
} else {
format!(
"Provider alias '{}' is not defined. Available aliases: {}",
alias,
known.join(", ")
)
};
return Err(MonosecretError::ProviderNotFound(msg));
}
}
Ok(Some(uris))
}
fn explicit_provider_spec(&self, override_arg: Option<&str>) -> Option<String> {
override_arg
.map(ToString::to_string)
.or_else(|| self.provider.clone())
.or_else(|| env_var_with_legacy("MONOSECRET_PROVIDER", "SECRETSPEC_PROVIDER"))
}
pub(crate) fn resolve_provider_override(&self, override_arg: Option<&str>) -> Option<String> {
let spec = self.explicit_provider_spec(override_arg)?;
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>> {
let profile_name = self.resolve_profile_name(None);
if let Some(uri) = self.resolve_provider_override(override_arg) {
return self.provider_from_uri(&uri, &profile_name);
}
if let Some(first_ref) = secret_config.providers.as_ref().and_then(|p| p.first()) {
let alias = first_ref.provider_alias().to_string();
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(|| {
MonosecretError::ProviderNotFound(format!(
"Provider alias '{alias}' could not be resolved"
))
})?;
return self.provider_from_alias(&alias, &uri, &profile_name);
}
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, SecretRequest)>>> {
if let Some(uri) = self.resolve_provider_override(override_arg) {
return Ok(Some(vec![(uri, SecretRequest::default())]));
}
self.resolve_provider_ref_uris(secret_config.providers.as_deref())
}
pub(crate) fn get_provider(
&self,
provider_arg: Option<&str>,
) -> 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(MonosecretError::NoProviderConfigured)?;
let profile_name = self.resolve_profile_name(None);
self.provider_from_uri(&provider_spec, &profile_name)
}
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_entries: Option<&[(String, SecretRequest)]>,
default_provider_arg: Option<&str>,
) -> Result<Option<SecretString>> {
if let Some(entries) = provider_entries {
let mut last_error: Option<MonosecretError> = None;
let mut any_healthy = false;
for (uri, request) in entries {
tracing::debug!(
provider = %redact_provider_uri(uri),
secret = %secret_name,
profile = %profile_name,
path = ?request.path,
key = ?request.key,
"attempting provider lookup"
);
let provider = match self.provider_from_uri(uri, profile_name) {
Ok(p) => p,
Err(e) => {
warn_provider_failure(uri, secret_name, &e);
last_error = Some(e);
continue;
}
};
match provider.get_with_request(project_name, secret_name, profile_name, request) {
Ok(Some(value)) => {
tracing::debug!(provider = %redact_provider_uri(uri), secret = %secret_name, "provider lookup found secret");
return Ok(Some(value));
}
Ok(None) => {
any_healthy = true;
}
Err(e) => {
warn_provider_failure(uri, secret_name, &e);
last_error = Some(e);
}
}
}
match last_error {
Some(e) if !any_healthy => Err(e),
_ => Ok(None),
}
} else {
let backend = self.get_provider(default_provider_arg)?;
backend.get(project_name, secret_name, profile_name)
}
}
pub fn set(&self, name: &str, value: Option<String>) -> Result<()> {
self.ensure_reason()?;
let profile_name = self.resolve_profile_name(None);
self.require_profile(&profile_name)?;
let Some(secret_config) = self.resolve_secret_config(name, None) else {
let profile = self.resolve_profile(Some(&profile_name))?;
let mut available_secrets = profile
.into_iter()
.map(|(name, _)| name)
.collect::<Vec<_>>();
available_secrets.sort();
return Err(MonosecretError::SecretNotFound(format!(
"Secret '{}' is not defined in profile '{}'. Available secrets: {}",
name,
profile_name,
available_secrets.join(", ")
)));
};
let (backend, write_request) = if self.resolve_provider_override(None).is_none() {
if let Some(first_ref) = secret_config.providers.as_ref().and_then(|p| p.first()) {
let alias = first_ref.provider_alias().to_string();
let provider_uris = self
.resolve_provider_aliases(Some(std::slice::from_ref(&alias)))?
.expect("provider aliases are supplied");
let uri = provider_uris
.into_iter()
.next()
.expect("provider alias resolution returns one URI per alias");
(
self.provider_from_alias(&alias, &uri, &profile_name)?,
SecretRequest::from_provider_ref(first_ref),
)
} else {
(
self.resolve_write_provider(&secret_config, None)?,
SecretRequest::default(),
)
}
} else {
(
self.resolve_write_provider(&secret_config, None)?,
SecretRequest::default(),
)
};
if !backend.allows_set() {
return Err(MonosecretError::ProviderOperationFailed(format!(
"Provider '{}' is read-only and does not support setting values",
backend.name()
)));
}
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() {
return Err(MonosecretError::ProviderOperationFailed(
"Secret value cannot be empty".to_string(),
));
}
backend.set_with_request(
&self.config.project.name,
name,
&value,
&profile_name,
&write_request,
)?;
eprintln!(
"{} Secret '{}' saved to {} (profile: {})",
"✓".green(),
name,
backend.name(),
profile_name
);
Ok(())
}
pub fn get(&self, name: &str) -> Result<()> {
self.ensure_reason()?;
let profile_name = self.resolve_profile_name(None);
let secret_config = self
.resolve_secret_config(name, None)
.ok_or_else(|| MonosecretError::SecretNotFound(name.to_string()))?;
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)?;
match self.get_secret_from_providers(
&self.config.project.name,
name,
&profile_name,
provider_uris.as_deref(),
None,
)? {
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| {
MonosecretError::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| {
MonosecretError::Io(io::Error::other(format!(
"Failed to persist temporary file: {e}"
)))
})?;
println!("{}", persisted_path.display());
} else {
println!("{default_value}");
}
Ok(())
} else {
Err(MonosecretError::SecretNotFound(name.to_string()))
}
}
}
}
#[allow(clippy::needless_pass_by_value)]
pub fn ensure_secrets(
&self,
provider_arg: Option<String>,
profile: Option<String>,
interactive: bool,
) -> Result<ValidatedSecrets> {
self.ensure_secrets_selected(
provider_arg.as_deref(),
profile.as_deref(),
interactive,
None,
)
}
fn ensure_secrets_selected(
&self,
provider_arg: Option<&str>,
profile: Option<&str>,
interactive: bool,
selected_names: Option<&HashSet<String>>,
) -> Result<ValidatedSecrets> {
let profile_display = self.resolve_profile_name(profile);
let validation_result = self.validate_selected(selected_names)?;
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(MonosecretError::RequiredSecretMissing(
validation_errors.missing_required.join(", "),
));
}
self.prompt_and_store_missing(
&validation_errors.missing_required,
&profile_display,
provider_arg,
|msg| {
inquire::Password::new(msg)
.without_confirmation()
.prompt()
.map_err(Into::into)
},
)?;
match self.validate()? {
Ok(valid_secrets) => Ok(valid_secrets),
Err(still_errors) => {
Err(MonosecretError::RequiredSecretMissing(
still_errors.missing_required.join(", "),
))
}
}
} else {
Err(MonosecretError::RequiredSecretMissing(
validation_errors.missing_required.join(", "),
))
}
}
}
}
pub(crate) fn prompt_and_store_missing(
&self,
missing: &[String],
profile_display: &str,
provider_arg: Option<&str>,
read_password: impl Fn(&str) -> Result<String>,
) -> Result<()> {
let total = missing.len();
let default_backend = self.get_provider(provider_arg)?;
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 value = read_password(&prompt_msg)?;
let backend = self.resolve_write_provider(&secret_config, provider_arg)?;
backend.set(
&self.config.project.name,
secret_name,
&SecretString::new(value.into()),
profile_display,
)?;
eprintln!(
"{} Secret '{}' saved to {} (profile: {})",
"✓".green(),
secret_name,
backend.name(),
profile_display
);
}
}
eprintln!("\nAll required secrets have been set.");
Ok(())
}
pub fn check(&self, no_prompt: bool) -> Result<ValidatedSecrets> {
self.ensure_reason()?;
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();
let mut sorted_profile: Vec<_> = profile.into_iter().collect();
sorted_profile.sort_by(|a, b| a.0.cmp(&b.0));
for (name, config) in &sorted_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 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<_>>();
let mut sorted_profile: Vec<_> = profile.into_iter().collect();
sorted_profile.sort_by(|a, b| a.0.cmp(&b.0));
for (name, config) in &sorted_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()?;
let profile_display = self.resolve_profile_name(None);
let from_provider_instance = self.provider_from_uri(from_provider, &profile_display)?;
eprintln!(
"Importing secrets from {} (profile: {})...\n",
from_provider.blue(),
profile_display.cyan()
);
let mut imported = 0;
let mut already_exists = 0;
let mut not_found = 0;
let profile = self.resolve_profile(Some(&profile_display))?;
for (name, config) in profile {
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) => {
if to_provider
.get(&self.config.project.name, &name, &profile_display)?
.is_some()
{
eprintln!(
"{} {} - {} {} (→ {})",
"○".yellow(),
name,
config.description.as_deref().unwrap_or("No description"),
"(already exists in target)".yellow(),
to_provider.name().blue()
);
already_exists += 1;
} else {
to_provider.set(
&self.config.project.name,
&name,
&value,
&profile_display,
)?;
eprintln!(
"{} {} - {} (→ {})",
"✓".green(),
name,
config.description.as_deref().unwrap_or("No description"),
to_provider.name().blue()
);
imported += 1;
}
}
None => {
if to_provider
.get(&self.config.project.name, &name, &profile_display)?
.is_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;
} else {
eprintln!(
"{} {} - {} {}",
"✗".red(),
name,
config.description.as_deref().unwrap_or("No description"),
"(not found in source)".red()
);
not_found += 1;
}
}
}
}
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,
);
}
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(MonosecretError::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(MonosecretError::GenerationFailed(format!(
"Secret '{name}' has generate config but no type"
)));
}
};
let value = crate::generator::generate(secret_type, gen_config)?;
let backend = self.get_writable_provider_for_secret(secret_config)?;
backend.set(&self.config.project.name, name, &value, profile_name)?;
eprintln!(
"{} {} - generated and saved to {} (profile: {})",
"✓".green(),
name,
backend.name(),
profile_name
);
Ok(Some(value))
}
fn write_secret_to_temp_file(
secret: &SecretString,
) -> Result<(tempfile::NamedTempFile, String)> {
use std::io::Write;
let mut temp_file = tempfile::NamedTempFile::new().map_err(MonosecretError::Io)?;
temp_file
.write_all(secret.expose_secret().as_bytes())
.map_err(MonosecretError::Io)?;
temp_file.flush().map_err(MonosecretError::Io)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = temp_file
.as_file()
.metadata()
.map_err(MonosecretError::Io)?
.permissions();
perms.set_mode(0o400);
temp_file
.as_file()
.set_permissions(perms)
.map_err(MonosecretError::Io)?;
}
let path_str = temp_file
.path()
.to_str()
.ok_or_else(|| {
MonosecretError::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.ensure_reason()?;
self.validate_selected(None)
}
fn validate_selected(
&self,
selected_names: Option<&HashSet<String>>,
) -> 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_name = self.resolve_profile_name(None);
let profile = self.resolve_profile(Some(&profile_name))?;
let all_secrets: Vec<(String, crate::config::Secret)> = profile
.into_iter()
.filter(|(name, _)| selected_names.is_none_or(|selected| selected.contains(name)))
.collect();
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();
let mut fetched_values: HashMap<String, SecretString> = 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 primary_entry = match (&override_uri, secret_config.providers.as_deref()) {
(Some(uri), _) => Some((uri.clone(), SecretRequest::default())),
(None, Some([first_ref, ..])) => {
self.resolve_provider_ref_uris(Some(std::slice::from_ref(first_ref)))?
.and_then(|entries| entries.into_iter().next())
}
_ => None,
};
let provider_uri = primary_entry.as_ref().map(|(uri, _)| uri.clone());
secret_primary_uris.insert(name.clone(), provider_uri.clone());
let can_batch = primary_entry
.as_ref()
.is_none_or(|(_, request)| request == &SecretRequest::default());
if can_batch {
provider_groups
.entry(provider_uri)
.or_default()
.push(name.clone());
continue;
}
let provider_entries =
self.resolve_provider_ref_uris(secret_config.providers.as_deref())?;
if let Some(value) = self.get_secret_from_providers(
&self.config.project.name,
name,
&profile_name,
provider_entries.as_deref(),
None,
)? {
fetched_values.insert(name.clone(), value);
}
}
let mut failed_primary_uris: HashMap<Option<String>, MonosecretError> = HashMap::new();
for (provider_uri, secret_names) in provider_groups {
let provider_result = if let Some(uri) = provider_uri.clone() {
self.provider_from_uri(&uri, &profile_name)
} else {
self.get_provider(None)
};
let provider = match provider_result {
Ok(p) => p,
Err(e) => {
warn_primary_provider_failure(provider_uri.as_deref(), &e);
failed_primary_uris.insert(provider_uri, e);
continue;
}
};
let keys: Vec<&str> = secret_names.iter().map(String::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(provider_uri.as_deref(), &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);
if let Some(value) = fetched_values.remove(&name) {
if as_path {
let (temp_file, path_str) = Self::write_secret_to_temp_file(&value)?;
temp_files.push(temp_file);
secrets.insert(name.clone(), SecretString::new(path_str.into()));
} else {
secrets.insert(name, value);
}
} else {
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_entries =
self.resolve_provider_ref_uris(Some(&providers[1..]))?;
self.get_secret_from_providers(
&self.config.project.name,
&name,
&profile_name,
fallback_entries.as_deref(),
None,
)?
}
_ if primary_failed => {
return Err(failed_primary_uris
.remove(primary_uri)
.expect("primary_failed implies entry present"));
}
_ => None,
};
if let Some(value) = fallback_value {
if as_path {
let (temp_file, path_str) = Self::write_secret_to_temp_file(&value)?;
temp_files.push(temp_file);
secrets.insert(name.clone(), SecretString::new(path_str.into()));
} else {
secrets.insert(name, value);
}
} else if let Some(generated) =
self.try_generate_secret(&name, &secret_config, &profile_name)?
{
if as_path {
let (temp_file, path_str) = Self::write_secret_to_temp_file(&generated)?;
temp_files.push(temp_file);
secrets.insert(name.clone(), SecretString::new(path_str.into()));
} else {
secrets.insert(name, generated);
}
} else if let Some(default_value) = default {
if as_path {
let (temp_file, path_str) = Self::write_secret_to_temp_file(
&SecretString::new(default_value.clone().into()),
)?;
temp_files.push(temp_file);
secrets.insert(name.clone(), SecretString::new(path_str.into()));
} else {
secrets.insert(
name.clone(),
SecretString::new(default_value.clone().into()),
);
}
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(Ok(ValidatedSecrets {
resolved: Resolved::new(secrets, report_provider_uri, profile_name.clone()),
missing_optional,
with_defaults,
temp_files,
}))
} else {
Ok(Err(ValidationErrors::new(
missing_required,
missing_optional,
with_defaults,
report_provider_uri,
profile_name.clone(),
)))
}
}
fn split_filter_values(values: &[String]) -> Vec<String> {
values
.iter()
.flat_map(|value| value.split(','))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect()
}
fn invalid_input(message: impl Into<String>) -> MonosecretError {
MonosecretError::Io(io::Error::new(io::ErrorKind::InvalidInput, message.into()))
}
fn selected_secret_names(
&self,
includes: &[String],
groups: &[String],
) -> Result<Option<HashSet<String>>> {
let include_names = Self::split_filter_values(includes);
let group_names = Self::split_filter_values(groups);
if include_names.is_empty() && group_names.is_empty() {
return Ok(None);
}
let profile_name = self.resolve_profile_name(None);
let profile = self.resolve_profile(Some(&profile_name))?;
let mut selected = HashSet::new();
for name in include_names {
if !profile.secrets.contains_key(&name) {
return Err(Self::invalid_input(format!(
"Included secret '{name}' is not defined in profile '{profile_name}'"
)));
}
selected.insert(name);
}
for group in group_names {
if !self
.config
.declared_groups()
.is_some_and(|declared| declared.contains_key(&group))
{
return Err(Self::invalid_input(format!(
"Group '{group}' is not declared in the top-level [groups] table"
)));
}
let mut matched = false;
for (name, secret) in &profile.secrets {
let effective = self
.resolve_secret_config(name, Some(&profile_name))
.unwrap_or_else(|| secret.clone());
if effective
.groups
.as_ref()
.is_some_and(|groups| groups.iter().any(|candidate| candidate == &group))
{
matched = true;
selected.insert(name.clone());
}
}
if !matched {
return Err(Self::invalid_input(format!(
"Group '{group}' does not match any secrets in profile '{profile_name}'"
)));
}
}
Ok(Some(selected))
}
#[allow(clippy::needless_pass_by_value)]
pub fn run(&self, command: Vec<String>) -> Result<()> {
self.ensure_reason()?;
let exit_code = self.run_command(&command)?;
std::process::exit(exit_code);
}
#[allow(clippy::needless_pass_by_value)]
pub fn run_filtered(
&self,
command: Vec<String>,
includes: &[String],
groups: &[String],
) -> Result<()> {
let exit_code = self.run_command_filtered(&command, includes, groups)?;
std::process::exit(exit_code);
}
pub fn env_vars(
&self,
includes: &[String],
groups: &[String],
) -> Result<Vec<(String, String)>> {
self.ensure_reason()?;
let selected = self.selected_secret_names(includes, groups)?;
let validation = self.ensure_secrets_selected(None, None, false, selected.as_ref())?;
let mut pairs: Vec<(String, String)> = validation
.resolved
.secrets
.iter()
.map(|(k, v)| (k.clone(), v.expose_secret().to_string()))
.collect();
pairs.sort_by(|a, b| a.0.cmp(&b.0));
Ok(pairs)
}
pub(crate) fn run_command(&self, command: &[String]) -> Result<i32> {
self.run_command_with_selection(command, None)
}
pub(crate) fn run_command_filtered(
&self,
command: &[String],
includes: &[String],
groups: &[String],
) -> Result<i32> {
let selected = self.selected_secret_names(includes, groups)?;
self.run_command_with_selection(command, selected.as_ref())
}
fn run_command_with_selection(
&self,
command: &[String],
selected_names: Option<&HashSet<String>>,
) -> Result<i32> {
if command.is_empty() {
return Err(MonosecretError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
"No command specified. Usage: monosecret run -- <command> [args...]",
)));
}
let validation_result = self.ensure_secrets_selected(None, None, false, selected_names)?;
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 mut cmd = Command::new(&command[0]);
cmd.args(&command[1..]);
cmd.envs(&env_vars);
let status = cmd.status()?;
Ok(status.code().unwrap_or(1))
}
}
#[cfg(test)]
mod provider_uri_redaction_tests {
use super::redact_provider_uri;
#[test]
fn provider_uri_redaction_handles_plain_invalid_and_credentials() {
assert_eq!(redact_provider_uri("keyring://"), "keyring://");
assert_eq!(redact_provider_uri("not a uri"), "not a uri");
let redacted = redact_provider_uri("onepassword+token://secret-token@Development/dotfiles");
assert!(!redacted.contains("secret-token"));
assert!(redacted.contains("redacted"));
let redacted = redact_provider_uri("https://user:pass@example.com/path");
assert!(!redacted.contains("user"));
assert!(!redacted.contains("pass"));
assert!(redacted.contains("redacted"));
}
}
#[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);
}
}