use crate::CallerContext;
use crate::audit::{AuditAction, AuditContext, AuditLogger, AuditOutcome};
use crate::cache::{self, CacheEntryStatus, CacheOwnership};
use crate::compiled_spec::{CompiledSpec, MissingPolicy};
use crate::config::{
Config, CredentialSource, ExtractFormat, GlobalConfig, NativeAddress, Profile, ProviderAlias,
RequireReason, Resolved, SecretEncoding, SecretExtract,
};
use crate::error::{Result, SecretSpecError};
use crate::plan::{PlannedSecret, ResolutionPlan, ResolvedCache, Route};
use crate::provider::{
Address, OwnedAddress, ProducedValuePersistence, Provider as ProviderTrait,
ProviderCredentials, same_storage_container,
};
use crate::report::{ResolutionReport, ResolutionStatus, SecretResolution};
use crate::resolve::{
NamedResolution, RESOLVE_SCHEMA_VERSION, ResolveResponse, ResolvedSecret, ResolvedSource,
};
use crate::spec::Spec;
use crate::validation::{ConstraintKind, ConstraintViolation, ValidatedSecrets, ValidationErrors};
use colored::Colorize;
use data_encoding::{
BASE64, BASE64_NOPAD, BASE64URL, BASE64URL_NOPAD, Encoding, HEXLOWER, HEXLOWER_PERMISSIVE,
};
use secrecy::{ExposeSecret, SecretSlice, SecretString};
#[cfg(unix)]
use signal_hook::consts::signal::{SIGHUP, SIGINT, SIGTERM};
#[cfg(unix)]
use signal_hook::iterator::{Handle as SignalHandle, Signals};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::env;
use std::hash::Hash;
use std::io::{self, IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus};
use std::sync::{Arc, Mutex};
#[cfg(unix)]
use std::thread::JoinHandle;
use std::time::Duration;
#[cfg(unix)]
struct ChildSignalForwarder {
signals: Option<Signals>,
handle: SignalHandle,
thread: Option<JoinHandle<()>>,
}
#[cfg(unix)]
impl ChildSignalForwarder {
fn prepare() -> io::Result<Self> {
let signals = Signals::new([SIGTERM, SIGINT, SIGHUP])?;
let handle = signals.handle();
Ok(Self {
signals: Some(signals),
handle,
thread: None,
})
}
fn start(&mut self, child_pid: u32) {
let mut signals = self
.signals
.take()
.expect("signal forwarder can only be started once");
self.thread = Some(std::thread::spawn(move || {
for signal in signals.forever() {
unsafe {
libc::kill(child_pid as libc::pid_t, signal);
}
}
}));
}
}
#[cfg(unix)]
impl Drop for ChildSignalForwarder {
fn drop(&mut self) {
self.handle.close();
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
fn command_exit_code(status: ExitStatus) -> i32 {
if let Some(code) = status.code() {
return code;
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(signal) = status.signal() {
return 128 + signal;
}
}
1
}
fn format_secret_label(name: &str, description: Option<&str>) -> String {
match description {
Some(description) => format!(
"{} {} {}",
name.cyan().bold(),
"-".dimmed(),
description.dimmed()
),
None => name.cyan().bold().to_string(),
}
}
fn validation_failure(errors: ValidationErrors) -> SecretSpecError {
if errors.constraint_violations.is_empty() {
SecretSpecError::RequiredSecretMissing(errors.missing_required.join(", "))
} else {
SecretSpecError::ValidationFailed(Box::new(errors))
}
}
#[derive(Clone, Copy)]
enum Surface {
Scoped,
WholeProfile,
}
impl Surface {
fn names(self, secrets: &Secrets, profile: &str) -> Result<Vec<String>> {
match self {
Self::Scoped => secrets.resolve_profile_secret_names(Some(profile)),
Self::WholeProfile => secrets.profile_secret_names_unscoped(Some(profile)),
}
}
}
fn resolved_source(entry: &SecretResolution) -> ResolvedSource {
if entry.generated {
ResolvedSource::Generated
} else if entry.default_applied {
ResolvedSource::Default
} else if entry.composed {
ResolvedSource::Composed
} else {
ResolvedSource::Provider
}
}
pub(crate) const HIDDEN_SECRET_LABEL: &str = "a hidden composition input";
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
}
fn cache_warning(secret_name: &str, message: impl std::fmt::Display) {
eprintln!(
"{} cache failed for {}: {}",
"warning:".yellow(),
secret_name.bold(),
message
);
}
fn cache_read_warning(secret_name: &str, message: impl std::fmt::Display) {
cache_warning(
secret_name,
format!("{message}; consulting authoritative providers"),
);
}
fn group_names(group: &[&PlannedSecret]) -> String {
group
.iter()
.map(|planned| planned.name.as_str())
.collect::<Vec<_>>()
.join(", ")
}
enum CachedEntry {
Fresh(SecretString),
Stale,
Foreign,
}
fn cached_entry(
planned: &PlannedSecret,
cache: &ResolvedCache,
stored: &SecretString,
project: &str,
profile: &str,
) -> CachedEntry {
let route_fingerprint = planned.cache_fingerprint(cache, project, profile);
match cache::inspect_entry(
stored,
project,
profile,
&route_fingerprint,
cache.max_age_secs,
) {
Ok(CacheEntryStatus::Fresh(value)) => CachedEntry::Fresh(value),
Ok(CacheEntryStatus::Stale) => CachedEntry::Stale,
Ok(CacheEntryStatus::OursUnreadable) => {
cache_read_warning(&planned.name, "the cache entry could not be read");
CachedEntry::Stale
}
Ok(CacheEntryStatus::Foreign { project, profile }) => {
cache_read_warning(
&planned.name,
format!("the cache holds {project}/{profile}'s entry at this address"),
);
CachedEntry::Foreign
}
Ok(CacheEntryStatus::Unrecognized) => {
cache_read_warning(
&planned.name,
"the cache holds a value SecretSpec did not write",
);
CachedEntry::Foreign
}
Err(error) => {
cache_read_warning(&planned.name, error);
CachedEntry::Stale
}
}
}
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 ProviderKey = (String, String);
type GroupFetch<'a> = (
Option<&'a str>,
Vec<&'a PlannedSecret>,
Box<dyn ProviderTrait>,
);
struct FallbackReadRequest<'a> {
planned: &'a PlannedSecret,
diagnostic_name: &'a str,
provider_specs: &'a [String],
project: &'a str,
profile: &'a str,
}
struct FallbackRead {
value: Option<SecretString>,
provider_uri: Option<String>,
native_address: Option<NativeAddress>,
}
impl FallbackRead {
fn missing(provider_uri: Option<String>, native_address: Option<NativeAddress>) -> Self {
Self {
value: None,
provider_uri,
native_address,
}
}
}
type FallbackReadResult = Result<FallbackRead>;
struct PreparedImport {
planned: PlannedSecret,
target_provider: Box<dyn ProviderTrait>,
source_address: OwnedAddress,
target_address: OwnedAddress,
source_value: Option<SecretString>,
target_value: Option<SecretString>,
copied: bool,
source_deleted: bool,
}
struct ImportAliasDivergence {
alias: String,
affected_secrets: Vec<String>,
}
#[derive(Default)]
struct ImportSummary {
imported: usize,
already_exists: usize,
not_found: usize,
deleted_from_source: usize,
kept_in_source: usize,
}
impl ImportSummary {
fn audit_outcome(&self) -> AuditOutcome {
if self.imported > 0 {
AuditOutcome::Written
} else if self.already_exists > 0 {
AuditOutcome::Found
} else {
AuditOutcome::Missing
}
}
}
struct ImportPlan<'a> {
secrets: &'a Secrets,
from_provider: &'a str,
profile: String,
delete_source: bool,
source_provider: Option<Arc<dyn ProviderTrait>>,
source_uri: Option<String>,
source_display: Option<String>,
entries: Vec<PreparedImport>,
read_names: Vec<String>,
summary: ImportSummary,
}
impl<'a> ImportPlan<'a> {
fn new(
secrets: &'a Secrets,
from_provider: &'a str,
profile: String,
delete_source: bool,
) -> Self {
Self {
secrets,
from_provider,
profile,
delete_source,
source_provider: None,
source_uri: None,
source_display: None,
entries: Vec::new(),
read_names: Vec::new(),
summary: ImportSummary::default(),
}
}
fn run(&mut self) -> Result<()> {
self.prepare_source()?;
self.prepare_entries()?;
self.validate_target_collisions()?;
if self.delete_source {
self.validate_cleanup_collisions()?;
}
self.copy_missing_targets()?;
if self.delete_source {
self.verify_copied_targets()?;
self.delete_matching_sources()?;
}
self.report_entries();
Ok(())
}
fn prepare_source(&mut self) -> Result<()> {
let source = self
.secrets
.build_provider(self.from_provider.to_string(), Some(&self.profile))?;
let provider_uri = source.uri();
self.source_uri = Some(provider_uri.clone());
self.source_display = Some(
if self
.secrets
.lookup_provider_alias_entry(self.from_provider)
.is_some()
{
format!("provider alias '{}' ({provider_uri})", self.from_provider)
} else {
provider_uri.clone()
},
);
if self.delete_source
&& !crate::provider::spec_provider_deletes(
&self
.secrets
.resolve_provider_spec(self.from_provider.to_string()),
)
{
return Err(SecretSpecError::ProviderOperationFailed(format!(
"provider '{}' does not support deleting secrets and cannot be used with import --delete-source",
source.name()
)));
}
eprintln!(
"Importing secrets from {} (profile: {})...\n",
self.source_display
.as_deref()
.expect("source display is set with the provider URI")
.blue(),
self.profile.cyan()
);
self.source_provider = Some(Arc::from(source));
Ok(())
}
fn prepare_entries(&mut self) -> Result<()> {
let source_provider = Arc::clone(
self.source_provider
.as_ref()
.expect("the source provider is prepared first"),
);
let import_names = self
.secrets
.profile_secret_names_unscoped(Some(&self.profile))?;
let mut planned_imports = Vec::new();
for name in import_names {
let planned = self
.secrets
.plan_secret(&name, &self.profile, None)?
.expect("Secret should exist since we're iterating over it");
if planned.route.is_none() {
continue;
}
if planned.extract().is_some() {
return Err(SecretSpecError::ExtractedSecretReadOnly(
planned.name.clone(),
));
}
planned_imports.push(planned);
}
let divergences = self.secrets.literal_import_alias_divergences(
self.from_provider,
source_provider.as_ref(),
&planned_imports,
&self.profile,
);
Secrets::warn_literal_import_alias_divergences(
self.source_uri
.as_deref()
.expect("the source URI is prepared first"),
&divergences,
);
for planned in planned_imports {
let route = planned
.route
.as_ref()
.expect("planned imports are provider-backed");
self.read_names.push(planned.name.clone());
let source_address = self.secrets.address_for_spec(
&planned,
Some(self.from_provider),
&self.secrets.config.project.name,
&self.profile,
)?;
let target_address = self.secrets.address_for_spec(
&planned,
route.group_key(),
&self.secrets.config.project.name,
&self.profile,
)?;
let target_provider = self
.secrets
.write_provider_for_route(route, Some(&self.profile))?;
if self.delete_source
&& source_provider.same_entries(
source_address.as_address(),
target_provider.as_ref(),
target_address.as_address(),
)?
{
return Err(SecretSpecError::ProviderOperationFailed(format!(
"refusing to delete '{}' from the import source because source and destination resolve to the same provider entry ({})",
planned.name,
source_provider.uri()
)));
}
let source_value = source_provider.get(source_address.as_address())?;
let target_value = target_provider.get(target_address.as_address())?;
if let Some(value) = &source_value {
Secrets::validate_import_value(&planned, &planned.name, value)?;
if target_value.is_none() {
target_provider.check_writable(target_address.as_address())?;
}
let target_will_match = target_value
.as_ref()
.is_none_or(|existing| existing.expose_secret() == value.expose_secret());
if self.delete_source && target_will_match {
source_provider.check_deletable(source_address.as_address())?;
}
}
self.entries.push(PreparedImport {
planned,
target_provider,
source_address,
target_address,
source_value,
target_value,
copied: false,
source_deleted: false,
});
}
Ok(())
}
fn validate_target_collisions(&self) -> Result<()> {
for left_index in 0..self.entries.len() {
let left = &self.entries[left_index];
for right in &self.entries[left_index + 1..] {
if left.target_provider.same_entries(
left.target_address.as_address(),
right.target_provider.as_ref(),
right.target_address.as_address(),
)? {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"refusing to import '{}' and '{}' because they resolve to the same destination provider entry ({})",
left.planned.name,
right.planned.name,
left.target_provider.uri()
)));
}
}
}
Ok(())
}
fn validate_cleanup_collisions(&self) -> Result<()> {
let source_provider = self
.source_provider
.as_ref()
.expect("the source provider is prepared first");
for (source_index, source) in self.entries.iter().enumerate() {
let Some(source_value) = &source.source_value else {
continue;
};
let source_will_be_deleted = source.target_value.as_ref().is_none_or(|target_value| {
source_value.expose_secret() == target_value.expose_secret()
});
if !source_will_be_deleted {
continue;
}
for (target_index, target) in self.entries.iter().enumerate() {
if source_index == target_index {
continue;
}
if source_provider.same_entries(
source.source_address.as_address(),
target.target_provider.as_ref(),
target.target_address.as_address(),
)? {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"refusing to delete '{}' from the import source because it resolves to the destination provider entry for '{}' ({})",
source.planned.name,
target.planned.name,
target.target_provider.uri()
)));
}
}
}
Ok(())
}
fn copy_missing_targets(&mut self) -> Result<()> {
for entry in &mut self.entries {
let (Some(value), None) = (&entry.source_value, &entry.target_value) else {
continue;
};
let route = entry
.planned
.route
.as_ref()
.expect("prepared imports are provider-backed");
let set_result = entry
.target_provider
.set(entry.target_address.as_address(), value);
self.secrets.audit_write_result(
&set_result,
&entry.planned.name,
&self.profile,
Some(entry.target_provider.uri()),
entry.target_address.native(),
None,
);
set_result?;
self.secrets
.sync_cache_after_write(&entry.planned, route, &self.profile, value);
entry.copied = true;
self.summary.imported += 1;
}
Ok(())
}
fn verify_copied_targets(&mut self) -> Result<()> {
for entry in self.entries.iter_mut().filter(|entry| entry.copied) {
let expected = entry
.source_value
.as_ref()
.expect("copied entries have source values");
let stored = entry
.target_provider
.get(entry.target_address.as_address())?
.ok_or_else(|| {
SecretSpecError::ProviderOperationFailed(format!(
"destination verification failed for '{}'; the source value was retained",
entry.planned.name
))
})?;
if stored.expose_secret() != expected.expose_secret() {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"destination verification failed for '{}'; the source value was retained",
entry.planned.name
)));
}
Secrets::validate_import_value(&entry.planned, &entry.planned.name, &stored)?;
entry.target_value = Some(stored);
}
Ok(())
}
fn delete_matching_sources(&mut self) -> Result<()> {
let source_provider = Arc::clone(
self.source_provider
.as_ref()
.expect("the source provider is prepared first"),
);
for entry in &mut self.entries {
let (Some(source), Some(target)) = (&entry.source_value, &entry.target_value) else {
continue;
};
if source.expose_secret() != target.expose_secret() {
continue;
}
let delete_result = source_provider.delete(entry.source_address.as_address());
self.secrets.audit_delete_result(
&delete_result,
&entry.planned.name,
&self.profile,
Some(source_provider.uri()),
entry.source_address.native(),
);
entry.source_deleted = delete_result?;
self.summary.deleted_from_source += usize::from(entry.source_deleted);
}
Ok(())
}
fn report_entries(&mut self) {
for entry in &self.entries {
let name = &entry.planned.name;
let label = format_secret_label(name, entry.planned.config().description.as_deref());
let target_name = entry.target_provider.name().blue();
if entry.copied {
if self.delete_source && entry.source_deleted {
eprintln!(
"{} {} (→ {}; deleted from source)",
"✓".green(),
label,
target_name
);
} else {
eprintln!("{} {} (→ {})", "✓".green(), label, target_name);
}
continue;
}
match (&entry.source_value, &entry.target_value) {
(Some(source), Some(target)) => {
self.summary.already_exists += 1;
if self.delete_source && source.expose_secret() != target.expose_secret() {
self.summary.kept_in_source += 1;
eprintln!(
"{} {} {} (→ {}; source retained)",
"â—‹".yellow(),
label,
"(target value differs)".yellow(),
target_name
);
} else if self.delete_source && entry.source_deleted {
eprintln!(
"{} {} {} (→ {}; deleted from source)",
"✓".green(),
label,
"(already exists in target)".yellow(),
target_name
);
} else {
eprintln!(
"{} {} {} (→ {})",
"â—‹".yellow(),
label,
"(already exists in target)".yellow(),
target_name
);
}
}
(None, Some(_)) => {
self.summary.already_exists += 1;
eprintln!(
"{} {} {} (→ {})",
"â—‹".blue(),
label,
"(already in target, not in source)".blue(),
target_name
);
}
(None, None) => {
self.summary.not_found += 1;
eprintln!("{} {} {}", "✗".red(), label, "(not found in source)".red());
}
(Some(_), None) => {
unreachable!("a prepared missing target was copied or returned an error")
}
}
}
}
}
type SingleFlightSlot<V> = Arc<Mutex<Option<V>>>;
struct RetryingOnceMap<K, V> {
entries: Mutex<HashMap<K, SingleFlightSlot<V>>>,
}
impl<K, V> Default for RetryingOnceMap<K, V> {
fn default() -> Self {
Self {
entries: Mutex::new(HashMap::new()),
}
}
}
impl<K, V> RetryingOnceMap<K, V>
where
K: Clone + Eq + Hash,
V: Clone,
{
fn get_or_try_init<E, F>(&self, key: K, initialize: F) -> std::result::Result<V, E>
where
F: FnOnce() -> std::result::Result<V, E>,
{
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(value) = cached.as_ref() {
return Ok(value.clone());
}
match initialize() {
Ok(value) => {
*cached = Some(value.clone());
Ok(value)
}
Err(error) => {
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(error)
}
}
}
#[cfg(any(feature = "cli", test))]
fn clear(&self) {
self.entries.lock().unwrap().clear();
}
}
#[derive(Default)]
struct ProviderCredentialsCache {
entries: RetryingOnceMap<ProviderCredentialsKey, ProviderCredentials>,
}
impl ProviderCredentialsCache {
fn get_or_try_init<F>(
&self,
key: ProviderCredentialsKey,
resolve: F,
) -> Result<ProviderCredentials>
where
F: FnOnce() -> Result<ProviderCredentials>,
{
self.entries.get_or_try_init(key, resolve)
}
#[cfg(any(feature = "cli", test))]
fn clear(&self) {
self.entries.clear();
}
}
#[derive(Default)]
struct ProviderCache {
entries: RetryingOnceMap<ProviderKey, Arc<dyn ProviderTrait>>,
}
impl ProviderCache {
fn get_or_try_init<F>(&self, key: ProviderKey, build: F) -> Result<Arc<dyn ProviderTrait>>
where
F: FnOnce() -> Result<Box<dyn ProviderTrait>>,
{
self.entries.get_or_try_init(key, || build().map(Arc::from))
}
}
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,
Run,
None,
}
impl Materialize {
fn values(self) -> bool {
self != Self::None
}
fn prompts(self) -> bool {
self == Self::Run
}
}
type ExecutionResult = Result<std::result::Result<ValidatedSecrets, ValidationErrors>>;
struct ResolutionExecution<'secrets, 'plan, 'filter, 'addresses> {
manager: &'secrets Secrets,
plan: &'plan ResolutionPlan,
materialize: Materialize,
output_filter: Option<&'filter HashSet<String>>,
read_addresses: Option<&'addresses mut HashMap<String, NativeAddress>>,
values: HashMap<String, SecretString>,
missing_required: Vec<String>,
missing_optional: Vec<String>,
with_defaults: Vec<(String, String)>,
temp_files: Vec<tempfile::NamedTempFile>,
resolution: Vec<SecretResolution>,
group_uris: HashMap<Option<&'plan str>, String>,
fetched_values: HashMap<String, SecretString>,
failed_primary_uris: HashMap<Option<&'plan str>, SecretSpecError>,
cached_uris: HashMap<String, String>,
fallback_results: HashMap<String, FallbackReadResult>,
}
impl<'secrets, 'plan, 'filter, 'addresses>
ResolutionExecution<'secrets, 'plan, 'filter, 'addresses>
{
fn new(
manager: &'secrets Secrets,
plan: &'plan ResolutionPlan,
materialize: Materialize,
output_filter: Option<&'filter HashSet<String>>,
read_addresses: Option<&'addresses mut HashMap<String, NativeAddress>>,
) -> Self {
Self {
manager,
plan,
materialize,
output_filter,
read_addresses,
values: HashMap::new(),
missing_required: Vec::new(),
missing_optional: Vec::new(),
with_defaults: Vec::new(),
temp_files: Vec::new(),
resolution: Vec::new(),
group_uris: HashMap::new(),
fetched_values: HashMap::new(),
failed_primary_uris: HashMap::new(),
cached_uris: HashMap::new(),
fallback_results: HashMap::new(),
}
}
fn run(mut self) -> ExecutionResult {
if self.plan.secrets.is_empty() {
return Ok(Ok(ValidatedSecrets {
resolved: Resolved::new(HashMap::new(), String::new(), self.plan.profile.clone()),
missing_optional: Vec::new(),
with_defaults: Vec::new(),
resolution: Vec::new(),
temp_files: Vec::new(),
}));
}
self.read_cached_values();
self.fetch_primary_values()?;
self.fetch_fallback_values();
self.resolve_provider_backed_values()?;
self.resolve_composed_values()?;
self.finish()
}
fn read_cached_values(&mut self) {
for (name, (value, uri)) in self
.manager
.read_cached_group(self.plan, &self.plan.profile)
{
self.cached_uris.insert(name.clone(), uri);
self.fetched_values.insert(name, value);
}
}
fn fetch_primary_values(&mut self) -> Result<()> {
let plan = self.plan;
let project = self.manager.config.project.name.as_str();
let profile = plan.profile.as_str();
let mut group_fetches: Vec<GroupFetch<'plan>> = Vec::new();
for (provider_uri, group) in plan.groups() {
let group: Vec<&PlannedSecret> = group
.into_iter()
.filter(|planned| !self.cached_uris.contains_key(&planned.name))
.collect();
if group.is_empty() {
continue;
}
match self
.manager
.get_route_provider(provider_uri, Some(&plan.profile))
{
Ok(provider) => {
self.group_uris.insert(provider_uri, provider.uri());
group_fetches.push((provider_uri, group, provider));
}
Err(error) => {
let shown = provider_uri.map(crate::audit::redact_uri_strict);
warn_primary_provider_failure(shown.as_deref(), &error);
self.failed_primary_uris.insert(provider_uri, error);
}
}
}
for (provider_spec, group, provider) in &group_fetches {
self.manager.check_single_store_ref_coords(
*provider_spec,
group,
provider.as_ref(),
project,
profile,
)?;
}
fn fetch_group<'a>(
manager: &Secrets,
(provider_uri, group, provider): GroupFetch<'a>,
project: &str,
profile: &str,
) -> (Option<&'a str>, Result<HashMap<String, SecretString>>) {
let result = manager.fetch_group(&*provider, provider_uri, &group, project, profile);
(provider_uri, result)
}
let fetch_results: Vec<(Option<&'plan str>, Result<_>)> = if group_fetches.len() <= 1 {
group_fetches
.into_iter()
.map(|group| fetch_group(self.manager, group, project, profile))
.collect()
} else {
std::thread::scope(|scope| {
let handles: Vec<_> = group_fetches
.into_iter()
.map(|group| scope.spawn(|| fetch_group(self.manager, 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) => self.fetched_values.extend(batch_results),
Err(error) => {
let display_uri = self.group_uris.get(&provider_uri).map(String::as_str);
warn_primary_provider_failure(display_uri, &error);
self.failed_primary_uris.insert(provider_uri, error);
}
}
}
Ok(())
}
fn fetch_fallback_values(&mut self) {
let project = self.manager.config.project.name.as_str();
let profile = self.plan.profile.as_str();
let provider_cache = ProviderCache::default();
let pending_fallbacks: Vec<&PlannedSecret> = self
.plan
.secrets
.iter()
.filter(|planned| {
!self.fetched_values.contains_key(&planned.name)
&& planned
.route
.as_ref()
.and_then(Route::fallback_specs)
.is_some()
})
.collect();
self.fallback_results = crate::provider::map_concurrently(
&pending_fallbacks,
crate::provider::get_each_concurrency(),
|planned| {
let route = planned
.route
.as_ref()
.expect("pending fallback has a provider route");
let fallback = route
.fallback_specs()
.expect("pending fallback has fallback specs");
let result = self.manager.read_fallback_chain(
&provider_cache,
FallbackReadRequest {
planned,
diagnostic_name: Secrets::diagnostic_secret_name(
&planned.name,
self.output_filter,
),
provider_specs: fallback,
project,
profile,
},
);
(planned.name.clone(), result)
},
)
.into_iter()
.collect();
}
fn resolve_provider_backed_values(&mut self) -> Result<()> {
let manager = self.manager;
let plan = self.plan;
let materialize = self.materialize;
let output_filter = self.output_filter;
let project = manager.config.project.name.as_str();
let profile = plan.profile.as_str();
for planned in &plan.secrets {
let Some(route) = &planned.route else {
continue;
};
let name = &planned.name;
let required = planned.required();
let diagnostic_name = Secrets::diagnostic_secret_name(name, output_filter);
let primary_uri = route.group_key();
let status;
let mut source_provider = None;
let mut default_applied = false;
let mut generated = false;
match self.fetched_values.remove(name.as_str()) {
Some(value) => {
let was_cached = self.cached_uris.contains_key(name);
source_provider = self
.cached_uris
.remove(name)
.or_else(|| self.group_uris.get(&primary_uri).cloned());
if !was_cached
&& let Some(addresses) = self.read_addresses.as_deref_mut()
&& let Ok(address) =
manager.address_for_spec(planned, primary_uri, project, profile)
&& let Some(native) = address.native()
{
addresses.insert(name.clone(), native.clone());
}
if !was_cached && materialize.values() {
manager.write_cached_secret(planned, route, profile, &value);
}
if materialize.values() {
manager.insert_resolved(
&mut self.values,
&mut self.temp_files,
planned,
diagnostic_name,
value,
ResolvedRepresentation::Stored,
)?;
}
status = ResolutionStatus::Resolved;
}
None => {
let primary_failed = self.failed_primary_uris.contains_key(&primary_uri);
if let Some(addresses) = self.read_addresses.as_deref_mut()
&& let Ok(address) =
manager.address_for_spec(planned, primary_uri, project, profile)
&& let Some(native) = address.native()
{
addresses.insert(name.clone(), native.clone());
}
let fallback_result = match route.fallback_specs() {
Some(_) => {
let resolved = self
.fallback_results
.remove(name)
.expect("primary miss with fallback was prefetched")?;
if resolved.value.is_none() && primary_failed {
let error = self
.failed_primary_uris
.remove(&primary_uri)
.expect("primary_failed implies entry present");
return Err(error);
}
resolved
}
None if primary_failed => {
let error = self
.failed_primary_uris
.remove(&primary_uri)
.expect("primary_failed implies entry present");
return Err(error);
}
None => FallbackRead::missing(None, None),
};
let FallbackRead {
value: fallback_value,
provider_uri: fallback_uri,
native_address: fallback_reference,
} = fallback_result;
if let Some(addresses) = self.read_addresses.as_deref_mut()
&& let Some(reference) = fallback_reference
{
addresses.insert(name.clone(), reference);
}
if let Some(value) = fallback_value {
source_provider = fallback_uri;
if materialize.values() {
manager.write_cached_secret(planned, route, profile, &value);
manager.insert_resolved(
&mut self.values,
&mut self.temp_files,
planned,
diagnostic_name,
value,
ResolvedRepresentation::Stored,
)?;
}
status = ResolutionStatus::Resolved;
} else {
match planned.secret.missing {
MissingPolicy::Prompt => {
if materialize.prompts()
&& output_filter.is_none_or(|filter| filter.contains(name))
{
let prompted = manager.try_prompt_secret(planned, profile)?;
manager.insert_resolved(
&mut self.values,
&mut self.temp_files,
planned,
diagnostic_name,
prompted,
ResolvedRepresentation::Logical,
)?;
status = ResolutionStatus::Resolved;
} else if required {
self.missing_required.push(name.clone());
status = ResolutionStatus::MissingRequired;
} else {
self.missing_optional.push(name.clone());
status = ResolutionStatus::MissingOptional;
}
}
MissingPolicy::Generate => {
if materialize.values() {
generated = true;
let generated_value = manager
.try_generate_secret(planned, profile)?
.expect("compiled Generate policy has a generator");
manager.insert_resolved(
&mut self.values,
&mut self.temp_files,
planned,
diagnostic_name,
generated_value,
ResolvedRepresentation::Logical,
)?;
status = ResolutionStatus::Resolved;
} else if required
&& manager.generated_value_is_stored(planned, profile)
{
self.missing_required.push(name.clone());
status = ResolutionStatus::MissingRequired;
} else {
generated = true;
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.values() {
manager.insert_resolved(
&mut self.values,
&mut self.temp_files,
planned,
diagnostic_name,
SecretString::new(default_value.clone().into()),
ResolvedRepresentation::Logical,
)?;
self.with_defaults
.push((name.clone(), default_value.clone()));
}
status = ResolutionStatus::Resolved;
}
MissingPolicy::Error => {
self.missing_required.push(name.clone());
status = ResolutionStatus::MissingRequired;
}
MissingPolicy::Omit => {
self.missing_optional.push(name.clone());
status = ResolutionStatus::MissingOptional;
}
}
}
}
}
self.resolution.push(SecretResolution {
name: name.clone(),
status,
required,
source_provider,
default_applied,
generated,
composed: false,
as_path: planned.as_path(),
});
}
Ok(())
}
fn resolve_composed_values(&mut self) -> Result<()> {
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> = self
.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 self
.plan
.secrets
.iter()
.filter(|secret| secret.is_composed())
{
composition_order(planned, &composed, &mut visited, &mut ordered);
}
if ordered.is_empty() {
return Ok(());
}
let mut statuses: HashMap<String, ResolutionStatus> = self
.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 self.materialize.values() {
let rendered = template
.render(|dependency| {
self.values
.get(dependency)
.map(|value| value.expose_secret())
})
.map_err(SecretSpecError::CompositionFailed)?;
self.manager.insert_resolved(
&mut self.values,
&mut self.temp_files,
planned,
Secrets::diagnostic_secret_name(&planned.name, self.output_filter),
SecretString::new(rendered.into()),
ResolvedRepresentation::Logical,
)?;
}
ResolutionStatus::Resolved
} else {
match planned.secret.missing {
MissingPolicy::Error => {
self.missing_required.push(planned.name.clone());
ResolutionStatus::MissingRequired
}
MissingPolicy::Omit => {
self.missing_optional.push(planned.name.clone());
ResolutionStatus::MissingOptional
}
MissingPolicy::Generate | MissingPolicy::UseDefault | MissingPolicy::Prompt => {
unreachable!("composed source conflicts are rejected at load time")
}
}
};
statuses.insert(planned.name.clone(), status.clone());
self.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(),
});
}
Ok(())
}
fn apply_output_filter(&mut self) {
let Some(filter) = self.output_filter else {
return;
};
self.values.retain(|name, _| filter.contains(name));
self.resolution.retain(|entry| filter.contains(&entry.name));
self.missing_required.retain(|name| filter.contains(name));
self.missing_optional.retain(|name| filter.contains(name));
self.with_defaults.retain(|(name, _)| filter.contains(name));
}
fn constraint_violations(&self) -> Vec<ConstraintViolation> {
let resolved_names: HashSet<&str> = self
.resolution
.iter()
.filter(|entry| entry.status == ResolutionStatus::Resolved)
.map(|entry| entry.name.as_str())
.collect();
let compiled_profile = self
.manager
.manifest
.profile(&self.plan.profile)
.expect("profile is validated before execution");
let constraints = (self.output_filter.is_some()
|| self.plan.secrets.len() == compiled_profile.secrets.len())
.then_some(&compiled_profile.constraints);
let Some(constraints) = constraints else {
return Vec::new();
};
let visible_members = |members: &Vec<String>| -> Vec<String> {
match self.output_filter {
Some(filter) => members
.iter()
.filter(|name| filter.contains(name.as_str()))
.cloned()
.collect(),
None => members.clone(),
}
};
let mut violations = Vec::new();
for group in &constraints.at_least_one {
let members = visible_members(&group.members);
if members.is_empty() {
continue;
}
let present: Vec<String> = members
.iter()
.filter(|name| resolved_names.contains(name.as_str()))
.cloned()
.collect();
if present.is_empty() {
violations.push(ConstraintViolation {
kind: ConstraintKind::AtLeastOne,
group: group.name.clone(),
secrets: members,
present,
});
}
}
for group in &constraints.exactly_one {
let members = visible_members(&group.members);
if members.is_empty() {
continue;
}
let present: Vec<String> = members
.iter()
.filter(|name| resolved_names.contains(name.as_str()))
.cloned()
.collect();
if present.len() != 1 {
violations.push(ConstraintViolation {
kind: ConstraintKind::ExactlyOne,
group: group.name.clone(),
secrets: members,
present,
});
}
}
violations
}
fn finish(mut self) -> ExecutionResult {
let report_provider_uri = self.manager.validation_report_provider_uri(
self.plan.override_uri.as_deref(),
self.plan
.secrets
.iter()
.filter_map(|secret| secret.route.as_ref())
.map(|route| route.primary()),
Some(&self.plan.profile),
)?;
self.apply_output_filter();
let constraint_violations = self.constraint_violations();
if !self.missing_required.is_empty() || !constraint_violations.is_empty() {
let mut errors = ValidationErrors::new(
self.missing_required,
self.missing_optional,
self.with_defaults,
report_provider_uri,
self.plan.profile.clone(),
);
errors.resolution = self.resolution;
errors.constraint_violations = constraint_violations;
Ok(Err(errors))
} else {
Ok(Ok(ValidatedSecrets {
resolved: Resolved::new(
self.values,
report_provider_uri,
self.plan.profile.clone(),
),
missing_optional: self.missing_optional,
with_defaults: self.with_defaults,
resolution: self.resolution,
temp_files: self.temp_files,
}))
}
}
}
enum PreparedSecret {
Inline(SecretString),
File {
owner: tempfile::NamedTempFile,
path: String,
},
}
#[derive(Clone, Copy)]
enum ResolvedRepresentation {
Stored,
Logical,
}
pub(crate) 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: CompiledSpec,
pub(crate) config_dir: PathBuf,
global_config: Option<GlobalConfig>,
provider: Option<String>,
profile: Option<String>,
scope: Option<String>,
ignore_ambient_scope: bool,
reason: Option<String>,
caller: Option<CallerContext>,
require_reason: RequireReason,
audit: Option<AuditLogger>,
provider_credentials_cache: ProviderCredentialsCache,
write_target_reporter: Option<WriteTargetReporter>,
prompt_reader: Option<PromptReader>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WriteTarget {
pub name: String,
pub provider_uri: String,
pub profile: String,
pub target: String,
}
type WriteTargetReporter = Arc<dyn Fn(&WriteTarget) + Send + Sync>;
type PromptReader = Arc<dyn Fn(&str, &str) -> Result<SecretString> + Send + Sync>;
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 = CompiledSpec::compile(&config);
Self {
config,
manifest,
config_dir: PathBuf::from("."),
global_config,
provider,
profile,
scope: None,
ignore_ambient_scope: false,
reason: None,
caller: None,
require_reason: RequireReason::Never,
audit: None,
provider_credentials_cache: ProviderCredentialsCache::default(),
write_target_reporter: None,
prompt_reader: 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 spec = Spec::try_from(path)?;
Self::from_spec(spec)
}
pub fn from_spec(spec: Spec) -> Result<Self> {
let base_dir = spec.base_dir.clone().unwrap_or_else(|| PathBuf::from("."));
Self::from_spec_at(spec, base_dir)
}
pub fn from_spec_at(spec: Spec, base_dir: impl Into<PathBuf>) -> Result<Self> {
let (config, manifest) = spec.into_parts();
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: config.project.require_reason.unwrap_or_default(),
config,
manifest,
config_dir: base_dir.into(),
global_config,
provider: None,
profile: None,
scope: None,
ignore_ambient_scope: false,
reason: env_reason(),
caller: None,
audit,
provider_credentials_cache: ProviderCredentialsCache::default(),
write_target_reporter: None,
prompt_reader: None,
})
}
pub(crate) fn load_config(config: Config, config_dir: PathBuf) -> Result<Self> {
let spec = Spec::from_config_document(config)?;
Self::from_spec_at(spec, config_dir)
}
#[cfg(any(feature = "cli", test))]
pub(crate) fn set_write_target_reporter(
&mut self,
reporter: impl Fn(&WriteTarget) + Send + Sync + 'static,
) {
self.write_target_reporter = Some(Arc::new(reporter));
}
#[cfg(test)]
pub(crate) fn set_prompt_reader(
&mut self,
reader: impl Fn(&str, &str) -> Result<SecretString> + Send + Sync + 'static,
) {
self.prompt_reader = Some(Arc::new(reader));
}
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 set_scope(&mut self, scope: impl Into<String>) {
if let Some(scope) = non_blank(&scope.into()) {
self.scope = Some(scope);
}
}
pub fn set_ignore_ambient_scope(&mut self, ignore: bool) {
self.ignore_ambient_scope = ignore;
}
pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
if let Some(reason) = normalize_reason(&reason.into()) {
self.reason = Some(reason);
}
self
}
pub fn with_caller(mut self, caller: CallerContext) -> Self {
if let Some(caller) = caller.normalized() {
self.caller = Some(caller);
}
self
}
pub fn with_default_reason(mut self, reason: impl Into<String>) -> Self {
if self.reason.is_none() {
self.reason = normalize_reason(&reason.into());
}
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(())
}
pub(crate) fn build_provider(
&self,
spec: String,
profile: Option<&str>,
) -> Result<Box<dyn ProviderTrait>> {
self.build_provider_for_use(spec, profile, false)
}
fn build_route_provider(
&self,
spec: String,
profile: Option<&str>,
) -> Result<Box<dyn ProviderTrait>> {
self.build_provider_for_use(spec, profile, true)
}
fn build_provider_for_use(
&self,
spec: String,
profile: Option<&str>,
allow_inline_cached: bool,
) -> Result<Box<dyn ProviderTrait>> {
self.ensure_provider_use_allowed(&spec, allow_inline_cached)?;
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,
allow_inline_cached,
Some(&profile),
)
}
fn shared_provider(
&self,
cache: &ProviderCache,
spec: &str,
profile: Option<&str>,
allow_inline_cached: bool,
) -> Result<Arc<dyn ProviderTrait>> {
let key = (self.resolve_profile_name(profile), spec.to_string());
cache.get_or_try_init(key, || {
self.build_provider_for_use(spec.to_string(), profile, allow_inline_cached)
})
}
fn ensure_provider_use_allowed(&self, spec: &str, allow_inline_cached: bool) -> Result<()> {
if self
.cached_alias(spec)
.is_some_and(|alias| !allow_inline_cached || alias.authoritative_uri().is_none())
{
return Err(SecretSpecError::ProviderOperationFailed(format!(
"cached provider alias '{spec}' is a complete route; select it through a \
secret's providers list, the default provider, or --provider"
)));
}
Ok(())
}
pub(crate) fn build_source_provider(&self, spec: &str) -> Result<Box<dyn ProviderTrait>> {
self.build_provider_with_credentials(spec, ProviderCredentials::new(), false, None)
}
fn build_provider_with_credentials(
&self,
spec: &str,
credentials: ProviderCredentials,
allow_inline_cached: bool,
profile: Option<&str>,
) -> Result<Box<dyn ProviderTrait>> {
self.ensure_provider_use_allowed(spec, allow_inline_cached)?;
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());
provider.set_caller(self.caller.clone());
if let Some(profile) = profile {
provider.set_profile(profile);
}
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)
.and_then(ProviderAlias::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(entry
.credentials()
.map(sorted_credential_entries)
.unwrap_or_default()
.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 profile = self.resolve_profile_name(None);
let provider = self.build_source_provider(&source.provider)?;
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 Some(credentials) = alias.credentials() 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(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_some_and(|credentials| !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 {
let scope = match action {
AuditAction::Check | AuditAction::Run | AuditAction::Export => {
self.resolve_scope_name(None)
}
AuditAction::Get
| AuditAction::Set
| AuditAction::Delete
| AuditAction::Import
| AuditAction::CacheClear
| AuditAction::CacheRefresh => None,
};
logger.record(
action,
AuditContext {
project: &self.config.project.name,
profile,
scope: scope.as_deref(),
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(),
caller: self.caller.as_ref(),
},
);
}
}
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 audit_delete_result(
&self,
result: &Result<bool>,
key: &str,
profile: &str,
provider_uri: Option<String>,
reference: Option<&NativeAddress>,
) {
let (outcome, error_kind) = match result {
Ok(true) => (AuditOutcome::Deleted, None),
Ok(false) => (AuditOutcome::Missing, None),
Err(error) => (AuditOutcome::Error, Some(error.kind())),
};
self.record(
AuditAction::Delete,
profile,
outcome,
AuditFields {
key: Some(key),
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 decode_stored_value(
encoding: SecretEncoding,
diagnostic_name: &str,
value: &SecretString,
) -> Result<SecretSlice<u8>> {
let encoded = value
.expose_secret()
.strip_suffix("\r\n")
.or_else(|| value.expose_secret().strip_suffix('\n'))
.unwrap_or_else(|| value.expose_secret());
fn decode_base(
encoded: &[u8],
padded: &Encoding,
unpadded: &Encoding,
) -> std::result::Result<Vec<u8>, String> {
if encoded.contains(&b'=') {
padded.decode(encoded).map_err(|error| error.to_string())
} else {
padded
.decode(encoded)
.or_else(|_| unpadded.decode(encoded))
.map_err(|error| error.to_string())
}
}
let decoded = match encoding {
SecretEncoding::Base64 => decode_base(encoded.as_bytes(), &BASE64, &BASE64_NOPAD),
SecretEncoding::Base64Url => {
decode_base(encoded.as_bytes(), &BASE64URL, &BASE64URL_NOPAD)
}
SecretEncoding::Hex => HEXLOWER_PERMISSIVE
.decode(encoded.as_bytes())
.map_err(|error| error.to_string()),
}
.map_err(|reason| SecretSpecError::DecodeFailed {
name: diagnostic_name.to_string(),
encoding: encoding.as_str(),
reason,
})?;
Ok(decoded.into())
}
fn encode_logical_value(encoding: SecretEncoding, value: &SecretString) -> SecretString {
let bytes = value.expose_secret().as_bytes();
let encoded = match encoding {
SecretEncoding::Base64 => BASE64.encode(bytes),
SecretEncoding::Base64Url => BASE64URL_NOPAD.encode(bytes),
SecretEncoding::Hex => HEXLOWER.encode(bytes),
};
SecretString::new(encoded.into())
}
fn encoded_for_storage(planned: &PlannedSecret, value: &SecretString) -> Option<SecretString> {
planned
.encoding()
.map(|encoding| Self::encode_logical_value(encoding, value))
}
fn validate_import_value(
planned: &PlannedSecret,
diagnostic_name: &str,
value: &SecretString,
) -> Result<()> {
let Some(encoding) = planned.encoding() else {
return Ok(());
};
let decoded = Self::decode_stored_value(encoding, diagnostic_name, value)?;
if !planned.as_path() {
std::str::from_utf8(decoded.expose_secret()).map_err(|error| {
SecretSpecError::DecodeFailed {
name: diagnostic_name.to_string(),
encoding: encoding.as_str(),
reason: format!(
"decoded bytes are not valid UTF-8 ({error}); set `as_path = true` to expose binary data"
),
}
})?;
}
Ok(())
}
pub(crate) fn extract_stored_value(
extract: &SecretExtract,
diagnostic_name: &str,
value: &str,
) -> Result<SecretString> {
let failed = |reason: String| SecretSpecError::DecodeFailed {
name: diagnostic_name.to_string(),
encoding: extract.format.as_str(),
reason,
};
match extract.format {
ExtractFormat::Json => {
let document: serde_json::Value = serde_json::from_str(value)
.map_err(|error| failed(format!("stored value is not valid JSON: {error}")))?;
let selected = document.pointer(&extract.pointer).ok_or_else(|| {
failed(format!(
"JSON Pointer '{}' did not match the stored document",
extract.pointer
))
})?;
Ok(crate::json_field::render(selected))
}
ExtractFormat::Ini => crate::ini_field::select(value, &extract.pointer)
.map(|selected| SecretString::new(selected.into()))
.map_err(failed),
}
}
fn prepare_resolved(
&self,
planned: &PlannedSecret,
diagnostic_name: &str,
value: SecretString,
representation: ResolvedRepresentation,
) -> Result<PreparedSecret> {
let decoded = match (representation, planned.encoding()) {
(ResolvedRepresentation::Stored, Some(encoding)) => {
let value = Self::decode_stored_value(encoding, diagnostic_name, &value)?;
Some((encoding, value))
}
_ => None,
};
let extracted = match (representation, planned.extract()) {
(ResolvedRepresentation::Stored, Some(extract)) => {
let text = match &decoded {
Some((encoding, decoded)) => {
std::str::from_utf8(decoded.expose_secret()).map_err(|error| {
SecretSpecError::DecodeFailed {
name: diagnostic_name.to_string(),
encoding: encoding.as_str(),
reason: format!(
"decoded bytes are not valid UTF-8 and cannot be extracted as {} ({error})",
extract.format.as_str()
),
}
})?
}
None => value.expose_secret(),
};
Some(Self::extract_stored_value(extract, diagnostic_name, text)?)
}
_ => None,
};
if planned.as_path() {
let bytes = extracted
.as_ref()
.map(|value| value.expose_secret().as_bytes())
.or_else(|| decoded.as_ref().map(|(_, decoded)| decoded.expose_secret()))
.unwrap_or_else(|| value.expose_secret().as_bytes());
let (owner, path) = self.write_secret_to_temp_file(bytes)?;
Ok(PreparedSecret::File { owner, path })
} else if let Some(extracted) = extracted {
Ok(PreparedSecret::Inline(extracted))
} else if let Some((encoding, decoded)) = decoded {
let text = std::str::from_utf8(decoded.expose_secret()).map_err(|error| {
SecretSpecError::DecodeFailed {
name: diagnostic_name.to_string(),
encoding: encoding.as_str(),
reason: format!(
"decoded bytes are not valid UTF-8 ({error}); set `as_path = true` to expose binary data"
),
}
})?;
Ok(PreparedSecret::Inline(SecretString::new(
text.to_owned().into(),
)))
} else {
Ok(PreparedSecret::Inline(value))
}
}
fn insert_resolved(
&self,
secrets: &mut HashMap<String, SecretString>,
temp_files: &mut Vec<tempfile::NamedTempFile>,
planned: &PlannedSecret,
diagnostic_name: &str,
value: SecretString,
representation: ResolvedRepresentation,
) -> Result<()> {
match self.prepare_resolved(planned, diagnostic_name, value, representation)? {
PreparedSecret::Inline(value) => {
secrets.insert(planned.name.clone(), value);
}
PreparedSecret::File { owner, path } => {
temp_files.push(owner);
secrets.insert(planned.name.clone(), SecretString::new(path.into()));
}
}
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())
}
pub(crate) fn resolve_scope_name(&self, scope: Option<&str>) -> Option<String> {
scope
.map(str::to_string)
.or_else(|| self.scope.clone())
.or_else(|| {
if self.ignore_ambient_scope {
return None;
}
env::var("SECRETSPEC_SCOPE")
.ok()
.as_deref()
.and_then(non_blank)
})
}
fn active_scope_members(&self) -> Result<Option<HashSet<&str>>> {
let Some(scope_name) = self.resolve_scope_name(None) else {
return Ok(None);
};
let scope = self
.config
.scopes
.as_ref()
.and_then(|scopes| scopes.get(&scope_name))
.ok_or_else(|| {
let mut available: Vec<&str> = self
.config
.scopes
.iter()
.flat_map(|scopes| scopes.keys())
.map(String::as_str)
.collect();
available.sort();
let available = if available.is_empty() {
"none defined".to_string()
} else {
available.join(", ")
};
SecretSpecError::InvalidScope(format!(
"'{}' is not defined in secretspec.toml. Available scopes: {}",
scope_name, available
))
})?;
Ok(Some(scope.secrets.iter().map(String::as_str).collect()))
}
fn scope_excluded_names(&self) -> Result<Vec<String>> {
let Some(admitted) = self.active_scope_members()? else {
return Ok(Vec::new());
};
let mut excluded: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for profile in self.manifest.profiles.values() {
for name in profile.secrets.keys() {
if !admitted.contains(name.as_str()) {
excluded.insert(name.clone());
}
}
}
Ok(excluded.into_iter().collect())
}
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 all = self.profile_secret_names_unscoped(profile)?;
match self.active_scope_members()? {
None => Ok(all),
Some(members) => Ok(all
.into_iter()
.filter(|name| members.contains(name.as_str()))
.collect()),
}
}
pub(crate) fn profile_secret_names_unscoped(
&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())
}
fn accessed_names(&self, profile_name: &str, visible: &[String]) -> Vec<String> {
fn visit(
name: &str,
profile: &crate::compiled_spec::CompiledProfile,
acc: &mut HashSet<String>,
) {
if !acc.insert(name.to_string()) {
return;
}
if let Some(secret) = profile.secrets.get(name)
&& let Some(template) = &secret.composition
{
for dependency in template.dependencies() {
visit(dependency, profile, acc);
}
}
}
let Some(profile) = self.manifest.profile(profile_name) else {
return visible.to_vec();
};
let mut acc = HashSet::new();
for name in visible {
visit(name, profile, &mut acc);
}
let mut names: Vec<String> = acc.into_iter().collect();
names.sort();
names
}
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,
) -> Result<Vec<(String, crate::config::Secret)>> {
let members = self.active_scope_members()?;
Ok(self
.manifest
.profile(profile_name)
.into_iter()
.flat_map(|profile| &profile.secrets)
.filter(|(name, _)| members.as_ref().is_none_or(|m| m.contains(name.as_str())))
.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()),
)
}
pub(crate) 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)
.and_then(|alias| alias.authoritative_uri())
.map(str::to_string)
}
pub(crate) fn cached_alias(&self, spec: &str) -> Option<&ProviderAlias> {
self.lookup_provider_alias_entry(spec)
.filter(|alias| alias.is_cached())
}
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 self.cached_alias(spec).is_some() {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"cached provider alias '{spec}' is a complete route and cannot be used where a \
leaf provider is required"
)));
}
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(
&self,
provider: &dyn ProviderTrait,
provider_spec: Option<&str>,
group: &[&PlannedSecret],
project: &str,
profile: &str,
) -> Result<HashMap<String, SecretString>> {
let addresses = group
.iter()
.map(|planned| self.address_for_spec(planned, provider_spec, project, profile))
.collect::<Result<Vec<_>>>()?;
let requests: Vec<(&str, Address<'_>)> = group
.iter()
.zip(&addresses)
.map(|(planned, address)| (planned.name.as_str(), address.as_address()))
.collect();
provider.get_many(&requests)
}
fn read_cached_group(
&self,
plan: &ResolutionPlan,
profile: &str,
) -> HashMap<String, (SecretString, String)> {
let mut groups: BTreeMap<&str, Vec<&PlannedSecret>> = BTreeMap::new();
for planned in &plan.secrets {
if let Some(cache) = planned.route.as_ref().and_then(Route::cache) {
groups.entry(cache.spec.as_str()).or_default().push(planned);
}
}
let mut cached = HashMap::new();
for (spec, group) in groups {
let provider = match self.build_provider(spec.to_string(), Some(profile)) {
Ok(provider) => provider,
Err(error) => {
cache_read_warning(&group_names(&group), error);
continue;
}
};
let requests: Vec<(&str, Address<'_>)> = group
.iter()
.map(|planned| {
(
planned.name.as_str(),
self.cache_address(profile, &planned.name),
)
})
.collect();
let stored = match provider.get_many(&requests) {
Ok(stored) => stored,
Err(error) => {
cache_read_warning(&group_names(&group), error);
continue;
}
};
let uri = provider.uri();
for planned in group {
let Some(stored) = stored.get(&planned.name) else {
continue;
};
let cache = planned
.route
.as_ref()
.and_then(Route::cache)
.expect("the group was built from secrets with a cached route");
match cached_entry(planned, cache, stored, &self.config.project.name, profile) {
CachedEntry::Fresh(value) => {
cached.insert(planned.name.clone(), (value, uri.clone()));
}
CachedEntry::Stale => {
self.evict_cache_entry(provider.as_ref(), &planned.name, profile)
}
CachedEntry::Foreign => {}
}
}
}
cached
}
fn cache_address<'a>(&'a self, profile: &'a str, name: &'a str) -> Address<'a> {
Address::convention(&self.config.project.name, profile, name)
}
fn write_cached_secret(
&self,
planned: &PlannedSecret,
route: &Route,
profile: &str,
value: &SecretString,
) {
let Some(cache) = route.cache() else {
return;
};
let provider = match self.build_provider(cache.spec.clone(), Some(profile)) {
Ok(provider) => provider,
Err(error) => {
self.remediate_failed_cache_refresh(planned, cache, profile, error, None);
return;
}
};
if let Err(error) =
self.check_cache_address_is_ours(provider.as_ref(), &planned.name, profile)
{
cache_warning(&planned.name, format!("not caching: {error}"));
return;
}
let serialized = match cache::encode_entry(
&self.config.project.name,
profile,
cache.max_age_secs,
planned.cache_fingerprint(cache, &self.config.project.name, profile),
value,
) {
Ok(serialized) => serialized,
Err(error) => {
self.remediate_failed_cache_refresh(
planned,
cache,
profile,
error,
Some(provider.as_ref()),
);
return;
}
};
let address = self.cache_address(profile, &planned.name);
let result = provider.check_writable(address).and_then(|()| {
provider.set_expiring(
address,
&serialized,
Duration::from_secs(cache.max_age_secs),
)
});
let (outcome, error_kind) = match &result {
Ok(()) => (AuditOutcome::Written, None),
Err(error) => (AuditOutcome::Error, Some(error.kind())),
};
self.record(
AuditAction::CacheRefresh,
profile,
outcome,
AuditFields {
key: Some(&planned.name),
provider_uri: Some(provider.uri()),
error_kind,
..Default::default()
},
);
if let Err(error) = result {
self.remediate_failed_cache_refresh(
planned,
cache,
profile,
error,
Some(provider.as_ref()),
);
}
}
fn remediate_failed_cache_refresh(
&self,
planned: &PlannedSecret,
cache: &ResolvedCache,
profile: &str,
failure: impl std::fmt::Display,
provider: Option<&dyn ProviderTrait>,
) {
cache_warning(&planned.name, failure);
let result = match provider {
Some(provider) => self.delete_cache_entry(provider, &planned.name, profile),
None => self
.build_source_provider(&cache.spec)
.and_then(|provider| {
self.delete_cache_entry(provider.as_ref(), &planned.name, profile)
}),
};
if let Err(error) = result {
cache_warning(
&planned.name,
format!(
"could not drop the superseded entry either: {error}. Run \
`secretspec cache clear {}` — until then a read may serve the old value",
planned.name
),
);
}
}
fn evict_cache_entry(&self, provider: &dyn ProviderTrait, name: &str, profile: &str) {
if let Err(error) = self.delete_cache_entry(provider, name, profile) {
cache_warning(
name,
format!("could not drop an unusable cache entry: {error}"),
);
}
}
fn check_cache_address_is_ours(
&self,
provider: &dyn ProviderTrait,
name: &str,
profile: &str,
) -> Result<()> {
let Ok(Some(stored)) = provider.get(self.cache_address(profile, name)) else {
return Ok(());
};
match cache::ownership(&stored, &self.config.project.name, profile) {
CacheOwnership::Ours | CacheOwnership::Expired | CacheOwnership::OursUnreadable => {
Ok(())
}
CacheOwnership::Foreign { project, profile } => {
Err(SecretSpecError::ProviderOperationFailed(format!(
"the cache holds {project}/{profile}'s entry for '{name}' at this address, so \
it is not ours to change. Give this project's cache a store or path of its own."
)))
}
CacheOwnership::Unrecognized => Err(SecretSpecError::ProviderOperationFailed(format!(
"the value stored for '{name}' is not a SecretSpec cache entry, so it is not ours \
to change. Check that the cache provider addresses a store only SecretSpec writes \
to."
))),
}
}
fn delete_cache_entry(
&self,
provider: &dyn ProviderTrait,
name: &str,
profile: &str,
) -> Result<bool> {
self.check_cache_address_is_ours(provider, name, profile)?;
let result = provider.delete(self.cache_address(profile, name));
match &result {
Ok(true) => self.record(
AuditAction::CacheClear,
profile,
AuditOutcome::Deleted,
AuditFields {
key: Some(name),
provider_uri: Some(provider.uri()),
..Default::default()
},
),
Ok(false) => {}
Err(error) => self.record_key_error(
AuditAction::CacheClear,
profile,
name,
Some(provider.uri()),
None,
error,
),
}
result
}
fn invalidate_cached_secret(
&self,
planned: &PlannedSecret,
route: &Route,
profile: &str,
) -> Result<bool> {
let Some(cache) = route.cache() else {
return Ok(false);
};
let provider = self.build_provider(cache.spec.clone(), Some(profile))?;
self.delete_cache_entry(provider.as_ref(), &planned.name, profile)
}
fn sync_cache_after_write(
&self,
planned: &PlannedSecret,
route: &Route,
profile: &str,
value: &SecretString,
) {
if route.cache().is_some() {
self.write_cached_secret(planned, route, profile, value);
return;
}
let default_spec = self.configured_default_provider_spec();
let names_cache = planned
.config()
.providers
.as_deref()
.unwrap_or_default()
.iter()
.map(String::as_str)
.chain(default_spec.as_deref())
.any(|spec| self.cached_alias(spec).is_some());
if !names_cache {
return;
}
let declared = match self.route_for(planned.config(), &None) {
Ok(declared) => declared,
Err(error) => {
cache_warning(&planned.name, error);
return;
}
};
if let Err(error) = self.invalidate_cached_secret(planned, &declared, profile) {
cache_warning(
&planned.name,
format!(
"could not drop the cache entry this write superseded: {error}. Run \
`secretspec cache clear {}` — until then a read may serve the old value",
planned.name
),
);
}
}
fn sync_cache_after_delete(&self, planned: &PlannedSecret, route: &Route, profile: &str) {
if route.cache().is_some() {
if let Err(error) = self.invalidate_cached_secret(planned, route, profile) {
cache_warning(
&planned.name,
format!(
"could not drop the cache entry for the deleted secret: {error}. Run \
`secretspec cache clear {}` — until then a read may serve the deleted value",
planned.name
),
);
}
return;
}
let default_spec = self.configured_default_provider_spec();
let names_cache = planned
.config()
.providers
.as_deref()
.unwrap_or_default()
.iter()
.map(String::as_str)
.chain(default_spec.as_deref())
.any(|spec| self.cached_alias(spec).is_some());
if !names_cache {
return;
}
let declared = match self.route_for(planned.config(), &None) {
Ok(declared) => declared,
Err(error) => {
cache_warning(&planned.name, error);
return;
}
};
if let Err(error) = self.invalidate_cached_secret(planned, &declared, profile) {
cache_warning(
&planned.name,
format!(
"could not drop the cache entry for the deleted secret: {error}. Run \
`secretspec cache clear {}` — until then a read may serve the deleted value",
planned.name
),
);
}
}
fn write_provider_for_route(
&self,
route: &Route,
profile: Option<&str>,
) -> Result<Box<dyn ProviderTrait>> {
self.get_route_provider(route.group_key(), profile)
}
fn preflight_write(
&self,
planned: &PlannedSecret,
profile: &str,
backend: &dyn ProviderTrait,
) -> Result<()> {
let address = self.address_for_spec(
planned,
planned.route.as_ref().and_then(Route::group_key),
&self.config.project.name,
profile,
)?;
let addr = address.as_address();
backend.check_writable(addr)?;
let Some(reporter) = &self.write_target_reporter else {
return Ok(());
};
reporter(&WriteTarget {
name: planned.name.clone(),
provider_uri: backend.uri(),
profile: profile.to_string(),
target: backend.describe_write_target(addr)?,
});
Ok(())
}
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 get_route_provider(
&self,
provider_arg: Option<&str>,
profile: Option<&str>,
) -> Result<Box<dyn ProviderTrait>> {
let provider_spec = self.default_provider_spec(provider_arg)?;
self.build_route_provider(provider_spec, profile)
}
fn default_provider_spec(&self, provider_arg: Option<&str>) -> Result<String> {
self.explicit_provider_spec(provider_arg)
.or_else(|| self.configured_default_provider_spec())
.ok_or(SecretSpecError::NoProviderConfigured)
}
pub(crate) fn configured_default_provider_spec(&self) -> Option<String> {
self.global_config
.as_ref()
.and_then(|config| config.defaults.provider.clone())
}
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 => {
let spec = self.default_provider_spec(None)?;
if self.cached_alias(&spec).is_some() {
return Ok(crate::audit::redact_uri_strict(
&self.override_display_uri(&spec)?,
));
}
self.get_provider(Some(&spec), profile)
.map(|provider| provider.uri())
}
}
}
fn read_fallback_chain(
&self,
provider_cache: &ProviderCache,
request: FallbackReadRequest<'_>,
) -> Result<FallbackRead> {
let mut last_error: Option<SecretSpecError> = None;
let mut any_healthy = false;
let mut last_uri: Option<String> = None;
let mut last_reference: Option<NativeAddress> = None;
for spec in request.provider_specs {
let uri = match self.resolve_one_provider(spec) {
Ok(uri) => uri,
Err(error) => {
warn_provider_failure(
&crate::audit::redact_uri_strict(spec),
request.diagnostic_name,
&error,
);
last_error = Some(error);
continue;
}
};
let provider =
match self.shared_provider(provider_cache, spec, Some(request.profile), false) {
Ok(provider) => provider,
Err(error) => {
warn_provider_failure(
&crate::audit::redact_uri_strict(&uri),
request.diagnostic_name,
&error,
);
last_error = Some(error);
continue;
}
};
let provider_uri = provider.uri();
last_uri = Some(provider_uri.clone());
let address = self.address_for_spec(
request.planned,
Some(spec),
request.project,
request.profile,
)?;
last_reference = address.native().cloned();
match provider.get(address.as_address()) {
Ok(Some(value)) => {
return Ok(FallbackRead {
value: Some(value),
provider_uri: Some(provider_uri),
native_address: last_reference,
});
}
Ok(None) => {
any_healthy = true;
}
Err(error) => {
warn_provider_failure(&provider_uri, request.diagnostic_name, &error);
last_error = Some(error);
}
}
}
match last_error {
Some(error) if !any_healthy => Err(error),
_ => Ok(FallbackRead::missing(last_uri, last_reference)),
}
}
pub fn clear_cache(&self, name: Option<&str>) -> Result<usize> {
self.ensure_reason_for(AuditAction::CacheClear, name)?;
let profile = self.resolve_profile_name(None);
let named = name.is_some();
let names = match name {
Some(name) => {
self.require_profile(&profile)?;
vec![name.to_string()]
}
None => self.resolve_profile_secret_names(Some(&profile))?,
};
let mut cleared = 0;
let mut failures: Vec<(String, SecretSpecError)> = Vec::new();
for name in names {
let planned = match self.plan_declared_secret(&name, &profile)? {
Some(planned) => planned,
None => {
return Err(SecretSpecError::SecretNotFound(format!(
"Secret '{name}' is not defined in profile '{profile}'"
)));
}
};
let Some(route) = &planned.route else {
if named {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"secret '{name}' is composed and has no provider cache"
)));
}
continue;
};
if route.cache().is_none() {
if named {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"secret '{name}' does not use a cached provider route"
)));
}
continue;
}
match self.invalidate_cached_secret(&planned, route, &profile) {
Ok(deleted) => cleared += usize::from(deleted),
Err(error) if named => return Err(error),
Err(error) => failures.push((name, error)),
}
}
if let Some((name, error)) = failures.first() {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"cleared {cleared} cache {entries}, but {count} could not be cleared \
('{name}': {error})",
entries = if cleared == 1 { "entry" } else { "entries" },
count = failures.len(),
)));
}
Ok(cleared)
}
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.profile_secret_names_unscoped(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);
};
if planned.extract().is_some() {
let err = SecretSpecError::ExtractedSecretReadOnly(name.to_string());
self.record_key_error(
AuditAction::Set,
&profile_name,
name,
route.primary().map(str::to_string),
planned.reference(),
&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 address = self.address_for_spec(
&planned,
route.group_key(),
&self.config.project.name,
&profile_name,
)?;
let addr = address.as_address();
if let Err(err) = self.preflight_write(&planned, &profile_name, backend.as_ref()) {
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 encoded_value = Self::encoded_for_storage(&planned, &value);
let stored_value = encoded_value.as_ref().unwrap_or(&value);
let result = backend.set(addr, stored_value);
self.audit_write_result(
&result,
name,
&profile_name,
Some(backend.uri()),
address.native(),
None,
);
result?;
self.sync_cache_after_write(&planned, route, &profile_name, stored_value);
eprintln!(
"{} Secret '{}' saved to {} (profile: {})",
"✓".green(),
name,
backend.name(),
profile_name
);
Ok(())
}
pub fn delete(&self, name: &str) -> Result<bool> {
self.ensure_reason_for(AuditAction::Delete, 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(error) => {
self.record_key_error(AuditAction::Delete, &profile_name, name, None, None, &error);
return Err(error);
}
Ok(None) => {
let available = self.profile_secret_names_unscoped(Some(&profile_name))?;
let error = SecretSpecError::SecretNotFound(format!(
"Secret '{name}' is not defined in profile '{profile_name}'. Available secrets: {}",
available.join(", ")
));
self.record_key_error(AuditAction::Delete, &profile_name, name, None, None, &error);
return Err(error);
}
};
let Some(route) = &planned.route else {
let error = SecretSpecError::ComposedSecretReadOnly(name.to_string());
self.record_key_error(AuditAction::Delete, &profile_name, name, None, None, &error);
return Err(error);
};
if planned.extract().is_some() {
let error = SecretSpecError::ExtractedSecretReadOnly(name.to_string());
self.record_key_error(
AuditAction::Delete,
&profile_name,
name,
route.primary().map(str::to_string),
planned.reference(),
&error,
);
return Err(error);
}
let backend = match self.write_provider_for_route(route, Some(&profile_name)) {
Ok(backend) => backend,
Err(error) => {
self.record_key_error(
AuditAction::Delete,
&profile_name,
name,
None,
planned.reference(),
&error,
);
return Err(error);
}
};
let address = self.address_for_spec(
&planned,
route.group_key(),
&self.config.project.name,
&profile_name,
)?;
let result = backend.delete(address.as_address());
self.audit_delete_result(
&result,
name,
&profile_name,
Some(backend.uri()),
address.native(),
);
let deleted = result?;
self.sync_cache_after_delete(&planned, route, &profile_name);
Ok(deleted)
}
pub fn get(&self, name: &str) -> Result<()> {
match self.resolve_named_within(name, Surface::WholeProfile)? {
NamedResolution::Resolved(secret) => {
let rendered = secret
.value
.or(secret.path)
.expect("a resolved secret carries either a value or a path");
println!("{rendered}");
Ok(())
}
NamedResolution::Missing { .. } | NamedResolution::Undeclared => {
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(validation_failure(validation_errors));
}
let missing =
self.scoped_promptable_missing(&validation_errors, &profile_display)?;
if missing.is_empty() {
return Err(validation_failure(validation_errors));
}
if let Some(name) = missing.iter().find(|name| {
self.resolve_secret_config(name, Some(&profile_display))
.is_some_and(|secret| secret.extract.is_some())
}) {
return Err(SecretSpecError::ExtractedSecretReadOnly(name.clone()));
}
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 route = planned
.route
.as_ref()
.expect("prompted names are provider-backed leaves");
let backend =
self.write_provider_for_route(route, Some(&profile_display))?;
if let Err(error) =
self.preflight_write(&planned, &profile_display, backend.as_ref())
{
self.record_key_error(
AuditAction::Set,
&profile_display,
secret_name,
Some(backend.uri()),
planned.reference(),
&error,
);
return Err(error);
}
let prompt_msg =
format!("[{}/{}] Enter value for {}:", i + 1, total, secret_name,);
let prompt = inquire::Password::new(&prompt_msg).without_confirmation();
let value = SecretString::new(prompt.prompt()?.into());
let encoded_value = Self::encoded_for_storage(&planned, &value);
let stored_value = encoded_value.as_ref().unwrap_or(&value);
let address = self.address_for_spec(
&planned,
route.group_key(),
&self.config.project.name,
&profile_display,
)?;
let set_result = backend.set(address.as_address(), stored_value);
self.audit_write_result(
&set_result,
secret_name,
&profile_display,
Some(backend.uri()),
address.native(),
None,
);
set_result?;
self.sync_cache_after_write(
&planned,
route,
&profile_display,
stored_value,
);
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(validation_failure(still_errors)),
}
} else {
Err(validation_failure(validation_errors))
}
}
}
}
pub fn check(&self, no_prompt: bool) -> Result<ValidatedSecrets> {
self.check_with_writer(no_prompt, &mut io::stderr())
}
pub fn check_with_writer(
&self,
no_prompt: bool,
out: &mut dyn Write,
) -> Result<ValidatedSecrets> {
self.ensure_reason_for(AuditAction::Check, None)?;
let profile_display = self.resolve_profile_name(None);
writeln!(
out,
"Checking secrets in {} (profile: {})...\n",
self.config.project.name.bold(),
profile_display.cyan()
)?;
match self.validate()? {
Ok(valid) => {
self.display_validation_success(out, &valid)?;
Ok(valid)
}
Err(errors) => {
self.display_validation_errors(out, &errors)?;
self.ensure_secrets(None, None, !no_prompt)
}
}
}
fn display_validation_success(
&self,
out: &mut dyn Write,
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)? {
let label = format_secret_label(name, config.description.as_deref());
if missing_optional.contains(&name) {
optional_count += 1;
writeln!(out, "{} {} {}", "â—‹".blue(), label, "(optional)".blue())?;
} else if config.default.is_some() && default_names.contains(&name) {
found_count += 1;
writeln!(
out,
"{} {} {}",
"â—‹".yellow(),
label,
"(has default)".yellow()
)?;
} else {
found_count += 1;
writeln!(out, "{} {}", "✓".green(), label)?;
}
}
writeln!(
out,
"\n{}",
Self::format_summary(found_count, 0, optional_count)
)?;
Ok(())
}
fn display_validation_errors(
&self,
out: &mut dyn Write,
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)? {
let label = format_secret_label(name, config.description.as_deref());
if errors.missing_required.contains(name) {
missing_count += 1;
writeln!(out, "{} {} {}", "✗".red(), label, "(required)".red())?;
} else if errors.missing_optional.contains(name) {
optional_count += 1;
writeln!(out, "{} {} {}", "â—‹".blue(), label, "(optional)".blue())?;
} else {
found_count += 1;
if default_names.contains(name) {
writeln!(
out,
"{} {} {}",
"â—‹".yellow(),
label,
"(has default)".yellow()
)?;
} else {
writeln!(out, "{} {}", "✓".green(), label)?;
}
}
}
writeln!(
out,
"\n{}",
Self::format_summary(found_count, missing_count, optional_count)
)?;
for violation in &errors.constraint_violations {
writeln!(out, "{} {}", "Constraint failed:".red().bold(), violation)?;
}
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()
)
}
}
fn literal_import_alias_divergences(
&self,
source_spec: &str,
source_provider: &dyn ProviderTrait,
planned: &[PlannedSecret],
profile: &str,
) -> Vec<ImportAliasDivergence> {
if self.lookup_provider_alias_entry(source_spec).is_some() {
return Vec::new();
}
self.known_provider_aliases()
.into_iter()
.filter_map(|alias_name| {
let alias = self.lookup_provider_alias_entry(&alias_name)?;
if alias.is_cached() {
return None;
}
let has_active_mapping = alias.reference_template().is_some()
|| planned.iter().any(|secret| {
secret
.config()
.refs
.as_ref()
.is_some_and(|refs| refs.contains_key(&alias_name))
});
if !has_active_mapping {
return None;
}
let alias_provider = self.build_source_provider(&alias_name).ok()?;
if !same_storage_container(source_provider, alias_provider.as_ref()) {
return None;
}
let affected_secrets = planned
.iter()
.filter_map(|secret| {
let literal_address = self
.address_for_spec(
secret,
Some(source_spec),
&self.config.project.name,
profile,
)
.ok()?;
let alias_address = self
.address_for_spec(
secret,
Some(&alias_name),
&self.config.project.name,
profile,
)
.ok()?;
let differs = match source_provider.same_entries(
literal_address.as_address(),
alias_provider.as_ref(),
alias_address.as_address(),
) {
Ok(same) => !same,
Err(_) => literal_address != alias_address,
};
differs.then(|| secret.name.clone())
})
.collect::<Vec<_>>();
(!affected_secrets.is_empty()).then_some(ImportAliasDivergence {
alias: alias_name,
affected_secrets,
})
})
.collect()
}
fn warn_literal_import_alias_divergences(
source_uri: &str,
divergences: &[ImportAliasDivergence],
) {
for divergence in divergences {
let count = divergence.affected_secrets.len();
let noun = if count == 1 { "secret" } else { "secrets" };
let example = &divergence.affected_secrets[0];
eprintln!(
"{} import source {} uses convention naming, but provider alias {} addresses {} {} differently in the same storage container (for example, {}). Use that alias as the source if its alias-specific coordinates are intended; keep the literal source to use convention-named entries.",
"warning:".yellow(),
source_uri.bold(),
format!("'{}'", divergence.alias).bold(),
count,
noun,
example.bold(),
);
}
}
pub fn import(&self, from_provider: &str) -> Result<()> {
self.import_internal(from_provider, false)
}
pub fn import_with_delete_source(&self, from_provider: &str) -> Result<()> {
self.import_internal(from_provider, true)
}
fn import_internal(&self, from_provider: &str, delete_source: bool) -> Result<()> {
self.ensure_reason_for(AuditAction::Import, None)?;
let mut plan = ImportPlan::new(
self,
from_provider,
self.resolve_profile_name(None),
delete_source,
);
if let Err(error) = plan.run() {
self.record(
AuditAction::Import,
&plan.profile,
AuditOutcome::Error,
AuditFields {
keys: &plan.read_names,
provider_uri: plan.source_uri.clone(),
error_kind: Some(error.kind()),
..Default::default()
},
);
return Err(error);
}
eprintln!(
"\nSummary: {} imported, {} already exists, {} not found in source",
plan.summary.imported.to_string().green(),
plan.summary.already_exists.to_string().yellow(),
plan.summary.not_found.to_string().red()
);
if delete_source {
eprintln!(
"Source cleanup: {} deleted, {} retained because the target differs",
plan.summary.deleted_from_source.to_string().green(),
plan.summary.kept_in_source.to_string().yellow()
);
}
if plan.summary.imported > 0 {
eprintln!(
"\n{} Successfully imported {} secrets from {}",
"✓".green(),
plan.summary.imported,
plan.source_display
.as_deref()
.unwrap_or("configured provider"),
);
}
self.record(
AuditAction::Import,
&plan.profile,
plan.summary.audit_outcome(),
AuditFields {
keys: &plan.read_names,
provider_uri: plan.source_uri.clone(),
..Default::default()
},
);
Ok(())
}
fn generated_value_is_stored(&self, planned: &PlannedSecret, profile_name: &str) -> bool {
planned
.route
.as_ref()
.and_then(|route| {
self.write_provider_for_route(route, Some(profile_name))
.ok()
})
.is_none_or(|backend| {
backend.generated_value_persistence() == ProducedValuePersistence::Persist
})
}
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),
};
if planned.extract().is_some() {
return Err(SecretSpecError::ExtractedSecretReadOnly(
planned.name.clone(),
));
}
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 route = planned
.route
.as_ref()
.expect("a generating secret is provider-backed");
let address = self.address_for_spec(
planned,
route.group_key(),
&self.config.project.name,
profile_name,
)?;
let addr = address.as_address();
let backend = self.write_provider_for_route(route, Some(profile_name))?;
if backend.generated_value_persistence() == ProducedValuePersistence::Ephemeral {
eprintln!(
"{} {} - generated for this resolution without provider storage (profile: {})",
"✓".green(),
name,
profile_name
);
return Ok(Some(value));
}
backend.check_writable(addr)?;
let encoded_value = Self::encoded_for_storage(planned, &value);
let stored_value = encoded_value.as_ref().unwrap_or(&value);
let set_result = backend.set(addr, stored_value);
self.audit_write_result(
&set_result,
name,
profile_name,
Some(backend.uri()),
address.native(),
None,
);
set_result?;
self.sync_cache_after_write(
planned,
planned
.route
.as_ref()
.expect("a generating secret is provider-backed"),
profile_name,
stored_value,
);
eprintln!(
"{} {} - generated and saved to {} (profile: {})",
"✓".green(),
name,
backend.name(),
profile_name
);
Ok(Some(value))
}
fn prompt_run_secret(&self, name: &str, profile: &str) -> Result<SecretString> {
let value = if let Some(reader) = &self.prompt_reader {
reader(name, profile)?
} else {
let message = format!("Enter value for {name} (profile: {profile}):");
let entered = inquire::Password::new(&message)
.without_confirmation()
.prompt()
.map_err(|error| match error {
inquire::InquireError::NotTTY | inquire::InquireError::IO(_) => {
SecretSpecError::PromptUnavailable(name.to_string())
}
other => SecretSpecError::InquireError(other),
})?;
SecretString::new(entered.into())
};
if value.expose_secret().is_empty() {
return Err(SecretSpecError::PromptValueEmpty(name.to_string()));
}
Ok(value)
}
fn try_prompt_secret(
&self,
planned: &PlannedSecret,
profile_name: &str,
) -> Result<SecretString> {
let name = planned.name.as_str();
let route = planned
.route
.as_ref()
.expect("a prompted secret is provider-backed");
let address = self.address_for_spec(
planned,
route.group_key(),
&self.config.project.name,
profile_name,
)?;
let addr = address.as_address();
let backend = self.write_provider_for_route(route, Some(profile_name))?;
let persistence = backend.prompted_value_persistence();
if persistence == ProducedValuePersistence::Persist {
self.preflight_write(planned, profile_name, backend.as_ref())?;
}
let value = self.prompt_run_secret(name, profile_name)?;
if persistence == ProducedValuePersistence::Ephemeral {
eprintln!(
"{} {} - entered for this run without provider storage (profile: {})",
"✓".green(),
name,
profile_name
);
return Ok(value);
}
let encoded_value = Self::encoded_for_storage(planned, &value);
let stored_value = encoded_value.as_ref().unwrap_or(&value);
let set_result = backend.set(addr, stored_value);
self.audit_write_result(
&set_result,
name,
profile_name,
Some(backend.uri()),
address.native(),
None,
);
set_result?;
self.sync_cache_after_write(planned, route, profile_name, stored_value);
eprintln!(
"{} {} - entered and saved to {} (profile: {})",
"✓".green(),
name,
backend.name(),
profile_name
);
Ok(value)
}
fn write_secret_to_temp_file(
&self,
secret: &[u8],
) -> Result<(tempfile::NamedTempFile, String)> {
use std::io::Write;
let mut temp_file = tempfile::NamedTempFile::new().map_err(SecretSpecError::Io)?;
temp_file.write_all(secret).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)
}
pub fn resolve_named(&self, name: &str) -> Result<NamedResolution> {
self.resolve_named_within(name, Surface::Scoped)
}
fn resolve_named_within(&self, name: &str, surface: Surface) -> Result<NamedResolution> {
self.ensure_reason_for(AuditAction::Get, Some(name))?;
let profile_name = self.resolve_profile_name(None);
let visible = match surface.names(self, &profile_name) {
Ok(visible) => visible,
Err(err) => {
self.record_key_error(AuditAction::Get, &profile_name, name, None, None, &err);
return Err(err);
}
};
if !visible.iter().any(|declared| declared == name) {
let err = SecretSpecError::SecretNotFound(name.to_string());
self.record_key_error(AuditAction::Get, &profile_name, name, None, None, &err);
return Ok(NamedResolution::Undeclared);
}
let names = self.composed_dependency_names(name, &profile_name);
let plan = match self.build_plan_from_names(profile_name.clone(), names) {
Ok(plan) => plan,
Err(err) => {
self.record_key_error(AuditAction::Get, &profile_name, name, None, None, &err);
return Err(err);
}
};
let mut read_addresses = HashMap::new();
let outcome =
match self.execute_plan(&plan, Materialize::Values, None, Some(&mut read_addresses)) {
Ok(outcome) => outcome,
Err(err) => {
let reference = read_addresses.get(name);
self.record_key_error(
AuditAction::Get,
&profile_name,
name,
None,
reference,
&err,
);
return Err(err);
}
};
let reference = read_addresses.get(name);
match outcome {
Ok(mut validated) => {
let entry = validated
.resolution
.iter()
.find(|entry| entry.name == name)
.expect("the planned target always has a resolution entry")
.clone();
if entry.status != ResolutionStatus::Resolved {
self.record(
AuditAction::Get,
&profile_name,
AuditOutcome::Missing,
AuditFields {
key: Some(name),
reference,
..Default::default()
},
);
return Ok(NamedResolution::Missing {
required: entry.required,
});
}
validated.keep_temp_files()?;
let raw = validated
.resolved
.secrets
.get(name)
.expect("a Resolved entry always has a value")
.expose_secret()
.to_string();
let (value, path) = if entry.as_path {
(None, Some(raw))
} else {
(Some(raw), None)
};
self.record(
AuditAction::Get,
&profile_name,
if entry.default_applied {
AuditOutcome::Default
} else {
AuditOutcome::Found
},
AuditFields {
key: Some(name),
provider_uri: entry.source_provider.clone(),
reference,
..Default::default()
},
);
Ok(NamedResolution::Resolved(ResolvedSecret {
value,
path,
as_path: entry.as_path,
source: resolved_source(&entry),
source_provider: entry.source_provider,
}))
}
Err(errors) => {
if !errors.constraint_violations.is_empty() {
let err = SecretSpecError::ValidationFailed(Box::new(errors));
self.record_key_error(
AuditAction::Get,
&profile_name,
name,
None,
reference,
&err,
);
return Err(err);
}
let required = errors
.resolution
.iter()
.find(|entry| entry.name == name)
.is_none_or(|entry| entry.required);
self.record(
AuditAction::Get,
&profile_name,
AuditOutcome::Missing,
AuditFields {
key: Some(name),
reference,
..Default::default()
},
);
Ok(NamedResolution::Missing { required })
}
}
}
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 = resolved_source(entry);
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(),
scope: self.resolve_scope_name(None),
secrets,
missing_required: Vec::new(),
missing_optional,
})
}
Err(errors) => {
if !errors.constraint_violations.is_empty() {
return Err(SecretSpecError::ValidationFailed(Box::new(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(),
scope: self.resolve_scope_name(None),
secrets: BTreeMap::new(),
missing_required,
missing_optional,
})
}
}
}
pub fn report(&self) -> Result<ResolutionReport> {
let mut report = match self.validate_audited(true, Materialize::None)? {
Ok(validated) => validated.report(),
Err(errors) => errors.report(),
};
report.scope = self.resolve_scope_name(None);
Ok(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 visible_result = self.resolve_profile_secret_names(Some(&profile_name));
let visible: Vec<String> = visible_result.as_ref().ok().cloned().unwrap_or_default();
let scoped = self.resolve_scope_name(None).is_some();
let (worklist, output_filter): (Vec<String>, Option<HashSet<String>>) = if scoped {
let worklist = self.accessed_names(&profile_name, &visible);
(worklist, Some(visible.into_iter().collect()))
} else {
(visible, None)
};
let audit_keys: Vec<String> = if self.audit.is_some() {
worklist.clone()
} else {
Vec::new()
};
let result: Result<std::result::Result<ValidatedSecrets, ValidationErrors>> =
visible_result
.and_then(|_| self.build_plan_from_names(profile_name.clone(), worklist))
.and_then(|plan| {
self.execute_plan(&plan, materialize, output_filter.as_ref(), None)
});
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::compiled_spec::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::compiled_spec::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
}
pub(crate) fn scoped_promptable_missing(
&self,
errors: &ValidationErrors,
profile_name: &str,
) -> Result<Vec<String>> {
let mut missing = self.promptable_missing_names(errors, profile_name);
if self.resolve_scope_name(None).is_some() {
let visible: HashSet<String> = self
.resolve_profile_secret_names(Some(profile_name))?
.into_iter()
.collect();
missing.retain(|name| visible.contains(name));
}
Ok(missing)
}
pub(crate) fn diagnostic_secret_name<'a>(
name: &'a str,
output_filter: Option<&HashSet<String>>,
) -> &'a str {
match output_filter {
Some(filter) if !filter.contains(name) => HIDDEN_SECRET_LABEL,
_ => name,
}
}
fn check_single_store_ref_coords(
&self,
provider_spec: Option<&str>,
group: &[&PlannedSecret],
provider: &dyn ProviderTrait,
project: &str,
profile: &str,
) -> Result<()> {
for planned in group {
let Some(route) = &planned.route else {
continue;
};
if route.fallback_specs().is_some() {
continue;
}
let address = self.address_for_spec(planned, provider_spec, project, profile)?;
if address.native().is_some() {
provider.resolve_coords(address.as_address())?;
}
}
Ok(())
}
fn execute_plan(
&self,
plan: &ResolutionPlan,
materialize: Materialize,
output_filter: Option<&HashSet<String>>,
read_addresses: Option<&mut HashMap<String, NativeAddress>>,
) -> ExecutionResult {
ResolutionExecution::new(self, plan, materialize, output_filter, read_addresses).run()
}
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 resolution = self
.validate_audited(false, Materialize::Run)
.and_then(|result| result.map_err(validation_failure));
let validation_result = match resolution {
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 excluded = self.scope_excluded_names()?;
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);
for key in &excluded {
cmd.env_remove(key);
}
#[cfg(unix)]
let mut signal_forwarder = ChildSignalForwarder::prepare()?;
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 mut child = child?;
#[cfg(unix)]
signal_forwarder.start(child.id());
let status = child.wait()?;
Ok(command_exit_code(status))
}
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(())
}
pub(crate) 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 construction_tests {
use super::*;
#[test]
fn from_spec_uses_explicit_logical_base_directory() {
let spec = Spec::from_toml(
r#"
[project]
name = "embedded"
revision = "1.0"
require_reason = false
[profiles.default]
TOKEN = { description = "Embedded token", required = false }
"#,
)
.unwrap();
let base_dir = PathBuf::from("a-base-directory-that-does-not-exist");
let secrets = Secrets::from_spec_at(spec, &base_dir).unwrap();
assert_eq!(secrets.config_dir, base_dir);
}
}
#[cfg(test)]
mod write_target_tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
struct CountingProvider {
writability_checks: AtomicUsize,
descriptions: AtomicUsize,
}
impl ProviderTrait for CountingProvider {
fn convention_address(
&self,
_project: &str,
_profile: &str,
key: &str,
) -> Result<NativeAddress> {
Ok(NativeAddress {
item: key.to_string(),
..Default::default()
})
}
fn get(&self, _addr: Address<'_>) -> Result<Option<SecretString>> {
Ok(None)
}
fn set(&self, _addr: Address<'_>, _value: &SecretString) -> Result<()> {
Ok(())
}
fn check_writable(&self, _addr: Address<'_>) -> Result<()> {
self.writability_checks.fetch_add(1, Ordering::SeqCst);
Ok(())
}
fn describe_write_target(&self, _addr: Address<'_>) -> Result<String> {
self.descriptions.fetch_add(1, Ordering::SeqCst);
Ok("described".to_string())
}
fn name(&self) -> &'static str {
"counting"
}
fn uri(&self) -> String {
"counting".to_string()
}
}
#[test]
fn library_preflight_skips_target_description_without_a_reporter() {
let config = crate::tests::resolve_test_config(HashMap::from([(
"API_KEY".to_string(),
crate::config::Secret {
description: Some("API key".to_string()),
..Default::default()
},
)]));
let spec = Secrets::new(config, None, None, None);
let planned = spec
.plan_secret("API_KEY", "default", None)
.unwrap()
.unwrap();
let provider = CountingProvider {
writability_checks: AtomicUsize::new(0),
descriptions: AtomicUsize::new(0),
};
spec.preflight_write(&planned, "default", &provider)
.unwrap();
assert_eq!(provider.writability_checks.load(Ordering::SeqCst), 1);
assert_eq!(provider.descriptions.load(Ordering::SeqCst), 0);
}
}
#[cfg(test)]
mod display_tests {
use super::*;
#[test]
fn secret_label_emphasizes_the_name_and_deemphasizes_the_description() {
assert_eq!(
format_secret_label("DATABASE_URL", Some("PostgreSQL connection string")),
format!(
"{} {} {}",
"DATABASE_URL".cyan().bold(),
"-".dimmed(),
"PostgreSQL connection string".dimmed()
)
);
}
#[test]
fn secret_label_omits_a_missing_description_without_a_placeholder() {
let label = format_secret_label("DATABASE_URL", None);
assert_eq!(label, "DATABASE_URL".cyan().bold().to_string());
assert!(!label.contains("No description"));
assert!(!label.contains('-'));
}
}
#[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_uses_minimal_round_trip_quoting() {
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 with_default_reason_only_fills_an_absent_reason() {
let spec = || {
Secrets::new(
crate::tests::resolve_test_config(HashMap::new()),
None,
None,
None,
)
};
assert_eq!(
spec().with_default_reason(" nightly export ").reason,
Some("nightly export".to_string())
);
assert_eq!(
spec()
.with_reason("running migrations")
.with_default_reason("nightly export")
.reason,
Some("running migrations".to_string())
);
assert_eq!(spec().with_default_reason(" ").reason, None);
assert_eq!(
spec().with_reason("deploy").with_default_reason("").reason,
Some("deploy".to_string())
);
}
#[test]
fn caller_context_is_normalized_but_never_counts_as_a_reason() {
let mut spec = Secrets::new(
crate::tests::resolve_test_config(HashMap::new()),
None,
None,
None,
)
.with_caller(
CallerContext::new(" git ")
.with_operation(" credential_get ")
.with_resource(" github.com "),
);
assert_eq!(
spec.caller,
Some(
CallerContext::new("git")
.with_operation("credential_get")
.with_resource("github.com")
)
);
spec.require_reason = RequireReason::Always;
assert!(matches!(
spec.ensure_reason(),
Err(SecretSpecError::ReasonRequired)
));
assert!(spec.with_reason("release package").ensure_reason().is_ok());
}
#[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_cache_tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Barrier};
use std::thread;
use std::time::Duration;
fn env_provider() -> Result<Box<dyn ProviderTrait>> {
crate::provider::provider_from_spec("env://", ProviderCredentials::new())
}
#[test]
fn one_key_builds_once_and_hands_back_the_same_instance() {
let cache = ProviderCache::default();
let builds = AtomicUsize::new(0);
let key = ("default".to_string(), "env://".to_string());
let first = cache
.get_or_try_init(key.clone(), || {
builds.fetch_add(1, Ordering::SeqCst);
env_provider()
})
.unwrap();
let second = cache
.get_or_try_init(key, || {
builds.fetch_add(1, Ordering::SeqCst);
env_provider()
})
.unwrap();
assert_eq!(builds.load(Ordering::SeqCst), 1);
assert!(Arc::ptr_eq(&first, &second));
}
#[test]
fn distinct_keys_build_independently() {
let cache = ProviderCache::default();
let builds = AtomicUsize::new(0);
let build_under = |profile: &str| {
cache
.get_or_try_init((profile.to_string(), "env://".to_string()), || {
builds.fetch_add(1, Ordering::SeqCst);
env_provider()
})
.unwrap()
};
let default = build_under("default");
let production = build_under("production");
assert_eq!(builds.load(Ordering::SeqCst), 2);
assert!(!Arc::ptr_eq(&default, &production));
}
#[test]
fn failures_are_not_memoized() {
let cache = ProviderCache::default();
let key = ("default".to_string(), "env://".to_string());
let failed = cache.get_or_try_init(key.clone(), || {
Err(SecretSpecError::ProviderOperationFailed("nope".into()))
});
assert!(failed.is_err());
assert!(cache.get_or_try_init(key, env_provider).is_ok());
}
#[test]
fn concurrent_construction_for_one_key_is_single_flight() {
const CALLERS: usize = 8;
let cache = Arc::new(ProviderCache::default());
let start = Arc::new(Barrier::new(CALLERS));
let builds = Arc::new(AtomicUsize::new(0));
let threads: Vec<_> = (0..CALLERS)
.map(|_| {
let cache = Arc::clone(&cache);
let start = Arc::clone(&start);
let builds = Arc::clone(&builds);
thread::spawn(move || {
start.wait();
cache
.get_or_try_init(("default".into(), "env://".into()), || {
builds.fetch_add(1, Ordering::SeqCst);
thread::sleep(Duration::from_millis(50));
env_provider()
})
.unwrap()
})
})
.collect();
let providers: Vec<_> = threads
.into_iter()
.map(|thread| thread.join().unwrap())
.collect();
assert_eq!(builds.load(Ordering::SeqCst), 1);
for provider in &providers {
assert!(Arc::ptr_eq(provider, &providers[0]));
}
}
}
#[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::leaf(
"bws://proj",
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 encoding_tests {
use super::*;
#[test]
fn decoding_accepts_exactly_one_trailing_line_ending() {
for encoded in ["Zg==\n", "Zg==\r\n"] {
let value = SecretString::new(encoded.to_string().into());
let decoded =
Secrets::decode_stored_value(SecretEncoding::Base64, "VALUE", &value).unwrap();
assert_eq!(decoded.expose_secret(), b"f");
}
let value = SecretString::new("Zg==\n\n".to_string().into());
let error =
Secrets::decode_stored_value(SecretEncoding::Base64, "VALUE", &value).unwrap_err();
assert_eq!(error.kind(), "decode_failed");
}
#[test]
fn encoding_uses_canonical_storage_representations() {
let cases = [
(SecretEncoding::Base64, "value", "dmFsdWU="),
(SecretEncoding::Base64Url, "hello?", "aGVsbG8_"),
(SecretEncoding::Hex, "value", "76616c7565"),
];
for (encoding, logical, expected) in cases {
let logical = SecretString::new(logical.to_string().into());
let stored = Secrets::encode_logical_value(encoding, &logical);
assert_eq!(stored.expose_secret(), expected);
}
}
}
#[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,
scopes: 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 run_prompt_tests {
use super::*;
use crate::config::Secret;
use secrecy::ExposeSecret;
use std::sync::atomic::{AtomicUsize, Ordering};
fn prompted_spec() -> Secrets {
let config = crate::tests::resolve_test_config(HashMap::from([(
"DEPLOY_PASSWORD".to_string(),
Secret {
description: Some("One-time deployment password".to_string()),
required: Some(true),
providers: Some(vec!["null".to_string()]),
prompt: Some(true),
..Default::default()
},
)]));
Secrets::new(config, None, None, None)
}
fn prompted_dotenv_spec(path: &std::path::Path) -> Secrets {
let config = crate::tests::resolve_test_config(HashMap::from([(
"DEPLOY_PASSWORD".to_string(),
Secret {
description: Some("Deployment password".to_string()),
required: Some(true),
providers: Some(vec![format!("dotenv://{}", path.display())]),
prompt: Some(true),
..Default::default()
},
)]));
Secrets::new(config, None, None, None)
}
#[test]
fn run_prompts_again_for_each_resolution_without_storing() {
let _env = crate::tests::scrub_resolution_env();
let prompts = Arc::new(AtomicUsize::new(0));
let observed = Arc::clone(&prompts);
let mut spec = prompted_spec();
spec.set_prompt_reader(move |name, profile| {
assert_eq!(name, "DEPLOY_PASSWORD");
assert_eq!(profile, "default");
observed.fetch_add(1, Ordering::SeqCst);
Ok(SecretString::new("entered-once".into()))
});
for expected_prompts in 1..=2 {
let validated = spec
.validate_audited(false, Materialize::Run)
.unwrap()
.unwrap();
assert_eq!(
validated.resolved.secrets["DEPLOY_PASSWORD"].expose_secret(),
"entered-once"
);
assert_eq!(prompts.load(Ordering::SeqCst), expected_prompts);
}
assert!(
spec.validate_audited(false, Materialize::Values)
.unwrap()
.is_err()
);
}
#[test]
fn writable_provider_persists_the_prompted_value() {
let _env = crate::tests::scrub_resolution_env();
let temp_dir = tempfile::TempDir::new().unwrap();
let dotenv_path = temp_dir.path().join("prompt.env");
let prompts = Arc::new(AtomicUsize::new(0));
let observed = Arc::clone(&prompts);
let mut spec = prompted_dotenv_spec(&dotenv_path);
spec.set_prompt_reader(move |name, profile| {
assert_eq!(name, "DEPLOY_PASSWORD");
assert_eq!(profile, "default");
observed.fetch_add(1, Ordering::SeqCst);
Ok(SecretString::new("persisted-answer".into()))
});
for _ in 0..2 {
let validated = spec
.validate_audited(false, Materialize::Run)
.unwrap()
.unwrap();
assert_eq!(
validated.resolved.secrets["DEPLOY_PASSWORD"].expose_secret(),
"persisted-answer"
);
}
assert_eq!(prompts.load(Ordering::SeqCst), 1);
assert_eq!(
std::fs::read_to_string(dotenv_path).unwrap(),
"DEPLOY_PASSWORD=persisted-answer\n"
);
}
#[test]
fn run_surfaces_an_unavailable_controlling_terminal() {
let _env = crate::tests::scrub_resolution_env();
let mut spec = prompted_spec();
spec.set_prompt_reader(|name, _| Err(SecretSpecError::PromptUnavailable(name.to_string())));
let error = match spec.validate_audited(false, Materialize::Run) {
Err(error) => error,
Ok(_) => panic!("run resolution should fail without a controlling terminal"),
};
assert!(matches!(
error,
SecretSpecError::PromptUnavailable(name) if name == "DEPLOY_PASSWORD"
));
}
#[cfg(unix)]
#[test]
fn run_injects_the_prompted_value_into_the_child() {
let _env = crate::tests::scrub_resolution_env();
let mut spec = prompted_spec();
spec.set_prompt_reader(|_, _| Ok(SecretString::new("entered-once".into())));
let exit = spec
.run_command(vec![
"sh".to_string(),
"-c".to_string(),
"test \"$DEPLOY_PASSWORD\" = entered-once".to_string(),
])
.unwrap();
assert_eq!(exit, 0);
}
}
#[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,
scopes: 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_route_provider(primary, None).unwrap();
spec.check_single_store_ref_coords(
primary,
&group,
provider.as_ref(),
&spec.config.project.name,
"default",
)?;
}
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"
);
}
}