use assert_cmd::Command;
use predicates::prelude::*;
use std::fs;
use tempfile::TempDir;
fn murk(dir: &TempDir, key: &str) -> Command {
let mut cmd = Command::cargo_bin("murk").unwrap();
cmd.current_dir(dir.path())
.env("MURK_KEY", key)
.env("HOME", dir.path())
.env_remove("MURK_KEY_FILE");
cmd
}
fn init_vault(dir: &TempDir) -> (String, String) {
Command::cargo_bin("murk")
.unwrap()
.args(["init", "--vault", "test.murk"])
.current_dir(dir.path())
.env("HOME", dir.path())
.write_stdin("testuser\n")
.assert()
.success();
let env_contents = fs::read_to_string(dir.path().join(".env")).unwrap();
let murk_key = if let Some(path) = env_contents.lines().find_map(|l| {
l.strip_prefix("export MURK_KEY_FILE=")
.or_else(|| l.strip_prefix("MURK_KEY_FILE="))
}) {
let path = path.trim().trim_matches('\'');
fs::read_to_string(path).unwrap().trim().to_string()
} else {
panic!("no MURK_KEY_FILE found in .env");
};
let identity: age::x25519::Identity = murk_key.parse().unwrap();
let pubkey = identity.to_public().to_string();
(murk_key, pubkey)
}
#[test]
fn load_empty_vault_file() {
let dir = TempDir::new().unwrap();
let (key, _) = init_vault(&dir);
fs::write(dir.path().join("test.murk"), "").unwrap();
murk(&dir, &key)
.args(["ls", "--vault", "test.murk"])
.assert()
.failure()
.stderr(predicate::str::contains("vault parse error"));
}
#[test]
fn load_vault_not_json() {
let dir = TempDir::new().unwrap();
let (key, _) = init_vault(&dir);
fs::write(dir.path().join("test.murk"), "this is not json at all").unwrap();
murk(&dir, &key)
.args(["ls", "--vault", "test.murk"])
.assert()
.failure()
.stderr(predicate::str::contains("vault parse error"));
}
#[test]
fn load_vault_wrong_version() {
let dir = TempDir::new().unwrap();
let (key, _) = init_vault(&dir);
let json = r#"{"version":"99.0","created":"2026-01-01T00:00:00Z","vault_name":".murk","recipients":[],"schema":{},"secrets":{},"meta":""}"#;
fs::write(dir.path().join("test.murk"), json).unwrap();
murk(&dir, &key)
.args(["ls", "--vault", "test.murk"])
.assert()
.failure()
.stderr(predicate::str::contains("unsupported vault version"));
}
#[test]
fn load_vault_missing_fields() {
let dir = TempDir::new().unwrap();
let (key, _) = init_vault(&dir);
fs::write(dir.path().join("test.murk"), r#"{"version":"2.0"}"#).unwrap();
murk(&dir, &key)
.args(["ls", "--vault", "test.murk"])
.assert()
.failure();
}
#[test]
fn load_vault_extra_fields_accepted() {
let dir = TempDir::new().unwrap();
let (key, _) = init_vault(&dir);
let contents = fs::read_to_string(dir.path().join("test.murk")).unwrap();
let mut val: serde_json::Value = serde_json::from_str(&contents).unwrap();
val["unknown_field"] = serde_json::json!("should be ignored");
fs::write(
dir.path().join("test.murk"),
serde_json::to_string_pretty(&val).unwrap(),
)
.unwrap();
murk(&dir, &key)
.args(["ls", "--vault", "test.murk"])
.assert()
.success();
}
#[test]
fn load_vault_huge_key_name() {
let dir = TempDir::new().unwrap();
let (key, _) = init_vault(&dir);
let contents = fs::read_to_string(dir.path().join("test.murk")).unwrap();
let mut val: serde_json::Value = serde_json::from_str(&contents).unwrap();
let huge_key = "A".repeat(10_000);
val["schema"][&huge_key] = serde_json::json!({"description": "huge"});
fs::write(
dir.path().join("test.murk"),
serde_json::to_string_pretty(&val).unwrap(),
)
.unwrap();
murk(&dir, &key)
.args(["ls", "--vault", "test.murk"])
.assert()
.success();
}
#[test]
fn import_env_with_null_bytes() {
let dir = TempDir::new().unwrap();
let (key, _) = init_vault(&dir);
murk(&dir, &key)
.args(["add", "EXISTING", "--vault", "test.murk"])
.write_stdin("val\n")
.assert()
.success();
fs::write(dir.path().join("bad.env"), "KEY=val\x00ue\n").unwrap();
murk(&dir, &key)
.args(["import", "bad.env", "--vault", "test.murk", "--force"])
.assert()
.success();
}
#[test]
fn import_empty_file() {
let dir = TempDir::new().unwrap();
let (key, _) = init_vault(&dir);
fs::write(dir.path().join("empty.env"), "").unwrap();
murk(&dir, &key)
.args(["import", "empty.env", "--vault", "test.murk"])
.assert()
.success()
.stderr(predicate::str::contains("no secrets found"));
}
#[test]
fn import_only_comments() {
let dir = TempDir::new().unwrap();
let (key, _) = init_vault(&dir);
fs::write(
dir.path().join("comments.env"),
"# just\n# comments\n# here\n",
)
.unwrap();
murk(&dir, &key)
.args(["import", "comments.env", "--vault", "test.murk"])
.assert()
.success()
.stderr(predicate::str::contains("no secrets found"));
}
#[cfg(unix)]
#[test]
fn lock_file_symlink_rejected() {
let dir = TempDir::new().unwrap();
let (key, _) = init_vault(&dir);
let lock_path = dir.path().join("test.murk.lock");
std::os::unix::fs::symlink("/tmp/evil_target", &lock_path).unwrap();
murk(&dir, &key)
.args(["add", "KEY", "--vault", "test.murk"])
.write_stdin("val\n")
.assert()
.failure();
}
#[cfg(unix)]
#[test]
fn env_file_symlink_rejected() {
let dir = TempDir::new().unwrap();
let env_path = dir.path().join(".env");
std::os::unix::fs::symlink("/tmp/evil_env_target", &env_path).unwrap();
Command::cargo_bin("murk")
.unwrap()
.args(["init", "--vault", "test.murk"])
.current_dir(dir.path())
.write_stdin("testuser\n")
.assert()
.failure()
.stderr(predicate::str::contains("symlink"));
}
#[cfg(unix)]
#[test]
fn world_readable_key_file_rejected() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let (key, _) = init_vault(&dir);
let loose_key = dir.path().join("loose.key");
fs::write(&loose_key, &key).unwrap();
fs::set_permissions(&loose_key, fs::Permissions::from_mode(0o644)).unwrap();
let fake_home = TempDir::new().unwrap();
Command::cargo_bin("murk")
.unwrap()
.args(["export", "--vault", "test.murk"])
.current_dir(dir.path())
.env("MURK_KEY_FILE", loose_key.to_str().unwrap())
.env_remove("MURK_KEY")
.env("HOME", fake_home.path())
.assert()
.failure()
.stderr(predicate::str::contains("readable by others"));
murk(&dir, &key)
.args(["ls", "--vault", "test.murk"])
.assert()
.success();
}
#[test]
fn merge_driver_empty_files() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("base.murk"), "").unwrap();
fs::write(dir.path().join("ours.murk"), "").unwrap();
fs::write(dir.path().join("theirs.murk"), "").unwrap();
Command::cargo_bin("murk")
.unwrap()
.args([
"merge-driver",
dir.path().join("base.murk").to_str().unwrap(),
dir.path().join("ours.murk").to_str().unwrap(),
dir.path().join("theirs.murk").to_str().unwrap(),
])
.assert()
.failure()
.stderr(predicate::str::contains("parsing base"));
}
#[test]
fn merge_driver_mismatched_versions() {
let dir = TempDir::new().unwrap();
let v2 = r#"{"version":"2.0","created":"2026-01-01T00:00:00Z","vault_name":".murk","recipients":["age1a"],"schema":{},"secrets":{},"meta":""}"#;
let v99 = r#"{"version":"99.0","created":"2026-01-01T00:00:00Z","vault_name":".murk","recipients":["age1a"],"schema":{},"secrets":{},"meta":""}"#;
fs::write(dir.path().join("base.murk"), v2).unwrap();
fs::write(dir.path().join("ours.murk"), v2).unwrap();
fs::write(dir.path().join("theirs.murk"), v99).unwrap();
Command::cargo_bin("murk")
.unwrap()
.args([
"merge-driver",
dir.path().join("base.murk").to_str().unwrap(),
dir.path().join("ours.murk").to_str().unwrap(),
dir.path().join("theirs.murk").to_str().unwrap(),
])
.assert()
.failure()
.stderr(predicate::str::contains("unsupported vault version"));
}
#[test]
fn add_invalid_key_name() {
let dir = TempDir::new().unwrap();
let (key, _) = init_vault(&dir);
murk(&dir, &key)
.args(["add", "BAD KEY", "--vault", "test.murk"])
.write_stdin("val\n")
.assert()
.failure();
}
#[test]
fn add_empty_key_name() {
let dir = TempDir::new().unwrap();
let (key, _) = init_vault(&dir);
murk(&dir, &key)
.args(["add", "", "--vault", "test.murk"])
.write_stdin("val\n")
.assert()
.failure();
}
#[test]
fn operations_on_missing_vault() {
let dir = TempDir::new().unwrap();
for cmd in &["ls", "export", "info"] {
Command::cargo_bin("murk")
.unwrap()
.args([cmd, "--vault", "nonexistent.murk"])
.current_dir(dir.path())
.env("MURK_KEY", "AGE-SECRET-KEY-1FAKE")
.assert()
.failure();
}
}
#[test]
fn scan_binary_files_skipped() {
let dir = TempDir::new().unwrap();
let (key, _) = init_vault(&dir);
murk(&dir, &key)
.args(["add", "SECRET", "--vault", "test.murk"])
.write_stdin("supersecretvalue123\n")
.assert()
.success();
let project = dir.path().join("project");
fs::create_dir(&project).unwrap();
let mut binary_content = vec![0u8; 100];
binary_content.extend_from_slice(b"supersecretvalue123");
binary_content.extend_from_slice(&[0xFF, 0xFE, 0x00]);
fs::write(project.join("data.bin"), &binary_content).unwrap();
fs::write(project.join("config.txt"), "password=supersecretvalue123").unwrap();
let output = murk(&dir, &key)
.args(["scan", project.to_str().unwrap(), "--vault", "test.murk"])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("config.txt"),
"should find leak in text file"
);
}
#[test]
fn invalid_murk_key_fails() {
let dir = TempDir::new().unwrap();
let (_key, _) = init_vault(&dir);
murk(&dir, "not-a-valid-age-key")
.args(["ls", "--vault", "test.murk"])
.assert()
.success();
murk(&dir, "not-a-valid-age-key")
.args(["export", "--vault", "test.murk"])
.assert()
.failure(); }
#[test]
fn empty_murk_key_fails() {
let dir = TempDir::new().unwrap();
let (_key, _) = init_vault(&dir);
let fake_home = TempDir::new().unwrap();
fs::remove_file(dir.path().join(".env")).ok();
Command::cargo_bin("murk")
.unwrap()
.args(["export", "--vault", "test.murk"])
.current_dir(dir.path())
.env("MURK_KEY", "")
.env_remove("MURK_KEY_FILE")
.env("HOME", fake_home.path())
.assert()
.failure()
.stderr(predicate::str::contains("MURK_KEY not set"));
}
#[test]
fn vault_with_empty_secrets_and_schema() {
let dir = TempDir::new().unwrap();
let (key, _) = init_vault(&dir);
murk(&dir, &key)
.args(["ls", "--vault", "test.murk"])
.assert()
.success();
murk(&dir, &key)
.args(["info", "--vault", "test.murk"])
.assert()
.success();
}
#[test]
fn get_nonexistent_key() {
let dir = TempDir::new().unwrap();
let (key, _) = init_vault(&dir);
murk(&dir, &key)
.args(["get", "DOES_NOT_EXIST", "--vault", "test.murk"])
.assert()
.failure();
}
#[test]
fn rm_nonexistent_key_is_idempotent() {
let dir = TempDir::new().unwrap();
let (key, _) = init_vault(&dir);
murk(&dir, &key)
.args(["rm", "DOES_NOT_EXIST", "--vault", "test.murk"])
.assert()
.success();
}
#[test]
fn revoke_nonexistent_recipient() {
let dir = TempDir::new().unwrap();
let (key, _) = init_vault(&dir);
murk(&dir, &key)
.args(["circle", "revoke", "nobody", "--vault", "test.murk"])
.assert()
.failure()
.stderr(predicate::str::contains("not found"));
}
#[test]
fn revoke_last_recipient_fails() {
let dir = TempDir::new().unwrap();
let (key, pubkey) = init_vault(&dir);
murk(&dir, &key)
.args(["circle", "revoke", &pubkey, "--vault", "test.murk"])
.assert()
.failure()
.stderr(predicate::str::contains("last recipient"));
}
#[test]
fn authorize_invalid_pubkey() {
let dir = TempDir::new().unwrap();
let (key, _) = init_vault(&dir);
murk(&dir, &key)
.args([
"circle",
"authorize",
"not-a-valid-pubkey",
"--vault",
"test.murk",
])
.assert()
.failure();
}
#[test]
fn double_add_updates_value() {
let dir = TempDir::new().unwrap();
let (key, _) = init_vault(&dir);
murk(&dir, &key)
.args(["add", "KEY", "--vault", "test.murk"])
.write_stdin("first\n")
.assert()
.success();
murk(&dir, &key)
.args(["add", "KEY", "--vault", "test.murk"])
.write_stdin("second\n")
.assert()
.success();
murk(&dir, &key)
.args(["get", "KEY", "--vault", "test.murk"])
.assert()
.success()
.stdout(predicate::str::contains("second"));
}
#[test]
fn import_collision_blocked_without_force() {
let dir = TempDir::new().unwrap();
let (key, _) = init_vault(&dir);
murk(&dir, &key)
.args(["add", "EXISTING", "--vault", "test.murk"])
.write_stdin("original\n")
.assert()
.success();
fs::write(dir.path().join("collision.env"), "EXISTING=overwritten\n").unwrap();
murk(&dir, &key)
.args(["import", "collision.env", "--vault", "test.murk"])
.assert()
.failure()
.stderr(predicate::str::contains("already exists"));
murk(&dir, &key)
.args(["get", "EXISTING", "--vault", "test.murk"])
.assert()
.success()
.stdout(predicate::str::contains("original"));
}
#[test]
fn import_collision_allowed_with_force() {
let dir = TempDir::new().unwrap();
let (key, _) = init_vault(&dir);
murk(&dir, &key)
.args(["add", "EXISTING", "--vault", "test.murk"])
.write_stdin("original\n")
.assert()
.success();
fs::write(dir.path().join("collision.env"), "EXISTING=overwritten\n").unwrap();
murk(&dir, &key)
.args(["import", "collision.env", "--force", "--vault", "test.murk"])
.assert()
.success();
murk(&dir, &key)
.args(["get", "EXISTING", "--vault", "test.murk"])
.assert()
.success()
.stdout(predicate::str::contains("overwritten"));
}
#[test]
fn tampered_ciphertext_fails_integrity() {
let dir = TempDir::new().unwrap();
let (key, _) = init_vault(&dir);
murk(&dir, &key)
.args(["add", "SECRET", "--vault", "test.murk"])
.write_stdin("realvalue\n")
.assert()
.success();
let contents = fs::read_to_string(dir.path().join("test.murk")).unwrap();
let mut val: serde_json::Value = serde_json::from_str(&contents).unwrap();
val["secrets"]["SECRET"]["shared"] = serde_json::json!("dGFtcGVyZWQ=");
fs::write(
dir.path().join("test.murk"),
serde_json::to_string_pretty(&val).unwrap(),
)
.unwrap();
murk(&dir, &key)
.args(["get", "SECRET", "--vault", "test.murk"])
.assert()
.failure()
.stderr(predicate::str::contains("integrity").or(predicate::str::contains("tampered")));
}
#[test]
fn tampered_recipients_fails_integrity() {
let dir = TempDir::new().unwrap();
let (key, _) = init_vault(&dir);
murk(&dir, &key)
.args(["add", "SECRET", "--vault", "test.murk"])
.write_stdin("realvalue\n")
.assert()
.success();
let contents = fs::read_to_string(dir.path().join("test.murk")).unwrap();
let mut val: serde_json::Value = serde_json::from_str(&contents).unwrap();
val["recipients"]
.as_array_mut()
.unwrap()
.push(serde_json::json!(
"age1injected000000000000000000000000000000000000000000000000000"
));
fs::write(
dir.path().join("test.murk"),
serde_json::to_string_pretty(&val).unwrap(),
)
.unwrap();
murk(&dir, &key)
.args(["get", "SECRET", "--vault", "test.murk"])
.assert()
.failure()
.stderr(predicate::str::contains("integrity").or(predicate::str::contains("tampered")));
}