#![allow(clippy::module_name_repetitions)]
use std::collections::BTreeMap;
use std::io::{self, Write};
use std::path::PathBuf;
use std::str::FromStr;
use anyhow::{anyhow, bail, Context, Result};
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
use secretenv_core::{
resolve_manifest, resolve_registry, Backend, BackendRegistry, BackendUri, Config, HistoryEntry,
Manifest, RegistryCache, RegistrySelection,
};
#[derive(Debug, Parser)]
#[command(
name = "secretenv",
version,
about = "Run commands with secrets injected from any backend"
)]
pub struct Cli {
#[arg(long, global = true)]
pub config: Option<PathBuf>,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
#[command(long_about = "\
Run a command with secrets injected as env vars.
Pipe-based stdout/stderr redaction (Mode A) is on by default. Redaction \
catches secrets that the child writes to its own stdout/stderr; it does \
NOT catch:
- writes to /dev/tty (escapes the pipe)
- syslog(3) / journald / kernel-level logging
- mmap'd output
- core dumps + post-mortem analysis
- children that re-fetch values via an SDK directly
See `docs/security.md` for the full Limits matrix.
")]
Run(RunArgs),
#[command(subcommand)]
Registry(RegistryCommand),
#[command(subcommand)]
Profile(ProfileCommand),
Resolve(ResolveArgs),
Get(GetArgs),
Doctor(DoctorArgs),
Setup(SetupArgs),
Completions(CompletionsArgs),
Redact(RedactArgs),
#[command(subcommand)]
Mcp(McpCommand),
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
#[clap(rename_all = "lowercase")]
pub enum AllowMutationsCli {
Never,
Confirm,
Always,
}
impl AllowMutationsCli {
const fn to_mcp(self) -> secretenv_mcp::AllowMutations {
match self {
Self::Never => secretenv_mcp::AllowMutations::Never,
Self::Confirm => secretenv_mcp::AllowMutations::Confirm,
Self::Always => secretenv_mcp::AllowMutations::Always,
}
}
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
#[clap(rename_all = "lowercase")]
pub enum ConfirmViaCli {
Auto,
Elicitation,
Tty,
Notification,
None,
}
impl ConfirmViaCli {
const fn to_mcp(self) -> secretenv_mcp::ConfirmVia {
match self {
Self::Auto => secretenv_mcp::ConfirmVia::Auto,
Self::Elicitation => secretenv_mcp::ConfirmVia::Elicitation,
Self::Tty => secretenv_mcp::ConfirmVia::Tty,
Self::Notification => secretenv_mcp::ConfirmVia::Notification,
Self::None => secretenv_mcp::ConfirmVia::None,
}
}
}
#[derive(Debug, Subcommand)]
pub enum McpCommand {
Serve {
#[arg(long, value_name = "MODE")]
allow_mutations: Option<AllowMutationsCli>,
#[arg(long, value_name = "SURFACE")]
confirm_via: Option<ConfirmViaCli>,
},
Disable {
#[arg(long, value_name = "DURATION")]
duration: Option<String>,
},
Enable,
Setup {
#[arg(long, value_name = "IDE", conflicts_with = "list_ides")]
ide: Option<String>,
#[arg(long, conflicts_with = "ide")]
list_ides: bool,
#[arg(long, value_name = "PATH", default_value = "secretenv")]
binary: String,
#[arg(long)]
write: bool,
#[arg(long, requires = "write")]
force: bool,
},
}
#[derive(Debug, Args)]
pub struct RedactArgs {
pub path: String,
#[arg(long)]
pub registry: Option<String>,
#[arg(long, value_delimiter = ',')]
pub alias: Vec<String>,
#[arg(long, conflicts_with = "dry_run")]
pub in_place: bool,
#[arg(long, requires = "in_place")]
pub backup: Option<String>,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub allow_foreign_owner: bool,
#[arg(long)]
pub redact_token: Option<String>,
}
#[derive(Debug, Subcommand)]
pub enum ProfileCommand {
Install {
name: String,
#[arg(long)]
url: Option<String>,
},
List {
#[arg(long)]
json: bool,
},
Update {
name: Option<String>,
},
Uninstall { name: String },
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Args)]
pub struct RunArgs {
#[arg(long)]
pub registry: Option<String>,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub verbose: bool,
#[arg(long, conflicts_with = "no_redact")]
pub redact: bool,
#[arg(long, conflicts_with = "redact", requires = "i_know")]
pub no_redact: bool,
#[arg(long)]
pub i_know: bool,
#[arg(long)]
pub redact_token: Option<String>,
#[arg(trailing_var_arg = true, required = true)]
pub command: Vec<String>,
}
#[derive(Debug, Subcommand)]
pub enum RegistryCommand {
List {
#[arg(long)]
registry: Option<String>,
},
Get {
alias: String,
#[arg(long)]
registry: Option<String>,
},
Set {
alias: String,
uri: String,
#[arg(long)]
registry: Option<String>,
},
Unset {
alias: String,
#[arg(long)]
registry: Option<String>,
},
History {
alias: String,
#[arg(long)]
registry: Option<String>,
#[arg(long)]
json: bool,
},
Migrate {
alias: String,
dest_uri: String,
#[arg(long)]
dry_run: bool,
#[arg(long, short)]
yes: bool,
#[arg(long)]
from: Option<String>,
#[arg(long)]
delete_source: bool,
#[arg(long)]
json: bool,
#[arg(long)]
registry: Option<String>,
},
Invite {
#[arg(long)]
registry: Option<String>,
#[arg(long)]
invitee: Option<String>,
#[arg(long)]
json: bool,
},
}
#[derive(Debug, Args)]
pub struct ResolveArgs {
pub alias: String,
#[arg(long)]
pub registry: Option<String>,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct GetArgs {
pub alias: String,
#[arg(long)]
pub registry: Option<String>,
#[arg(long, short)]
pub yes: bool,
}
#[derive(Debug, Args)]
pub struct DoctorArgs {
#[arg(long)]
pub json: bool,
#[arg(long)]
pub fix: bool,
#[arg(long)]
pub extensive: bool,
}
#[derive(Debug, Args)]
pub struct SetupArgs {
pub registry_uri: String,
#[arg(long)]
pub region: Option<String>,
#[arg(long)]
pub profile: Option<String>,
#[arg(long)]
pub account: Option<String>,
#[arg(long)]
pub vault_address: Option<String>,
#[arg(long)]
pub vault_namespace: Option<String>,
#[arg(long)]
pub gcp_project: Option<String>,
#[arg(long)]
pub gcp_impersonate_service_account: Option<String>,
#[arg(long)]
pub azure_vault_url: Option<String>,
#[arg(long)]
pub azure_tenant: Option<String>,
#[arg(long)]
pub azure_subscription: Option<String>,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub skip_doctor: bool,
}
#[derive(Debug, Args)]
pub struct CompletionsArgs {
pub shell: Shell,
#[arg(long)]
pub output: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Shell {
Bash,
Zsh,
Fish,
}
impl Cli {
pub async fn run(&self, config: &Config, backends: &BackendRegistry) -> Result<()> {
match &self.command {
Command::Run(args) => {
let _: crate::reports::RunReport = cmd_run(args, config, backends).await?;
Ok(())
}
Command::Registry(rc) => {
let _: crate::reports::RegistryReport = cmd_registry(rc, config, backends).await?;
Ok(())
}
Command::Resolve(args) => {
let _: crate::reports::ResolveReport = cmd_resolve(args, config, backends).await?;
Ok(())
}
Command::Get(args) => {
let _: crate::reports::GetReport = cmd_get(args, config, backends).await?;
Ok(())
}
Command::Doctor(args) => {
crate::doctor::run_doctor(
config,
backends,
crate::doctor::DoctorOpts {
json: args.json,
fix: args.fix,
extensive: args.extensive,
},
)
.await
}
Command::Profile(pc) => {
let _: crate::reports::ProfileReport =
cmd_profile(pc, self.config.as_deref()).await?;
Ok(())
}
Command::Setup(args) => {
let _: crate::reports::SetupReport =
cmd_setup(args, self.config.as_deref()).await?;
Ok(())
}
Command::Completions(args) => {
let _: crate::reports::CompletionsReport = cmd_completions(args)?;
Ok(())
}
Command::Redact(args) => {
let _: crate::reports::RedactReport = cmd_redact(args, config, backends).await?;
Ok(())
}
Command::Mcp(mc) => cmd_mcp(mc, self.config.clone()).await,
}
}
}
fn parse_duration(s: &str) -> Result<std::time::Duration> {
let s = s.trim();
let (n_str, unit) = s.split_at(
s.find(|c: char| !c.is_ascii_digit())
.ok_or_else(|| anyhow::anyhow!("duration `{s}` missing unit suffix (s|m|h|d)"))?,
);
let n: u64 = n_str
.parse()
.with_context(|| format!("duration `{s}` has invalid numeric portion `{n_str}`"))?;
let secs = match unit {
"s" => n,
"m" => n * 60,
"h" => n * 60 * 60,
"d" => n * 60 * 60 * 24,
other => {
anyhow::bail!("duration `{s}` has unknown unit `{other}` (use s|m|h|d)")
}
};
Ok(std::time::Duration::from_secs(secs))
}
async fn cmd_mcp(mc: &McpCommand, config_path: Option<PathBuf>) -> Result<()> {
match mc {
McpCommand::Serve { allow_mutations, confirm_via } => {
let overrides = secretenv_mcp::PolicyOverrides {
allow_mutations: allow_mutations.map(AllowMutationsCli::to_mcp),
confirm_via: confirm_via.map(ConfirmViaCli::to_mcp),
};
secretenv_mcp::serve_with_overrides(config_path, overrides).await
}
McpCommand::Disable { duration } => {
let d = duration.as_deref().map(parse_duration).transpose()?;
let path = secretenv_mcp::disable(d)?;
match d {
None => eprintln!(
"SecretEnv MCP server disabled (indefinite). Sentinel: {}",
path.display()
),
Some(dur) => eprintln!(
"SecretEnv MCP server disabled for {} seconds. Sentinel: {}",
dur.as_secs(),
path.display()
),
}
Ok(())
}
McpCommand::Enable => {
secretenv_mcp::enable()?;
eprintln!("SecretEnv MCP server enabled (disable sentinel cleared).");
Ok(())
}
McpCommand::Setup { ide, list_ides, binary, write, force } => {
cmd_mcp_setup(ide.as_deref(), *list_ides, binary, *write, *force)
}
}
}
fn cmd_mcp_setup(
ide: Option<&str>,
list_ides: bool,
binary: &str,
write: bool,
force: bool,
) -> Result<()> {
use secretenv_mcp::setup::{expand_home, find_profile, render_config, IDE_PROFILES};
if list_ides {
println!("Supported IDEs for `secretenv mcp setup`:\n");
let widest = IDE_PROFILES.iter().map(|p| p.key.len()).max().unwrap_or(0);
for p in IDE_PROFILES {
println!(
" {key:<width$} {name}\n config: {path}\n note: {note}\n",
key = p.key,
width = widest,
name = p.display_name,
path = p.config_path,
note = p.note,
);
}
return Ok(());
}
let ide_key = ide.ok_or_else(|| {
anyhow::anyhow!(
"missing --ide <name>. Run `secretenv mcp setup --list-ides` to see options."
)
})?;
let profile = find_profile(ide_key).ok_or_else(|| {
anyhow::anyhow!(
"unknown IDE `{ide_key}`. Run `secretenv mcp setup --list-ides` to see options.",
)
})?;
let body = render_config(profile, binary);
if !write {
println!("# MCP config block for {} ({}):", profile.display_name, profile.key);
println!("# Target file: {}", profile.config_path);
println!("# Note: {}", profile.note);
println!("# ---");
print!("{body}");
return Ok(());
}
if profile.key == "generic" {
anyhow::bail!(
"`--ide generic` is print-only — it doesn't target a specific config file. \
Re-run without `--write`, then paste the block into the IDE's MCP config \
(compatible with Claude Code, Cursor, Cline, Gemini CLI / Code Assist).",
);
}
if profile.key == "claude-code" {
anyhow::bail!(
"`--ide claude-code` is print-only — it emits the official `claude mcp add` \
shell command rather than overwriting `~/.claude.json` (which carries \
unrelated Claude Code state). Re-run without `--write` and run the \
printed command in your shell.",
);
}
let target = expand_home(profile.config_path)
.with_context(|| format!("expanding home directory for `{}`", profile.config_path))?;
if target.exists() && !force {
anyhow::bail!(
"target file `{}` already exists. Re-run with `--force` to overwrite, \
or paste the block from `secretenv mcp setup --ide {}` into the \
existing file manually.",
target.display(),
profile.key,
);
}
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("creating parent directory `{}` for IDE config", parent.display())
})?;
}
std::fs::write(&target, &body)
.with_context(|| format!("writing MCP config to `{}`", target.display()))?;
eprintln!(
"Wrote {} MCP config for {} ({}).",
body.len(),
profile.display_name,
target.display()
);
Ok(())
}
pub fn resolve_selection(
explicit: Option<&str>,
env_registry: Option<&str>,
config: &Config,
) -> Result<RegistrySelection> {
if let Some(s) = explicit {
return s.parse().context("parsing --registry value");
}
if let Some(env) = env_registry {
if !env.is_empty() {
return env.parse().context("parsing $SECRETENV_REGISTRY");
}
}
if config.registries.contains_key("default") {
return Ok(RegistrySelection::Name("default".to_owned()));
}
Err(anyhow!(
"no registry selected — pass --registry <name-or-uri>, set \
$SECRETENV_REGISTRY, or add a [registries.default] block to config.toml"
))
}
fn resolve_selection_from_env(
explicit: Option<&str>,
config: &Config,
) -> Result<RegistrySelection> {
let env = std::env::var("SECRETENV_REGISTRY").ok();
resolve_selection(explicit, env.as_deref(), config)
}
async fn cmd_run(
args: &RunArgs,
config: &Config,
backends: &BackendRegistry,
) -> Result<crate::reports::RunReport> {
use secretenv_core::{run_with_options, RedactMode, RunOptions};
let starting_dir = std::env::current_dir().context("determining current directory")?;
let manifest = Manifest::load(&starting_dir)
.context("loading secretenv.toml (walked upward from $CWD)")?;
let selection = resolve_selection_from_env(args.registry.as_deref(), config)?;
let mut cache = RegistryCache::new();
let aliases = resolve_registry(config, &selection, backends, &mut cache).await?;
let resolved = resolve_manifest(&manifest, &aliases)?;
let alias_count = resolved.len() as u64;
if args.no_redact {
use std::io::IsTerminal as _;
if io::stderr().is_terminal() {
eprintln!(
"WARNING: --no-redact disables runtime secret filtering. Resolved \
values will appear verbatim in the child's stdout/stderr."
);
eprint!("Type \"yes\" to continue: ");
io::stderr().flush().ok();
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.context("reading --no-redact confirmation from stdin")?;
if input.trim() != "yes" {
bail!("--no-redact aborted by user (did not type \"yes\")");
}
}
}
let redact = if args.no_redact {
RedactMode::ForceExec
} else if args.redact {
RedactMode::ForcePipe
} else {
RedactMode::Auto
};
let options = RunOptions {
dry_run: args.dry_run,
verbose: args.verbose,
redact,
redact_token: args.redact_token.clone(),
};
let dispatch = if args.dry_run {
crate::reports::RunDispatch::DryRun
} else if matches!(redact, RedactMode::ForceExec) {
crate::reports::RunDispatch::Exec
} else {
crate::reports::RunDispatch::PipeRedact
};
run_with_options(&resolved, backends, &args.command, &options).await?;
Ok(crate::reports::RunReport {
alias_count,
dispatch,
outcome: crate::reports::CommandOutcome::Ok,
})
}
async fn cmd_resolve(
args: &ResolveArgs,
config: &Config,
backends: &BackendRegistry,
) -> Result<crate::reports::ResolveReport> {
use secretenv_core::{Manifest, DEFAULT_CHECK_TIMEOUT};
let selection = resolve_selection_from_env(args.registry.as_deref(), config)?;
let mut cache = RegistryCache::new();
let aliases = resolve_registry(config, &selection, backends, &mut cache).await?;
let (target, source) = aliases.get(&args.alias).ok_or_else(|| {
anyhow!(
"alias '{}' not found in registry cascade [{}]",
args.alias,
format_sources(&aliases)
)
})?;
let target = target.clone();
let source = source.clone();
let layer_index = aliases.sources().position(|u| u.raw == source.raw).unwrap_or(0);
let env_var = std::env::current_dir()
.ok()
.and_then(|cwd| Manifest::load(&cwd).ok())
.and_then(|m| manifest_env_var_for_alias(&m, &args.alias));
let backend_check = match backends.get(&target.scheme) {
Some(b) => {
let op_label = format!("{}::check", b.backend_type());
let check_future = async { Ok(b.check().await) };
match secretenv_core::with_timeout(DEFAULT_CHECK_TIMEOUT, &op_label, check_future).await
{
Ok(status) => ResolveBackendCheck::Checked {
backend_type: b.backend_type().to_owned(),
status,
},
Err(err) => ResolveBackendCheck::CheckFailed {
backend_type: b.backend_type().to_owned(),
message: format!("{err:#}"),
},
}
}
None => ResolveBackendCheck::UnregisteredScheme,
};
let report = ResolveOutput {
alias: args.alias.clone(),
env_var,
resolved: target.raw.clone(),
source_uri: source.raw.clone(),
layer_index,
backend_scheme: target.scheme.clone(),
check: backend_check,
};
if args.json {
println!("{}", serde_json::to_string_pretty(&report.to_json())?);
} else {
print!("{}", report.render_human());
}
Ok(crate::reports::ResolveReport {
cascade_layer_index: u32::try_from(report.layer_index).unwrap_or(u32::MAX),
backend_type: report.backend_scheme,
outcome: crate::reports::CommandOutcome::Ok,
})
}
fn manifest_env_var_for_alias(manifest: &secretenv_core::Manifest, alias: &str) -> Option<String> {
for (env_var, decl) in &manifest.secrets {
if let secretenv_core::SecretDecl::Alias { from } = decl {
let Ok(parsed) = secretenv_core::BackendUri::parse(from) else {
continue;
};
if parsed.is_alias() {
let found = parsed.path.trim_start_matches('/');
if found == alias {
return Some(env_var.clone());
}
}
}
}
None
}
enum ResolveBackendCheck {
Checked { backend_type: String, status: secretenv_core::BackendStatus },
CheckFailed { backend_type: String, message: String },
UnregisteredScheme,
}
struct ResolveOutput {
alias: String,
env_var: Option<String>,
resolved: String,
source_uri: String,
layer_index: usize,
backend_scheme: String,
check: ResolveBackendCheck,
}
impl ResolveOutput {
fn render_human(&self) -> String {
use std::fmt::Write as _;
let mut out = String::new();
writeln!(out, "alias: {}", self.alias).ok();
writeln!(out, "env var: {}", self.env_var.as_deref().unwrap_or("(none)")).ok();
writeln!(out, "resolved: {}", self.resolved).ok();
writeln!(out, "source: {} (cascade layer {})", self.source_uri, self.layer_index).ok();
writeln!(out, "backend: {}", self.render_backend_line()).ok();
out
}
fn render_backend_line(&self) -> String {
use secretenv_core::BackendStatus;
match &self.check {
ResolveBackendCheck::Checked { backend_type, status } => {
let (state, detail) = match status {
BackendStatus::Ok { cli_version: _, identity } => {
("authenticated".to_owned(), format!("({identity})"))
}
BackendStatus::NotAuthenticated { hint } => {
("NOT authenticated".to_owned(), format!("(hint: {hint})"))
}
BackendStatus::CliMissing { cli_name, install_hint } => {
(format!("CLI '{cli_name}' missing"), format!("(install: {install_hint})"))
}
BackendStatus::Error { message } => {
("error".to_owned(), format!("({message})"))
}
};
format!("{backend_type} instance '{}' — {state} {detail}", self.backend_scheme)
}
ResolveBackendCheck::CheckFailed { backend_type, message } => {
format!(
"{backend_type} instance '{}' — check failed ({message})",
self.backend_scheme
)
}
ResolveBackendCheck::UnregisteredScheme => {
format!(
"instance '{}' is not registered in config.toml (resolve succeeded; fetch would fail)",
self.backend_scheme
)
}
}
}
fn to_json(&self) -> serde_json::Value {
use secretenv_core::BackendStatus;
let check = match &self.check {
ResolveBackendCheck::Checked { backend_type, status } => {
let (status_key, detail) = match status {
BackendStatus::Ok { cli_version, identity } => (
"ok",
serde_json::json!({
"cli_version": cli_version,
"identity": identity,
}),
),
BackendStatus::NotAuthenticated { hint } => {
("not_authenticated", serde_json::json!({ "hint": hint }))
}
BackendStatus::CliMissing { cli_name, install_hint } => (
"cli_missing",
serde_json::json!({
"cli_name": cli_name,
"install_hint": install_hint,
}),
),
BackendStatus::Error { message } => {
("error", serde_json::json!({ "message": message }))
}
};
serde_json::json!({
"backend_type": backend_type,
"instance": self.backend_scheme,
"status": status_key,
"detail": detail,
})
}
ResolveBackendCheck::CheckFailed { backend_type, message } => serde_json::json!({
"backend_type": backend_type,
"instance": self.backend_scheme,
"status": "check_failed",
"detail": { "message": message },
}),
ResolveBackendCheck::UnregisteredScheme => serde_json::json!({
"instance": self.backend_scheme,
"status": "unregistered_scheme",
"detail": {},
}),
};
serde_json::json!({
"alias": self.alias,
"env_var": self.env_var,
"resolved": self.resolved,
"source": {
"uri": self.source_uri,
"layer": self.layer_index,
},
"backend": check,
})
}
}
async fn cmd_get(
args: &GetArgs,
config: &Config,
backends: &BackendRegistry,
) -> Result<crate::reports::GetReport> {
let selection = resolve_selection_from_env(args.registry.as_deref(), config)?;
let mut cache = RegistryCache::new();
let aliases = resolve_registry(config, &selection, backends, &mut cache).await?;
let target = aliases
.get(&args.alias)
.ok_or_else(|| {
anyhow!(
"alias '{}' not found in registry cascade [{}]",
args.alias,
format_sources(&aliases)
)
})?
.0
.clone();
let confirmed = args.yes || confirm_print_secret(&args.alias)?;
if !confirmed {
bail!("aborted by user");
}
let backend = backends
.get(&target.scheme)
.ok_or_else(|| anyhow!("no backend instance '{}' is configured", target.scheme))?;
let backend_type = backend.backend_type().to_owned();
let value = backend.get(&target).await?;
println!("{}", value.expose_secret());
Ok(crate::reports::GetReport {
backend_type,
confirmed,
outcome: crate::reports::CommandOutcome::Ok,
})
}
#[allow(clippy::too_many_lines)] async fn cmd_redact(
args: &RedactArgs,
config: &Config,
backends: &BackendRegistry,
) -> Result<crate::reports::RedactReport> {
use secretenv_core::redact::{
scrub_file_in_place, Scrubber, SubstitutionToken, TaintedSet, TaintedValue,
};
let selection = resolve_selection_from_env(args.registry.as_deref(), config)?;
let mut cache = RegistryCache::new();
let aliases = resolve_registry(config, &selection, backends, &mut cache).await?;
let alias_names: Vec<String> = if args.alias.is_empty() {
aliases.iter().map(|(name, _target, _source)| name.clone()).collect()
} else {
args.alias.clone()
};
if alias_names.is_empty() {
bail!(
"secretenv redact: no aliases to redact — registry cascade is empty \
({}). Add aliases via `secretenv registry set` first.",
format_sources(&aliases),
);
}
let mut tainted = TaintedSet::new();
for name in &alias_names {
let Some((target, _src)) = aliases.get(name) else {
bail!("alias '{name}' not found in registry cascade [{}]", format_sources(&aliases));
};
let backend = backends
.get(&target.scheme)
.ok_or_else(|| anyhow!("no backend instance '{}' is configured", target.scheme))?;
let value = backend.get(target).await.with_context(|| {
format!("fetching value for alias '{name}' (target='{}')", target.raw)
})?;
tainted.insert(TaintedValue::from_alias(name.clone(), value.expose_secret()));
}
let token = args
.redact_token
.as_ref()
.map_or(SubstitutionToken::AliasAware, |s| SubstitutionToken::Fixed(s.clone()));
let Some(scrubber) = Scrubber::new(&tainted, token)? else {
eprintln!(
"secretenv redact: tainted set is empty after the {}-byte minimum filter; \
nothing to redact.",
secretenv_core::redact::MIN_TAINTED_LEN,
);
return Ok(crate::reports::RedactReport {
mode: crate::reports::RedactMode::Stdout,
match_count: 0,
byte_count: 0,
outcome: crate::reports::CommandOutcome::Ok,
});
};
let path = std::path::Path::new(&args.path);
secretenv_core::redact::refuse_special_paths(path)?;
secretenv_core::redact::refuse_foreign_owner(path, args.allow_foreign_owner)?;
if args.dry_run {
let mut sink = std::io::sink();
let mut reader = secretenv_core::redact::open_no_follow(path).with_context(|| {
format!("redact: opening '{}' with O_NOFOLLOW for dry-run", path.display())
})?;
let rep = scrubber.scrub_reader(&mut reader, &mut sink)?;
eprintln!(
"secretenv redact: would redact {} match(es) totaling {} byte(s) in '{}'",
rep.match_count,
rep.byte_count,
path.display(),
);
return Ok(crate::reports::RedactReport {
mode: crate::reports::RedactMode::DryRun,
match_count: rep.match_count,
byte_count: rep.byte_count,
outcome: crate::reports::CommandOutcome::DryRun,
});
}
if args.in_place {
let rep =
scrub_file_in_place(path, &scrubber, args.backup.as_deref(), args.allow_foreign_owner)?;
eprintln!(
"secretenv redact: rewrote '{}' — {} match(es), {} byte(s) replaced{}",
path.display(),
rep.match_count,
rep.byte_count,
args.backup
.as_deref()
.map_or(String::new(), |s| format!("; backup at '{}{s}'", path.display())),
);
return Ok(crate::reports::RedactReport {
mode: crate::reports::RedactMode::InPlace,
match_count: rep.match_count,
byte_count: rep.byte_count,
outcome: crate::reports::CommandOutcome::Ok,
});
}
let mut reader = secretenv_core::redact::open_no_follow(path)
.with_context(|| format!("redact: opening '{}' with O_NOFOLLOW", path.display()))?;
let mut stdout = io::stdout().lock();
let rep = scrubber.scrub_reader(&mut reader, &mut stdout)?;
drop(stdout);
eprintln!(
"secretenv redact: {} match(es), {} byte(s) replaced in '{}'",
rep.match_count,
rep.byte_count,
path.display(),
);
Ok(crate::reports::RedactReport {
mode: crate::reports::RedactMode::Stdout,
match_count: rep.match_count,
byte_count: rep.byte_count,
outcome: crate::reports::CommandOutcome::Ok,
})
}
fn confirm_print_secret(alias: &str) -> Result<bool> {
eprint!("about to print the secret value for '{alias}' to stdout. continue? [y/N] ");
io::stderr().flush().ok();
let mut input = String::new();
io::stdin().read_line(&mut input).context("reading confirmation from stdin")?;
Ok(matches!(input.trim().to_lowercase().as_str(), "y" | "yes"))
}
async fn cmd_registry(
rc: &RegistryCommand,
config: &Config,
backends: &BackendRegistry,
) -> Result<crate::reports::RegistryReport> {
let (subcommand, aliases_touched) = match rc {
RegistryCommand::List { registry } => {
registry_list(registry.as_deref(), config, backends).await?;
("list", 0)
}
RegistryCommand::Get { alias, registry } => {
registry_get(alias, registry.as_deref(), config, backends).await?;
("get", 1)
}
RegistryCommand::Set { alias, uri, registry } => {
registry_set(alias, uri, registry.as_deref(), config, backends).await?;
("set", 1)
}
RegistryCommand::Unset { alias, registry } => {
registry_unset(alias, registry.as_deref(), config, backends).await?;
("unset", 1)
}
RegistryCommand::History { alias, registry, json } => {
registry_history(alias, registry.as_deref(), *json, config, backends).await?;
("history", 1)
}
RegistryCommand::Invite { registry, invitee, json } => {
registry_invite(registry.as_deref(), invitee.as_deref(), *json, config)?;
("invite", 0)
}
RegistryCommand::Migrate {
alias,
dest_uri,
dry_run,
yes,
from,
delete_source,
json,
registry,
} => {
registry_migrate(
alias,
dest_uri,
*dry_run,
*yes,
from.as_deref(),
*delete_source,
*json,
registry.as_deref(),
config,
backends,
)
.await?;
("migrate", 1)
}
};
Ok(crate::reports::RegistryReport {
subcommand,
aliases_touched,
outcome: crate::reports::CommandOutcome::Ok,
})
}
fn registry_invite(
registry: Option<&str>,
invitee: Option<&str>,
json: bool,
config: &Config,
) -> Result<()> {
let selection = resolve_selection_from_env(registry, config)?;
let invitation = crate::invite::build_invitation(config, &selection, invitee)?;
if json {
println!("{}", crate::invite::render_json(&invitation)?);
} else {
print!("{}", crate::invite::render_human(&invitation));
}
Ok(())
}
async fn registry_list(
registry: Option<&str>,
config: &Config,
backends: &BackendRegistry,
) -> Result<()> {
let selection = resolve_selection_from_env(registry, config)?;
let mut cache = RegistryCache::new();
let aliases = resolve_registry(config, &selection, backends, &mut cache).await?;
let mut entries: Vec<_> =
aliases.iter().map(|(a, target, _source)| (a.clone(), target.raw.clone())).collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
for (alias, uri) in entries {
println!("{alias} = {uri}");
}
Ok(())
}
async fn registry_get(
alias: &str,
registry: Option<&str>,
config: &Config,
backends: &BackendRegistry,
) -> Result<()> {
let selection = resolve_selection_from_env(registry, config)?;
let mut cache = RegistryCache::new();
let aliases = resolve_registry(config, &selection, backends, &mut cache).await?;
let (target, _source) = aliases.get(alias).ok_or_else(|| {
anyhow!("alias '{alias}' not found in registry cascade [{}]", format_sources(&aliases))
})?;
println!("{}", target.raw);
Ok(())
}
async fn registry_set(
alias: &str,
target_uri: &str,
registry: Option<&str>,
config: &Config,
backends: &BackendRegistry,
) -> Result<()> {
let target = BackendUri::parse(target_uri)
.with_context(|| format!("target '{target_uri}' is not a valid URI"))?;
if target.is_alias() {
bail!("target must be a direct backend URI, not a secretenv:// alias");
}
if backends.get(&target.scheme).is_none() {
bail!(
"target '{target_uri}' references backend instance '{}' which is not configured",
target.scheme
);
}
let (source_uri, backend) = pick_registry_source(registry, config, backends)?;
let current = backend
.list(&source_uri)
.await
.with_context(|| format!("reading registry document at '{}'", source_uri.raw))?;
let mut map: BTreeMap<String, String> = current.into_iter().collect();
map.insert(alias.to_owned(), target_uri.to_owned());
let serialized = secretenv_core::serialize_registry_doc(backend.registry_format(), &map)?;
backend
.set(&source_uri, &serialized)
.await
.with_context(|| format!("writing updated registry document to '{}'", source_uri.raw))?;
eprintln!("set {alias} → {target_uri} in registry at '{}'", source_uri.raw);
Ok(())
}
#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
async fn registry_migrate(
alias: &str,
dest_uri: &str,
dry_run: bool,
yes: bool,
from: Option<&str>,
delete_source: bool,
json: bool,
registry: Option<&str>,
config: &Config,
backends: &BackendRegistry,
) -> Result<()> {
let args = secretenv_migrate::MigrateArgs {
alias: alias.to_owned(),
dest_uri: dest_uri.to_owned(),
source_uri: from.map(str::to_owned),
registry: registry.map(str::to_owned),
dry_run,
delete_source,
};
let plan = secretenv_migrate::build_migration_plan(&args, config, backends).await?;
if !dry_run && !yes {
eprintln!("About to migrate {}:", plan.alias);
eprintln!(" from: {}", plan.source_uri.raw);
eprintln!(" to: {}", plan.dest_uri.raw);
eprintln!();
eprintln!(
"This will read the current value from the source and write it to the destination."
);
eprintln!("The registry pointer will be updated on success.");
if delete_source {
eprintln!("The source value WILL be deleted after a separate confirmation.");
} else {
eprintln!("The source value will NOT be deleted.");
}
if !prompt_yes_no("\nContinue? [y/N] ")? {
eprintln!("aborted; no changes made.");
return Ok(());
}
}
let post_commit_consent = |plan: &secretenv_migrate::MigrationPlan| -> bool {
if dry_run {
return false;
}
eprintln!();
eprintln!(" 4/4 About to permanently delete {}.", plan.source_uri.raw);
eprintln!(" This cannot be undone.");
prompt_yes_no(" Continue? [y/N] ").unwrap_or_default()
};
let result =
secretenv_migrate::migrate_with_plan(plan, &args, backends, post_commit_consent).await;
let report = match result {
Ok(r) => r,
Err(e) => {
if let Some(flip) = e.downcast_ref::<secretenv_migrate::PointerFlipFailed>() {
eprintln!("Error: migration partially failed.");
eprintln!();
eprintln!(" Step 1/3 Read from source: OK");
eprintln!(" Step 2/3 Write to destination: OK");
eprintln!(" Step 3/3 Registry pointer update: FAILED");
eprintln!();
eprintln!(
"IMPORTANT: The value has been written to {dest}.",
dest = flip.dest_uri_raw
);
eprintln!(" The registry still points at the original source.");
eprintln!(" The value now exists in TWO backends.");
eprintln!();
eprintln!("To complete the migration:");
eprintln!(
" secretenv registry set {alias} {dest}",
alias = flip.alias,
dest = flip.dest_uri_raw
);
eprintln!();
eprintln!("To roll back (delete from destination):");
eprintln!(" {}", flip.dest_delete_hint);
}
return Err(e);
}
};
if json {
println!("{}", render_migrate_json(&report)?);
} else {
print_migrate_human(&report);
}
Ok(())
}
fn prompt_yes_no(prompt: &str) -> Result<bool> {
use std::io::{BufRead, Write};
eprint!("{prompt}");
std::io::stderr().flush().ok();
let stdin = std::io::stdin();
let mut line = String::new();
let n = stdin.lock().read_line(&mut line).context("reading stdin for confirmation")?;
if n == 0 {
return Ok(false);
}
Ok(line.trim().starts_with(['y', 'Y']))
}
fn print_migrate_human(report: &secretenv_migrate::MigrateReport) {
use secretenv_migrate::MigrateReportOutcome;
match report.outcome {
MigrateReportOutcome::DryRun => {
eprintln!("secretenv migrate (dry-run):");
eprintln!(" alias: {}", report.alias);
eprintln!(" source type: {}", report.source_backend_type);
eprintln!(" dest type: {}", report.dest_backend_type);
eprintln!("\nProbes:");
for (instance, result) in &report.probe_results {
eprintln!(" {instance:20} {result}");
}
eprintln!("\nDry-run complete. No changes made. Remove --dry-run to execute.");
}
MigrateReportOutcome::Success => {
eprintln!("Migration complete.");
eprintln!(" alias: {}", report.alias);
eprintln!(
" probe / read / write / flip ms: {} / {} / {} / {}",
report.phase_durations.probe_ms,
report.phase_durations.read_ms,
report.phase_durations.write_ms,
report.phase_durations.pointer_flip_ms,
);
if let Some(ms) = report.phase_durations.source_delete_ms {
eprintln!(" source-delete ms: {ms}");
eprintln!(" source value deleted.");
} else if let Some(hint) = &report.delete_hint {
eprintln!(" source value still present. To remove it:");
eprintln!(" {hint}");
}
}
MigrateReportOutcome::SourceDeleteFailedPostCommit => {
eprintln!("Migration committed but source-delete failed.");
eprintln!(" alias: {}", report.alias);
if let Some(hint) = &report.delete_hint {
eprintln!(" Cleanup the source manually:");
eprintln!(" {hint}");
}
}
MigrateReportOutcome::PartialFailurePointerFlip => {
eprintln!("Migration partially failed.");
}
_ => eprintln!("Migration completed with an unknown outcome variant — upgrade secretenv to render details."),
}
}
fn render_migrate_json(report: &secretenv_migrate::MigrateReport) -> Result<String> {
use secretenv_migrate::MigrateReportOutcome;
let outcome = match report.outcome {
MigrateReportOutcome::Success => "success",
MigrateReportOutcome::DryRun => "dry-run",
MigrateReportOutcome::SourceDeleteFailedPostCommit => "source-delete-failed-post-commit",
MigrateReportOutcome::PartialFailurePointerFlip => "partial-failure-pointer-flip",
_ => "unknown",
};
let mut durations = serde_json::json!({
"probe_ms": report.phase_durations.probe_ms,
"read_ms": report.phase_durations.read_ms,
"write_ms": report.phase_durations.write_ms,
"pointer_flip_ms": report.phase_durations.pointer_flip_ms,
});
if let Some(ms) = report.phase_durations.source_delete_ms {
durations["source_delete_ms"] = serde_json::json!(ms);
}
let probe_results: Vec<serde_json::Value> = report
.probe_results
.iter()
.map(|(instance, status)| {
serde_json::json!({
"instance": instance,
"status": status,
})
})
.collect();
let value = serde_json::json!({
"alias": report.alias,
"source_backend_type": report.source_backend_type,
"dest_backend_type": report.dest_backend_type,
"outcome": outcome,
"phase_durations_ms": durations,
"delete_source": report.delete_source,
"probe_results": probe_results,
"transaction_id": report.transaction_id,
});
Ok(serde_json::to_string_pretty(&value)?)
}
async fn registry_history(
alias: &str,
registry: Option<&str>,
json: bool,
config: &Config,
backends: &BackendRegistry,
) -> Result<()> {
let selection = resolve_selection_from_env(registry, config)?;
let mut cache = RegistryCache::new();
let aliases = resolve_registry(config, &selection, backends, &mut cache).await?;
let (target, _source) = aliases.get(alias).ok_or_else(|| {
anyhow!("alias '{alias}' not found in registry cascade [{}]", format_sources(&aliases))
})?;
let target = target.clone();
let backend = backends
.get(&target.scheme)
.ok_or_else(|| anyhow!("no backend instance '{}' is configured", target.scheme))?;
let entries = backend.history(&target).await?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&render_history_json(alias, &target.raw, &entries))?
);
} else {
print!("{}", render_history_human(alias, &target.raw, &entries));
}
Ok(())
}
#[allow(clippy::write_literal)] fn render_history_human(alias: &str, uri: &str, entries: &[HistoryEntry]) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(out, "alias: {alias}");
let _ = writeln!(out, "resolved: {uri}");
let _ = writeln!(out);
if entries.is_empty() {
let _ = writeln!(out, "(no versions reported by the backend)");
return out;
}
let v_w = entries.iter().map(|e| e.version.len()).max().unwrap_or(7).max(7);
let t_w = entries.iter().map(|e| e.timestamp.len()).max().unwrap_or(20).max(20);
let a_w =
entries.iter().map(|e| e.actor.as_deref().unwrap_or("-").len()).max().unwrap_or(6).max(6);
let _ = writeln!(
out,
"{:<v_w$} {:<t_w$} {:<a_w$} {}",
"VERSION",
"TIMESTAMP",
"ACTOR",
"DESCRIPTION",
v_w = v_w,
t_w = t_w,
a_w = a_w
);
for e in entries {
let _ = writeln!(
out,
"{:<v_w$} {:<t_w$} {:<a_w$} {}",
e.version,
e.timestamp,
e.actor.as_deref().unwrap_or("-"),
e.description.as_deref().unwrap_or(""),
v_w = v_w,
t_w = t_w,
a_w = a_w
);
}
out
}
fn render_history_json(alias: &str, uri: &str, entries: &[HistoryEntry]) -> serde_json::Value {
serde_json::json!({
"alias": alias,
"resolved": uri,
"versions": entries
.iter()
.map(|e| serde_json::json!({
"version": e.version,
"timestamp": e.timestamp,
"actor": e.actor,
"description": e.description,
}))
.collect::<Vec<_>>(),
})
}
async fn registry_unset(
alias: &str,
registry: Option<&str>,
config: &Config,
backends: &BackendRegistry,
) -> Result<()> {
let (source_uri, backend) = pick_registry_source(registry, config, backends)?;
let current = backend
.list(&source_uri)
.await
.with_context(|| format!("reading registry document at '{}'", source_uri.raw))?;
let mut map: BTreeMap<String, String> = current.into_iter().collect();
if map.remove(alias).is_none() {
bail!("alias '{alias}' not found in registry at '{}'", source_uri.raw);
}
let serialized = secretenv_core::serialize_registry_doc(backend.registry_format(), &map)?;
backend
.set(&source_uri, &serialized)
.await
.with_context(|| format!("writing updated registry document to '{}'", source_uri.raw))?;
eprintln!("unset {alias} in registry at '{}'", source_uri.raw);
Ok(())
}
fn pick_registry_source<'a>(
registry: Option<&str>,
config: &Config,
backends: &'a BackendRegistry,
) -> Result<(BackendUri, &'a dyn Backend)> {
let selection = resolve_selection_from_env(registry, config)?;
let source_uri: BackendUri = match selection {
RegistrySelection::Uri(u) => u,
RegistrySelection::Name(name) => {
let reg = config
.registries
.get(&name)
.ok_or_else(|| anyhow!("no registry named '{name}' in config.toml"))?;
let first =
reg.sources.first().ok_or_else(|| anyhow!("registry '{name}' has no sources"))?;
BackendUri::parse(first).with_context(|| {
format!("registry '{name}' sources[0] = '{first}' is not a valid URI")
})?
}
};
let backend = backends.get(&source_uri.scheme).ok_or_else(|| {
anyhow!(
"registry source '{}' targets backend '{}' which is not configured",
source_uri.raw,
source_uri.scheme
)
})?;
Ok((source_uri, backend))
}
fn format_sources(aliases: &secretenv_core::AliasMap) -> String {
aliases.sources().map(|u| u.raw.as_str()).collect::<Vec<_>>().join(", ")
}
async fn cmd_setup(
args: &SetupArgs,
target_config: Option<&std::path::Path>,
) -> Result<crate::reports::SetupReport> {
let opts = crate::setup::SetupOpts {
registry_uri: args.registry_uri.clone(),
region: args.region.clone(),
profile: args.profile.clone(),
account: args.account.clone(),
vault_address: args.vault_address.clone(),
vault_namespace: args.vault_namespace.clone(),
gcp_project: args.gcp_project.clone(),
gcp_impersonate_service_account: args.gcp_impersonate_service_account.clone(),
azure_vault_url: args.azure_vault_url.clone(),
azure_tenant: args.azure_tenant.clone(),
azure_subscription: args.azure_subscription.clone(),
force: args.force,
skip_doctor: args.skip_doctor,
target: target_config.map(std::path::Path::to_path_buf),
};
crate::setup::run_setup(&opts).await?;
Ok(crate::reports::SetupReport {
force: args.force,
outcome: crate::reports::CommandOutcome::Ok,
})
}
async fn cmd_profile(
pc: &ProfileCommand,
target_config: Option<&std::path::Path>,
) -> Result<crate::reports::ProfileReport> {
let config_path: std::path::PathBuf = match target_config {
Some(p) => p.to_path_buf(),
None => secretenv_core::default_config_path_xdg()?,
};
let opts = crate::profile::ProfileOpts {
profiles_dir: secretenv_core::profiles_dir_for(&config_path),
};
let subcommand = match pc {
ProfileCommand::Install { name, url } => {
crate::profile::install(name, url.as_deref(), &opts).await?;
"install"
}
ProfileCommand::List { json } => {
let installed = crate::profile::list(&opts)?;
render_profile_list(&installed, *json)?;
"list"
}
ProfileCommand::Update { name } => {
if let Some(n) = name {
let outcome = crate::profile::update_one(n, &opts).await?;
match outcome {
crate::profile::UpdateOutcome::UpToDate => {
eprintln!("Profile '{n}' is already up to date.");
}
crate::profile::UpdateOutcome::Refreshed => {
eprintln!("Profile '{n}' refreshed.");
}
}
} else {
let reports = crate::profile::update_all(&opts).await?;
render_profile_update_reports(&reports)?;
}
"update"
}
ProfileCommand::Uninstall { name } => {
crate::profile::uninstall(name, &opts)?;
"uninstall"
}
};
Ok(crate::reports::ProfileReport { subcommand, outcome: crate::reports::CommandOutcome::Ok })
}
fn render_profile_list(installed: &[crate::profile::InstalledProfile], json: bool) -> Result<()> {
if json {
let json =
serde_json::to_string_pretty(&installed).context("serializing profile list to JSON")?;
println!("{json}");
return Ok(());
}
if installed.is_empty() {
println!("No profiles installed.");
return Ok(());
}
println!("{:<24} {:<20} SOURCE", "NAME", "INSTALLED");
for p in installed {
println!("{:<24} {:<20} {}", p.name, p.installed_at, p.source_url);
}
Ok(())
}
fn render_profile_update_reports(reports: &[crate::profile::UpdateReport]) -> Result<()> {
if reports.is_empty() {
println!("No profiles installed.");
return Ok(());
}
let mut had_error = false;
for r in reports {
match &r.outcome {
Ok(crate::profile::UpdateOutcome::UpToDate) => {
println!("{:<24} up to date", r.name);
}
Ok(crate::profile::UpdateOutcome::Refreshed) => {
println!("{:<24} refreshed", r.name);
}
Err(e) => {
had_error = true;
println!("{:<24} ERROR: {e:#}", r.name);
}
}
}
if had_error {
anyhow::bail!("one or more profile updates failed");
}
Ok(())
}
fn cmd_completions(args: &CompletionsArgs) -> Result<crate::reports::CompletionsReport> {
use std::io::IsTerminal as _;
let mut cmd = Cli::command();
let bin = "secretenv";
let mut buf: Vec<u8> = Vec::new();
match args.shell {
Shell::Bash => {
clap_complete::generate(clap_complete::shells::Bash, &mut cmd, bin, &mut buf);
}
Shell::Zsh => {
clap_complete::generate(clap_complete::shells::Zsh, &mut cmd, bin, &mut buf);
}
Shell::Fish => {
clap_complete::generate(clap_complete::shells::Fish, &mut cmd, bin, &mut buf);
}
}
if let Some(path) = &args.output {
std::fs::write(path, &buf)
.with_context(|| format!("writing completion script to '{}'", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o644)).with_context(
|| format!("chmod 0o644 on completion script '{}'", path.display()),
)?;
}
eprintln!("wrote {} completion script to '{}'", args.shell.name(), path.display());
} else {
std::io::Write::write_all(&mut std::io::stdout(), &buf)
.context("writing completion script to stdout")?;
if std::io::stdout().is_terminal() {
eprintln!();
eprintln!("{}", args.shell.install_hint());
}
}
Ok(crate::reports::CompletionsReport { outcome: crate::reports::CommandOutcome::Ok })
}
impl Shell {
const fn name(self) -> &'static str {
match self {
Self::Bash => "bash",
Self::Zsh => "zsh",
Self::Fish => "fish",
}
}
const fn install_hint(self) -> &'static str {
match self {
Self::Bash => {
"# install: add to ~/.bashrc (or /etc/bash_completion.d/):\n\
# source <(secretenv completions bash)"
}
Self::Zsh => {
"# install (replace PATH with a directory in your fpath):\n\
# secretenv completions zsh > \"$HOME/.zsh/completions/_secretenv\"\n\
# then ensure your ~/.zshrc has:\n\
# fpath=(~/.zsh/completions $fpath)\n\
# autoload -U compinit && compinit"
}
Self::Fish => {
"# install:\n\
# secretenv completions fish > \"$HOME/.config/fish/completions/secretenv.fish\""
}
}
}
}
const _: fn() = || {
let _ = <RegistrySelection as FromStr>::from_str;
};
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use std::collections::HashMap;
use secretenv_core::{BackendConfig, RegistryConfig};
use super::*;
fn config_with_default() -> Config {
Config {
registries: HashMap::from([(
"default".to_owned(),
RegistryConfig { sources: vec!["local:///tmp/r.toml".to_owned()] },
)]),
backends: HashMap::from([(
"local".to_owned(),
BackendConfig { backend_type: "local".into(), raw_fields: HashMap::new() },
)]),
mcp: None,
}
}
#[test]
fn selection_prefers_explicit_flag() {
let cfg = config_with_default();
let sel = resolve_selection(Some("prod"), None, &cfg).unwrap();
match sel {
RegistrySelection::Name(n) => assert_eq!(n, "prod"),
RegistrySelection::Uri(_) => panic!("expected Name"),
}
}
#[test]
fn selection_uses_env_when_flag_absent() {
let cfg = config_with_default();
let sel = resolve_selection(None, Some("shared"), &cfg).unwrap();
match sel {
RegistrySelection::Name(n) => assert_eq!(n, "shared"),
RegistrySelection::Uri(_) => panic!("expected Name"),
}
}
#[test]
fn selection_falls_back_to_default_when_no_flag_or_env() {
let cfg = config_with_default();
let sel = resolve_selection(None, None, &cfg).unwrap();
match sel {
RegistrySelection::Name(n) => assert_eq!(n, "default"),
RegistrySelection::Uri(_) => panic!("expected Name"),
}
}
#[test]
fn selection_errors_when_nothing_configured() {
let cfg = Config::default();
let err = resolve_selection(None, None, &cfg).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("no registry selected"), "clear error: {msg}");
}
#[test]
fn selection_interprets_triple_slash_as_uri() {
let cfg = Config::default();
let sel = resolve_selection(Some("local:///tmp/r.toml"), None, &cfg).unwrap();
match sel {
RegistrySelection::Uri(u) => assert_eq!(u.scheme, "local"),
RegistrySelection::Name(_) => panic!("expected Uri"),
}
}
#[test]
fn selection_treats_empty_env_as_absent() {
let cfg = config_with_default();
let sel = resolve_selection(None, Some(""), &cfg).unwrap();
match sel {
RegistrySelection::Name(n) => assert_eq!(n, "default"),
RegistrySelection::Uri(_) => panic!("expected Name"),
}
}
}