diff --git a/.gitignore b/.gitignore index 2b39066..d402255 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,14 @@ flamegraph.svg CLAUDE.md memory/ -# Local e2e test fixtures (real, proprietary containers — never committed) -/fixtures/ +# e2e fixtures: ignore anything dropped into fixtures/ (your own real containers +# stay private) EXCEPT the committed synthetic test fixtures and their README. +/fixtures/* +!/fixtures/README.md +!/fixtures/TEST.bin +!/fixtures/TEST_1.00.00_20240101120000_0.app +!/fixtures/TEST_1.01.00_20240102120000_1_1.00.00.app +!/fixtures/TEST_T001_20240101120000_0.opt + +# Fixture generator (kept local, not committed) +/tests/fixtures_gen/ diff --git a/fixtures/README.md b/fixtures/README.md new file mode 100644 index 0000000..22b91b1 --- /dev/null +++ b/fixtures/README.md @@ -0,0 +1,34 @@ +# Test fixtures + +These are **synthetic** SEGA fscrypt containers used by `tests/e2e.rs`. They +contain only dummy files (a few text/XML/stub binaries), so they are safe to +commit — there is no proprietary game content inside. + +| File | Type | What it exercises | +|------|------|-------------------| +| `TEST_T001_20240101120000_0.opt` | OPTION | exFAT decrypt + extract (built-in OPTION key) | +| `TEST_1.00.00_20240101120000_0.app` | APP (base) | outer NTFS → `internal_0.vhd` (fixed VHD) → inner NTFS extract | +| `TEST_1.01.00_20240102120000_1_1.00.00.app` | APP (delta) | differencing `internal_1.vhd`, auto-merged against the base by VHD GUID | +| `TEST.bin` | key | external key (16-byte key + 16-byte IV) for game id `TEST` | + +The delta is a real differencing VHD linked to the base; decrypting it (with the +base alongside) merges the two and yields the base files plus the delta's added +`data/patch_notes.txt` and a modified `readme.txt`. + +The `.app` fixtures use the synthetic game id `TEST`, which is **not** in the +built-in key table — so they are decrypted via the external-key-file fallback +using the committed `TEST.bin` (this also exercises that fallback path). +`tests/e2e.rs` copies `TEST.bin` next to the containers and runs from there so +the binary finds it. The OPTION fixture uses the built-in OPTION key. Everything +here — keys and filesystem data alike — is synthetic. + +You can also drop your own **real** `.app`/`.opt` files in this folder to test +against them; everything here except these committed fixtures is git-ignored. + +## Regenerating + +These are generated by a local, uncommitted script set (NTFS/exFAT image +creation in Docker + a small SEGA fscrypt packer adapted from +[`beerpsi/x`](https://gitea.tendokyu.moe/beerpsi/x)). The byte output is not +reproducible (NTFS/exFAT embed creation timestamps), but regenerated fixtures are +functionally equivalent. diff --git a/fixtures/TEST.bin b/fixtures/TEST.bin new file mode 100644 index 0000000..fefa1cc Binary files /dev/null and b/fixtures/TEST.bin differ diff --git a/fixtures/TEST_1.00.00_20240101120000_0.app b/fixtures/TEST_1.00.00_20240101120000_0.app new file mode 100644 index 0000000..45e5bd4 Binary files /dev/null and b/fixtures/TEST_1.00.00_20240101120000_0.app differ diff --git a/fixtures/TEST_1.01.00_20240102120000_1_1.00.00.app b/fixtures/TEST_1.01.00_20240102120000_1_1.00.00.app new file mode 100644 index 0000000..f98fd79 Binary files /dev/null and b/fixtures/TEST_1.01.00_20240102120000_1_1.00.00.app differ diff --git a/fixtures/TEST_T001_20240101120000_0.opt b/fixtures/TEST_T001_20240101120000_0.opt new file mode 100644 index 0000000..456096f Binary files /dev/null and b/fixtures/TEST_T001_20240101120000_0.opt differ diff --git a/tests/e2e.rs b/tests/e2e.rs index b95cad4..33fbaf7 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -1,10 +1,11 @@ -//! End-to-end decryption test driven by local fixtures. +//! End-to-end decryption test driven by the fixtures in `fixtures/`. //! -//! Drop real `.app` / `.opt` containers into the git-ignored `fixtures/` -//! directory at the crate root. Each one is copied to a temp dir, run through -//! the built binary, and checked for a non-empty extraction output. When -//! `fixtures/` is absent or empty the test is a no-op, so the suite stays green -//! without the (proprietary) sample data. +//! The committed fixtures are small, fully synthetic containers (see +//! `fixtures/README.md`) — a base APP, a delta APP, and an OPTION — so this runs +//! the whole decrypt-and-extract path, including the delta/base VHD merge, with +//! no proprietary data. You can also drop your own real `.app`/`.opt` files in +//! `fixtures/` (they are git-ignored) and they'll be exercised too. When +//! `fixtures/` is empty the test is a no-op, so the suite stays green regardless. use std::fs; use std::path::{Path, PathBuf}; @@ -41,18 +42,34 @@ fn decrypts_fixture_containers() { } // Work in a temp dir so extraction never touches the fixtures folder. The - // tool extracts next to its input, so we copy each fixture in first. + // tool extracts next to its input, so we copy every fixture in *first* — a + // delta APP needs its base APP sitting alongside for the auto-merge. let workdir = std::env::temp_dir().join(format!("fsdecrypt-e2e-{}", std::process::id())); let _ = fs::remove_dir_all(&workdir); fs::create_dir_all(&workdir).expect("create work dir"); - for src in &files { - let name = src.file_name().unwrap().to_os_string(); - let input = workdir.join(&name); - fs::copy(src, &input).expect("copy fixture into work dir"); + // Copy *every* fixture file in — the containers plus any external + // `{game_id}.bin` key files an unlisted game needs. + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures"); + if let Ok(entries) = fs::read_dir(&dir) { + for e in entries.filter_map(|e| e.ok()) { + let p = e.path(); + if p.is_file() { + let _ = fs::copy(&p, workdir.join(p.file_name().unwrap())); + } + } + } + let inputs: Vec = files + .iter() + .map(|f| workdir.join(f.file_name().unwrap())) + .collect(); + for input in &inputs { + let name = input.file_name().unwrap().to_os_string(); + // Run from the work dir so the binary resolves `{game_id}.bin` keys there. let output = Command::new(env!("CARGO_BIN_EXE_fsdecrypt")) - .arg(&input) + .current_dir(&workdir) + .arg(input) .output() .expect("failed to run fsdecrypt"); @@ -69,7 +86,7 @@ fn decrypts_fixture_containers() { let produced = fs::read_dir(&out_dir).map(|rd| rd.count()).unwrap_or(0); assert!( produced > 0, - "{name:?}: expected extracted entries in {}", + "{name:?}: expected extracted entries in {}\n--- stdout ---\n{stdout}", out_dir.display() ); println!("ok: {name:?} -> {produced} top-level entr(y/ies) extracted");