#![allow(missing_docs)]
use assert_cmd::Command;
use std::process::Command as StdCommand;
const CODEX32_ALPHABET: &[u8; 32] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
fn encode_chunked(template: &str) -> Vec<String> {
let out = StdCommand::new(assert_cmd::cargo::cargo_bin("md"))
.args(["encode", "--force-chunked", template])
.output()
.expect("invoke md encode --force-chunked");
assert!(
out.status.success(),
"md encode --force-chunked {template:?} failed: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let s = String::from_utf8(out.stdout).expect("stdout utf-8");
let chunks: Vec<String> = s
.lines()
.filter(|l| l.starts_with("md1"))
.map(String::from)
.collect();
assert!(!chunks.is_empty(), "expected at least one chunk; got {chunks:?}");
chunks
}
const MULTI_CHUNK_TEMPLATE: &str =
"wsh(sortedmulti(2,@0/1'/2'/3'/4'/5'/6'/7'/8'/9'/10'/11'/12'/13'/14'/15'/<0;1>/*,\
@1/101'/102'/103'/104'/105'/106'/107'/108'/109'/110'/111'/112'/113'/114'/115'/<0;1>/*,\
@2/201'/202'/203'/204'/205'/206'/207'/208'/209'/210'/211'/212'/213'/214'/215'/<0;1>/*,\
@3/301'/302'/303'/304'/305'/306'/307'/308'/309'/310'/311'/312'/313'/314'/315'/<0;1>/*))";
fn encode_multi_chunk() -> Vec<String> {
let chunks = encode_chunked(MULTI_CHUNK_TEMPLATE);
assert!(
chunks.len() >= 2,
"MULTI_CHUNK_TEMPLATE must produce 2+ chunks; got {}: {chunks:?}",
chunks.len()
);
chunks
}
fn corrupt_at(chunk: &str, pos: usize, xor_mask: u8) -> String {
let hrp_len = 3; let mut chars: Vec<char> = chunk.chars().collect();
let abs_idx = hrp_len + pos;
let original_sym = CODEX32_ALPHABET
.iter()
.position(|&b| b == chars[abs_idx].to_ascii_lowercase() as u8)
.expect("char in codex32 alphabet") as u8;
let new_sym = (original_sym ^ (xor_mask & 0x1F)) & 0x1F;
chars[abs_idx] = CODEX32_ALPHABET[new_sym as usize] as char;
chars.iter().collect()
}
fn corrupt_many(chunk: &str, positions: &[(usize, u8)]) -> String {
positions
.iter()
.fold(chunk.to_string(), |acc, &(p, m)| corrupt_at(&acc, p, m))
}
#[test]
fn repair_single_chunk_happy_path() {
let chunks = encode_chunked("wpkh(@0/<0;1>/*)");
assert_eq!(
chunks.len(),
1,
"single-chunk fixture must produce exactly 1 chunk; got {chunks:?}"
);
let valid = &chunks[0];
let corrupted = corrupt_at(valid, 10, 0b10110);
let mut cmd = Command::cargo_bin("md").unwrap();
let out = cmd
.args(["repair", &corrupted])
.output()
.expect("invoke md repair");
let code = out.status.code().expect("exited normally");
assert_eq!(
code,
5,
"expected exit 5 (REPAIR_APPLIED); stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("stdout utf-8");
assert!(
stdout.contains("# Repair report"),
"expected `# Repair report` header; got {stdout:?}"
);
assert!(
stdout.contains("md1 chunk 0: 1 correction at position 10"),
"expected per-chunk correction line at position 10; got {stdout:?}"
);
assert!(
stdout.lines().any(|line| line == valid.as_str()),
"expected corrected chunk to match the original valid md1; got {stdout:?}"
);
}
#[test]
fn repair_multi_chunk_all_valid_passthrough() {
let chunks = encode_multi_chunk();
let mut args: Vec<String> = vec!["repair".into()];
args.extend(chunks.iter().cloned());
let mut cmd = Command::cargo_bin("md").unwrap();
let out = cmd.args(&args).output().expect("invoke md repair");
let code = out.status.code().expect("exited normally");
assert_eq!(
code,
0,
"expected exit 0 for clean multi-chunk pass-through; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("stdout utf-8");
assert!(
!stdout.contains("# Repair report"),
"clean input must not emit a Repair report; got {stdout:?}"
);
for c in &chunks {
assert!(
stdout.lines().any(|line| line == c.as_str()),
"expected pass-through chunk on stdout; missing {c:?}; got {stdout:?}"
);
}
}
#[test]
fn repair_multi_chunk_one_corrupted() {
let chunks = encode_multi_chunk();
let target_idx = chunks.len() / 2;
let mut corrupted_chunks = chunks.clone();
corrupted_chunks[target_idx] = corrupt_at(&chunks[target_idx], 3, 0b01011);
let mut args: Vec<String> = vec!["repair".into()];
args.extend(corrupted_chunks.iter().cloned());
let mut cmd = Command::cargo_bin("md").unwrap();
let out = cmd.args(&args).output().expect("invoke md repair");
let code = out.status.code().expect("exited normally");
assert_eq!(
code,
5,
"expected exit 5 (REPAIR_APPLIED); stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("stdout utf-8");
assert!(
stdout.contains("# Repair report"),
"expected `# Repair report` header; got {stdout:?}"
);
let expected_line = format!("md1 chunk {target_idx}: 1 correction at position 3");
assert!(
stdout.contains(&expected_line),
"expected per-chunk correction line {expected_line:?}; got {stdout:?}"
);
for c in &chunks {
assert!(
stdout.lines().any(|line| line == c.as_str()),
"expected restored chunk on stdout; missing {c:?}; got {stdout:?}"
);
}
}
#[test]
fn repair_multi_chunk_atomic_failure_per_d28() {
let chunks = encode_multi_chunk();
let target_idx = 1usize;
let mut corrupted_chunks = chunks.clone();
let dp_len = chunks[target_idx].len() - 3; let positions: Vec<(usize, u8)> = vec![
(0, 0b00001),
(dp_len / 5, 0b00010),
(2 * dp_len / 5, 0b00100),
(3 * dp_len / 5, 0b01000),
(dp_len - 1, 0b10000),
];
corrupted_chunks[target_idx] = corrupt_many(&chunks[target_idx], &positions);
let mut args: Vec<String> = vec!["repair".into()];
args.extend(corrupted_chunks.iter().cloned());
let mut cmd = Command::cargo_bin("md").unwrap();
let out = cmd.args(&args).output().expect("invoke md repair");
let code = out.status.code().expect("exited normally");
assert_eq!(
code,
2,
"expected exit 2 (atomic-fail per D28); stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
let expected_chunk_phrase = format!("chunk {target_idx}");
assert!(
stderr.contains(&expected_chunk_phrase),
"expected stderr to name failing chunk {target_idx:?} (looking for {expected_chunk_phrase:?}); got stderr={stderr:?}"
);
let stdout = String::from_utf8(out.stdout).expect("stdout utf-8");
assert!(
stdout.is_empty(),
"D28 atomic-fail: stdout must be empty; got {stdout:?}"
);
assert!(
!stdout.contains("md1"),
"D28 atomic-fail: no md1 chunks on stdout; got {stdout:?}"
);
}
#[test]
fn repair_json_multi_chunk_envelope_shape() {
let chunks = encode_multi_chunk();
let target_idx = chunks.len() / 2;
let mut corrupted_chunks = chunks.clone();
corrupted_chunks[target_idx] = corrupt_at(&chunks[target_idx], 7, 0b11001);
let mut args: Vec<String> = vec!["repair".into(), "--json".into()];
args.extend(corrupted_chunks.iter().cloned());
let mut cmd = Command::cargo_bin("md").unwrap();
let out = cmd.args(&args).output().expect("invoke md repair --json");
let code = out.status.code().expect("exited normally");
assert_eq!(
code,
5,
"expected exit 5 for JSON-mode multi-chunk repair; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("stdout utf-8");
let envelope: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("stdout parses as JSON");
assert_eq!(
envelope["schema_version"],
serde_json::Value::String("1".into()),
"schema_version must equal \"1\" (string)"
);
assert_eq!(
envelope["kind"],
serde_json::Value::String("md1".into()),
"kind must equal \"md1\""
);
let corrected = envelope["corrected_chunks"]
.as_array()
.expect("corrected_chunks must be a JSON array");
assert_eq!(
corrected.len(),
chunks.len(),
"corrected_chunks length must equal input chunk count"
);
for (i, c) in chunks.iter().enumerate() {
assert_eq!(
corrected[i],
serde_json::Value::String(c.clone()),
"corrected_chunks[{i}] must equal the restored chunk"
);
}
let repairs = envelope["repairs"]
.as_array()
.expect("repairs must be a JSON array");
assert_eq!(repairs.len(), 1, "exactly 1 corrupted chunk → 1 repair entry");
let r0 = &repairs[0];
assert_eq!(
r0["chunk_index"],
serde_json::Value::from(target_idx as u64),
"chunk_index must equal the corrupted chunk's index"
);
assert_eq!(
r0["original_chunk"],
serde_json::Value::String(corrupted_chunks[target_idx].clone())
);
assert_eq!(
r0["corrected_chunk"],
serde_json::Value::String(chunks[target_idx].clone())
);
let positions = r0["corrected_positions"]
.as_array()
.expect("corrected_positions must be a JSON array");
assert_eq!(positions.len(), 1, "single-flip → one position entry");
let p0 = &positions[0];
assert_eq!(p0["position"], serde_json::Value::from(7u32));
assert!(p0["was"].is_string(), "was must be a string");
assert!(p0["now"].is_string(), "now must be a string");
assert_ne!(p0["was"], p0["now"], "was != now for a real correction");
}