use std::collections::HashMap;
use std::io::{IsTerminal, Write as _};
use anyhow::{Context, Result};
use colored::Colorize;
use sha2::{Digest, Sha256};
use tsafe_cli::tsafe_aws::{
pull_secrets, push_secret, AwsConfig, AwsCredentials, AwsError, SmPushOutcome,
};
use tsafe_core::{audit::AuditEntry, events::emit_event};
use crate::helpers::*;
fn value_fingerprint(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
let result = hasher.finalize();
result[..6].iter().map(|b| format!("{b:02x}")).collect()
}
pub fn normalize_to_provider(local_key: &str) -> String {
local_key.replace('_', "-").to_lowercase()
}
#[tracing::instrument(skip_all, fields(provider = "aws-sm", dry_run))]
pub(crate) fn cmd_aws_push(
profile: &str,
region: Option<&str>,
prefix: Option<&str>,
dry_run: bool,
yes: bool,
delete_missing: bool,
) -> Result<()> {
tracing::Span::current().record("dry_run", dry_run);
let mut cfg = match region {
Some(r) => {
let endpoint = format!("https://secretsmanager.{r}.amazonaws.com");
AwsConfig::with_endpoint(r, endpoint)
}
None => AwsConfig::from_env().with_context(|| {
"AWS region is not configured\n\
\n Fix: export AWS_DEFAULT_REGION=us-east-1 (or pass --region)\
\n Help: tsafe explain pull-auth"
})?,
};
if let Ok(test_url) = std::env::var("TSAFE_AWS_SM_TEST_ENDPOINT") {
cfg.endpoint = test_url;
} else if !cfg.endpoint.contains("secretsmanager") {
cfg.endpoint = format!("https://secretsmanager.{}.amazonaws.com", cfg.region);
}
let get_creds = || -> Result<AwsCredentials, AwsError> {
AwsCredentials::from_env_or_imds().map_err(|e| AwsError::Auth(format!("{e}")))
};
let remote_raw: Vec<(String, String)> =
pull_secrets(&cfg, &get_creds, prefix).with_context(|| {
"failed to fetch secrets from AWS Secrets Manager\n\
\n Credential setup: tsafe explain pull-auth\
\n Required env: AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY + AWS_DEFAULT_REGION"
})?;
let mut remote_secrets: HashMap<String, String> = HashMap::new();
for (upper_key, value) in &remote_raw {
let provider_name = upper_key.replace('_', "-").to_lowercase();
remote_secrets.insert(provider_name, value.clone());
}
let vault = open_vault(profile)?;
let all_keys: Vec<String> = vault
.list()
.iter()
.map(|k| k.to_string())
.filter(|k| {
if let Some(pfx) = prefix {
return k.to_uppercase().starts_with(&pfx.to_uppercase());
}
true
})
.collect();
let mut provider_name_map: HashMap<String, Vec<String>> = HashMap::new();
for local_key in &all_keys {
let provider_name = normalize_to_provider(local_key);
provider_name_map
.entry(provider_name)
.or_default()
.push(local_key.clone());
}
let mut collisions: Vec<(String, Vec<String>)> = provider_name_map
.into_iter()
.filter(|(_, locals)| locals.len() > 1)
.collect();
collisions.sort_by(|(a, _), (b, _)| a.cmp(b));
if !collisions.is_empty() {
let msg = collisions
.iter()
.map(|(provider, locals)| {
format!(
" provider key '{}' claimed by: {}",
provider,
locals.join(", ")
)
})
.collect::<Vec<_>>()
.join("\n");
anyhow::bail!(
"reverse-normalization collision detected — two local keys map to the same \
AWS Secrets Manager name:\n{msg}\n\
Rename one of the colliding local keys before pushing."
);
}
let mut to_create: Vec<(String, String, String)> = Vec::new(); let mut to_update: Vec<(String, String, String, String)> = Vec::new(); let mut unchanged = 0usize;
for local_key in &all_keys {
let provider_name = normalize_to_provider(local_key);
let local_value = vault.get(local_key).map_err(|e| anyhow::anyhow!("{e}"))?;
let local_hash = value_fingerprint(local_value.as_str());
match remote_secrets.get(&provider_name) {
None => {
to_create.push((local_key.clone(), provider_name, local_hash));
}
Some(remote_value) => {
let remote_hash = value_fingerprint(remote_value);
if remote_hash == local_hash {
unchanged += 1;
} else {
to_update.push((local_key.clone(), provider_name, remote_hash, local_hash));
}
}
}
}
let mut to_delete: Vec<String> = Vec::new();
if delete_missing {
let local_provider_names: std::collections::HashSet<String> =
all_keys.iter().map(|k| normalize_to_provider(k)).collect();
for provider_name in remote_secrets.keys() {
if !local_provider_names.contains(provider_name) {
let should_delete = if let Some(pfx) = prefix {
provider_name
.to_uppercase()
.starts_with(&pfx.to_uppercase())
} else {
true
};
if should_delete {
to_delete.push(provider_name.clone());
}
}
}
to_delete.sort();
}
let total_changes = to_create.len() + to_update.len() + to_delete.len();
if total_changes == 0 && unchanged == 0 {
println!(
"{} No secrets to push — local vault is empty for the given filter.",
"i".blue()
);
return Ok(());
}
println!(
"{} Pre-flight diff for AWS Secrets Manager (region: {}):",
"→".cyan().bold(),
cfg.region
);
for (local_key, provider_name, hash) in &to_create {
println!(
" {} {} {} (local: {})",
"create".green().bold(),
provider_name,
format!("(sha256: {hash})").dimmed(),
local_key
);
}
for (local_key, provider_name, old_hash, new_hash) in &to_update {
println!(
" {} {} {} (local: {})",
"update".yellow().bold(),
provider_name,
format!("(sha256: {old_hash} → {new_hash})").dimmed(),
local_key
);
}
for provider_name in &to_delete {
println!(
" {} {} {}",
"delete".red().bold(),
provider_name,
"(--delete-missing)".dimmed()
);
}
let delete_note = if !to_delete.is_empty() {
format!(", {} delete(s)", to_delete.len())
} else {
String::new()
};
println!(
" {} {} create(s), {} update(s){delete_note}, {} unchanged",
"---".dimmed(),
to_create.len(),
to_update.len(),
unchanged
);
if dry_run {
println!("{} Dry-run complete — no writes made.", "✓".green());
return Ok(());
}
if total_changes == 0 {
println!(
"{} Nothing to push — all secrets are up to date.",
"✓".green()
);
return Ok(());
}
if !yes {
if std::io::stdin().is_terminal() {
let delete_prompt = if !to_delete.is_empty() {
format!(", {} delete(s)", to_delete.len())
} else {
String::new()
};
eprint!(
"Push {} create(s), {} update(s){delete_prompt} to AWS Secrets Manager? [y/N] ",
to_create.len(),
to_update.len()
);
std::io::stderr().flush().ok();
let mut response = String::new();
std::io::stdin()
.read_line(&mut response)
.context("failed to read confirmation")?;
if !response.trim().eq_ignore_ascii_case("y") {
println!("{} Push aborted.", "i".blue());
return Ok(());
}
} else {
anyhow::bail!(
"non-interactive stdin and --yes not passed — refusing to push without confirmation.\n\
Add --yes to push from CI or non-interactive contexts:\n tsafe aws-push --yes"
);
}
}
let mut created = 0usize;
let mut updated = 0usize;
let deleted = 0usize;
let mut errors: Vec<String> = Vec::new();
for (local_key, provider_name, _hash) in &to_create {
let value = vault.get(local_key).map_err(|e| anyhow::anyhow!("{e}"))?;
match push_secret(&cfg, &get_creds, provider_name, value.as_str()) {
Ok(SmPushOutcome::Created | SmPushOutcome::Updated) => {
tracing::debug!(provider_name = %provider_name, local_key = %local_key, "created secret in AWS SM");
created += 1;
}
Ok(SmPushOutcome::Unchanged) => {
}
Ok(SmPushOutcome::Deleted) => {}
Err(e) => {
errors.push(format!("failed to create '{provider_name}': {e}"));
}
}
}
for (local_key, provider_name, _old_hash, _new_hash) in &to_update {
let value = vault.get(local_key).map_err(|e| anyhow::anyhow!("{e}"))?;
match push_secret(&cfg, &get_creds, provider_name, value.as_str()) {
Ok(SmPushOutcome::Updated | SmPushOutcome::Created | SmPushOutcome::Unchanged) => {
tracing::debug!(provider_name = %provider_name, local_key = %local_key, "updated secret in AWS SM");
updated += 1;
}
Ok(SmPushOutcome::Deleted) => {}
Err(e) => {
errors.push(format!("failed to update '{provider_name}': {e}"));
}
}
}
for provider_name in &to_delete {
tracing::warn!(
provider_name = %provider_name,
"aws-push: --delete-missing delete skipped — DeleteSecret not yet implemented"
);
errors.push(format!(
"delete '{provider_name}' skipped — aws-push --delete-missing delete is not yet implemented; \
remove the secret manually in the AWS Console or CLI"
));
}
let audit_context =
format!("created={created} updated={updated} unchanged={unchanged} deleted={deleted}");
audit(profile)
.append(&AuditEntry::success(
profile,
"aws-push",
Some(&audit_context),
))
.ok();
emit_event(profile, "aws-push", Some(&audit_context));
if errors.is_empty() {
println!(
"{} Pushed to AWS Secrets Manager (region: {}): {created} created, {updated} updated, {unchanged} unchanged.",
"✓".green(),
cfg.region
);
} else {
println!(
"{} Partial push to AWS Secrets Manager (region: {}): {created} created, {updated} updated, {unchanged} unchanged.",
"!".yellow(),
cfg.region
);
for error in &errors {
eprintln!("{} {error}", "error:".red().bold());
}
anyhow::bail!("{} secret(s) failed to push", errors.len());
}
Ok(())
}