tsafe-core 1.0.12

Core runtime engine for tsafe — encrypted credential storage, process injection contracts, audit log, RBAC
Documentation
//! Bulk copy/move all vault keys under a namespace prefix (`FROM/` → `TO/`).
//!
//! Shared by the CLI (`tsafe ns copy|move`) and the TUI.

use std::collections::HashMap;

use crate::errors::{SafeError, SafeResult};
use crate::vault::{validate_secret_key, Vault};

/// Validate a single namespace segment (no `/`), suitable as `NS/KEY` prefix.
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"))
}

/// Keys belonging to `prefix`: the key exactly `prefix` or any `prefix/...`.
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()
}

/// Map a full key under `from_prefix` to the same suffix under `to_prefix`.
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}")
    }
}

/// Build `(src, dest)` pairs for every key under `from`; checks `to` label and destination conflicts.
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)
}

/// Copy each `src` to `dest` (decrypt + set). Overwrites `dest` if present (use after `plan` with `force`).
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(())
}

/// Rename each `src` to `dest` preserving ciphertext history.
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());
    }

    // ── validate_namespace_segment ────────────────────────────────────────────

    #[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());
    }

    // ── plan_namespace_bulk ───────────────────────────────────────────────────

    #[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())
        );
    }

    // ── apply_namespace_copy ──────────────────────────────────────────────────

    #[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();

        // Original key must still be present after a copy.
        assert_eq!(*v.get("prod/API_KEY").unwrap(), "secret-value");
    }

    // ── apply_namespace_move ──────────────────────────────────────────────────

    #[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();

        // Source key must be gone after a move.
        assert!(matches!(
            v.get("prod/API_KEY"),
            Err(SafeError::SecretNotFound { .. })
        ));
    }
}