use std::collections::HashMap;
use anyhow::{Context, Result};
use colored::Colorize;
use tsafe_core::{audit::AuditEntry, events::emit_event};
use crate::helpers::*;
#[cfg(feature = "cloud-pull-vault")]
use tsafe_cli::tsafe_hcp::auth::VaultAuth;
#[cfg_attr(not(feature = "cloud-pull-vault"), allow(dead_code))]
pub(crate) fn cmd_vault_pull(
profile: &str,
addr: Option<&str>,
token: Option<&str>,
mount: Option<&str>,
prefix: Option<&str>,
overwrite: bool,
) -> Result<()> {
cmd_vault_pull_ns(profile, addr, token, mount, prefix, overwrite, None)
}
#[cfg_attr(not(feature = "cloud-pull-vault"), allow(dead_code))]
pub(crate) fn cmd_vault_pull_ns(
profile: &str,
addr: Option<&str>,
token: Option<&str>,
mount: Option<&str>,
prefix: Option<&str>,
overwrite: bool,
ns: Option<&str>,
) -> Result<()> {
cmd_vault_pull_full(
profile, addr, token, mount, prefix, overwrite, ns, None, None,
)
}
#[cfg(feature = "cloud-pull-vault")]
#[allow(clippy::too_many_arguments)]
pub(crate) fn cmd_vault_pull_full(
profile: &str,
addr: Option<&str>,
token: Option<&str>,
mount: Option<&str>,
prefix: Option<&str>,
overwrite: bool,
ns: Option<&str>,
vault_auth_config: Option<&tsafe_core::pullconfig::VaultAuthConfig>,
vault_namespace: Option<&str>,
) -> Result<()> {
if token.is_some() {
eprintln!(
"Warning: --token passes the Vault token via CLI argument, which is visible \
in the process table. Store your Vault token in tsafe and inject it securely:\n\
\n tsafe exec -- tsafe vault-pull\n\
\ntsafe sets VAULT_TOKEN from your vault before spawning the child process."
);
}
let base = addr
.map(|s| s.to_string())
.or_else(|| std::env::var("TSAFE_HCP_URL").ok())
.unwrap_or_else(|| "http://127.0.0.1:8200".into());
let base = base.trim_end_matches('/');
if !base.starts_with("https://")
&& !base.starts_with("http://127.0.0.1")
&& !base.starts_with("http://localhost")
{
anyhow::bail!(
"vault-pull address must use https:// for remote servers \
(plain HTTP is only allowed for localhost)"
);
}
let mount = mount.unwrap_or("secret");
let resolved_namespace: Option<String> = vault_namespace.map(|s| s.to_string()).or_else(|| {
std::env::var("VAULT_NAMESPACE")
.ok()
.filter(|s| !s.is_empty())
});
let agent = build_http_agent();
let vault_auth: VaultAuth = if let Some(t) = token {
VaultAuth::Token(t.to_string())
} else if let Some(auth_cfg) = vault_auth_config {
use tsafe_core::pullconfig::VaultAuthConfig;
match auth_cfg {
VaultAuthConfig::Token { token: Some(t) } => VaultAuth::Token(t.clone()),
VaultAuthConfig::Token { token: None } => {
VaultAuth::from_env().map_err(|e| anyhow::anyhow!("{e}"))?
}
VaultAuthConfig::Approle { role_id, secret_id } => VaultAuth::AppRole {
role_id: role_id.clone(),
secret_id: secret_id.clone(),
},
}
} else {
VaultAuth::from_env().map_err(|e| anyhow::anyhow!("{e}"))?
};
let client_token = vault_auth
.acquire_token(base, resolved_namespace.as_deref(), &agent)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let list_path = match prefix {
Some(p) => format!("{base}/v1/{mount}/metadata/{}", p.trim_matches('/')),
None => format!("{base}/v1/{mount}/metadata/"),
};
let mut list_req = agent
.request("LIST", &list_path)
.set("X-Vault-Token", &client_token);
if let Some(ref ns_val) = resolved_namespace {
list_req = list_req.set("X-Vault-Namespace", ns_val);
}
let list_resp: serde_json::Value = list_req
.call()
.map_err(|e| {
anyhow::anyhow!(
"HashiCorp Vault list failed — check TSAFE_HCP_URL and Vault credentials: {e}"
)
})?
.into_json()?;
let keys: Vec<String> = list_resp["data"]["keys"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("unexpected Vault response: no 'data.keys' field"))?
.iter()
.filter_map(|v| v.as_str())
.filter(|k| !k.ends_with('/')) .map(|k| k.to_string())
.collect();
if keys.is_empty() {
println!("{} No secrets found at path", "i".blue());
return Ok(());
}
let mut vault = open_vault(profile)?;
let mut imported = 0usize;
let mut skipped = 0usize;
for key_name in &keys {
let secret_path = match prefix {
Some(p) => format!("{base}/v1/{mount}/data/{}/{key_name}", p.trim_matches('/')),
None => format!("{base}/v1/{mount}/data/{key_name}"),
};
let mut get_req = agent.get(&secret_path).set("X-Vault-Token", &client_token);
if let Some(ref ns_val) = resolved_namespace {
get_req = get_req.set("X-Vault-Namespace", ns_val);
}
let resp: serde_json::Value = get_req
.call()
.with_context(|| format!("failed to fetch '{key_name}' from Vault"))?
.into_json()?;
let data = resp["data"]["data"]
.as_object()
.ok_or_else(|| anyhow::anyhow!("unexpected Vault KV v2 response for '{key_name}'"))?;
for (field, val) in data {
let raw_key = normalize_vault_key(key_name, field);
let local_key = match ns {
Some(ns_prefix) => format!("{ns_prefix}.{raw_key}"),
None => raw_key,
};
let value = match val.as_str() {
Some(s) => s,
None => {
eprintln!(
"{} Skipping '{local_key}' — value is not a string (got {})",
"warn:".yellow().bold(),
if val.is_number() {
"number"
} else if val.is_boolean() {
"boolean"
} else {
"non-string"
}
);
continue;
}
};
if !overwrite && vault.list().contains(&local_key.as_str()) {
skipped += 1;
continue;
}
vault.set(&local_key, value, HashMap::new())?;
imported += 1;
}
}
audit(profile)
.append(&AuditEntry::success(profile, "vault-pull", None))
.ok();
emit_event(profile, "vault-pull", None);
println!(
"{} Imported {imported} secret(s) from HashiCorp Vault '{base}'{}",
"✓".green(),
if skipped > 0 {
format!(" ({skipped} skipped — use --overwrite to replace)")
} else {
String::new()
}
);
Ok(())
}
#[cfg(not(feature = "cloud-pull-vault"))]
#[allow(clippy::too_many_arguments, dead_code)]
pub(crate) fn cmd_vault_pull_full(
_profile: &str,
_addr: Option<&str>,
_token: Option<&str>,
_mount: Option<&str>,
_prefix: Option<&str>,
_overwrite: bool,
_ns: Option<&str>,
_vault_auth_config: Option<&tsafe_core::pullconfig::VaultAuthConfig>,
_vault_namespace: Option<&str>,
) -> Result<()> {
anyhow::bail!("HashiCorp Vault pull requires the cloud-pull-vault feature")
}
#[cfg_attr(not(feature = "cloud-pull-vault"), allow(dead_code))]
pub(crate) fn normalize_vault_key(key_name: &str, field: &str) -> String {
format!(
"{}_{}",
key_name.replace('-', "_").to_uppercase(),
field.to_uppercase()
)
}
#[cfg(feature = "cloud-pull-1password")]
fn op_field_label_to_key(label: &str) -> String {
tsafe_cli::op_mapping::op_field_label_to_key(label)
}
#[cfg(feature = "cloud-pull-1password")]
pub(crate) fn cmd_op_pull(
profile: &str,
item: &str,
op_vault: Option<&str>,
overwrite: bool,
) -> Result<()> {
cmd_op_pull_ns(profile, item, op_vault, overwrite, None)
}
#[cfg(feature = "cloud-pull-1password")]
pub(crate) fn cmd_op_pull_ns(
profile: &str,
item: &str,
op_vault: Option<&str>,
overwrite: bool,
ns: Option<&str>,
) -> Result<()> {
use tsafe_cli::tsafe_op::config::OpConnectConfig;
if let Ok(connect_cfg) = OpConnectConfig::from_env() {
return cmd_op_pull_ns_connect(&connect_cfg, profile, item, op_vault, overwrite, ns);
}
let mut cmd = std::process::Command::new("op");
cmd.args(["item", "get", item, "--format", "json"]);
if let Some(v) = op_vault {
cmd.args(["--vault", v]);
}
let output = cmd
.output()
.context("could not run 'op' — install the 1Password CLI and sign in first")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("op exited with status {}: {stderr}", output.status);
}
let item_json: serde_json::Value = serde_json::from_slice(&output.stdout)
.context("could not parse 'op item get' JSON output")?;
let fields = item_json["fields"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("unexpected 'op' JSON: no 'fields' array"))?;
let candidates: Vec<(String, &str)> = fields
.iter()
.filter_map(|field| {
let label = field["label"].as_str().unwrap_or_default();
let value = field["value"].as_str().unwrap_or_default();
if value.is_empty() {
None
} else {
Some((op_field_label_to_key(label), value))
}
})
.collect();
if !overwrite {
let mut seen: HashMap<&str, Vec<usize>> = HashMap::new();
for (idx, (key, _)) in candidates.iter().enumerate() {
seen.entry(key.as_str()).or_default().push(idx);
}
let colliding: Vec<&str> = seen
.iter()
.filter(|(_, indices)| indices.len() > 1)
.map(|(key, _)| *key)
.collect();
if !colliding.is_empty() {
let mut sorted = colliding.clone();
sorted.sort_unstable();
anyhow::bail!(
"1Password item '{item}' has fields that normalise to the same key name; \
this would cause a silent collision.\n\
Colliding key(s): {}\n\
Fix: rename the fields in 1Password, or pass --overwrite to use last-listed-wins.",
sorted.join(", ")
);
}
}
let mut vault = open_vault(profile)?;
let mut imported = 0usize;
let mut skipped = 0usize;
for (raw_key, value) in &candidates {
let local_key = match ns {
Some(ns_prefix) => format!("{ns_prefix}.{raw_key}"),
None => raw_key.clone(),
};
if !overwrite && vault.list().contains(&local_key.as_str()) {
skipped += 1;
continue;
}
vault.set(&local_key, value, HashMap::new())?;
imported += 1;
}
audit(profile)
.append(&AuditEntry::success(profile, "op-pull", Some(item)))
.ok();
emit_event(profile, "op-pull", Some(item));
println!(
"{} Imported {imported} field(s) from 1Password item '{item}'{}",
"✓".green(),
if skipped > 0 {
format!(" ({skipped} skipped — use --overwrite to replace)")
} else {
String::new()
}
);
Ok(())
}
#[cfg(feature = "cloud-pull-1password")]
fn cmd_op_pull_ns_connect(
connect_cfg: &tsafe_cli::tsafe_op::config::OpConnectConfig,
profile: &str,
item: &str,
vault_name: Option<&str>,
overwrite: bool,
ns: Option<&str>,
) -> Result<()> {
use tsafe_cli::tsafe_op::client;
let vaults = client::list_vaults(connect_cfg)
.map_err(|e| anyhow::anyhow!("1Password Connect vault list failed: {e}"))?;
let vault_uuid: String = if let Some(name) = vault_name {
let matches: Vec<_> = vaults.iter().filter(|v| v.name == name).collect();
match matches.len() {
0 => anyhow::bail!(
"1Password Connect: no vault named '{}' is visible to this token.\n\
Available vaults: {}",
name,
vaults
.iter()
.map(|v| v.name.as_str())
.collect::<Vec<_>>()
.join(", ")
),
1 => matches[0].id.clone(),
_ => anyhow::bail!(
"1Password Connect: vault name '{}' matches {} vaults — names must be unique.\n\
Rename one vault in 1Password so the name is unambiguous, then retry.",
name,
matches.len()
),
}
} else {
vaults.into_iter().next().map(|v| v.id).ok_or_else(|| {
anyhow::anyhow!(
"1Password Connect: no vaults are visible to this token — \
check OP_CONNECT_TOKEN permissions"
)
})?
};
let items = client::list_items(connect_cfg, &vault_uuid)
.map_err(|e| anyhow::anyhow!("1Password Connect item list failed: {e}"))?;
let item_matches: Vec<_> = items.iter().filter(|i| i.title == item).collect();
let item_uuid = match item_matches.len() {
0 => anyhow::bail!("1Password Connect: no item titled '{item}' found in vault"),
1 => item_matches[0].id.clone(),
_ => anyhow::bail!(
"1Password Connect: item title '{}' matches {} items — titles must be unique",
item,
item_matches.len()
),
};
let op_item = client::get_item(connect_cfg, &vault_uuid, &item_uuid)
.map_err(|e| anyhow::anyhow!("1Password Connect item fetch failed: {e}"))?;
let candidates: Vec<(String, String)> = op_item
.fields
.iter()
.filter(|f| !f.value.is_empty())
.map(|f| (op_field_label_to_key(&f.label), f.value.clone()))
.collect();
if !overwrite {
let mut seen: HashMap<&str, Vec<usize>> = HashMap::new();
for (idx, (key, _)) in candidates.iter().enumerate() {
seen.entry(key.as_str()).or_default().push(idx);
}
let mut colliding: Vec<&str> = seen
.iter()
.filter(|(_, indices)| indices.len() > 1)
.map(|(key, _)| *key)
.collect();
if !colliding.is_empty() {
colliding.sort_unstable();
anyhow::bail!(
"1Password item '{item}' has fields that normalise to the same key name; \
this would cause a silent collision.\n\
Colliding key(s): {}\n\
Fix: rename the fields in 1Password, or pass --overwrite to use last-listed-wins.",
colliding.join(", ")
);
}
}
let mut vault = open_vault(profile)?;
let mut imported = 0usize;
let mut skipped = 0usize;
for (raw_key, value) in &candidates {
let local_key = match ns {
Some(ns_prefix) => format!("{ns_prefix}.{raw_key}"),
None => raw_key.clone(),
};
if !overwrite && vault.list().contains(&local_key.as_str()) {
skipped += 1;
continue;
}
vault.set(&local_key, value, HashMap::new())?;
imported += 1;
}
audit(profile)
.append(&AuditEntry::success(profile, "op-pull", Some(item)))
.ok();
emit_event(profile, "op-pull", Some(item));
println!(
"{} Imported {imported} field(s) from 1Password item '{item}' via Connect{}",
"✓".green(),
if skipped > 0 {
format!(" ({skipped} skipped — use --overwrite to replace)")
} else {
String::new()
}
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vault_key_normalization_hyphens_to_underscores() {
assert_eq!(normalize_vault_key("my-secret", "value"), "MY_SECRET_VALUE");
}
#[test]
fn vault_key_normalization_already_uppercase() {
assert_eq!(normalize_vault_key("DB_CONFIG", "HOST"), "DB_CONFIG_HOST");
}
#[test]
fn vault_key_normalization_mixed_case_field() {
assert_eq!(
normalize_vault_key("db-config", "Password"),
"DB_CONFIG_PASSWORD"
);
}
#[test]
fn vault_key_normalization_compound_key_name() {
assert_eq!(
normalize_vault_key("my-app-api-key", "secret"),
"MY_APP_API_KEY_SECRET"
);
}
fn test_agent() -> ureq::Agent {
ureq::AgentBuilder::new()
.timeout_connect(std::time::Duration::from_secs(5))
.timeout(std::time::Duration::from_secs(10))
.build()
}
#[test]
fn vault_kv_list_and_get_happy_path() {
let mut server = mockito::Server::new();
let token = "test-vault-token";
let _list = server
.mock("LIST", "/v1/secret/metadata/app")
.match_header("X-Vault-Token", token)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(r#"{"data":{"keys":["my-api-key"]}}"#)
.create();
let _get = server
.mock("GET", "/v1/secret/data/app/my-api-key")
.match_header("X-Vault-Token", token)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(r#"{"data":{"data":{"value":"s3cr3t","version":1}}}"#)
.create();
let agent = test_agent();
let list_resp: serde_json::Value = agent
.request("LIST", &format!("{}/v1/secret/metadata/app", server.url()))
.set("X-Vault-Token", token)
.call()
.expect("LIST should succeed")
.into_json()
.expect("LIST response should be valid JSON");
let keys: Vec<&str> = list_resp["data"]["keys"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.filter(|k| !k.ends_with('/'))
.collect();
assert_eq!(keys, vec!["my-api-key"]);
let get_resp: serde_json::Value = agent
.get(&format!("{}/v1/secret/data/app/{}", server.url(), keys[0]))
.set("X-Vault-Token", token)
.call()
.expect("GET should succeed")
.into_json()
.expect("GET response should be valid JSON");
let data = get_resp["data"]["data"].as_object().unwrap();
assert!(data.contains_key("value"));
assert_eq!(data["value"].as_str().unwrap(), "s3cr3t");
let local_key = normalize_vault_key(keys[0], "value");
assert_eq!(local_key, "MY_API_KEY_VALUE");
}
#[test]
fn vault_kv_list_path_not_found_returns_404() {
let mut server = mockito::Server::new();
let token = "test-vault-token";
let _m = server
.mock("LIST", "/v1/secret/metadata/nonexistent")
.match_header("X-Vault-Token", token)
.with_status(404)
.with_header("Content-Type", "application/json")
.with_body(r#"{"errors":[]}"#)
.create();
let agent = test_agent();
let result = agent
.request(
"LIST",
&format!("{}/v1/secret/metadata/nonexistent", server.url()),
)
.set("X-Vault-Token", token)
.call();
assert!(
matches!(result, Err(ureq::Error::Status(404, _))),
"expected 404 for nonexistent path, got: {:?}",
result.map(|_| ())
);
}
#[test]
fn vault_kv_list_expired_token_returns_403() {
let mut server = mockito::Server::new();
let _m = server
.mock("LIST", "/v1/secret/metadata/")
.with_status(403)
.with_header("Content-Type", "application/json")
.with_body(r#"{"errors":["1 error occurred:\n\t* permission denied\n\n"]}"#)
.create();
let agent = test_agent();
let result = agent
.request("LIST", &format!("{}/v1/secret/metadata/", server.url()))
.set("X-Vault-Token", "expired-or-invalid-token")
.call();
assert!(
matches!(result, Err(ureq::Error::Status(403, _))),
"expected 403 for expired token, got: {:?}",
result.map(|_| ())
);
}
#[test]
fn vault_kv_sealed_vault_returns_503() {
let mut server = mockito::Server::new();
let _m = server
.mock("LIST", "/v1/secret/metadata/")
.with_status(503)
.with_header("Content-Type", "application/json")
.with_body(r#"{"errors":["error performing token check: Vault is sealed"]}"#)
.create();
let agent = test_agent();
let result = agent
.request("LIST", &format!("{}/v1/secret/metadata/", server.url()))
.set("X-Vault-Token", "any-token")
.call();
assert!(
matches!(result, Err(ureq::Error::Status(503, _))),
"expected 503 for sealed vault, got: {:?}",
result.map(|_| ())
);
}
#[test]
fn vault_kv_get_specific_version_returns_versioned_data() {
let mut server = mockito::Server::new();
let token = "test-vault-token";
let _m = server
.mock("GET", "/v1/secret/data/my-secret?version=2")
.match_header("X-Vault-Token", token)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(
r#"{"data":{"data":{"api_key":"v2-value"},"metadata":{"version":2,"destroyed":false}}}"#,
)
.create();
let agent = test_agent();
let resp: serde_json::Value = agent
.get(&format!(
"{}/v1/secret/data/my-secret?version=2",
server.url()
))
.set("X-Vault-Token", token)
.call()
.expect("GET with version should succeed")
.into_json()
.expect("response should be valid JSON");
let data = resp["data"]["data"].as_object().unwrap();
assert_eq!(data["api_key"].as_str().unwrap(), "v2-value");
let local_key = normalize_vault_key("my-secret", "api_key");
assert_eq!(local_key, "MY_SECRET_API_KEY");
}
#[test]
fn vault_kv_get_missing_data_field_is_rejected() {
let mut server = mockito::Server::new();
let token = "test-vault-token";
let _m = server
.mock("GET", "/v1/secret/data/bad-secret")
.match_header("X-Vault-Token", token)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(r#"{"data":{"metadata":{"version":1}}}"#)
.create();
let agent = test_agent();
let resp: serde_json::Value = agent
.get(&format!("{}/v1/secret/data/bad-secret", server.url()))
.set("X-Vault-Token", token)
.call()
.expect("GET should return 200")
.into_json()
.expect("response should be valid JSON");
assert!(
resp["data"]["data"].as_object().is_none(),
"expected missing data.data to return None from as_object()"
);
}
#[test]
fn vault_kv_list_skips_subdirectory_entries() {
let keys_raw = ["api-key", "nested/", "db-password"];
let filtered: Vec<&str> = keys_raw
.iter()
.copied()
.filter(|k| !k.ends_with('/'))
.collect();
assert_eq!(filtered, vec!["api-key", "db-password"]);
assert!(!filtered.contains(&"nested/"));
}
#[test]
fn vault_kv_non_string_field_value_is_skipped() {
let json_num = serde_json::json!(42);
let json_bool = serde_json::json!(true);
let json_str = serde_json::json!("hello");
assert!(json_num.as_str().is_none(), "number should not be a string");
assert!(json_bool.as_str().is_none(), "bool should not be a string");
assert_eq!(json_str.as_str(), Some("hello"));
}
#[test]
fn namespace_header_is_present_on_kv_list() {
let mut server = mockito::Server::new();
let token = "test-vault-token";
let _m = server
.mock("LIST", "/v1/secret/metadata/")
.match_header("X-Vault-Token", token)
.match_header("X-Vault-Namespace", "team-alpha")
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(r#"{"data":{"keys":[]}}"#)
.create();
let agent = test_agent();
let result = agent
.request("LIST", &format!("{}/v1/secret/metadata/", server.url()))
.set("X-Vault-Token", token)
.set("X-Vault-Namespace", "team-alpha")
.call();
assert!(
result.is_ok(),
"expected 200 with namespace header, got: {result:?}"
);
}
#[cfg(feature = "cloud-pull-1password")]
#[test]
fn op_field_label_spaces_to_screaming_snake() {
assert_eq!(op_field_label_to_key("My Secret"), "MY_SECRET");
}
#[cfg(feature = "cloud-pull-1password")]
#[test]
fn op_field_label_hyphens_to_screaming_snake() {
assert_eq!(op_field_label_to_key("db-password"), "DB_PASSWORD");
}
#[cfg(feature = "cloud-pull-1password")]
#[test]
fn op_field_label_already_upper_passthrough() {
assert_eq!(op_field_label_to_key("API_KEY"), "API_KEY");
}
#[cfg(feature = "cloud-pull-1password")]
#[test]
fn op_field_label_mixed_spaces_and_hyphens() {
assert_eq!(op_field_label_to_key("my-API key"), "MY_API_KEY");
}
}