use std::collections::HashMap;
use crate::errors::{SafeError, SafeResult};
use crate::vault::{validate_secret_key, Vault};
pub fn validate_namespace_segment(ns: &str) -> SafeResult<()> {
if ns.is_empty() {
return Err(SafeError::InvalidVault {
reason: "namespace must not be empty".into(),
});
}
if ns.contains('/') {
return Err(SafeError::InvalidVault {
reason: "namespace must be a single segment (no '/' in the name)".into(),
});
}
validate_secret_key(&format!("{ns}/x"))
}
pub fn keys_under_namespace_prefix<'a>(list: &[&'a str], prefix: &str) -> Vec<&'a str> {
list.iter()
.copied()
.filter(|k| *k == prefix || k.starts_with(&format!("{prefix}/")))
.collect()
}
pub fn dest_key_namespace_reprefix(from_prefix: &str, to_prefix: &str, key: &str) -> String {
if key == from_prefix {
to_prefix.to_string()
} else {
let rest = key
.strip_prefix(from_prefix)
.and_then(|s| s.strip_prefix('/'))
.unwrap_or("");
format!("{to_prefix}/{rest}")
}
}
pub fn plan_namespace_bulk(
vault: &Vault,
from: &str,
to: &str,
force: bool,
) -> SafeResult<Vec<(String, String)>> {
validate_namespace_segment(from)?;
validate_namespace_segment(to)?;
if from == to {
return Err(SafeError::InvalidVault {
reason: "source and destination namespace are the same".into(),
});
}
let list: Vec<String> = vault.list().into_iter().map(String::from).collect();
let list_ref: Vec<&str> = list.iter().map(String::as_str).collect();
let matches = keys_under_namespace_prefix(&list_ref, from);
let mut pairs = Vec::with_capacity(matches.len());
for key in matches {
let dest = dest_key_namespace_reprefix(from, to, key);
if !force && vault.file().secrets.contains_key(&dest) {
return Err(SafeError::InvalidVault {
reason: format!(
"destination key '{dest}' already exists — remove it or use CLI with --force"
),
});
}
pairs.push((key.to_string(), dest));
}
Ok(pairs)
}
pub fn apply_namespace_copy(vault: &mut Vault, pairs: &[(String, String)]) -> SafeResult<()> {
for (src, dest) in pairs {
let value = vault.get(src)?;
let tags = vault
.file()
.secrets
.get(src)
.map(|e| e.tags.clone())
.unwrap_or_default();
if let Some((rewritten_value, rewritten_tags)) =
rewrite_alias_for_namespace_copy_or_move(src, dest, value.as_str(), &tags)
{
vault.set(dest, &rewritten_value, rewritten_tags)?;
} else {
vault.set(dest, value.as_str(), tags)?;
}
}
Ok(())
}
pub fn apply_namespace_move(
vault: &mut Vault,
pairs: &[(String, String)],
force: bool,
) -> SafeResult<()> {
for (src, dest) in pairs {
let value = vault.get(src)?;
let tags = vault
.file()
.secrets
.get(src)
.map(|e| e.tags.clone())
.unwrap_or_default();
let rewritten = rewrite_alias_for_namespace_copy_or_move(src, dest, value.as_str(), &tags);
vault.rename_key(src, dest, force)?;
if let Some((rewritten_value, rewritten_tags)) = rewritten {
vault.set(dest, &rewritten_value, rewritten_tags)?;
}
}
Ok(())
}
fn rewrite_alias_for_namespace_copy_or_move(
src: &str,
dest: &str,
value: &str,
tags: &HashMap<String, String>,
) -> Option<(String, HashMap<String, String>)> {
if tags.get("type").map(String::as_str) != Some("alias") {
return None;
}
let target = tags
.get("target")
.map(String::as_str)
.or_else(|| value.strip_prefix("@alias:"))?;
let from_prefix = src.split('/').next().unwrap_or(src);
let to_prefix = dest.split('/').next().unwrap_or(dest);
if target != from_prefix && !target.starts_with(&format!("{from_prefix}/")) {
return None;
}
let rewritten_target = dest_key_namespace_reprefix(from_prefix, to_prefix, target);
let mut rewritten_tags = tags.clone();
rewritten_tags.insert("target".into(), rewritten_target.clone());
Some((format!("@alias:{rewritten_target}"), rewritten_tags))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn dest_key_namespace_reprefix_examples() {
assert_eq!(
dest_key_namespace_reprefix("prod", "staging", "prod/API_KEY"),
"staging/API_KEY"
);
assert_eq!(
dest_key_namespace_reprefix("prod", "staging", "prod"),
"staging"
);
}
#[test]
fn keys_under_namespace_prefix_includes_exact_key() {
let list = vec!["prod", "prod/A", "prod/B", "other"];
let m = keys_under_namespace_prefix(&list, "prod");
assert_eq!(m, vec!["prod", "prod/A", "prod/B"]);
}
#[test]
fn plan_empty_namespace_ok() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("v.json");
let mut v = Vault::create(path.as_path(), b"pw").unwrap();
v.set("other/X", "1", HashMap::new()).unwrap();
let p = plan_namespace_bulk(&v, "prod", "staging", false).unwrap();
assert!(p.is_empty());
}
#[test]
fn validate_namespace_segment_rejects_empty() {
let result = validate_namespace_segment("");
assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
}
#[test]
fn validate_namespace_segment_rejects_slash() {
let result = validate_namespace_segment("a/b");
assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
}
#[test]
fn validate_namespace_segment_accepts_valid_names() {
assert!(validate_namespace_segment("prod").is_ok());
assert!(validate_namespace_segment("staging").is_ok());
assert!(validate_namespace_segment("my_app").is_ok());
assert!(validate_namespace_segment("ns2").is_ok());
}
#[test]
fn plan_namespace_bulk_rejects_same_source_and_dest() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("v.json");
let v = Vault::create(path.as_path(), b"pw").unwrap();
let result = plan_namespace_bulk(&v, "prod", "prod", false);
assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
}
#[test]
fn plan_namespace_bulk_rejects_dest_conflict_without_force() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("v.json");
let mut v = Vault::create(path.as_path(), b"pw").unwrap();
v.set("prod/KEY", "old", HashMap::new()).unwrap();
v.set("staging/KEY", "existing", HashMap::new()).unwrap();
let result = plan_namespace_bulk(&v, "prod", "staging", false);
assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
}
#[test]
fn plan_namespace_bulk_allows_conflict_with_force() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("v.json");
let mut v = Vault::create(path.as_path(), b"pw").unwrap();
v.set("prod/KEY", "new", HashMap::new()).unwrap();
v.set("staging/KEY", "existing", HashMap::new()).unwrap();
let pairs = plan_namespace_bulk(&v, "prod", "staging", true).unwrap();
assert_eq!(pairs.len(), 1);
assert_eq!(
pairs[0],
("prod/KEY".to_string(), "staging/KEY".to_string())
);
}
#[test]
fn apply_namespace_copy_creates_dest_keys_with_correct_values() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("v.json");
let mut v = Vault::create(path.as_path(), b"pw").unwrap();
v.set("prod/API_KEY", "secret-value", HashMap::new())
.unwrap();
v.set("prod/DB_URL", "postgres://host/db", HashMap::new())
.unwrap();
let pairs = plan_namespace_bulk(&v, "prod", "staging", false).unwrap();
apply_namespace_copy(&mut v, &pairs).unwrap();
assert_eq!(*v.get("staging/API_KEY").unwrap(), "secret-value");
assert_eq!(*v.get("staging/DB_URL").unwrap(), "postgres://host/db");
}
#[test]
fn apply_namespace_copy_leaves_source_keys_intact() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("v.json");
let mut v = Vault::create(path.as_path(), b"pw").unwrap();
v.set("prod/API_KEY", "secret-value", HashMap::new())
.unwrap();
let pairs = plan_namespace_bulk(&v, "prod", "staging", false).unwrap();
apply_namespace_copy(&mut v, &pairs).unwrap();
assert_eq!(*v.get("prod/API_KEY").unwrap(), "secret-value");
}
#[test]
fn apply_namespace_move_renames_keys_to_dest() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("v.json");
let mut v = Vault::create(path.as_path(), b"pw").unwrap();
v.set("prod/API_KEY", "secret-value", HashMap::new())
.unwrap();
let pairs = plan_namespace_bulk(&v, "prod", "staging", false).unwrap();
apply_namespace_move(&mut v, &pairs, false).unwrap();
assert_eq!(*v.get("staging/API_KEY").unwrap(), "secret-value");
}
#[test]
fn apply_namespace_move_removes_source_keys() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("v.json");
let mut v = Vault::create(path.as_path(), b"pw").unwrap();
v.set("prod/API_KEY", "secret-value", HashMap::new())
.unwrap();
let pairs = plan_namespace_bulk(&v, "prod", "staging", false).unwrap();
apply_namespace_move(&mut v, &pairs, false).unwrap();
assert!(matches!(
v.get("prod/API_KEY"),
Err(SafeError::SecretNotFound { .. })
));
}
}