use std::{fs, process::Command};
use serde_json::Value;
use tempfile::TempDir;
use super::{assert_json_recovery_advice_fields, heddle, heddle_output};
fn write_test_private_key(path: &std::path::Path, pem: &str) {
objects::fs_atomic::write_file_atomic_secret(path, pem.as_bytes())
.expect("write test private key");
}
fn setup_repo_with_secret() -> (TempDir, String) {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).unwrap();
fs::create_dir_all(temp.path().join("config")).unwrap();
fs::write(
temp.path().join("config/secrets.toml"),
b"api_token = \"super-secret-leaked-value\"\n",
)
.unwrap();
heddle(&["capture", "-m", "leak the secret"], Some(temp.path())).unwrap();
let raw = heddle(
&["--output", "json", "log", "--limit", "1"],
Some(temp.path()),
)
.unwrap();
let value: Value = serde_json::from_str(&raw).unwrap();
let state = value["states"][0]["change_id"]
.as_str()
.expect("log --output json should expose change_id")
.to_string();
(temp, state)
}
fn setup_git_overlay_repo_with_secret() -> (TempDir, String) {
let temp = TempDir::new().unwrap();
git_overlay_fixture_cmd(temp.path(), &["init", "-b", "main"]);
git_overlay_fixture_cmd(temp.path(), &["config", "user.name", "Heddle Test"]);
git_overlay_fixture_cmd(temp.path(), &["config", "user.email", "heddle@example.com"]);
fs::write(temp.path().join("README.md"), "seed\n").unwrap();
git_overlay_fixture_cmd(temp.path(), &["add", "."]);
git_overlay_fixture_cmd(temp.path(), &["commit", "-m", "seed"]);
heddle(&["adopt", "--ref", "main"], Some(temp.path())).unwrap();
fs::create_dir_all(temp.path().join("config")).unwrap();
fs::write(
temp.path().join("config/secrets.toml"),
b"api_token = \"super-secret-leaked-value\"\n",
)
.unwrap();
heddle(
&["--output", "json", "commit", "-m", "leak the secret"],
Some(temp.path()),
)
.expect("heddle commit");
let raw = heddle(
&["--output", "json", "log", "--limit", "1"],
Some(temp.path()),
)
.unwrap();
let value: Value = serde_json::from_str(&raw).unwrap();
let state = value["states"][0]["change_id"]
.as_str()
.expect("log --output json should expose change_id")
.to_string();
(temp, state)
}
fn git_overlay_fixture_cmd(path: &std::path::Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(path)
.output()
.unwrap_or_else(|err| panic!("git {args:?} should run: {err}"));
assert!(
output.status.success(),
"git {args:?} failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn redact_apply_writes_record_and_emits_short_id() {
let (temp, state) = setup_repo_with_secret();
let raw = heddle(
&[
"--output",
"json",
"redact",
"apply",
&state,
"--path",
"config/secrets.toml",
"--reason",
"leaked credential",
],
Some(temp.path()),
)
.expect("redact apply should succeed");
let value: Value = serde_json::from_str(&raw).expect("redact apply output should be JSON");
let redaction_id = value["redaction_id"].as_str().expect("redaction_id");
assert_eq!(
redaction_id.len(),
8,
"redaction id should be an 8-hex-char short form: {redaction_id}"
);
assert!(
redaction_id.chars().all(|c| c.is_ascii_hexdigit()),
"redaction id should be hex: {redaction_id}"
);
assert_eq!(value["path"].as_str().unwrap(), "config/secrets.toml");
assert_eq!(value["reason"].as_str().unwrap(), "leaked credential");
assert_eq!(value["states_redacted"].as_u64().unwrap(), 1);
}
#[test]
fn redact_list_surfaces_every_active_redaction() {
let (temp, state) = setup_repo_with_secret();
heddle(
&[
"redact",
"apply",
&state,
"--path",
"config/secrets.toml",
"--reason",
"leaked credential",
],
Some(temp.path()),
)
.unwrap();
let raw = heddle(&["--output", "json", "redact", "list"], Some(temp.path()))
.expect("redact list should succeed");
let value: Value = serde_json::from_str(&raw).expect("redact list should emit JSON");
assert_eq!(value["count"].as_u64().unwrap(), 1);
let entries = value["redactions"].as_array().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["path"].as_str().unwrap(), "config/secrets.toml");
assert_eq!(entries[0]["reason"].as_str().unwrap(), "leaked credential");
assert!(!entries[0]["purged"].as_bool().unwrap());
}
#[test]
fn redact_show_resolves_by_short_id() {
let (temp, state) = setup_repo_with_secret();
let apply_raw = heddle(
&[
"--output",
"json",
"redact",
"apply",
&state,
"--path",
"config/secrets.toml",
"--reason",
"leaked credential",
],
Some(temp.path()),
)
.unwrap();
let apply: Value = serde_json::from_str(&apply_raw).unwrap();
let id = apply["redaction_id"].as_str().unwrap().to_string();
let raw = heddle(
&["--output", "json", "redact", "show", &id],
Some(temp.path()),
)
.expect("redact show should accept short id");
let value: Value = serde_json::from_str(&raw).expect("redact show should emit JSON");
assert_eq!(value["redaction_id"].as_str().unwrap(), id);
let stub = value["stub_preview"]
.as_str()
.expect("stub_preview present");
assert!(stub.contains("redacted by Heddle"));
assert!(stub.contains("leaked credential"));
}
#[test]
fn purge_apply_refuses_without_force() {
let (temp, state) = setup_repo_with_secret();
heddle(
&[
"redact",
"apply",
&state,
"--path",
"config/secrets.toml",
"--reason",
"leaked credential",
],
Some(temp.path()),
)
.unwrap();
let output = heddle_output(
&[
"--output",
"json",
"redact",
"purge",
"apply",
&state,
"--path",
"config/secrets.toml",
],
Some(temp.path()),
)
.expect("invoke purge apply");
assert!(
!output.status.success(),
"purge without --force must refuse"
);
assert!(
output.stdout.is_empty(),
"JSON-mode purge refusal must not write stdout: {}",
String::from_utf8_lossy(&output.stdout)
);
let stderr = String::from_utf8_lossy(&output.stderr);
let err: Value =
serde_json::from_str(&stderr).expect("purge refusal should emit JSON error envelope");
assert_json_recovery_advice_fields(&err, &err.to_string());
assert!(
err["kind"] == "destructive_requires_force"
&& err["error"]
.as_str()
.is_some_and(|error| error.contains("Refusing to purge")
&& error.contains("destructive action requires --force"))
&& err["unsafe_condition"]
.as_str()
.is_some_and(|condition| condition.contains("purge is irreversible"))
&& err["preserved"]
.as_str()
.is_some_and(|preserved| preserved.contains("nothing was removed"))
&& err["hint"]
.as_str()
.is_some_and(|hint| hint.contains("heddle redact list")
&& hint.contains("heddle redact purge apply")
&& hint.contains("--force")),
"refusal must use the shared destructive-force advice: {stderr}"
);
}
#[test]
fn undo_redact_refusal_uses_json_error_envelope() {
let (temp, state) = setup_repo_with_secret();
heddle(
&[
"redact",
"apply",
&state,
"--path",
"config/secrets.toml",
"--reason",
"leaked credential",
],
Some(temp.path()),
)
.unwrap();
let output = heddle_output(&["--output", "json", "undo"], Some(temp.path()))
.expect("invoke undo redaction");
assert!(!output.status.success(), "undo redaction should refuse");
assert!(
output.stdout.is_empty(),
"JSON-mode undo refusal must not write stdout: {}",
String::from_utf8_lossy(&output.stdout)
);
let stderr = String::from_utf8_lossy(&output.stderr);
let err: Value =
serde_json::from_str(&stderr).expect("undo refusal should emit JSON error envelope");
assert_json_recovery_advice_fields(&err, &err.to_string());
assert!(
err["kind"] == "redaction_undo_requires_confirmation"
&& err["error"]
.as_str()
.is_some_and(|error| error.contains("Refusing to undo")
&& error.contains("redact apply")
&& error.contains("re-expose previously-hidden content"))
&& err["preserved"]
.as_str()
.is_some_and(|preserved| preserved.contains("no undo mutation was applied"))
&& err["hint"]
.as_str()
.is_some_and(|hint| hint.contains("--allow-redact-undo")),
"undo redaction refusal should expose typed JSON advice: {stderr}"
);
}
#[test]
fn purge_apply_with_force_records_and_marks_redaction_purged() {
let (temp, state) = setup_repo_with_secret();
heddle(
&[
"redact",
"apply",
&state,
"--path",
"config/secrets.toml",
"--reason",
"leaked credential",
],
Some(temp.path()),
)
.unwrap();
let raw = heddle(
&[
"--output",
"json",
"redact",
"purge",
"apply",
&state,
"--path",
"config/secrets.toml",
"--force",
],
Some(temp.path()),
)
.expect("purge apply --force should succeed");
let value: Value = serde_json::from_str(&raw).expect("purge apply should emit JSON");
assert_eq!(value["redactions_marked"].as_u64().unwrap(), 1);
let list_raw = heddle(&["--output", "json", "redact", "list"], Some(temp.path())).unwrap();
let list: Value = serde_json::from_str(&list_raw).unwrap();
let entries = list["redactions"].as_array().unwrap();
assert!(
entries[0]["purged"].as_bool().unwrap(),
"after purge, the redaction must surface as purged in list output"
);
let purge_list_raw = heddle(
&["--output", "json", "redact", "purge", "list"],
Some(temp.path()),
)
.unwrap();
let purge_list: Value = serde_json::from_str(&purge_list_raw).unwrap();
assert_eq!(
purge_list["count"].as_u64().unwrap(),
1,
"purge list must surface exactly one entry after one purge"
);
}
#[test]
fn purge_alias_routes_to_redact_purge_apply_output() {
let (temp, state) = setup_repo_with_secret();
heddle(
&[
"redact",
"apply",
&state,
"--path",
"config/secrets.toml",
"--reason",
"leaked credential",
],
Some(temp.path()),
)
.unwrap();
let raw = heddle(
&[
"--output",
"json",
"purge",
"apply",
&state,
"--path",
"config/secrets.toml",
"--force",
],
Some(temp.path()),
)
.expect("purge alias should route through redact purge apply");
let value: Value = serde_json::from_str(&raw).expect("purge alias should emit JSON");
assert_eq!(value["output_kind"], "purge_apply");
assert_eq!(value["redactions_marked"].as_u64().unwrap(), 1);
}
#[test]
fn redact_apply_with_sign_with_records_signature_verifiable_on_show() {
use crypto::Ed25519Signer;
let (temp, state) = setup_repo_with_secret();
let signer = Ed25519Signer::generate().expect("generate ed25519 signing key");
let key_pem = signer.to_pem().expect("export PEM");
let key_path = temp.path().join("redact_signing_key.pem");
write_test_private_key(&key_path, &key_pem);
let apply_raw = heddle(
&[
"--output",
"json",
"redact",
"apply",
&state,
"--path",
"config/secrets.toml",
"--reason",
"leaked credential",
"--sign-with",
&key_path.to_string_lossy(),
],
Some(temp.path()),
)
.expect("redact apply --sign-with should succeed");
let apply: Value = serde_json::from_str(&apply_raw).expect("redact apply JSON");
assert!(
apply["signed"].as_bool().unwrap(),
"redact apply with --sign-with must report signed=true"
);
assert_eq!(
apply["signature_algorithm"].as_str().unwrap(),
"ed25519",
"Ed25519 key file should be detected as ed25519"
);
let id = apply["redaction_id"].as_str().unwrap().to_string();
let show_raw = heddle(
&["--output", "json", "redact", "show", &id],
Some(temp.path()),
)
.unwrap();
let show: Value = serde_json::from_str(&show_raw).unwrap();
assert!(
show["signed"].as_bool().unwrap(),
"redact show must report signed=true after a signed apply"
);
assert_eq!(
show["signature_status"].as_str().unwrap(),
"verified",
"redact show must verify the signature it just stored — round-trip property"
);
assert_eq!(
show["signature_algorithm"].as_str().unwrap(),
"ed25519",
"show must surface the signing algorithm"
);
}
#[test]
fn redact_show_without_sign_with_reports_unsigned() {
let (temp, state) = setup_repo_with_secret();
let apply_raw = heddle(
&[
"--output",
"json",
"redact",
"apply",
&state,
"--path",
"config/secrets.toml",
"--reason",
"leaked credential",
],
Some(temp.path()),
)
.unwrap();
let apply: Value = serde_json::from_str(&apply_raw).unwrap();
assert!(
!apply["signed"].as_bool().unwrap(),
"redact apply without --sign-with must report signed=false"
);
let id = apply["redaction_id"].as_str().unwrap();
let show_raw = heddle(
&["--output", "json", "redact", "show", id],
Some(temp.path()),
)
.unwrap();
let show: Value = serde_json::from_str(&show_raw).unwrap();
assert!(!show["signed"].as_bool().unwrap());
assert_eq!(
show["signature_status"].as_str().unwrap(),
"unsigned",
"redact show must call unsigned redactions out explicitly"
);
}
#[test]
fn redact_apply_is_idempotent_on_identical_input() {
let (temp, state) = setup_repo_with_secret();
let first = heddle(
&[
"--output",
"json",
"redact",
"apply",
&state,
"--path",
"config/secrets.toml",
"--reason",
"leaked credential",
],
Some(temp.path()),
)
.unwrap();
let second = heddle(
&[
"--output",
"json",
"redact",
"apply",
&state,
"--path",
"config/secrets.toml",
"--reason",
"leaked credential",
],
Some(temp.path()),
)
.unwrap();
let _ = (first, second);
let list_raw = heddle(&["--output", "json", "redact", "list"], Some(temp.path())).unwrap();
let list: Value = serde_json::from_str(&list_raw).unwrap();
let entries = list["redactions"].as_array().unwrap();
let same_path: Vec<&Value> = entries
.iter()
.filter(|r| r["path"].as_str() == Some("config/secrets.toml"))
.collect();
assert!(
same_path.len() <= 2,
"repeated identical applies must NOT fan out into N entries; got {}",
same_path.len()
);
}
#[test]
fn purge_without_prior_redact_is_refused() {
let (temp, state) = setup_repo_with_secret();
let err = heddle(
&[
"redact",
"purge",
"apply",
&state,
"--path",
"config/secrets.toml",
"--force",
],
Some(temp.path()),
)
.expect_err("purge without prior redact must refuse");
assert!(
err.contains("no redaction"),
"refusal must name the missing-redaction precondition: {err}"
);
}
fn signed_redact_on_repo_a(
temp: &TempDir,
state: &str,
pem_path: &std::path::Path,
) -> serde_json::Value {
let raw = heddle(
&[
"--output",
"json",
"redact",
"apply",
state,
"--path",
"config/secrets.toml",
"--reason",
"leaked credential",
"--sign-with",
pem_path.to_str().unwrap(),
],
Some(temp.path()),
)
.expect("redact apply --sign-with should succeed on A");
serde_json::from_str(&raw).expect("apply output JSON")
}
#[test]
fn redact_apply_signed_propagates_to_cloned_replica() {
use crypto::Ed25519Signer;
let (a, state) = setup_repo_with_secret();
let signer = Ed25519Signer::generate().unwrap();
let pem = signer.to_pem().unwrap();
let pem_path = a.path().join("ed25519.pem");
write_test_private_key(&pem_path, &pem);
let apply = signed_redact_on_repo_a(&a, &state, &pem_path);
let redaction_id = apply["redaction_id"].as_str().unwrap().to_string();
let b_dir = TempDir::new().unwrap();
let b_path = b_dir.path().join("replica-b");
fs::create_dir_all(&b_path).unwrap();
heddle(&["init"], Some(&b_path)).expect("init B");
heddle(
&[
"redact",
"trust",
"add",
"--from-pem",
pem_path.to_str().unwrap(),
],
Some(&b_path),
)
.expect("B trusts A's signing key");
heddle(
&["remote", "add", "origin", a.path().to_str().unwrap()],
Some(&b_path),
)
.expect("remote add origin");
heddle(&["fetch", "origin"], Some(&b_path)).expect("fetch propagates signed redaction to B");
let list_raw = heddle(&["--output", "json", "redact", "list"], Some(&b_path)).unwrap();
let list: Value = serde_json::from_str(&list_raw).unwrap();
let rows = list["redactions"].as_array().expect("redactions array");
assert_eq!(
rows.len(),
1,
"B must see exactly one propagated redaction: {list_raw}"
);
let show_raw = heddle(
&["--output", "json", "redact", "show", &redaction_id],
Some(&b_path),
)
.unwrap();
let show: Value = serde_json::from_str(&show_raw).unwrap();
assert_eq!(
show["signature_status"].as_str().unwrap(),
"verified",
"B must verify the signature on the propagated redaction"
);
}
#[test]
fn redact_apply_unsigned_is_refused_at_clone_boundary() {
let (a, state) = setup_repo_with_secret();
let _ = heddle(
&[
"--output",
"json",
"redact",
"apply",
&state,
"--path",
"config/secrets.toml",
"--reason",
"leaked credential",
],
Some(a.path()),
)
.expect("unsigned local redact on A succeeds");
let b_dir = TempDir::new().unwrap();
let b_path = b_dir.path().join("replica-b");
let err = heddle(
&[
"clone",
a.path().to_str().unwrap(),
b_path.to_str().unwrap(),
],
Some(b_dir.path()),
)
.expect_err("clone must refuse unsigned redaction propagation");
assert!(
err.contains("no signature") || err.contains("Unsigned") || err.contains("unsigned"),
"clone rejection must explain the unsigned cause: {err}"
);
}
#[test]
fn purge_apply_signed_propagates_byte_removal_to_cloned_replica() {
use crypto::Ed25519Signer;
let (a, state) = setup_repo_with_secret();
let signer = Ed25519Signer::generate().unwrap();
let pem = signer.to_pem().unwrap();
let pem_path = a.path().join("ed25519.pem");
write_test_private_key(&pem_path, &pem);
let _ = signed_redact_on_repo_a(&a, &state, &pem_path);
heddle(
&[
"redact",
"purge",
"apply",
&state,
"--path",
"config/secrets.toml",
"--force",
],
Some(a.path()),
)
.expect("purge on A succeeds");
let b_dir = TempDir::new().unwrap();
let b_path = b_dir.path().join("replica-b");
fs::create_dir_all(&b_path).unwrap();
heddle(&["init"], Some(&b_path)).expect("init B");
heddle(
&[
"redact",
"trust",
"add",
"--from-pem",
pem_path.to_str().unwrap(),
],
Some(&b_path),
)
.expect("B trusts A's signing key");
heddle(
&["remote", "add", "origin", a.path().to_str().unwrap()],
Some(&b_path),
)
.expect("remote add origin");
heddle(&["fetch", "origin"], Some(&b_path))
.expect("fetch propagates signed redaction + purge to B");
let purge_list_raw = heddle(
&["--output", "json", "redact", "purge", "list"],
Some(&b_path),
)
.unwrap();
let purge_list: Value = serde_json::from_str(&purge_list_raw).unwrap();
let purges = purge_list["purges"].as_array().expect("purges array");
assert_eq!(
purges.len(),
1,
"B must see the propagated purge: {purge_list_raw}"
);
}
#[test]
fn tampered_redaction_is_refused_at_fetch_boundary() {
use crypto::Ed25519Signer;
use objects::object::RedactionsBlob;
let (a, state) = setup_repo_with_secret();
let signer = Ed25519Signer::generate().unwrap();
let pem = signer.to_pem().unwrap();
let pem_path = a.path().join("ed25519.pem");
write_test_private_key(&pem_path, &pem);
let _ = signed_redact_on_repo_a(&a, &state, &pem_path);
let redaction_dir = a.path().join(".heddle/redactions");
let entries: Vec<_> = fs::read_dir(&redaction_dir)
.expect("redactions dir exists on A")
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("bin"))
.collect();
assert_eq!(entries.len(), 1, "exactly one redaction expected on A");
let path = entries[0].path();
let bytes = fs::read(&path).unwrap();
let mut blob = RedactionsBlob::decode(&bytes).expect("decode A's redactions blob");
blob.redactions[0].reason = "post-sign tampered reason".to_string();
fs::write(&path, blob.encode().unwrap()).unwrap();
let b_dir = TempDir::new().unwrap();
let b_path = b_dir.path().join("replica-b");
fs::create_dir_all(&b_path).unwrap();
heddle(&["init"], Some(&b_path)).expect("init B");
heddle(
&[
"redact",
"trust",
"add",
"--from-pem",
pem_path.to_str().unwrap(),
],
Some(&b_path),
)
.expect("B trusts A's signing key");
heddle(
&["remote", "add", "origin", a.path().to_str().unwrap()],
Some(&b_path),
)
.expect("remote add origin");
let err = heddle(&["fetch", "origin"], Some(&b_path))
.expect_err("fetch must refuse a tampered redaction");
assert!(
err.contains("failed to verify") || err.contains("Tampered") || err.contains("tampered"),
"fetch rejection must explain the tamper cause: {err}"
);
}
fn redact_apply_json(temp: &TempDir, state: &str) -> Value {
let raw = heddle(
&[
"--output",
"json",
"redact",
"apply",
state,
"--path",
"config/secrets.toml",
"--reason",
"leak",
],
Some(temp.path()),
)
.expect("redact apply");
serde_json::from_str(&raw).expect("redact apply JSON")
}
#[test]
fn redact_apply_emits_ignore_hint_when_neither_file_covers_path() {
let (temp, state) = setup_repo_with_secret();
let apply = redact_apply_json(&temp, &state);
let hint = apply
.get("ignore_hint")
.expect("ignore_hint should be present when path is uncovered");
assert_eq!(hint["ignore_file"].as_str().unwrap(), ".heddleignore");
assert!(
!hint["already_exists"].as_bool().unwrap(),
"init should not install a default .heddleignore"
);
assert_eq!(
hint["suggested_pattern"].as_str().unwrap(),
"config/secrets.toml"
);
assert!(
hint["message"]
.as_str()
.unwrap()
.contains("create .heddleignore")
);
}
#[test]
fn redact_apply_emits_no_hint_when_heddleignore_literal_matches() {
let (temp, state) = setup_repo_with_secret();
fs::write(temp.path().join(".heddleignore"), "config/secrets.toml\n").unwrap();
let apply = redact_apply_json(&temp, &state);
assert!(
apply.get("ignore_hint").is_none() || apply["ignore_hint"].is_null(),
"literal-path coverage in .heddleignore must suppress the hint: {apply:?}"
);
}
#[test]
fn redact_apply_emits_no_hint_when_heddleignore_glob_matches() {
let (temp, state) = setup_repo_with_secret();
fs::write(temp.path().join(".heddleignore"), "config/*.toml\n").unwrap();
let apply = redact_apply_json(&temp, &state);
assert!(
apply.get("ignore_hint").is_none() || apply["ignore_hint"].is_null(),
"glob coverage in .heddleignore must suppress the hint: {apply:?}"
);
}
#[test]
fn redact_apply_emits_hint_when_only_gitignore_covers_the_path() {
let (temp, state) = setup_repo_with_secret();
fs::write(temp.path().join(".gitignore"), "config/*.toml\n").unwrap();
let apply = redact_apply_json(&temp, &state);
let hint = apply
.get("ignore_hint")
.expect(".gitignore coverage must NOT suppress the heddle-ignore hint");
assert_eq!(
hint["ignore_file"].as_str().unwrap(),
".heddleignore",
".gitignore is not consulted by native Heddle capture; hint must target .heddleignore"
);
assert!(
!hint["already_exists"].as_bool().unwrap(),
"init should not install a default .heddleignore"
);
}
#[test]
fn redact_apply_git_overlay_prefers_gitignore_hint() {
let (temp, state) = setup_git_overlay_repo_with_secret();
let apply = redact_apply_json(&temp, &state);
let hint = apply
.get("ignore_hint")
.expect("ignore_hint should be present when path is uncovered");
assert_eq!(hint["ignore_file"].as_str().unwrap(), ".gitignore");
assert!(
!hint["already_exists"].as_bool().unwrap(),
"fixture does not create a .gitignore"
);
assert!(
hint["message"]
.as_str()
.unwrap()
.contains("create .gitignore"),
"Git-overlay redaction should point at Git's shared ignore file: {hint}"
);
}
#[test]
fn redact_apply_emits_no_hint_when_repo_config_ignore_covers_path() {
let (temp, state) = setup_repo_with_secret();
let config_path = temp.path().join(".heddle/config.toml");
let existing = fs::read_to_string(&config_path).expect("read default config");
let patched = existing.replace("ignore = [", "ignore = [\n \"config/*.toml\",");
assert_ne!(
existing, patched,
"test fixture expected `ignore = [` in default config"
);
fs::write(&config_path, patched).unwrap();
let apply = redact_apply_json(&temp, &state);
assert!(
apply.get("ignore_hint").is_none() || apply["ignore_hint"].is_null(),
"repo-config worktree.ignore coverage must suppress the hint: {apply:?}"
);
}
#[test]
fn purge_apply_also_emits_ignore_hint() {
let (temp, state) = setup_repo_with_secret();
heddle(
&[
"redact",
"apply",
&state,
"--path",
"config/secrets.toml",
"--reason",
"leak",
],
Some(temp.path()),
)
.unwrap();
let raw = heddle(
&[
"--output",
"json",
"redact",
"purge",
"apply",
&state,
"--path",
"config/secrets.toml",
"--force",
],
Some(temp.path()),
)
.expect("purge apply");
let purge: Value = serde_json::from_str(&raw).unwrap();
let hint = purge
.get("ignore_hint")
.expect("purge output must include ignore_hint");
assert_eq!(hint["ignore_file"].as_str().unwrap(), ".heddleignore");
assert!(
!hint["already_exists"].as_bool().unwrap(),
"init should not install a default .heddleignore"
);
}
#[test]
fn redact_after_peer_fetch_still_propagates_on_resync() {
use crypto::Ed25519Signer;
let (a, state) = setup_repo_with_secret();
let b_dir = TempDir::new().unwrap();
let b_path = b_dir.path().join("replica-b");
heddle(
&[
"clone",
a.path().to_str().unwrap(),
b_path.to_str().unwrap(),
],
Some(b_dir.path()),
)
.expect("initial clone A → B");
let list_before: Value = serde_json::from_str(
&heddle(&["--output", "json", "redact", "list"], Some(&b_path)).unwrap(),
)
.unwrap();
assert_eq!(
list_before["redactions"].as_array().unwrap().len(),
0,
"B has no redactions yet (declared on A only after clone)"
);
let signer = Ed25519Signer::generate().unwrap();
let pem = signer.to_pem().unwrap();
let pem_path = a.path().join("ed25519.pem");
write_test_private_key(&pem_path, &pem);
let _ = signed_redact_on_repo_a(&a, &state, &pem_path);
heddle(
&[
"redact",
"trust",
"add",
"--from-pem",
pem_path.to_str().unwrap(),
],
Some(&b_path),
)
.expect("B trusts A's signing key");
heddle(
&["remote", "add", "origin", a.path().to_str().unwrap()],
Some(&b_path),
)
.expect("remote add origin");
heddle(&["fetch", "origin"], Some(&b_path)).expect("re-fetch A → B after redaction declared");
let list_after: Value = serde_json::from_str(
&heddle(&["--output", "json", "redact", "list"], Some(&b_path)).unwrap(),
)
.unwrap();
assert_eq!(
list_after["redactions"].as_array().unwrap().len(),
1,
"B must see the post-clone redaction after re-fetch: {list_after:?}"
);
}
#[test]
fn untrusted_signed_redaction_is_refused_at_fetch_boundary() {
use crypto::Ed25519Signer;
let (a, state) = setup_repo_with_secret();
let attacker = Ed25519Signer::generate().unwrap();
let pem = attacker.to_pem().unwrap();
let pem_path = a.path().join("attacker.pem");
write_test_private_key(&pem_path, &pem);
let _ = signed_redact_on_repo_a(&a, &state, &pem_path);
let b_dir = TempDir::new().unwrap();
let b_path = b_dir.path().join("replica-b");
fs::create_dir_all(&b_path).unwrap();
heddle(&["init"], Some(&b_path)).expect("init B");
heddle(
&["remote", "add", "origin", a.path().to_str().unwrap()],
Some(&b_path),
)
.expect("remote add origin");
let err = heddle(&["fetch", "origin"], Some(&b_path))
.expect_err("fetch must refuse untrusted signed redaction");
assert!(
err.contains("untrusted operator key"),
"fetch rejection must explain the untrusted-key cause: {err}"
);
let list: Value = serde_json::from_str(
&heddle(&["--output", "json", "redact", "list"], Some(&b_path)).unwrap(),
)
.unwrap();
assert_eq!(
list["redactions"].as_array().unwrap().len(),
0,
"B must have no redactions after refusal; refusal is atomic"
);
}
#[test]
fn redact_trust_add_and_list_round_trip() {
use crypto::Ed25519Signer;
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).expect("init");
let signer = Ed25519Signer::generate().unwrap();
let pem = signer.to_pem().unwrap();
let pem_path = temp.path().join("key.pem");
write_test_private_key(&pem_path, &pem);
let add_raw = heddle(
&[
"--output",
"json",
"redact",
"trust",
"add",
"--from-pem",
pem_path.to_str().unwrap(),
"--label",
"test-key",
],
Some(temp.path()),
)
.expect("trust add");
let add: Value = serde_json::from_str(&add_raw).unwrap();
assert_eq!(add["algorithm"].as_str().unwrap(), "ed25519");
assert_eq!(add["label"].as_str().unwrap(), "test-key");
let pubkey_hex = add["public_key"].as_str().unwrap().to_string();
let list_raw = heddle(
&["--output", "json", "redact", "trust", "list"],
Some(temp.path()),
)
.expect("trust list");
let list: Value = serde_json::from_str(&list_raw).unwrap();
assert_eq!(list["count"].as_u64().unwrap(), 1);
let entries = list["trusted_keys"].as_array().unwrap();
assert_eq!(entries[0]["public_key"].as_str().unwrap(), pubkey_hex);
assert_eq!(entries[0]["label"].as_str().unwrap(), "test-key");
let output = heddle_output(
&[
"--output",
"json",
"redact",
"trust",
"add",
"--from-pem",
pem_path.to_str().unwrap(),
],
Some(temp.path()),
)
.expect("invoke duplicate trust add");
assert!(
!output.status.success(),
"re-add must refuse duplicate trust keys"
);
assert!(
output.stdout.is_empty(),
"JSON-mode refusal must not write stdout: {}",
String::from_utf8_lossy(&output.stdout)
);
let stderr = String::from_utf8_lossy(&output.stderr);
let envelope: Value =
serde_json::from_str(&stderr).expect("stderr should be JSON error envelope");
assert_eq!(envelope["kind"], "redact_trust_key_duplicate");
assert!(
envelope["error"]
.as_str()
.is_some_and(|error| error.contains("already in the trust list")),
"duplicate-trust rejection must be clear: {stderr}"
);
}
#[test]
fn redact_empty_path_uses_typed_advice_json() {
let (temp, state) = setup_repo_with_secret();
let output = heddle_output(
&[
"--output",
"json",
"redact",
"apply",
&state,
"--path",
"",
"--reason",
"empty path smoke",
],
Some(temp.path()),
)
.expect("invoke redact apply");
assert!(!output.status.success(), "empty path must refuse");
assert!(
output.stdout.is_empty(),
"JSON-mode refusal must not write stdout: {}",
String::from_utf8_lossy(&output.stdout)
);
let stderr = String::from_utf8_lossy(&output.stderr);
let envelope: Value =
serde_json::from_str(&stderr).expect("stderr should be JSON error envelope");
assert_eq!(envelope["kind"], "redact_path_empty");
assert!(
envelope["hint"]
.as_str()
.is_some_and(|hint| hint.contains("--path <path>")),
"typed advice should name the recovery path: {stderr}"
);
}
#[test]
fn redact_trust_remove_missing_key_uses_typed_advice_json() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).expect("init");
let output = heddle_output(
&["--output", "json", "redact", "trust", "remove", "deadbeef"],
Some(temp.path()),
)
.expect("invoke redact trust remove");
assert!(!output.status.success(), "missing trust key must refuse");
assert!(
output.stdout.is_empty(),
"JSON-mode refusal must not write stdout: {}",
String::from_utf8_lossy(&output.stdout)
);
let stderr = String::from_utf8_lossy(&output.stderr);
let envelope: Value =
serde_json::from_str(&stderr).expect("stderr should be JSON error envelope");
assert_eq!(envelope["kind"], "redact_trust_key_not_found");
assert!(
envelope["hint"]
.as_str()
.is_some_and(|hint| hint.contains("heddle redact trust list")),
"typed advice should name the inspection command: {stderr}"
);
}
#[test]
fn redact_trust_add_missing_key_source_uses_typed_advice_json() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).expect("init");
let output = heddle_output(
&["--output", "json", "redact", "trust", "add"],
Some(temp.path()),
)
.expect("invoke redact trust add");
assert!(!output.status.success(), "missing key source must refuse");
assert!(
output.stdout.is_empty(),
"JSON-mode refusal must not write stdout: {}",
String::from_utf8_lossy(&output.stdout)
);
let stderr = String::from_utf8_lossy(&output.stderr);
let envelope: Value =
serde_json::from_str(&stderr).expect("stderr should be JSON error envelope");
assert_eq!(envelope["kind"], "redact_trust_key_source_required");
assert!(
envelope["hint"]
.as_str()
.is_some_and(|hint| hint.contains("--from-pem <PATH>")),
"typed advice should name key-source recovery: {stderr}"
);
}