use std::collections::{BTreeMap, HashMap};
use crate::types;
pub fn resolve_secrets(
vault: &types::Vault,
murk: &types::Murk,
pubkey: &str,
tags: &[String],
) -> BTreeMap<String, String> {
let mut values = murk.values.clone();
for (key, scoped_map) in &murk.scoped {
if let Some(value) = scoped_map.get(pubkey) {
values.insert(key.clone(), value.clone());
}
}
let allowed_keys: Option<std::collections::HashSet<&str>> = if tags.is_empty() {
None
} else {
Some(
vault
.schema
.iter()
.filter(|(_, e)| e.tags.iter().any(|t| tags.contains(t)))
.map(|(k, _)| k.as_str())
.collect(),
)
};
let mut result = BTreeMap::new();
for (k, v) in &values {
if allowed_keys
.as_ref()
.is_some_and(|a| !a.contains(k.as_str()))
{
continue;
}
result.insert(k.clone(), v.clone());
}
result
}
pub fn export_secrets(
vault: &types::Vault,
murk: &types::Murk,
pubkey: &str,
tags: &[String],
) -> BTreeMap<String, String> {
resolve_secrets(vault, murk, pubkey, tags)
.into_iter()
.map(|(k, v)| (k, v.replace('\'', "'\\''")))
.collect()
}
pub fn decrypt_vault_values(
vault: &types::Vault,
identity: &crate::crypto::MurkIdentity,
) -> HashMap<String, String> {
let pubkey = identity.pubkey_string().unwrap_or_default();
let mut values = HashMap::new();
for (key, entry) in &vault.secrets {
if !entry.shared.is_empty()
&& let Ok(value) = crate::decrypt_value(&entry.shared, identity).and_then(|pt| {
String::from_utf8(pt).map_err(|e| crate::error::MurkError::Secret(e.to_string()))
})
{
values.insert(key.clone(), value);
}
if let Some(encoded) = entry.scoped.get(&pubkey)
&& let Ok(value) = crate::decrypt_value(encoded, identity).and_then(|pt| {
String::from_utf8(pt).map_err(|e| crate::error::MurkError::Secret(e.to_string()))
})
{
values.insert(key.clone(), value);
}
}
values
}
pub fn parse_and_decrypt_values(
vault_contents: &str,
identity: &crate::crypto::MurkIdentity,
) -> Result<HashMap<String, String>, String> {
let vault = crate::vault::parse(vault_contents).map_err(|e| e.to_string())?;
Ok(decrypt_vault_values(&vault, identity))
}
#[derive(Debug, PartialEq, Eq)]
pub enum DiffKind {
Added,
Removed,
Changed,
}
#[derive(Debug)]
pub struct DiffEntry {
pub key: String,
pub kind: DiffKind,
pub old_value: Option<String>,
pub new_value: Option<String>,
}
pub fn diff_secrets(
old: &HashMap<String, String>,
new: &HashMap<String, String>,
) -> Vec<DiffEntry> {
let mut all_keys: Vec<&str> = old
.keys()
.chain(new.keys())
.map(String::as_str)
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
all_keys.sort_unstable();
let mut entries = Vec::new();
for key in all_keys {
match (old.get(key), new.get(key)) {
(None, Some(v)) => entries.push(DiffEntry {
key: key.into(),
kind: DiffKind::Added,
old_value: None,
new_value: Some(v.clone()),
}),
(Some(v), None) => entries.push(DiffEntry {
key: key.into(),
kind: DiffKind::Removed,
old_value: Some(v.clone()),
new_value: None,
}),
(Some(old_v), Some(new_v)) if old_v != new_v => entries.push(DiffEntry {
key: key.into(),
kind: DiffKind::Changed,
old_value: Some(old_v.clone()),
new_value: Some(new_v.clone()),
}),
_ => {}
}
}
entries
}
pub fn format_diff_lines(entries: &[DiffEntry], show_values: bool) -> Vec<String> {
entries
.iter()
.map(|entry| {
let symbol = match entry.kind {
DiffKind::Added => "+",
DiffKind::Removed => "-",
DiffKind::Changed => "~",
};
if show_values {
match entry.kind {
DiffKind::Added => format!(
"{symbol} {} = {}",
entry.key,
entry.new_value.as_deref().unwrap_or("")
),
DiffKind::Removed => format!(
"{symbol} {} = {}",
entry.key,
entry.old_value.as_deref().unwrap_or("")
),
DiffKind::Changed => format!(
"{symbol} {} {} → {}",
entry.key,
entry.old_value.as_deref().unwrap_or(""),
entry.new_value.as_deref().unwrap_or("")
),
}
} else {
format!("{symbol} {}", entry.key)
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use crate::types;
#[test]
fn export_secrets_basic() {
let mut vault = empty_vault();
vault.schema.insert(
"FOO".into(),
types::SchemaEntry {
description: String::new(),
example: None,
tags: vec![],
..Default::default()
},
);
let mut murk = empty_murk();
murk.values.insert("FOO".into(), "bar".into());
let exports = export_secrets(&vault, &murk, "age1pk", &[]);
assert_eq!(exports.len(), 1);
assert_eq!(exports["FOO"], "bar");
}
#[test]
fn export_secrets_scoped_override() {
let mut vault = empty_vault();
vault.schema.insert(
"KEY".into(),
types::SchemaEntry {
description: String::new(),
example: None,
tags: vec![],
..Default::default()
},
);
let mut murk = empty_murk();
murk.values.insert("KEY".into(), "shared".into());
let mut scoped = HashMap::new();
scoped.insert("age1pk".into(), "override".into());
murk.scoped.insert("KEY".into(), scoped);
let exports = export_secrets(&vault, &murk, "age1pk", &[]);
assert_eq!(exports["KEY"], "override");
}
#[test]
fn export_secrets_tag_filter() {
let mut vault = empty_vault();
vault.schema.insert(
"A".into(),
types::SchemaEntry {
description: String::new(),
example: None,
tags: vec!["db".into()],
..Default::default()
},
);
vault.schema.insert(
"B".into(),
types::SchemaEntry {
description: String::new(),
example: None,
tags: vec!["api".into()],
..Default::default()
},
);
let mut murk = empty_murk();
murk.values.insert("A".into(), "val_a".into());
murk.values.insert("B".into(), "val_b".into());
let exports = export_secrets(&vault, &murk, "age1pk", &["db".into()]);
assert_eq!(exports.len(), 1);
assert_eq!(exports["A"], "val_a");
}
#[test]
fn export_secrets_shell_escaping() {
let mut vault = empty_vault();
vault.schema.insert(
"KEY".into(),
types::SchemaEntry {
description: String::new(),
example: None,
tags: vec![],
..Default::default()
},
);
let mut murk = empty_murk();
murk.values.insert("KEY".into(), "it's a test".into());
let exports = export_secrets(&vault, &murk, "age1pk", &[]);
assert_eq!(exports["KEY"], "it'\\''s a test");
}
#[test]
fn diff_secrets_no_changes() {
let old = HashMap::from([("K".into(), "V".into())]);
let new = old.clone();
assert!(diff_secrets(&old, &new).is_empty());
}
#[test]
fn diff_secrets_added() {
let old = HashMap::new();
let new = HashMap::from([("KEY".into(), "val".into())]);
let entries = diff_secrets(&old, &new);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].kind, DiffKind::Added);
assert_eq!(entries[0].key, "KEY");
assert_eq!(entries[0].new_value.as_deref(), Some("val"));
}
#[test]
fn diff_secrets_removed() {
let old = HashMap::from([("KEY".into(), "val".into())]);
let new = HashMap::new();
let entries = diff_secrets(&old, &new);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].kind, DiffKind::Removed);
assert_eq!(entries[0].old_value.as_deref(), Some("val"));
}
#[test]
fn diff_secrets_changed() {
let old = HashMap::from([("KEY".into(), "old_val".into())]);
let new = HashMap::from([("KEY".into(), "new_val".into())]);
let entries = diff_secrets(&old, &new);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].kind, DiffKind::Changed);
assert_eq!(entries[0].old_value.as_deref(), Some("old_val"));
assert_eq!(entries[0].new_value.as_deref(), Some("new_val"));
}
#[test]
fn diff_secrets_mixed() {
let old = HashMap::from([
("KEEP".into(), "same".into()),
("REMOVE".into(), "gone".into()),
("CHANGE".into(), "old".into()),
]);
let new = HashMap::from([
("KEEP".into(), "same".into()),
("ADD".into(), "new".into()),
("CHANGE".into(), "new".into()),
]);
let entries = diff_secrets(&old, &new);
assert_eq!(entries.len(), 3);
let kinds: Vec<&DiffKind> = entries.iter().map(|e| &e.kind).collect();
assert!(kinds.contains(&&DiffKind::Added));
assert!(kinds.contains(&&DiffKind::Removed));
assert!(kinds.contains(&&DiffKind::Changed));
}
#[test]
fn diff_secrets_sorted_by_key() {
let old = HashMap::new();
let new = HashMap::from([
("Z".into(), "z".into()),
("A".into(), "a".into()),
("M".into(), "m".into()),
]);
let entries = diff_secrets(&old, &new);
let keys: Vec<&str> = entries.iter().map(|e| e.key.as_str()).collect();
assert_eq!(keys, vec!["A", "M", "Z"]);
}
#[test]
fn format_diff_lines_without_values() {
let entries = vec![
DiffEntry {
key: "NEW_KEY".into(),
kind: DiffKind::Added,
old_value: None,
new_value: Some("secret".into()),
},
DiffEntry {
key: "OLD_KEY".into(),
kind: DiffKind::Removed,
old_value: Some("old".into()),
new_value: None,
},
DiffEntry {
key: "MOD_KEY".into(),
kind: DiffKind::Changed,
old_value: Some("v1".into()),
new_value: Some("v2".into()),
},
];
let lines = format_diff_lines(&entries, false);
assert_eq!(lines, vec!["+ NEW_KEY", "- OLD_KEY", "~ MOD_KEY"]);
}
#[test]
fn format_diff_lines_with_values() {
let entries = vec![
DiffEntry {
key: "KEY".into(),
kind: DiffKind::Added,
old_value: None,
new_value: Some("new_val".into()),
},
DiffEntry {
key: "KEY2".into(),
kind: DiffKind::Changed,
old_value: Some("old".into()),
new_value: Some("new".into()),
},
];
let lines = format_diff_lines(&entries, true);
assert_eq!(lines[0], "+ KEY = new_val");
assert_eq!(lines[1], "~ KEY2 old → new");
}
#[test]
fn format_diff_lines_empty() {
let lines = format_diff_lines(&[], false);
assert!(lines.is_empty());
}
#[test]
fn resolve_secrets_basic() {
let mut vault = empty_vault();
vault.schema.insert(
"FOO".into(),
types::SchemaEntry {
description: String::new(),
example: None,
tags: vec![],
..Default::default()
},
);
let mut murk = empty_murk();
murk.values.insert("FOO".into(), "bar".into());
let resolved = resolve_secrets(&vault, &murk, "age1pk", &[]);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved["FOO"], "bar");
}
#[test]
fn resolve_secrets_no_escaping() {
let mut vault = empty_vault();
vault.schema.insert(
"KEY".into(),
types::SchemaEntry {
description: String::new(),
example: None,
tags: vec![],
..Default::default()
},
);
let mut murk = empty_murk();
murk.values.insert("KEY".into(), "it's a test".into());
let resolved = resolve_secrets(&vault, &murk, "age1pk", &[]);
assert_eq!(resolved["KEY"], "it's a test");
}
#[test]
fn resolve_secrets_scoped_override() {
let mut vault = empty_vault();
vault.schema.insert(
"KEY".into(),
types::SchemaEntry {
description: String::new(),
example: None,
tags: vec![],
..Default::default()
},
);
let mut murk = empty_murk();
murk.values.insert("KEY".into(), "shared".into());
let mut scoped = HashMap::new();
scoped.insert("age1pk".into(), "override".into());
murk.scoped.insert("KEY".into(), scoped);
let resolved = resolve_secrets(&vault, &murk, "age1pk", &[]);
assert_eq!(resolved["KEY"], "override");
}
#[test]
fn resolve_secrets_tag_filter() {
let mut vault = empty_vault();
vault.schema.insert(
"A".into(),
types::SchemaEntry {
description: String::new(),
example: None,
tags: vec!["db".into()],
..Default::default()
},
);
vault.schema.insert(
"B".into(),
types::SchemaEntry {
description: String::new(),
example: None,
tags: vec!["api".into()],
..Default::default()
},
);
let mut murk = empty_murk();
murk.values.insert("A".into(), "val_a".into());
murk.values.insert("B".into(), "val_b".into());
let resolved = resolve_secrets(&vault, &murk, "age1pk", &["db".into()]);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved["A"], "val_a");
}
#[test]
fn resolve_secrets_tag_in_schema_but_no_secret() {
let mut vault = empty_vault();
vault.schema.insert(
"ORPHAN".into(),
types::SchemaEntry {
description: "orphan key".into(),
example: None,
tags: vec!["db".into()],
..Default::default()
},
);
vault.schema.insert(
"REAL".into(),
types::SchemaEntry {
description: "has a value".into(),
example: None,
tags: vec!["db".into()],
..Default::default()
},
);
let mut murk = empty_murk();
murk.values.insert("REAL".into(), "real_val".into());
let resolved = resolve_secrets(&vault, &murk, "age1pk", &["db".into()]);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved["REAL"], "real_val");
assert!(!resolved.contains_key("ORPHAN"));
}
#[test]
fn resolve_secrets_scoped_pubkey_not_in_recipients() {
let mut vault = empty_vault();
vault.recipients = vec!["age1alice".into()];
vault.schema.insert(
"KEY".into(),
types::SchemaEntry {
description: String::new(),
example: None,
tags: vec![],
..Default::default()
},
);
let mut murk = empty_murk();
murk.values.insert("KEY".into(), "shared".into());
let mut scoped = HashMap::new();
scoped.insert("age1outsider".into(), "outsider_val".into());
murk.scoped.insert("KEY".into(), scoped);
let resolved = resolve_secrets(&vault, &murk, "age1outsider", &[]);
assert_eq!(resolved["KEY"], "outsider_val");
let resolved_alice = resolve_secrets(&vault, &murk, "age1alice", &[]);
assert_eq!(resolved_alice["KEY"], "shared");
}
#[test]
fn export_secrets_empty_vault() {
let vault = empty_vault();
let murk = empty_murk();
let exports = export_secrets(&vault, &murk, "age1pk", &[]);
assert!(exports.is_empty());
}
#[test]
fn decrypt_vault_values_basic() {
let (secret, pubkey) = generate_keypair();
let recipient = make_recipient(&pubkey);
let identity = make_identity(&secret);
let mut vault = empty_vault();
vault.recipients = vec![pubkey];
vault.secrets.insert(
"KEY1".into(),
types::SecretEntry {
shared: crate::encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
scoped: std::collections::BTreeMap::new(),
},
);
vault.secrets.insert(
"KEY2".into(),
types::SecretEntry {
shared: crate::encrypt_value(b"val2", &[recipient]).unwrap(),
scoped: std::collections::BTreeMap::new(),
},
);
let values = crate::export::decrypt_vault_values(&vault, &identity);
assert_eq!(values.len(), 2);
assert_eq!(values["KEY1"], "val1");
assert_eq!(values["KEY2"], "val2");
}
#[test]
fn decrypt_vault_values_wrong_key_skips() {
let (_, pubkey) = generate_keypair();
let recipient = make_recipient(&pubkey);
let (wrong_secret, _) = generate_keypair();
let wrong_identity = make_identity(&wrong_secret);
let mut vault = empty_vault();
vault.recipients = vec![pubkey];
vault.secrets.insert(
"KEY1".into(),
types::SecretEntry {
shared: crate::encrypt_value(b"val1", &[recipient]).unwrap(),
scoped: std::collections::BTreeMap::new(),
},
);
let values = crate::export::decrypt_vault_values(&vault, &wrong_identity);
assert!(values.is_empty());
}
#[test]
fn decrypt_vault_values_empty_vault() {
let (secret, _) = generate_keypair();
let identity = make_identity(&secret);
let vault = empty_vault();
let values = crate::export::decrypt_vault_values(&vault, &identity);
assert!(values.is_empty());
}
#[test]
fn diff_secrets_both_empty() {
let old = HashMap::new();
let new = HashMap::new();
assert!(diff_secrets(&old, &new).is_empty());
}
#[test]
fn parse_and_decrypt_values_roundtrip() {
let (secret, pubkey) = generate_keypair();
let recipient = make_recipient(&pubkey);
let identity = make_identity(&secret);
let mut vault = empty_vault();
vault.recipients = vec![pubkey];
vault.secrets.insert(
"KEY1".into(),
types::SecretEntry {
shared: crate::encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
scoped: std::collections::BTreeMap::new(),
},
);
vault.secrets.insert(
"KEY2".into(),
types::SecretEntry {
shared: crate::encrypt_value(b"val2", &[recipient]).unwrap(),
scoped: std::collections::BTreeMap::new(),
},
);
let json = serde_json::to_string(&vault).unwrap();
let values = parse_and_decrypt_values(&json, &identity).unwrap();
assert_eq!(values.len(), 2);
assert_eq!(values["KEY1"], "val1");
assert_eq!(values["KEY2"], "val2");
}
#[test]
fn parse_and_decrypt_values_invalid_json() {
let (secret, _) = generate_keypair();
let identity = make_identity(&secret);
let result = parse_and_decrypt_values("not valid json", &identity);
assert!(result.is_err());
}
#[test]
fn parse_and_decrypt_values_wrong_key() {
let (_, pubkey) = generate_keypair();
let recipient = make_recipient(&pubkey);
let (wrong_secret, _) = generate_keypair();
let wrong_identity = make_identity(&wrong_secret);
let mut vault = empty_vault();
vault.recipients = vec![pubkey];
vault.secrets.insert(
"KEY1".into(),
types::SecretEntry {
shared: crate::encrypt_value(b"val1", &[recipient]).unwrap(),
scoped: std::collections::BTreeMap::new(),
},
);
let json = serde_json::to_string(&vault).unwrap();
let values = parse_and_decrypt_values(&json, &wrong_identity).unwrap();
assert!(values.is_empty());
}
}