use anyhow::Result;
use colored::Colorize;
use tsafe_core::pullconfig::{self, PullSource};
#[cfg(feature = "cloud-pull-aws")]
use crate::cmd_aws_pull::cmd_aws_pull_ns;
#[cfg(feature = "cloud-pull-bitwarden")]
use crate::cmd_bitwarden_pull::cmd_bitwarden_pull_ns;
#[cfg(feature = "cloud-pull-gcp")]
use crate::cmd_gcp_pull::cmd_gcp_pull_ns;
#[cfg(feature = "cloud-pull-keepass")]
use crate::cmd_keepass_pull::cmd_keepass_pull_ns;
use crate::cmd_kv_pull::cmd_kv_pull_ns;
#[cfg(feature = "cloud-pull-aws")]
use crate::cmd_ssm_pull::cmd_ssm_pull_ns;
#[cfg(feature = "cloud-pull-1password")]
use crate::cmd_vault_pull::cmd_op_pull_ns;
#[cfg(feature = "cloud-pull-vault")]
use crate::cmd_vault_pull::cmd_vault_pull_full;
use crate::cli::PullOnError;
fn unsupported_source_name(source: &PullSource) -> Option<&'static str> {
match source {
PullSource::AzureKeyVault { .. } => None,
PullSource::HashiCorpVault { .. } => {
if cfg!(feature = "cloud-pull-vault") {
None
} else {
Some("HashiCorp Vault")
}
}
PullSource::OnePassword { .. } => {
if cfg!(feature = "cloud-pull-1password") {
None
} else {
Some("1Password")
}
}
PullSource::Aws { .. } => {
if cfg!(feature = "cloud-pull-aws") {
None
} else {
Some("AWS Secrets Manager")
}
}
PullSource::Gcp { .. } => {
if cfg!(feature = "cloud-pull-gcp") {
None
} else {
Some("GCP Secret Manager")
}
}
PullSource::SsmParameterStore { .. } => {
if cfg!(feature = "cloud-pull-aws") {
None
} else {
Some("AWS SSM Parameter Store")
}
}
PullSource::Keepass { .. } => {
if cfg!(feature = "cloud-pull-keepass") {
None
} else {
Some("KeePass")
}
}
PullSource::Bitwarden { .. } => {
if cfg!(feature = "cloud-pull-bitwarden") {
None
} else {
Some("Bitwarden")
}
}
}
}
fn unsupported_source_error(source: &PullSource) -> anyhow::Error {
let source_name = unsupported_source_name(source).unwrap_or("unknown source");
anyhow::anyhow!(
"{source_name} is not available in this tsafe build\n\
\n Core-only builds include Azure Key Vault via `tsafe kv-pull`.\n\
\n Fix: remove this source from the pull config or install a build with the required non-core pull feature.\n\
\n Check compiled capabilities: tsafe build-info"
)
}
fn source_display_label(source: &PullSource, index: usize, total: usize) -> String {
match source.name() {
Some(n) => format!("[{n}]"),
None => format!("[{}/{}]", index + 1, total),
}
}
#[tracing::instrument(skip_all, fields(source_count = sources.len(), dry_run = true))]
fn print_dry_run(cfg_path: &std::path::Path, sources: &[&PullSource]) {
println!("Dry run — no live API calls will be made, no secrets will be written.");
println!("Config: {}", cfg_path.display());
println!();
if sources.is_empty() {
println!(" (no sources match the current filter)");
return;
}
println!("Sources that would be invoked (in manifest order):");
for (i, source) in sources.iter().enumerate() {
let label = match source.name() {
Some(n) => format!("name={n}"),
None => format!("index={}", i + 1),
};
let ns_note = match source.ns() {
Some(ns) => format!(" ns={ns} (keys stored as {ns}.<KEY>)"),
None => String::new(),
};
match source {
PullSource::AzureKeyVault {
vault_url, prefix, ..
} => {
let prefix_note = prefix
.as_deref()
.map(|p| format!(" prefix={p}"))
.unwrap_or_default();
println!(" {i}. [{label}] akv: {vault_url}{prefix_note}{ns_note}");
}
PullSource::HashiCorpVault {
addr,
mount,
prefix,
..
} => {
let prefix_note = prefix
.as_deref()
.map(|p| format!(" prefix={p}"))
.unwrap_or_default();
println!(" {i}. [{label}] hcp: {addr} mount={mount}{prefix_note}{ns_note}");
}
PullSource::OnePassword { item, op_vault, .. } => {
let vault_note = op_vault
.as_deref()
.map(|v| format!(" vault={v}"))
.unwrap_or_default();
println!(" {i}. [{label}] op: item={item}{vault_note}{ns_note}");
}
PullSource::Aws { region, prefix, .. } => {
let region_str = region.as_deref().unwrap_or("(from env)");
let prefix_note = prefix
.as_deref()
.map(|p| format!(" prefix={p}"))
.unwrap_or_default();
println!(" {i}. [{label}] aws: region={region_str}{prefix_note}{ns_note}");
}
PullSource::SsmParameterStore { region, path, .. } => {
let region_str = region.as_deref().unwrap_or("(from env)");
let path_str = path.as_deref().unwrap_or("/");
println!(" {i}. [{label}] ssm: region={region_str} path={path_str}{ns_note}");
}
PullSource::Gcp {
project, prefix, ..
} => {
let project_str = project.as_deref().unwrap_or("(from env)");
let prefix_note = prefix
.as_deref()
.map(|p| format!(" prefix={p}"))
.unwrap_or_default();
println!(" {i}. [{label}] gcp: project={project_str}{prefix_note}{ns_note}");
}
PullSource::Keepass { path, group, .. } => {
let group_note = group
.as_deref()
.map(|g| format!(" group={g}"))
.unwrap_or_default();
println!(" {i}. [{label}] kp: {path}{group_note}{ns_note}");
}
PullSource::Bitwarden {
api_url, folder, ..
} => {
let api_str = api_url.as_deref().unwrap_or("https://api.bitwarden.com");
let folder_note = folder
.as_deref()
.map(|f| format!(" folder={f}"))
.unwrap_or_default();
println!(" {i}. [{label}] bw: {api_str}{folder_note}{ns_note}");
}
}
}
println!();
println!(
"Note: collision detection requires a live run. Run without --dry-run to detect key conflicts."
);
}
#[tracing::instrument(skip_all, fields(source_count, dry_run))]
pub(crate) fn cmd_pull(
profile: &str,
config_path: Option<&str>,
global_overwrite: bool,
on_error: PullOnError,
dry_run: bool,
source_filter: &[String],
) -> Result<()> {
let cfg_path = match config_path {
Some(p) => std::path::PathBuf::from(p),
None => {
let cwd = std::env::current_dir()?;
pullconfig::find_config(&cwd).ok_or_else(|| {
anyhow::anyhow!(
"no .tsafe.yml / .tsafe.json found (searched upward from {})",
cwd.display()
)
})?
}
};
let cfg = pullconfig::load(&cfg_path)?;
let filtered_sources: Vec<&PullSource> = if source_filter.is_empty() {
cfg.pulls.iter().collect()
} else {
cfg.pulls
.iter()
.filter(|s| {
s.name()
.map(|n| source_filter.iter().any(|f| f == n))
.unwrap_or(false)
})
.collect()
};
tracing::Span::current().record("source_count", filtered_sources.len());
tracing::Span::current().record("dry_run", dry_run);
if dry_run {
print_dry_run(&cfg_path, &filtered_sources);
return Ok(());
}
println!("Using config: {}", cfg_path.display());
if !source_filter.is_empty() {
println!("Source filter active: {}", source_filter.join(", "));
}
let total_sources = filtered_sources.len();
let mut successes = 0usize;
let mut failures = 0usize;
for (i, source) in filtered_sources.iter().enumerate() {
let source_label = source_display_label(source, i, total_sources);
if unsupported_source_name(source).is_some() {
let err = unsupported_source_error(source);
failures += 1;
match on_error {
PullOnError::FailAll => return Err(err),
PullOnError::SkipFailed | PullOnError::WarnOnly => {
eprintln!("{} Source {source_label} skipped: {err}", "!".yellow(),);
continue;
}
}
}
let ns = source.ns();
let run_result = match source {
PullSource::AzureKeyVault {
vault_url,
prefix,
overwrite,
..
} => {
let ow = global_overwrite || *overwrite;
println!("\n{source_label} Azure Key Vault: {vault_url}");
if let Some(ns) = ns {
println!(" namespace: {ns}");
}
std::env::set_var("TSAFE_AKV_URL", vault_url);
cmd_kv_pull_ns(profile, prefix.as_deref(), ow, on_error, ns)
}
#[cfg(feature = "cloud-pull-vault")]
PullSource::HashiCorpVault {
addr,
mount,
prefix,
overwrite,
auth,
vault_namespace,
..
} => {
let ow = global_overwrite || *overwrite;
println!("\n{source_label} HashiCorp Vault: {addr}");
if let Some(ns) = ns {
println!(" namespace: {ns}");
}
if let Some(vns) = vault_namespace.as_deref() {
println!(" vault-namespace: {vns}");
}
let expanded_auth = auth.as_ref().map(|a| a.clone().expand_env_vars());
cmd_vault_pull_full(
profile,
Some(addr.as_str()),
None,
Some(mount.as_str()),
prefix.as_deref(),
ow,
ns,
expanded_auth.as_ref(),
vault_namespace.as_deref(),
)
}
#[cfg(feature = "cloud-pull-1password")]
PullSource::OnePassword {
item,
op_vault,
overwrite,
..
} => {
let ow = global_overwrite || *overwrite;
println!("\n{source_label} 1Password: {item}");
if let Some(ns) = ns {
println!(" namespace: {ns}");
}
cmd_op_pull_ns(profile, item, op_vault.as_deref(), ow, ns)
}
#[cfg(feature = "cloud-pull-aws")]
PullSource::Aws {
region,
prefix,
overwrite,
..
} => {
let ow = global_overwrite || *overwrite;
let region_str = region.as_deref().unwrap_or("(from env)");
println!("\n{source_label} AWS Secrets Manager: {region_str}");
if let Some(ns) = ns {
println!(" namespace: {ns}");
}
cmd_aws_pull_ns(
profile,
region.as_deref(),
prefix.as_deref(),
ow,
on_error,
ns,
)
}
#[cfg(feature = "cloud-pull-gcp")]
PullSource::Gcp {
project,
prefix,
overwrite,
..
} => {
let ow = global_overwrite || *overwrite;
let project_str = project.as_deref().unwrap_or("(from env)");
println!("\n{source_label} GCP Secret Manager: {project_str}");
if let Some(ns) = ns {
println!(" namespace: {ns}");
}
cmd_gcp_pull_ns(
profile,
project.as_deref(),
prefix.as_deref(),
ow,
on_error,
ns,
)
}
#[cfg(feature = "cloud-pull-aws")]
PullSource::SsmParameterStore {
region,
path,
overwrite,
..
} => {
let ow = global_overwrite || *overwrite;
let region_str = region.as_deref().unwrap_or("(from env)");
let path_str = path.as_deref().unwrap_or("/");
println!("\n{source_label} AWS SSM Parameter Store: {region_str} path={path_str}");
if let Some(ns) = ns {
println!(" namespace: {ns}");
}
cmd_ssm_pull_ns(
profile,
region.as_deref(),
path.as_deref(),
ow,
on_error,
ns,
)
}
#[cfg(feature = "cloud-pull-keepass")]
PullSource::Keepass { overwrite, .. } => {
let ow = global_overwrite || *overwrite;
let path = match source {
PullSource::Keepass { path, .. } => path.as_str(),
_ => unreachable!(),
};
println!("\n{source_label} KeePass: {path}");
if let Some(ns) = ns {
println!(" namespace: {ns}");
}
cmd_keepass_pull_ns(profile, source, ow, on_error, ns)
}
#[cfg(feature = "cloud-pull-bitwarden")]
PullSource::Bitwarden {
api_url,
identity_url,
client_id,
client_secret,
folder,
password_env,
overwrite,
..
} => {
let ow = global_overwrite || *overwrite;
let api_str = api_url.as_deref().unwrap_or("https://api.bitwarden.com");
println!("\n{source_label} Bitwarden: {api_str}");
if let Some(ns) = ns {
println!(" namespace: {ns}");
}
cmd_bitwarden_pull_ns(
profile,
api_url.as_deref(),
identity_url.as_deref(),
client_id.as_deref(),
client_secret.as_deref(),
folder.as_deref(),
password_env.as_deref(),
ow,
on_error,
ns,
)
}
#[allow(unreachable_patterns)]
_ => Err(unsupported_source_error(source)),
};
if let Err(err) = run_result {
failures += 1;
match on_error {
PullOnError::FailAll => return Err(err),
PullOnError::SkipFailed | PullOnError::WarnOnly => {
eprintln!("{} Source {source_label} failed: {err}", "!".yellow(),);
}
}
} else {
successes += 1;
println!("{} Source {source_label} succeeded", "✓".green(),);
}
}
println!(
"\n{} Pull complete ({} sources)",
"✓".green(),
total_sources
);
println!(
"{} source summary: {} succeeded, {} failed",
"•".cyan(),
successes,
failures
);
if failures > 0 {
eprintln!(
"{} {} source(s) failed (mode: {:?})",
"!".yellow(),
failures,
on_error
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reports_only_sources_missing_from_this_build() {
let cases = [
(
PullSource::AzureKeyVault {
name: None,
ns: None,
vault_url: "https://example.vault.azure.net".into(),
prefix: None,
overwrite: false,
},
None,
),
(
PullSource::HashiCorpVault {
name: None,
ns: None,
addr: "https://vault.example.com".into(),
mount: "secret".into(),
prefix: None,
overwrite: false,
auth: None,
vault_namespace: None,
},
if cfg!(feature = "cloud-pull-vault") {
None
} else {
Some("HashiCorp Vault")
},
),
(
PullSource::OnePassword {
name: None,
ns: None,
item: "Database Credentials".into(),
op_vault: None,
overwrite: false,
},
if cfg!(feature = "cloud-pull-1password") {
None
} else {
Some("1Password")
},
),
(
PullSource::Aws {
name: None,
ns: None,
region: Some("us-east-1".into()),
prefix: None,
overwrite: false,
},
if cfg!(feature = "cloud-pull-aws") {
None
} else {
Some("AWS Secrets Manager")
},
),
(
PullSource::Gcp {
name: None,
ns: None,
project: Some("demo-project".into()),
prefix: None,
overwrite: false,
},
if cfg!(feature = "cloud-pull-gcp") {
None
} else {
Some("GCP Secret Manager")
},
),
(
PullSource::SsmParameterStore {
name: None,
ns: None,
region: Some("us-east-1".into()),
path: Some("/demo/".into()),
overwrite: false,
},
if cfg!(feature = "cloud-pull-aws") {
None
} else {
Some("AWS SSM Parameter Store")
},
),
(
PullSource::Keepass {
name: None,
ns: None,
path: "/tmp/test.kdbx".into(),
password_env: Some("TSAFE_KP_PASSWORD".into()),
keyfile_path: None,
group: None,
recursive: None,
overwrite: false,
},
if cfg!(feature = "cloud-pull-keepass") {
None
} else {
Some("KeePass")
},
),
(
PullSource::Bitwarden {
name: None,
ns: None,
api_url: None,
identity_url: None,
client_id: Some("org.testid".into()),
client_secret: Some("testsecret".into()),
folder: None,
password_env: None,
overwrite: false,
},
if cfg!(feature = "cloud-pull-bitwarden") {
None
} else {
Some("Bitwarden")
},
),
];
for (source, expected) in cases {
assert_eq!(unsupported_source_name(&source), expected);
}
}
#[test]
fn source_filter_selects_named_sources_only() {
let sources = vec![
PullSource::AzureKeyVault {
name: Some("prod-akv".into()),
ns: None,
vault_url: "https://prod.vault.azure.net".into(),
prefix: None,
overwrite: false,
},
PullSource::AzureKeyVault {
name: Some("staging-akv".into()),
ns: None,
vault_url: "https://staging.vault.azure.net".into(),
prefix: None,
overwrite: false,
},
PullSource::AzureKeyVault {
name: None,
ns: None,
vault_url: "https://anon.vault.azure.net".into(),
prefix: None,
overwrite: false,
},
];
let filter = ["prod-akv".to_string()];
let filtered: Vec<&PullSource> = sources
.iter()
.filter(|s| {
s.name()
.map(|n| filter.iter().any(|f| f == n))
.unwrap_or(false)
})
.collect();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name(), Some("prod-akv"));
}
#[test]
fn empty_source_filter_includes_all_sources() {
let sources = vec![
PullSource::AzureKeyVault {
name: Some("a".into()),
ns: None,
vault_url: "https://a.vault.azure.net".into(),
prefix: None,
overwrite: false,
},
PullSource::AzureKeyVault {
name: None,
ns: None,
vault_url: "https://b.vault.azure.net".into(),
prefix: None,
overwrite: false,
},
];
let filter: Vec<String> = vec![];
let filtered: Vec<&PullSource> = if filter.is_empty() {
sources.iter().collect()
} else {
sources
.iter()
.filter(|s| {
s.name()
.map(|n| filter.iter().any(|f| f == n))
.unwrap_or(false)
})
.collect()
};
assert_eq!(filtered.len(), 2);
}
#[test]
fn source_ns_accessor_returns_configured_ns() {
let source = PullSource::AzureKeyVault {
name: Some("prod-akv".into()),
ns: Some("prod".into()),
vault_url: "https://prod.vault.azure.net".into(),
prefix: None,
overwrite: false,
};
assert_eq!(source.ns(), Some("prod"));
assert_eq!(source.name(), Some("prod-akv"));
}
#[test]
fn sequential_execution_order_is_preserved() {
let sources = vec![
PullSource::AzureKeyVault {
name: Some("first".into()),
ns: None,
vault_url: "https://first.vault.azure.net".into(),
prefix: None,
overwrite: false,
},
PullSource::AzureKeyVault {
name: Some("second".into()),
ns: None,
vault_url: "https://second.vault.azure.net".into(),
prefix: None,
overwrite: false,
},
PullSource::AzureKeyVault {
name: Some("third".into()),
ns: None,
vault_url: "https://third.vault.azure.net".into(),
prefix: None,
overwrite: false,
},
];
let filtered: Vec<&PullSource> = sources.iter().collect();
assert_eq!(filtered.len(), 3);
let names: Vec<Option<&str>> = filtered.iter().map(|s| s.name()).collect();
assert_eq!(names, vec![Some("first"), Some("second"), Some("third")]);
let labels: Vec<String> = filtered
.iter()
.enumerate()
.map(|(i, s)| source_display_label(s, i, filtered.len()))
.collect();
assert_eq!(labels, vec!["[first]", "[second]", "[third]"]);
}
}