use std::collections::{HashMap, HashSet};
use std::io::{IsTerminal, Write as _};
use anyhow::{Context, Result};
use colored::Colorize;
use sha2::{Digest, Sha256};
use tsafe_cli::tsafe_gcp::{
acquire_token, pull_secrets, push_secret, GcpConfig, GcpError, GcpToken, PushOutcome,
};
use tsafe_core::{audit::AuditEntry, events::emit_event};
use crate::helpers::*;
const GCP_TEST_LOCAL_URL_ENV: &str = "TSAFE_GCP_TEST_LOCAL_URL";
const GCP_TEST_TOKEN_ENV: &str = "TSAFE_GCP_TEST_TOKEN";
const GCP_TEST_PROJECT_ENV: &str = "TSAFE_GCP_TEST_PROJECT";
fn is_allowed_local_test_url(url: &str) -> bool {
url.starts_with("http://127.0.0.1:")
|| url.starts_with("http://localhost:")
|| url.starts_with("http://[::1]:")
}
fn local_test_override() -> Result<Option<(GcpConfig, String)>> {
#[cfg(debug_assertions)]
{
if let Ok(raw_url) = std::env::var(GCP_TEST_LOCAL_URL_ENV) {
let url = raw_url.trim().to_string();
if !is_allowed_local_test_url(&url) {
anyhow::bail!(
"{GCP_TEST_LOCAL_URL_ENV} must use http://127.0.0.1:<port>, \
http://localhost:<port>, or http://[::1]:<port>"
);
}
let token = std::env::var(GCP_TEST_TOKEN_ENV).with_context(|| {
format!("{GCP_TEST_TOKEN_ENV} must be set when {GCP_TEST_LOCAL_URL_ENV} is used")
})?;
let project =
std::env::var(GCP_TEST_PROJECT_ENV).unwrap_or_else(|_| "test-project".to_string());
let cfg = GcpConfig::with_endpoint(project, format!("{url}/v1"));
return Ok(Some((cfg, token)));
}
}
Ok(None)
}
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 = "gcp", dry_run))]
pub(crate) fn cmd_gcp_push(
profile: &str,
project: Option<&str>,
prefix: Option<&str>,
ns: Option<&str>,
dry_run: bool,
yes: bool,
delete_missing: bool,
) -> Result<()> {
tracing::Span::current().record("dry_run", dry_run);
let (cfg, test_token) = match local_test_override()? {
Some((cfg, token)) => (cfg, Some(token)),
None => {
let cfg = match project {
Some(p) => GcpConfig::with_endpoint(p, "https://secretmanager.googleapis.com/v1"),
None => GcpConfig::from_env().with_context(|| {
"GCP project is not configured\n\
\n Fix: export GOOGLE_CLOUD_PROJECT=my-project (or pass --project)\
\n Help: tsafe explain pull-auth"
})?,
};
(cfg, None)
}
};
let acquire = || -> Result<GcpToken, GcpError> {
match &test_token {
Some(token) => Ok(GcpToken(token.clone())),
None => acquire_token().map_err(|e| GcpError::Auth(format!("{e}"))),
}
};
let remote_secrets: HashMap<String, String> = pull_secrets(&cfg, &acquire, None)
.context("failed to fetch remote secrets from GCP Secret Manager")?
.into_iter()
.map(|(upper_key, value)| {
let provider_name = upper_key.replace('_', "-").to_lowercase();
(provider_name, value)
})
.collect();
let vault = open_vault(profile)?;
let all_keys: Vec<String> = vault
.list()
.iter()
.map(|k| k.to_string())
.filter(|k| {
if let Some(ns_prefix) = ns {
let ns_slash = format!("{ns_prefix}/");
return k.starts_with(&ns_slash);
}
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 \
GCP Secret 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: 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 {
ns.is_none()
};
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 GCP Secret Manager (project: '{}'):",
"→".cyan().bold(),
cfg.project_id
);
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 !to_delete.is_empty() {
println!(
" {} GCP Secret Manager: deleted secrets are disabled but remain in the API until \
explicitly destroyed.",
"warn:".yellow().bold()
);
}
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 GCP Secret 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 gcp-push --yes"
);
}
}
let mut created = 0usize;
let mut updated = 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, &acquire, provider_name, value.as_str()) {
Ok(PushOutcome::Created | PushOutcome::Updated) => {
tracing::debug!(provider_name = %provider_name, local_key = %local_key, "created secret");
created += 1;
}
Ok(PushOutcome::Unchanged | PushOutcome::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, &acquire, provider_name, value.as_str()) {
Ok(PushOutcome::Created | PushOutcome::Updated | PushOutcome::Unchanged) => {
tracing::debug!(provider_name = %provider_name, local_key = %local_key, "updated secret");
updated += 1;
}
Ok(PushOutcome::Deleted) => {
}
Err(e) => {
errors.push(format!("failed to update '{provider_name}': {e}"));
}
}
}
let deleted = 0usize;
for provider_name in &to_delete {
errors.push(format!(
"delete of '{provider_name}' skipped — GCP Secret Manager deletion requires \
the Secret Manager Admin API (secretmanager.secrets.delete permission); \
disable the secret manually in the GCP console or via gcloud."
));
}
let audit_context =
format!("created={created} updated={updated} unchanged={unchanged} deleted={deleted}");
audit(profile)
.append(&AuditEntry::success(
profile,
"gcp-push",
Some(&audit_context),
))
.ok();
emit_event(profile, "gcp-push", Some(&audit_context));
if errors.is_empty() {
println!(
"{} Pushed to GCP Secret Manager (project: '{}'): \
{created} created, {updated} updated, {unchanged} unchanged.",
"✓".green(),
cfg.project_id
);
} else {
println!(
"{} Partial push to GCP (project: '{}'): \
{created} created, {updated} updated, {unchanged} unchanged.",
"!".yellow(),
cfg.project_id
);
for error in &errors {
eprintln!("{} {error}", "error:".red().bold());
}
anyhow::bail!("{} secret(s) failed to push", errors.len());
}
Ok(())
}