use std::collections::HashMap;
use std::io::{IsTerminal, Write as _};
use anyhow::{Context, Result};
use colored::Colorize;
use sha2::{Digest, Sha256};
use crate::tsafe_aws::{
pull_ssm_parameters, push_ssm_parameter, AwsConfig, AwsCredentials, AwsError, SsmPushOutcome,
};
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()
}
fn normalize_ssm_path_prefix(path: Option<&str>) -> String {
match path {
None | Some("") => "/".to_string(),
Some(p) => {
let with_leading = if p.starts_with('/') {
p.to_string()
} else {
format!("/{p}")
};
if with_leading.ends_with('/') {
with_leading
} else {
format!("{with_leading}/")
}
}
}
}
pub fn reconstruct_ssm_name(local_key: &str, ssm_path: &str) -> String {
let path_as_local_prefix = ssm_path
.trim_start_matches('/')
.trim_end_matches('/')
.replace(['/', '-'], "_")
.to_uppercase();
let suffix_local = if !path_as_local_prefix.is_empty() {
let try_strip = format!("{path_as_local_prefix}_");
if local_key.to_uppercase().starts_with(&try_strip) {
&local_key[try_strip.len()..]
} else if local_key.to_uppercase() == path_as_local_prefix {
local_key
} else {
local_key
}
} else {
local_key
};
let suffix_provider = suffix_local.replace('_', "-").to_lowercase();
let base = if ssm_path.ends_with('/') {
ssm_path.to_string()
} else {
format!("{ssm_path}/")
};
format!("{base}{suffix_provider}")
}
#[tracing::instrument(skip_all, fields(provider = "aws-ssm", dry_run))]
pub(crate) fn cmd_ssm_push(
profile: &str,
region: Option<&str>,
path: 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://ssm.{r}.amazonaws.com");
AwsConfig::with_endpoint(r, endpoint)
}
None => {
let mut c = 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"
})?;
c.endpoint = format!("https://ssm.{}.amazonaws.com", c.region);
c
}
};
if !cfg.endpoint.contains("/ssm.") && !cfg.endpoint.contains("://ssm.") {
cfg.endpoint = format!("https://ssm.{}.amazonaws.com", cfg.region);
}
let ssm_path = normalize_ssm_path_prefix(path);
let get_creds = || -> Result<AwsCredentials, AwsError> {
AwsCredentials::from_env_or_imds().map_err(|e| AwsError::Auth(format!("{e}")))
};
let remote_raw = pull_ssm_parameters(&cfg, &get_creds, Some(&ssm_path)).with_context(|| {
"failed to fetch parameters from AWS SSM Parameter Store\n\
\n Credential setup: tsafe explain pull-auth\
\n Required policy: ssm:GetParametersByPath + kms:Decrypt"
})?;
let remote_by_upper: HashMap<String, String> = remote_raw.into_iter().collect();
let vault = open_vault(profile)?;
let path_as_local_prefix: String = {
let stripped = ssm_path.trim_start_matches('/').trim_end_matches('/');
if stripped.is_empty() {
String::new()
} else {
format!("{}_", stripped.replace(['/', '-'], "_").to_uppercase())
}
};
let all_keys: Vec<String> = vault
.list()
.iter()
.map(|k| k.to_string())
.filter(|k| {
if path_as_local_prefix.is_empty() {
true
} else {
k.to_uppercase().starts_with(&path_as_local_prefix)
|| k.to_uppercase() == path_as_local_prefix.trim_end_matches('_')
}
})
.collect();
let mut ssm_name_map: HashMap<String, Vec<String>> = HashMap::new();
for local_key in &all_keys {
let ssm_name = reconstruct_ssm_name(local_key, &ssm_path);
ssm_name_map
.entry(ssm_name)
.or_default()
.push(local_key.clone());
}
let mut collisions: Vec<(String, Vec<String>)> = ssm_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(|(ssm_name, locals)| {
format!(
" SSM parameter '{}' claimed by: {}",
ssm_name,
locals.join(", ")
)
})
.collect::<Vec<_>>()
.join("\n");
anyhow::bail!(
"reverse-normalization collision detected — two local keys map to the same \
SSM parameter 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 ssm_name = reconstruct_ssm_name(local_key, &ssm_path);
let local_value = vault.get(local_key).map_err(|e| anyhow::anyhow!("{e}"))?;
let local_hash = value_fingerprint(local_value.as_str());
let ssm_upper_key = ssm_name
.trim_start_matches('/')
.replace(['/', '-'], "_")
.to_uppercase();
match remote_by_upper.get(&ssm_upper_key) {
None => {
to_create.push((local_key.clone(), ssm_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(), ssm_name, remote_hash, local_hash));
}
}
}
}
let mut to_delete: Vec<String> = Vec::new();
if delete_missing {
let local_ssm_names: std::collections::HashSet<String> = all_keys
.iter()
.map(|k| reconstruct_ssm_name(k, &ssm_path))
.collect();
for upper_key in remote_by_upper.keys() {
let ssm_name = format!(
"{}{}",
ssm_path,
upper_key
.trim_start_matches(&path_as_local_prefix)
.replace('_', "-")
.to_lowercase()
);
if !local_ssm_names.contains(&ssm_name) {
to_delete.push(ssm_name);
}
}
to_delete.sort();
}
let total_changes = to_create.len() + to_update.len() + to_delete.len();
if total_changes == 0 && unchanged == 0 {
println!(
"{} No parameters to push — local vault is empty for the given path filter.",
"i".blue()
);
return Ok(());
}
println!(
"{} Pre-flight diff for AWS SSM Parameter Store (region: {}, path: {}):",
"→".cyan().bold(),
cfg.region,
ssm_path
);
for (local_key, ssm_name, hash) in &to_create {
println!(
" {} {} {} (local: {})",
"create".green().bold(),
ssm_name,
format!("(sha256: {hash})").dimmed(),
local_key
);
}
for (local_key, ssm_name, old_hash, new_hash) in &to_update {
println!(
" {} {} {} (local: {})",
"update".yellow().bold(),
ssm_name,
format!("(sha256: {old_hash} → {new_hash})").dimmed(),
local_key
);
}
for ssm_name in &to_delete {
println!(
" {} {} {}",
"delete".red().bold(),
ssm_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 parameters 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 SSM Parameter Store? [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 ssm-push --yes"
);
}
}
let mut created = 0usize;
let mut updated = 0usize;
let deleted = 0usize;
let mut errors: Vec<String> = Vec::new();
for (local_key, ssm_name, _hash) in &to_create {
let value = vault.get(local_key).map_err(|e| anyhow::anyhow!("{e}"))?;
match push_ssm_parameter(&cfg, &get_creds, ssm_name, value.as_str(), false) {
Ok(SsmPushOutcome::Created | SsmPushOutcome::Updated) => {
tracing::debug!(ssm_name = %ssm_name, local_key = %local_key, "created SSM parameter");
created += 1;
}
Ok(SsmPushOutcome::Unchanged) => {}
Ok(SsmPushOutcome::Deleted) => {}
Err(e) => {
errors.push(format!("failed to create '{ssm_name}': {e}"));
}
}
}
for (local_key, ssm_name, _old_hash, _new_hash) in &to_update {
let value = vault.get(local_key).map_err(|e| anyhow::anyhow!("{e}"))?;
match push_ssm_parameter(&cfg, &get_creds, ssm_name, value.as_str(), true) {
Ok(SsmPushOutcome::Updated | SsmPushOutcome::Created | SsmPushOutcome::Unchanged) => {
tracing::debug!(ssm_name = %ssm_name, local_key = %local_key, "updated SSM parameter");
updated += 1;
}
Ok(SsmPushOutcome::Deleted) => {}
Err(e) => {
errors.push(format!("failed to update '{ssm_name}': {e}"));
}
}
}
for ssm_name in &to_delete {
tracing::warn!(ssm_name = %ssm_name, "ssm-push: --delete-missing delete skipped — DeleteParameter not yet implemented");
errors.push(format!(
"delete '{ssm_name}' skipped — ssm-push --delete-missing delete is not yet implemented; \
remove the parameter 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,
"ssm-push",
Some(&audit_context),
))
.ok();
emit_event(profile, "ssm-push", Some(&audit_context));
if errors.is_empty() {
println!(
"{} Pushed to SSM Parameter Store (region: {}, path: {}): {created} created, {updated} updated, {unchanged} unchanged.",
"✓".green(),
cfg.region,
ssm_path
);
} else {
println!(
"{} Partial push to SSM (region: {}, path: {}): {created} created, {updated} updated, {unchanged} unchanged.",
"!".yellow(),
cfg.region,
ssm_path
);
for error in &errors {
eprintln!("{} {error}", "error:".red().bold());
}
anyhow::bail!("{} parameter(s) failed to push", errors.len());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reconstruct_ssm_name_strips_path_prefix_from_local_key() {
assert_eq!(
reconstruct_ssm_name("MYAPP_DB_PASSWORD", "/myapp/"),
"/myapp/db-password"
);
}
#[test]
fn reconstruct_ssm_name_full_key_when_no_prefix_match() {
assert_eq!(reconstruct_ssm_name("DB_URL", "/myapp/"), "/myapp/db-url");
}
#[test]
fn reconstruct_ssm_name_root_path() {
assert_eq!(reconstruct_ssm_name("MY_KEY", "/"), "/my-key");
}
#[test]
fn reconstruct_ssm_name_nested_path() {
assert_eq!(
reconstruct_ssm_name("PROD_MYAPP_API_KEY", "/prod/myapp/"),
"/prod/myapp/api-key"
);
}
#[test]
fn normalize_ssm_path_prefix_adds_slashes() {
assert_eq!(normalize_ssm_path_prefix(Some("myapp")), "/myapp/");
assert_eq!(normalize_ssm_path_prefix(Some("/myapp")), "/myapp/");
assert_eq!(normalize_ssm_path_prefix(Some("/myapp/")), "/myapp/");
assert_eq!(normalize_ssm_path_prefix(None), "/");
assert_eq!(normalize_ssm_path_prefix(Some("")), "/");
}
}