Add the disc pipeline: identify, unpack and verify
Phase 1 of the roadmap. The disc is now turned into verified asset files, and
the result is checked against a committed hash table rather than against
whatever happens to be on disk.
gc-extract identify disc.iso SCUS_972.68 v1.01 NTSC, ELF SHA-1 matches
gc-extract unpack disc.iso out/ 5135 files, 4,500,106,960 bytes, 10.8s
gc-extract verify out/ table.tsv OK: 5135 files match
All 92 G/*.WAD containers parse. MPEG1 and MPEG2 are raw video and pass
through whole; the other 90 are indexed containers whose extents are extracted
individually.
Formats recovered and written up in docs/formats/:
- iso9660.rs. R&C2 lists its assets in the filesystem, unlike R&C1, so a
directory walk is the primary asset index. Three details that all fail
quietly rather than loudly: directory records store every 32-bit field twice
(little-endian then big-endian), the CD001 signature is at sector offset 1
rather than 0x8001, and the directory flag at record offset 25 is
authoritative where the identifier's trailing slash is not.
- global_wad.rs. Container headers hold a table of extents. All 92 indexed
containers use {offset in sectors, size in bytes}. Measured, not assumed: the
sector-count reading places every entry of all 27 LEVEL<n> and all 27
AUDIO<n> containers out of range, while the byte reading places all of them
correctly. SCENE<n> is the one exception, a flat sector list whose extents
are sized by the next value.
- wad.rs. Insomniac's LZ77 stream: four flag-selected packet classes, with 0-3
trailing literal bytes per packet carried in unused low bits. This is not a
bit stream, which is why it resists the usual LZ77 reading.
- buf.rs. A bounds-checked reader, so untrusted 2003 disc data produces typed
errors rather than panics.
gc-extract carries the three stages and a SHA-1 implementation with FIPS 180-4
test vectors, so identification works without pulling in a crypto dependency.
Verification: 54 unit tests, plus four integration tests that run only when
GC_DISC names a disc. Those four check identification against the recorded
values, cross-check the Rust identifier against tools/identify-disc.py, parse
all 92 containers, and confirm a full unpack matches the committed table. The
committed table is 316 KB of sizes and SHA-1s for all 5,135 files: hashes only,
no game content, so it is safe to version and it turns "matches retail" into
something a test can assert.
docs/plan/disc-pipeline.md records two wrong turns, because both are easy to
repeat and both failed by producing plausible wrong output rather than an
error: the container table was read backwards, and the LZ77 stream was modelled
as a bit stream. The first survived because it happened to be correct on the
one container used for manual checking.
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
//! Stage 3: verify extracted files against a committed hash table.
|
||||
//!
|
||||
//! The table records sizes and SHA-1 values only — never content. Its purpose
|
||||
//! is to let a test assert "this parse matches retail" rather than "this parse
|
||||
//! matches whatever happened to be on my disk". See `AGENTS.md`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// One expected file: path, size in bytes, SHA-1.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Expected {
|
||||
pub path: String,
|
||||
pub size: u64,
|
||||
pub sha1: String,
|
||||
}
|
||||
|
||||
/// Outcome of verifying a directory.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct VerifyReport {
|
||||
pub checked: usize,
|
||||
pub missing: Vec<String>,
|
||||
pub size_mismatch: Vec<(String, u64, u64)>,
|
||||
pub hash_mismatch: Vec<(String, String, String)>,
|
||||
/// Files present in the directory but absent from the table.
|
||||
pub unexpected: Vec<String>,
|
||||
}
|
||||
|
||||
impl VerifyReport {
|
||||
pub fn ok(&self) -> bool {
|
||||
self.missing.is_empty()
|
||||
&& self.size_mismatch.is_empty()
|
||||
&& self.hash_mismatch.is_empty()
|
||||
&& self.unexpected.is_empty()
|
||||
}
|
||||
|
||||
/// A short human-readable summary.
|
||||
pub fn summary(&self) -> String {
|
||||
if self.ok() {
|
||||
return format!("OK: {} files match the recorded table.", self.checked);
|
||||
}
|
||||
let mut lines = vec![format!(
|
||||
"FAILED: {} files checked, {} problems",
|
||||
self.checked,
|
||||
self.missing.len()
|
||||
+ self.size_mismatch.len()
|
||||
+ self.hash_mismatch.len()
|
||||
+ self.unexpected.len()
|
||||
)];
|
||||
for p in self.missing.iter().take(10) {
|
||||
lines.push(format!(" missing: {p}"));
|
||||
}
|
||||
for (p, want, got) in self.size_mismatch.iter().take(10) {
|
||||
lines.push(format!(" size: {p}: expected {want}, got {got}"));
|
||||
}
|
||||
for (p, want, got) in self.hash_mismatch.iter().take(10) {
|
||||
lines.push(format!(" hash: {p}: expected {want}, got {got}"));
|
||||
}
|
||||
for p in self.unexpected.iter().take(10) {
|
||||
lines.push(format!(" unexpected: {p}"));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a table from TSV lines of `sha1<TAB>size<TAB>path`.
|
||||
pub fn parse_table(text: &str) -> Vec<Expected> {
|
||||
text.lines()
|
||||
.filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
|
||||
.filter_map(|line| {
|
||||
let mut parts = line.split('\t');
|
||||
let sha1 = parts.next()?.trim().to_string();
|
||||
let size: u64 = parts.next()?.trim().parse().ok()?;
|
||||
let path = parts.next()?.trim().to_string();
|
||||
if sha1.len() != 40 || path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Expected { path, size, sha1 })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Render entries as a TSV table, sorted by path.
|
||||
pub fn render_table(entries: &[(String, u64, String)]) -> String {
|
||||
let mut sorted: Vec<_> = entries.to_vec();
|
||||
sorted.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let mut out = String::new();
|
||||
for (path, size, sha1) in sorted {
|
||||
out.push_str(&format!("{sha1}\t{size}\t{path}\n"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Verify `dir` against `expected`.
|
||||
pub fn verify(dir: &Path, expected: &[Expected]) -> VerifyReport {
|
||||
let mut report = VerifyReport::default();
|
||||
|
||||
let present = walk(dir);
|
||||
|
||||
for e in expected {
|
||||
report.checked += 1;
|
||||
match std::fs::read(dir.join(&e.path)) {
|
||||
Ok(data) => {
|
||||
if data.len() as u64 != e.size {
|
||||
report
|
||||
.size_mismatch
|
||||
.push((e.path.clone(), e.size, data.len() as u64));
|
||||
continue;
|
||||
}
|
||||
let got = crate::identify::sha1_hex(&data);
|
||||
if got != e.sha1 {
|
||||
report
|
||||
.hash_mismatch
|
||||
.push((e.path.clone(), e.sha1.clone(), got));
|
||||
}
|
||||
}
|
||||
Err(_) => report.missing.push(e.path.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
let expected_set: BTreeMap<&str, ()> = expected.iter().map(|e| (e.path.as_str(), ())).collect();
|
||||
for p in present {
|
||||
if !expected_set.contains_key(p.as_str()) {
|
||||
report.unexpected.push(p);
|
||||
}
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
/// List files under `dir`, as `/`-separated relative paths, sorted.
|
||||
fn walk(dir: &Path) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
collect(dir, dir, &mut out);
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
fn collect(root: &Path, dir: &Path, out: &mut Vec<String>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect(root, &path, out);
|
||||
} else if let Ok(rel) = path.strip_prefix(root) {
|
||||
out.push(rel.to_string_lossy().replace('\\', "/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_a_table_and_skips_comments() {
|
||||
let text = concat!(
|
||||
"# a comment\n",
|
||||
"da39a3ee5e6b4b0d3255bfef95601890afd80709\t0\tempty.bin\n",
|
||||
"a9993e364706816aba3e25717850c26c9cd0d89d\t3\tabc.bin\n",
|
||||
"\n",
|
||||
);
|
||||
let t = parse_table(text);
|
||||
assert_eq!(t.len(), 2);
|
||||
assert_eq!(t[0].path, "empty.bin");
|
||||
assert_eq!(t[0].size, 0);
|
||||
assert_eq!(t[1].path, "abc.bin");
|
||||
assert_eq!(t[1].size, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_malformed_rows() {
|
||||
let text = "notasha1\t3\tx.bin\n\
|
||||
a9993e364706816aba3e25717850c26c9cd0d89d\tnope\tx.bin\n\
|
||||
a9993e364706816aba3e25717850c26c9cd0d89d\t3\t";
|
||||
assert!(parse_table(text).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_a_rendered_table() {
|
||||
let entries = vec![
|
||||
(
|
||||
"b.bin".to_string(),
|
||||
2u64,
|
||||
"a9993e364706816aba3e25717850c26c9cd0d89d".to_string(),
|
||||
),
|
||||
(
|
||||
"a.bin".to_string(),
|
||||
1u64,
|
||||
"da39a3ee5e6b4b0d3255bfef95601890afd80709".to_string(),
|
||||
),
|
||||
];
|
||||
let text = render_table(&entries);
|
||||
assert!(
|
||||
text.lines().next().unwrap().starts_with("da39a3ee"),
|
||||
"sorted by path"
|
||||
);
|
||||
assert_eq!(parse_table(&text).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_detects_a_missing_file() {
|
||||
let dir = std::env::temp_dir().join(format!("gc-verify-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let expected = vec![Expected {
|
||||
path: "nope.bin".to_string(),
|
||||
size: 3,
|
||||
sha1: "a9993e364706816aba3e25717850c26c9cd0d89d".to_string(),
|
||||
}];
|
||||
let report = verify(&dir, &expected);
|
||||
assert!(!report.ok());
|
||||
assert_eq!(report.missing, vec!["nope.bin".to_string()]);
|
||||
assert!(report.summary().contains("missing"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_detects_a_hash_mismatch() {
|
||||
let dir = std::env::temp_dir().join(format!("gc-verify-hash-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("a.bin"), b"xyz").unwrap();
|
||||
|
||||
let expected = vec![Expected {
|
||||
path: "a.bin".to_string(),
|
||||
size: 3,
|
||||
// SHA-1 of "abc", not "xyz".
|
||||
sha1: "a9993e364706816aba3e25717850c26c9cd0d89d".to_string(),
|
||||
}];
|
||||
let report = verify(&dir, &expected);
|
||||
assert_eq!(report.hash_mismatch.len(), 1);
|
||||
assert!(report.summary().contains("hash"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_detects_an_unexpected_file() {
|
||||
let dir = std::env::temp_dir().join(format!("gc-verify-extra-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("extra.bin"), b"q").unwrap();
|
||||
let report = verify(&dir, &[]);
|
||||
assert_eq!(report.unexpected, vec!["extra.bin".to_string()]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_passes_a_correct_directory() {
|
||||
let dir = std::env::temp_dir().join(format!("gc-verify-ok-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("a.bin"), b"abc").unwrap();
|
||||
|
||||
let expected = vec![Expected {
|
||||
path: "a.bin".to_string(),
|
||||
size: 3,
|
||||
sha1: "a9993e364706816aba3e25717850c26c9cd0d89d".to_string(),
|
||||
}];
|
||||
let report = verify(&dir, &expected);
|
||||
assert!(report.ok(), "{}", report.summary());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user