diff --git a/AGENTS.md b/AGENTS.md index a24191f..e93118a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,25 +2,18 @@ Matching decompilation of **Syphon Filter 3 (USA)** for PlayStation. The goal is to rebuild original game binaries from C source with byte-for-byte identical output. A native PC port is out of scope until the matching decompilation is established. -## Current bootstrap state +## Session-start load order -- The legally dumped disc image is local-only: `Syphon Filter 3 (USA).bin`. -- Ghidra 12.1.2 is installed at `/opt/ghidra`. -- The locally built `ghidra_psx_ldr` extension is installed for Ghidra 12.1.2. -- PCSX-Redux is installed and launches normally. -- Do not assume the game serial, executable name, compiler, SDK version, archive format, compression, overlay layout, or RAM load addresses. Establish each from the bytes or runtime observation. +At the start of every session, read these files in order: -## Phase 0 — establish governance before reverse engineering +1. `AGENTS.md` — standing agent rules. +2. `PROJECT_CONTEXT.md` — permanent project constitution. +3. `phase-ends/DIGEST.md` — completed-phase findings. +4. `phase-ends/CURRENT_PHASE.md` — active in-phase state, when present. +5. The active phase plan in `phase-ends/`. +6. Task-relevant records under `docs/`. -The first task is to create the repository governance and state files: - -- `.gitignore` and `.gitattributes` -- `PROJECT_CONTEXT.md` (permanent constitution) -- `phase-ends/DIGEST.md` and `phase-ends/CURRENT_PHASE.md` -- `docs/SETUP.md`, `docs/formats.md`, and `docs/memory-map.md` -- a task-level Phase 1 plan - -Until those files exist, use this document as the bootstrap authority. Once they exist, update this file only to point to their session-start load order; do not duplicate their evolving state here. +The active plan and current-phase record govern day-to-day execution; do not duplicate their evolving state in this file. ## Mandatory behavior @@ -65,11 +58,3 @@ Until those files exist, use this document as the bootstrap authority. Once they - Keep current in-phase state in `phase-ends/CURRENT_PHASE.md`; write a checkpoint before ending a session. - After every four completed tasks, re-read these mandatory rules and state: `Rules check — re-read complete. Continuing with [next task].` - A phase closes only after every checklist item is explicitly verified and the developer confirms the milestone. Then write a PhaseEnd file, update the digest, and stop; do not begin the next phase in the same session. - -## Initial roadmap - -1. Phase 0: repository firewall, documentation/state structure, and disc-image characterization. -2. Phase 1: deterministic extraction, PS-X EXE discovery, and first Ghidra import. -3. Phase 2: runtime loader/overlay and archive-format investigation with PCSX-Redux. -4. Phase 3: matching toolchain, all-assembly rebuild, and compiler fingerprint. -5. Phase 4+: function matching, shared-body infrastructure, and scalable verification. diff --git a/docs/formats.md b/docs/formats.md index f03fe57..acde95d 100644 --- a/docs/formats.md +++ b/docs/formats.md @@ -13,7 +13,25 @@ The current USA disc image is exactly 294,018 sectors of 2352 bytes. Phase 0 T1 Filesystem/executable extraction may therefore read the BIN with a 2352-byte stride and offset 24 without a CUE. The following remain unverified: - track count and track boundaries beyond the data filesystem; -- executable filename and format; - archive, compression, audio, video, and overlay formats. Do not infer later format details from other Syphon Filter games or other regions. + +## PS-X EXE candidate (Phase 1 T3) + +The exact ISO9660 identifier `SCUS_946.40;1` from the local USA image was extracted without renaming. `tools/sf3_extract psx-exe-info` validated its `PS-X EXE` magic, 0x800-byte header, declared text bounds, and streaming SHA-1 against ignored `extracted/MANIFEST.tsv`. The header-field offsets were cross-checked against the locally installed loader source, `ghidra_psx_ldr/src/main/java/psx/PsxExe.java`. + +| Property | Header field / evidence | Verified value | +|---|---|---| +| File SHA-1 | Full extracted file; matches manifest | `e173426c157384ebf1b6caf8c6fea18a85a14af9` | +| File size | Full extracted file | 1,886,208 bytes | +| Header size | PS-X EXE header | 0x800 (2,048 bytes) | +| Text load address | 0x18 | `0x80010000` | +| Text size | 0x1C | 1,884,160 bytes (`0x001CC000`) | +| Declared text range | load address + text size | `[0x80010000, 0x801DC000)` | +| Entry PC | 0x10 | `0x800FB368` (within the declared text range) | +| Initial GP header field | 0x14 | `0x00000000` | +| SP base header field | 0x30 | `0x801FFFF0` | +| SP offset header field | 0x34 | `0x00000000` | + +These are static header declarations from the USA file, not PCSX-Redux observations. In particular, the zero GP and SP-offset values do not establish post-loader register behavior, and the declared text range is not yet a runtime memory-map claim. diff --git a/docs/memory-map.md b/docs/memory-map.md index e83d522..f6f6672 100644 --- a/docs/memory-map.md +++ b/docs/memory-map.md @@ -2,4 +2,15 @@ No USA runtime addresses are verified yet. +## Static PS-X EXE header declarations (Phase 1 T3) + +These entries are header-declared values from the local USA image's exact ISO9660 file `SCUS_946.40;1`. They are **not** Ghidra-import findings or PCSX-Redux runtime observations. + +| Address / field | Purpose | Source build / region | Evidence | Verification status | +|---|---|---|---|---| +| `[0x80010000, 0x801DC000)` | Declared text range | Local USA image | PS-X EXE header offsets 0x18/0x1C, parsed by `tools/sf3_extract`; field layout cross-checked with local `ghidra_psx_ldr` source | Header-valid; runtime load status unverified | +| `0x800FB368` | Declared entry PC | Local USA image | PS-X EXE header offset 0x10 | Header-valid and within declared text range; runtime execution unverified | +| `0x00000000` | Initial GP header field | Local USA image | PS-X EXE header offset 0x14 | Header-valid; not a runtime GP observation | +| `0x801FFFF0` + `0x00000000` | SP base and SP offset header fields | Local USA image | PS-X EXE header offsets 0x30/0x34 | Header-valid; no effective/runtime SP inference made | + Record each future entry with: address range, purpose, source build/region, evidence source (Ghidra or runtime observation), and verification status. Do not transfer addresses from other Syphon Filter releases without explicit USA validation. diff --git a/phase-ends/CURRENT_PHASE.md b/phase-ends/CURRENT_PHASE.md new file mode 100644 index 0000000..2934ea8 --- /dev/null +++ b/phase-ends/CURRENT_PHASE.md @@ -0,0 +1,23 @@ +# CURRENT_PHASE — Phase 1: Deterministic Extraction, Executable Discovery, and First Ghidra Import + +**Status:** in progress +**Plan:** `phase-ends/Phase1_PLAN.md` (approved by developer) + +## Tasks + +- [x] **P1-T1 — ISO walker:** added executable, dependency-free `tools/sf3_extract` and synthetic-only `unittest` coverage. The read-only `list` command validates MODE2/2352 sector geometry, ISO9660 descriptors and directory records, extents/bounds, output-path components, and recursive directory extents before returning deterministic paths. +- [x] **P1-T2 — Deterministic extraction:** extended `tools/sf3_extract` with transactional `extract`; it preserves exact ISO identifiers, copies every validated extent from the same open image handle, and writes ignored `extracted/MANIFEST.tsv` with path, first LBA, complete extents, size, and SHA-1. Two fresh direct runs produced identical manifests and file counts. +- [x] **P1-T3 — Executable characterization:** added synthetic-tested `psx-exe-info` validation to `tools/sf3_extract`. Exact ISO file `SCUS_946.40;1` passed PS-X EXE magic/header/text-bound checks and a streaming SHA-1 match against `extracted/MANIFEST.tsv`; permitted metadata is recorded in `docs/formats.md` and static-only header declarations in `docs/memory-map.md`. +- [ ] **P1-T4 — Ghidra import:** import the extracted executable with `ghidra_psx_ldr` into ignored `ghidra/` and record loader findings. +- [ ] **Rules check:** re-read `AGENTS.md` mandatory behavior after P1-T4. +- [ ] **P1-T5 — Reproducibility record:** document commands, results, and remaining unknowns; confirm no ROM-derived material is staged. + +## Session checkpoint + +Phase 1 opened in a fresh session after the completed Phase 0 close. The approved plan has been loaded, and the session-start load order and active checkpoint have been restored. + +P1-T1 is complete. `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tools/tests -v` initially passed 8 synthetic-only walker tests, including malformed record, non-MODE2 sector, out-of-bounds extent, unsafe path, recursive extent, and CLI refusal cases. `./tools/sf3_extract --help` passed as a direct-command smoke check. + +P1-T2 is complete. The host path is on native writable btrfs. The expanded synthetic-only suite passed 14 tests, including transactional extraction, multi-extent copying, refusal of a corrupt data sector and existing output, CLI extraction, and two fresh synthetic runs. Two serial fresh runs of `./tools/sf3_extract extract 'disks/Syphon Filter 3 (USA).bin' extracted` produced 96 files and 601,671,468 bytes each; their ignored manifests were byte-identical. All 96 final extracted files were re-hashed against the final manifest successfully. The first-run manifest is retained only in ignored `.run/p1-t2-manifest-run1.tsv`; the second direct run remains in ignored `extracted/`. No generated material is staged. + +P1-T3 is complete. The 19-test synthetic-only suite covers PS-X EXE parsing plus bad magic, declared-text-bound, and manifest-hash rejection. `SCUS_946.40;1` passed the header and manifest checks with file SHA-1 `e173426c157384ebf1b6caf8c6fea18a85a14af9`, declared text range `[0x80010000, 0x801DC000)`, and entry PC `0x800FB368`. The GP/SP values in `docs/formats.md` are raw header fields only; no Ghidra import or runtime observation has occurred. The ignored metadata report is `.run/p1-t3-psx-exe-info.tsv`. P1-T4 has not started. diff --git a/tools/sf3_extract b/tools/sf3_extract new file mode 100755 index 0000000..ce5b3b2 --- /dev/null +++ b/tools/sf3_extract @@ -0,0 +1,1146 @@ +#!/usr/bin/env python3 +"""Validate and extract the verified MODE2/2352 ISO9660 filesystem layout. + +The read-only ``list`` and transactional ``extract`` commands share one strict +walker. Extraction starts only after the full directory tree is validated. +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import hashlib +import os +from pathlib import Path +import re +import shutil +import sys +import tempfile +from typing import Sequence + + +RAW_SECTOR_SIZE = 2352 +USER_DATA_OFFSET = 24 +LOGICAL_BLOCK_SIZE = 2048 +PVD_LBA = 16 +VOLUME_DESCRIPTOR_TERMINATOR = 255 +MAX_VOLUME_DESCRIPTORS = 64 +DEFAULT_MAX_DIRECTORY_DEPTH = 64 +MANIFEST_FILENAME = "MANIFEST.tsv" +MANIFEST_HEADER = "path\tlba\textents\tsize\tsha1\n" +PSX_EXE_MAGIC = b"PS-X EXE" +PSX_EXE_HEADER_SIZE = 0x800 +PSX_EXE_INIT_PC_OFFSET = 0x10 +PSX_EXE_INIT_GP_OFFSET = 0x14 +PSX_EXE_TEXT_ADDRESS_OFFSET = 0x18 +PSX_EXE_TEXT_SIZE_OFFSET = 0x1C +PSX_EXE_DATA_ADDRESS_OFFSET = 0x20 +PSX_EXE_DATA_SIZE_OFFSET = 0x24 +PSX_EXE_BSS_ADDRESS_OFFSET = 0x28 +PSX_EXE_BSS_SIZE_OFFSET = 0x2C +PSX_EXE_SP_BASE_OFFSET = 0x30 +PSX_EXE_SP_OFFSET_OFFSET = 0x34 +UINT32_LIMIT = 1 << 32 +MODE2_SYNC = b"\x00" + (b"\xff" * 10) + b"\x00" +SAFE_COMPONENT = re.compile(r"[A-Za-z0-9._;-]+\Z") + +DIRECTORY_FLAG = 0x02 +ASSOCIATED_FILE_FLAG = 0x04 +MULTI_EXTENT_FLAG = 0x80 +RESERVED_FILE_FLAGS = 0x60 +SPECIAL_CURRENT_DIRECTORY = b"\x00" +SPECIAL_PARENT_DIRECTORY = b"\x01" +SPECIAL_IDENTIFIERS = {SPECIAL_CURRENT_DIRECTORY, SPECIAL_PARENT_DIRECTORY} + + +class IsoValidationError(ValueError): + """Raised when the input cannot be safely treated as this ISO filesystem.""" + + +class PsxExeValidationError(ValueError): + """Raised when a manifest-tracked file is not a valid PS-X EXE.""" + + +@dataclass(frozen=True) +class IsoExtent: + """One non-interleaved file-data extent, measured in logical blocks.""" + + lba: int + size: int + + +@dataclass(frozen=True) +class IsoEntry: + """A validated filesystem entry below the ISO root directory.""" + + path: str + is_directory: bool + size: int + extents: tuple[IsoExtent, ...] + flags: int + + @property + def lba(self) -> int: + """Return the first file-data LBA for a single- or multi-extent entry.""" + return self.extents[0].lba + + +@dataclass(frozen=True) +class ExtractionResult: + """The non-content result of one successful extraction.""" + + output_root: Path + manifest_path: Path + file_count: int + byte_count: int + + +@dataclass(frozen=True) +class ManifestEntry: + """The validated manifest fields used to identify one extracted file.""" + + path: str + lba: int + size: int + sha1: str + + +@dataclass(frozen=True) +class PsxExeInfo: + """Validated PS-X EXE metadata, not a runtime observation.""" + + path: str + sha1: str + file_size: int + text_load_address: int + text_size: int + entry_pc: int + initial_gp: int + sp_base: int + sp_offset: int + + +@dataclass(frozen=True) +class DirectoryRecord: + """The validated fields needed from one ISO9660 directory record.""" + + identifier: bytes + extent_lba: int + data_lba: int + data_length: int + extended_attribute_blocks: int + flags: int + volume_sequence: int + + @property + def is_directory(self) -> bool: + return bool(self.flags & DIRECTORY_FLAG) + + @property + def continues(self) -> bool: + return bool(self.flags & MULTI_EXTENT_FLAG) + + +@dataclass(frozen=True) +class Volume: + """Validated primary-volume information used by the walker.""" + + space_size: int + volume_sequence: int + root: DirectoryRecord + + +class RawMode2Image: + """Read fixed-size MODE2 raw sectors and expose their 2048-byte user data.""" + + def __init__(self, path: str | Path) -> None: + self.path = Path(path) + try: + self._file = self.path.open("rb") + self.size = self._file.seek(0, 2) + self._file.seek(0) + except OSError as exc: + raise IsoValidationError(f"cannot open image {self.path}: {exc}") from exc + + if self.size % RAW_SECTOR_SIZE: + self.close() + raise IsoValidationError( + f"image size {self.size} is not a multiple of {RAW_SECTOR_SIZE} bytes" + ) + + self.raw_sector_count = self.size // RAW_SECTOR_SIZE + if self.raw_sector_count <= PVD_LBA + 1: + self.close() + raise IsoValidationError("image is too small to contain an ISO9660 descriptor set") + + def __enter__(self) -> RawMode2Image: + return self + + def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None: + self.close() + + def close(self) -> None: + file = getattr(self, "_file", None) + if file is not None: + file.close() + self._file = None + + def ensure_raw_range(self, lba: int, blocks: int, context: str) -> None: + if lba < 0 or blocks < 0: + raise IsoValidationError(f"{context}: negative LBA or block count") + if lba > self.raw_sector_count or blocks > self.raw_sector_count - lba: + raise IsoValidationError( + f"{context}: LBA range {lba}+{blocks} exceeds image sector count " + f"{self.raw_sector_count}" + ) + + def read_user_block(self, lba: int) -> bytes: + self.ensure_raw_range(lba, 1, "logical-block read") + raw_offset = lba * RAW_SECTOR_SIZE + try: + self._file.seek(raw_offset) + sector = self._file.read(RAW_SECTOR_SIZE) + except OSError as exc: + raise IsoValidationError(f"cannot read raw sector {lba}: {exc}") from exc + + if len(sector) != RAW_SECTOR_SIZE: + raise IsoValidationError(f"raw sector {lba} is truncated") + if sector[: len(MODE2_SYNC)] != MODE2_SYNC: + raise IsoValidationError(f"raw sector {lba} has no MODE2 sync pattern") + if sector[15] != 2: + raise IsoValidationError(f"raw sector {lba} is not MODE2 (mode={sector[15]})") + + user_data = sector[USER_DATA_OFFSET : USER_DATA_OFFSET + LOGICAL_BLOCK_SIZE] + if len(user_data) != LOGICAL_BLOCK_SIZE: + raise IsoValidationError(f"raw sector {lba} has truncated MODE2 user data") + return user_data + + +def _both_endian_u16(data: bytes, offset: int, context: str) -> int: + if offset < 0 or offset + 4 > len(data): + raise IsoValidationError(f"{context}: truncated both-endian 16-bit value") + little = int.from_bytes(data[offset : offset + 2], "little") + big = int.from_bytes(data[offset + 2 : offset + 4], "big") + if little != big: + raise IsoValidationError( + f"{context}: little-endian value {little} disagrees with big-endian value {big}" + ) + return little + + +def _both_endian_u32(data: bytes, offset: int, context: str) -> int: + if offset < 0 or offset + 8 > len(data): + raise IsoValidationError(f"{context}: truncated both-endian 32-bit value") + little = int.from_bytes(data[offset : offset + 4], "little") + big = int.from_bytes(data[offset + 4 : offset + 8], "big") + if little != big: + raise IsoValidationError( + f"{context}: little-endian value {little} disagrees with big-endian value {big}" + ) + return little + + +def _validate_descriptor_header(descriptor: bytes, lba: int) -> None: + if len(descriptor) != LOGICAL_BLOCK_SIZE: + raise IsoValidationError(f"volume descriptor at LBA {lba} is truncated") + if descriptor[1:6] != b"CD001": + raise IsoValidationError(f"volume descriptor at LBA {lba} has no CD001 identifier") + if descriptor[6] != 1: + raise IsoValidationError( + f"volume descriptor at LBA {lba} has unsupported version {descriptor[6]}" + ) + + +def _parse_directory_record( + record_data: bytes, + *, + context: str, + volume_space: int, + raw_sector_count: int, + expected_volume_sequence: int, +) -> DirectoryRecord: + if not record_data: + raise IsoValidationError(f"{context}: missing directory-record length") + + record_length = record_data[0] + if record_length == 0: + raise IsoValidationError(f"{context}: zero-length directory record") + if record_length > len(record_data): + raise IsoValidationError( + f"{context}: record length {record_length} exceeds available bytes {len(record_data)}" + ) + if record_length < 34: + raise IsoValidationError(f"{context}: record length {record_length} is shorter than 34") + + record = record_data[:record_length] + identifier_length = record[32] + if identifier_length == 0: + raise IsoValidationError(f"{context}: directory record has an empty identifier") + padding_length = 1 if identifier_length % 2 == 0 else 0 + minimum_length = 33 + identifier_length + padding_length + if minimum_length > record_length: + raise IsoValidationError( + f"{context}: identifier length {identifier_length} exceeds record length {record_length}" + ) + if padding_length and record[33 + identifier_length] != 0: + raise IsoValidationError(f"{context}: identifier padding byte is nonzero") + + flags = record[25] + if flags & RESERVED_FILE_FLAGS: + raise IsoValidationError(f"{context}: directory record sets reserved file flags {flags:#04x}") + if record[26] != 0 or record[27] != 0: + raise IsoValidationError(f"{context}: interleaved files are not supported") + + extent_lba = _both_endian_u32(record, 2, f"{context} extent") + data_length = _both_endian_u32(record, 10, f"{context} data length") + volume_sequence = _both_endian_u16(record, 28, f"{context} volume sequence") + if volume_sequence != expected_volume_sequence: + raise IsoValidationError( + f"{context}: volume sequence {volume_sequence} is not " + f"{expected_volume_sequence}" + ) + + extended_attribute_blocks = record[1] + data_lba = extent_lba + extended_attribute_blocks + data_blocks = (data_length + LOGICAL_BLOCK_SIZE - 1) // LOGICAL_BLOCK_SIZE + + if extent_lba >= volume_space: + raise IsoValidationError( + f"{context}: extent LBA {extent_lba} is outside volume size {volume_space}" + ) + if data_lba > volume_space or data_blocks > volume_space - data_lba: + raise IsoValidationError( + f"{context}: data range {data_lba}+{data_blocks} exceeds volume size {volume_space}" + ) + if extent_lba >= raw_sector_count: + raise IsoValidationError( + f"{context}: extent LBA {extent_lba} is outside image sector count {raw_sector_count}" + ) + if data_lba > raw_sector_count or data_blocks > raw_sector_count - data_lba: + raise IsoValidationError( + f"{context}: data range {data_lba}+{data_blocks} exceeds image sector count " + f"{raw_sector_count}" + ) + + identifier = record[33 : 33 + identifier_length] + if identifier in SPECIAL_IDENTIFIERS: + if not (flags & DIRECTORY_FLAG): + raise IsoValidationError(f"{context}: special directory identifier is not a directory") + if flags & MULTI_EXTENT_FLAG: + raise IsoValidationError(f"{context}: special directory identifier is multi-extent") + + return DirectoryRecord( + identifier=identifier, + extent_lba=extent_lba, + data_lba=data_lba, + data_length=data_length, + extended_attribute_blocks=extended_attribute_blocks, + flags=flags, + volume_sequence=volume_sequence, + ) + + +def _read_volume(image: RawMode2Image) -> Volume: + pvd = image.read_user_block(PVD_LBA) + _validate_descriptor_header(pvd, PVD_LBA) + if pvd[0] != 1: + raise IsoValidationError(f"LBA {PVD_LBA} is not a primary volume descriptor") + + volume_space = _both_endian_u32(pvd, 80, "primary volume descriptor volume-space size") + if volume_space <= PVD_LBA + 1: + raise IsoValidationError(f"volume size {volume_space} cannot contain a descriptor set") + if volume_space > image.raw_sector_count: + raise IsoValidationError( + f"volume size {volume_space} exceeds image sector count {image.raw_sector_count}" + ) + + volume_set_size = _both_endian_u16(pvd, 120, "primary volume descriptor volume-set size") + volume_sequence = _both_endian_u16( + pvd, 124, "primary volume descriptor volume sequence" + ) + if volume_set_size != 1 or volume_sequence != 1: + raise IsoValidationError( + "multi-volume ISO9660 sets are not supported " + f"(set size {volume_set_size}, sequence {volume_sequence})" + ) + + block_size = _both_endian_u16(pvd, 128, "primary volume descriptor logical block size") + if block_size != LOGICAL_BLOCK_SIZE: + raise IsoValidationError( + f"logical block size {block_size} is not {LOGICAL_BLOCK_SIZE}" + ) + + terminator_found = False + descriptor_limit = min(volume_space, PVD_LBA + 1 + MAX_VOLUME_DESCRIPTORS) + for lba in range(PVD_LBA + 1, descriptor_limit): + descriptor = image.read_user_block(lba) + _validate_descriptor_header(descriptor, lba) + descriptor_type = descriptor[0] + if descriptor_type == VOLUME_DESCRIPTOR_TERMINATOR: + terminator_found = True + break + if descriptor_type not in {0, 1, 2, 3}: + raise IsoValidationError( + f"volume descriptor at LBA {lba} has unsupported type {descriptor_type}" + ) + if not terminator_found: + raise IsoValidationError( + f"no volume-descriptor terminator found within {MAX_VOLUME_DESCRIPTORS} descriptors" + ) + + root_record_length = pvd[156] + if root_record_length == 0: + raise IsoValidationError("primary volume descriptor has no root directory record") + if 156 + root_record_length > len(pvd): + raise IsoValidationError("primary volume descriptor root directory record is truncated") + root = _parse_directory_record( + pvd[156 : 156 + root_record_length], + context="primary volume descriptor root directory record", + volume_space=volume_space, + raw_sector_count=image.raw_sector_count, + expected_volume_sequence=volume_sequence, + ) + if root.identifier != SPECIAL_CURRENT_DIRECTORY or not root.is_directory: + raise IsoValidationError("primary volume descriptor root record is not a directory identifier") + if root.continues: + raise IsoValidationError("primary volume descriptor root directory is multi-extent") + if root.data_length == 0: + raise IsoValidationError("primary volume descriptor root directory has zero length") + + return Volume(space_size=volume_space, volume_sequence=volume_sequence, root=root) + + +def _safe_component(identifier: bytes, context: str) -> str: + try: + component = identifier.decode("ascii") + except UnicodeDecodeError as exc: + raise IsoValidationError(f"{context}: identifier is not ASCII") from exc + + if component in {"", ".", ".."}: + raise IsoValidationError(f"{context}: unsafe empty or dot path component") + if not SAFE_COMPONENT.fullmatch(component): + raise IsoValidationError(f"{context}: unsafe path component {component!r}") + if len(component.encode("ascii")) > 255: + raise IsoValidationError(f"{context}: path component exceeds 255 bytes") + return component + + +def _same_directory_target(left: DirectoryRecord, right: DirectoryRecord) -> bool: + return left.data_lba == right.data_lba and left.data_length == right.data_length + + +class Iso9660Walker: + """Walk one validated single-volume ISO9660 filesystem without writing files.""" + + def __init__(self, image_path: str | Path, *, max_depth: int = DEFAULT_MAX_DIRECTORY_DEPTH) -> None: + if max_depth < 0: + raise ValueError("max_depth must not be negative") + self.image_path = Path(image_path) + self.max_depth = max_depth + self._image: RawMode2Image | None = None + self._volume: Volume | None = None + self._seen_directories: set[tuple[int, int]] = set() + self._seen_paths: set[str] = set() + self._entries: list[IsoEntry] = [] + + def walk(self) -> tuple[IsoEntry, ...]: + with RawMode2Image(self.image_path) as image: + return self._walk_open_image(image) + + def _walk_open_image(self, image: RawMode2Image) -> tuple[IsoEntry, ...]: + """Walk an already-open image so extraction cannot switch inputs mid-run.""" + self._image = image + self._volume = _read_volume(image) + self._seen_directories.clear() + self._seen_paths.clear() + self._entries.clear() + self._walk_directory( + directory=self._volume.root, + parent=self._volume.root, + parts=(), + depth=0, + ) + return tuple(sorted(self._entries, key=lambda entry: entry.path)) + + @property + def image(self) -> RawMode2Image: + if self._image is None: + raise RuntimeError("ISO walker image is not open") + return self._image + + @property + def volume(self) -> Volume: + if self._volume is None: + raise RuntimeError("ISO walker volume has not been read") + return self._volume + + def _walk_directory( + self, + *, + directory: DirectoryRecord, + parent: DirectoryRecord, + parts: tuple[str, ...], + depth: int, + ) -> None: + if depth > self.max_depth: + raise IsoValidationError( + f"directory {'/'.join(parts) or ''} exceeds maximum depth {self.max_depth}" + ) + + directory_key = (directory.data_lba, directory.data_length) + if directory_key in self._seen_directories: + raise IsoValidationError( + f"directory {'/'.join(parts) or ''} reuses extent " + f"{directory.data_lba}+{directory.data_length}; cycle or alias refused" + ) + self._seen_directories.add(directory_key) + + records = self._read_directory_records(directory, parts) + self._validate_dot_records(records, directory, parent, parts) + + for group in self._coalesce_entries(records, parts): + first = group[0] + component = _safe_component( + first.identifier, f"directory {'/'.join(parts) or ''}" + ) + child_parts = (*parts, component) + relative_path = "/".join(child_parts) + if len(relative_path.encode("ascii")) > 4096: + raise IsoValidationError(f"path {relative_path!r} exceeds 4096 bytes") + if relative_path in self._seen_paths: + raise IsoValidationError(f"duplicate filesystem path {relative_path!r}") + self._seen_paths.add(relative_path) + + if first.flags & ASSOCIATED_FILE_FLAG: + raise IsoValidationError( + f"path {relative_path!r} is an associated file, which is not supported" + ) + + extents = tuple(IsoExtent(record.data_lba, record.data_length) for record in group) + entry = IsoEntry( + path=relative_path, + is_directory=first.is_directory, + size=sum(extent.size for extent in extents), + extents=extents, + flags=first.flags & ~MULTI_EXTENT_FLAG, + ) + self._entries.append(entry) + + if first.is_directory: + if len(group) != 1: + raise IsoValidationError(f"directory {relative_path!r} is multi-extent") + if first.data_length == 0: + raise IsoValidationError(f"directory {relative_path!r} has zero length") + self._walk_directory( + directory=first, + parent=directory, + parts=child_parts, + depth=depth + 1, + ) + + def _read_directory_records( + self, directory: DirectoryRecord, parts: tuple[str, ...] + ) -> list[DirectoryRecord]: + if not directory.is_directory: + raise IsoValidationError("attempted to walk a non-directory record") + if directory.data_length == 0: + raise IsoValidationError(f"directory {'/'.join(parts) or ''} has zero length") + + records: list[DirectoryRecord] = [] + block_count = (directory.data_length + LOGICAL_BLOCK_SIZE - 1) // LOGICAL_BLOCK_SIZE + for block_index in range(block_count): + block = self.image.read_user_block(directory.data_lba + block_index) + remaining = directory.data_length - (block_index * LOGICAL_BLOCK_SIZE) + valid_bytes = min(remaining, LOGICAL_BLOCK_SIZE) + cursor = 0 + while cursor < valid_bytes: + record_length = block[cursor] + record_context = ( + f"directory {'/'.join(parts) or ''}, LBA " + f"{directory.data_lba + block_index}, byte {cursor}" + ) + if record_length == 0: + if any(block[cursor:valid_bytes]): + raise IsoValidationError( + f"{record_context}: nonzero data follows directory padding" + ) + break + if record_length > LOGICAL_BLOCK_SIZE - cursor: + raise IsoValidationError( + f"{record_context}: directory record crosses a logical-block boundary" + ) + if record_length > valid_bytes - cursor: + raise IsoValidationError( + f"{record_context}: directory record exceeds declared directory length" + ) + records.append( + _parse_directory_record( + block[cursor : cursor + record_length], + context=record_context, + volume_space=self.volume.space_size, + raw_sector_count=self.image.raw_sector_count, + expected_volume_sequence=self.volume.volume_sequence, + ) + ) + cursor += record_length + if not records: + raise IsoValidationError(f"directory {'/'.join(parts) or ''} has no records") + return records + + def _validate_dot_records( + self, + records: Sequence[DirectoryRecord], + directory: DirectoryRecord, + parent: DirectoryRecord, + parts: tuple[str, ...], + ) -> None: + current = [record for record in records if record.identifier == SPECIAL_CURRENT_DIRECTORY] + previous = [record for record in records if record.identifier == SPECIAL_PARENT_DIRECTORY] + display_path = "/".join(parts) or "" + if len(current) != 1 or len(previous) != 1: + raise IsoValidationError( + f"directory {display_path} must contain exactly one current and parent record" + ) + if not _same_directory_target(current[0], directory): + raise IsoValidationError(f"directory {display_path} current record does not point to itself") + if not _same_directory_target(previous[0], parent): + raise IsoValidationError(f"directory {display_path} parent record does not point to its parent") + + def _coalesce_entries( + self, records: Sequence[DirectoryRecord], parts: tuple[str, ...] + ) -> list[tuple[DirectoryRecord, ...]]: + ordinary = [record for record in records if record.identifier not in SPECIAL_IDENTIFIERS] + groups: list[tuple[DirectoryRecord, ...]] = [] + index = 0 + display_path = "/".join(parts) or "" + + while index < len(ordinary): + first = ordinary[index] + if first.is_directory and first.continues: + raise IsoValidationError( + f"directory {display_path}: directory identifier is multi-extent" + ) + + group = [first] + index += 1 + while group[-1].continues: + if index >= len(ordinary): + raise IsoValidationError( + f"directory {display_path}: unterminated multi-extent file" + ) + following = ordinary[index] + if following.identifier != first.identifier: + raise IsoValidationError( + f"directory {display_path}: multi-extent file identifier changes" + ) + if following.is_directory: + raise IsoValidationError( + f"directory {display_path}: multi-extent file becomes a directory" + ) + if (following.flags & ~MULTI_EXTENT_FLAG) != ( + first.flags & ~MULTI_EXTENT_FLAG + ): + raise IsoValidationError( + f"directory {display_path}: multi-extent file flags change" + ) + group.append(following) + index += 1 + + groups.append(tuple(group)) + + return groups + + +def walk_image(image_path: str | Path, *, max_depth: int = DEFAULT_MAX_DIRECTORY_DEPTH) -> tuple[IsoEntry, ...]: + """Return every validated non-root entry in deterministic path order.""" + return Iso9660Walker(image_path, max_depth=max_depth).walk() + + +def _path_lexists(path: Path) -> bool: + """Return whether a path exists, including a broken symbolic link.""" + return os.path.lexists(path) + + +def _prepare_output_directory(destination: str | Path) -> tuple[Path, Path]: + """Create a private sibling directory; never overwrite a destination.""" + requested = Path(destination) + if requested.name in {"", ".", ".."}: + raise IsoValidationError("extraction destination must name a directory") + + parent = requested.parent + try: + parent.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise IsoValidationError(f"cannot create output parent {parent}: {exc}") from exc + if not parent.is_dir() or parent.is_symlink(): + raise IsoValidationError(f"output parent {parent} is not a real directory") + + try: + resolved_parent = parent.resolve(strict=True) + except OSError as exc: + raise IsoValidationError(f"cannot resolve output parent {parent}: {exc}") from exc + final_root = resolved_parent / requested.name + if _path_lexists(final_root): + raise IsoValidationError( + f"extraction destination {final_root} already exists; refusing to overwrite it" + ) + + try: + temporary_root = Path( + tempfile.mkdtemp(prefix=f".{final_root.name}.tmp-", dir=resolved_parent) + ).resolve(strict=True) + except OSError as exc: + raise IsoValidationError( + f"cannot create temporary extraction directory beside {final_root}: {exc}" + ) from exc + return final_root, temporary_root + + +def _entry_output_path(root: Path, iso_path: str) -> Path: + """Convert a walker-validated ISO path into a path strictly below ``root``.""" + components = iso_path.split("/") + if not components: + raise IsoValidationError("empty extraction path") + for component in components: + try: + encoded_component = component.encode("ascii") + except UnicodeEncodeError as exc: + raise IsoValidationError(f"non-ASCII extraction path component {component!r}") from exc + if _safe_component(encoded_component, "extraction path") != component: + raise IsoValidationError(f"unsafe extraction path component {component!r}") + + destination = root.joinpath(*components) + try: + destination.relative_to(root) + except ValueError as exc: + raise IsoValidationError(f"extraction path {iso_path!r} escapes its output root") from exc + return destination + + +def _create_output_directories(root: Path, entries: Sequence[IsoEntry]) -> None: + for entry in entries: + if not entry.is_directory: + continue + destination = _entry_output_path(root, entry.path) + try: + destination.mkdir(parents=True, exist_ok=False) + except OSError as exc: + raise IsoValidationError( + f"cannot create output directory for {entry.path!r}: {exc}" + ) from exc + + +def _copy_file_entry(image: RawMode2Image, entry: IsoEntry, destination: Path) -> str: + """Copy one validated file and return the SHA-1 of exactly written bytes.""" + if entry.is_directory: + raise IsoValidationError(f"cannot copy directory entry {entry.path!r} as a file") + + digest = hashlib.sha1() + copied = 0 + try: + with destination.open("xb") as output: + for extent in entry.extents: + remaining = extent.size + lba = extent.lba + while remaining: + block = image.read_user_block(lba) + chunk_size = min(remaining, LOGICAL_BLOCK_SIZE) + chunk = block[:chunk_size] + written = output.write(chunk) + if written != chunk_size: + raise IsoValidationError( + f"short write while extracting {entry.path!r}: " + f"wrote {written} of {chunk_size} bytes" + ) + digest.update(chunk) + copied += chunk_size + remaining -= chunk_size + lba += 1 + except OSError as exc: + raise IsoValidationError(f"cannot write extracted file {entry.path!r}: {exc}") from exc + + if copied != entry.size: + raise IsoValidationError( + f"extracted byte count {copied} does not match declared size {entry.size} " + f"for {entry.path!r}" + ) + return digest.hexdigest() + + +def _manifest_extent_field(entry: IsoEntry) -> str: + return ",".join(f"{extent.lba}:{extent.size}" for extent in entry.extents) + + +def _write_manifest(root: Path, records: Sequence[tuple[IsoEntry, str]]) -> Path: + manifest_path = root / MANIFEST_FILENAME + try: + with manifest_path.open("x", encoding="ascii", newline="\n") as manifest: + manifest.write(MANIFEST_HEADER) + for entry, digest in records: + manifest.write( + f"{entry.path}\t{entry.lba}\t{_manifest_extent_field(entry)}\t" + f"{entry.size}\t{digest}\n" + ) + except OSError as exc: + raise IsoValidationError(f"cannot write manifest {manifest_path}: {exc}") from exc + return manifest_path + + +def _validate_manifest_path(entries: Sequence[IsoEntry]) -> None: + for entry in entries: + if entry.path == MANIFEST_FILENAME or entry.path.startswith(f"{MANIFEST_FILENAME}/"): + raise IsoValidationError( + f"ISO path {entry.path!r} collides with generated manifest {MANIFEST_FILENAME!r}" + ) + + +def extract_image( + image_path: str | Path, + output_root: str | Path, + *, + max_depth: int = DEFAULT_MAX_DIRECTORY_DEPTH, +) -> ExtractionResult: + """Validate and atomically extract all regular files to a fresh output directory.""" + temporary_root: Path | None = None + try: + with RawMode2Image(image_path) as image: + walker = Iso9660Walker(image_path, max_depth=max_depth) + entries = walker._walk_open_image(image) + _validate_manifest_path(entries) + final_root, temporary_root = _prepare_output_directory(output_root) + _create_output_directories(temporary_root, entries) + + records: list[tuple[IsoEntry, str]] = [] + for entry in entries: + if entry.is_directory: + continue + destination = _entry_output_path(temporary_root, entry.path) + if not destination.parent.is_dir(): + raise IsoValidationError( + f"output parent for {entry.path!r} was not created as a directory" + ) + records.append((entry, _copy_file_entry(image, entry, destination))) + _write_manifest(temporary_root, records) + + if _path_lexists(final_root): + raise IsoValidationError( + f"extraction destination {final_root} appeared during extraction; refusing to overwrite it" + ) + try: + temporary_root.rename(final_root) + except OSError as exc: + raise IsoValidationError( + f"cannot atomically install extraction at {final_root}: {exc}" + ) from exc + + result = ExtractionResult( + output_root=final_root, + manifest_path=final_root / MANIFEST_FILENAME, + file_count=len(records), + byte_count=sum(entry.size for entry, _digest in records), + ) + temporary_root = None + return result + except Exception: + if temporary_root is not None: + shutil.rmtree(temporary_root, ignore_errors=True) + raise + + +def _validate_manifest_relative_path(path: str, context: str) -> None: + components = path.split("/") + if not components: + raise PsxExeValidationError(f"{context}: empty relative path") + for component in components: + try: + encoded_component = component.encode("ascii") + except UnicodeEncodeError as exc: + raise PsxExeValidationError( + f"{context}: non-ASCII path component {component!r}" + ) from exc + if _safe_component(encoded_component, context) != component: + raise PsxExeValidationError(f"{context}: unsafe path component {component!r}") + + +def _parse_manifest_extents(field: str, context: str) -> tuple[tuple[int, int], ...]: + if not field: + raise PsxExeValidationError(f"{context}: empty extent field") + extents: list[tuple[int, int]] = [] + for encoded_extent in field.split(","): + lba_text, separator, size_text = encoded_extent.partition(":") + if not separator or ":" in size_text: + raise PsxExeValidationError(f"{context}: malformed extent {encoded_extent!r}") + if not re.fullmatch(r"[0-9]+", lba_text) or not re.fullmatch(r"[0-9]+", size_text): + raise PsxExeValidationError(f"{context}: non-decimal extent {encoded_extent!r}") + extents.append((int(lba_text), int(size_text))) + return tuple(extents) + + +def _read_manifest_entry(manifest_path: str | Path, expected_path: str) -> ManifestEntry: + manifest = Path(manifest_path) + if manifest.is_symlink(): + raise PsxExeValidationError(f"manifest {manifest} must not be a symbolic link") + try: + manifest_text = manifest.read_text(encoding="ascii") + except UnicodeDecodeError as exc: + raise PsxExeValidationError(f"manifest {manifest} is not ASCII") from exc + except OSError as exc: + raise PsxExeValidationError(f"cannot read manifest {manifest}: {exc}") from exc + + lines = manifest_text.splitlines() + if not lines or lines[0] != MANIFEST_HEADER.rstrip("\n"): + raise PsxExeValidationError(f"manifest {manifest} has an unexpected header") + + candidate: ManifestEntry | None = None + seen_paths: set[str] = set() + for line_number, line in enumerate(lines[1:], start=2): + fields = line.split("\t") + if len(fields) != 5: + raise PsxExeValidationError( + f"manifest {manifest}, line {line_number}: expected five tab-separated fields" + ) + path, lba_text, extent_text, size_text, sha1 = fields + context = f"manifest {manifest}, line {line_number}" + _validate_manifest_relative_path(path, context) + if path in seen_paths: + raise PsxExeValidationError(f"{context}: duplicate path") + seen_paths.add(path) + if not re.fullmatch(r"[0-9]+", lba_text) or not re.fullmatch(r"[0-9]+", size_text): + raise PsxExeValidationError(f"{context}: non-decimal LBA or size") + if not re.fullmatch(r"[0-9a-f]{40}", sha1): + raise PsxExeValidationError(f"{context}: malformed SHA-1") + extents = _parse_manifest_extents(extent_text, context) + lba = int(lba_text) + size = int(size_text) + if extents[0][0] != lba: + raise PsxExeValidationError(f"{context}: first extent does not match LBA field") + if sum(extent_size for _extent_lba, extent_size in extents) != size: + raise PsxExeValidationError(f"{context}: extents do not sum to file size") + if path == expected_path: + candidate = ManifestEntry(path=path, lba=lba, size=size, sha1=sha1) + + if candidate is None: + raise PsxExeValidationError( + f"manifest {manifest} has no entry for executable path {expected_path!r}" + ) + return candidate + + +def _relative_path_from_manifest_root(executable_path: str | Path, manifest_path: str | Path) -> str: + executable = Path(executable_path) + manifest = Path(manifest_path) + if executable.is_symlink(): + raise PsxExeValidationError(f"executable {executable} must not be a symbolic link") + try: + root = manifest.parent.resolve(strict=True) + resolved_executable = executable.resolve(strict=True) + except OSError as exc: + raise PsxExeValidationError(f"cannot resolve executable or manifest root: {exc}") from exc + if not resolved_executable.is_file(): + raise PsxExeValidationError(f"executable {executable} is not a regular file") + try: + relative_path = resolved_executable.relative_to(root).as_posix() + except ValueError as exc: + raise PsxExeValidationError( + f"executable {executable} is outside manifest root {root}" + ) from exc + _validate_manifest_relative_path(relative_path, "executable path") + return relative_path + + +def _read_psx_exe_and_hash(executable_path: str | Path) -> tuple[bytes, int, str]: + executable = Path(executable_path) + if executable.is_symlink(): + raise PsxExeValidationError(f"executable {executable} must not be a symbolic link") + + digest = hashlib.sha1() + file_size = 0 + try: + with executable.open("rb") as executable_file: + header = executable_file.read(PSX_EXE_HEADER_SIZE) + if len(header) != PSX_EXE_HEADER_SIZE: + raise PsxExeValidationError( + f"executable {executable} is shorter than the {PSX_EXE_HEADER_SIZE:#x}-byte header" + ) + digest.update(header) + file_size += len(header) + while chunk := executable_file.read(1024 * 1024): + digest.update(chunk) + file_size += len(chunk) + except OSError as exc: + raise PsxExeValidationError(f"cannot read executable {executable}: {exc}") from exc + return header, file_size, digest.hexdigest() + + +def _header_u32(header: bytes, offset: int, field_name: str) -> int: + if offset < 0 or offset + 4 > len(header): + raise PsxExeValidationError(f"truncated PS-X EXE field {field_name}") + return int.from_bytes(header[offset : offset + 4], "little") + + +def _validate_psx_address_range(address: int, size: int, field_name: str) -> None: + if address + size > UINT32_LIMIT: + raise PsxExeValidationError(f"PS-X EXE {field_name} range wraps 32-bit address space") + + +def _parse_psx_exe_header( + header: bytes, + *, + path: str, + sha1: str, + file_size: int, +) -> PsxExeInfo: + if len(header) != PSX_EXE_HEADER_SIZE: + raise PsxExeValidationError("truncated PS-X EXE header") + if header[: len(PSX_EXE_MAGIC)] != PSX_EXE_MAGIC: + raise PsxExeValidationError("PS-X EXE magic is missing") + + entry_pc = _header_u32(header, PSX_EXE_INIT_PC_OFFSET, "initial PC") + initial_gp = _header_u32(header, PSX_EXE_INIT_GP_OFFSET, "initial GP") + text_load_address = _header_u32(header, PSX_EXE_TEXT_ADDRESS_OFFSET, "text address") + text_size = _header_u32(header, PSX_EXE_TEXT_SIZE_OFFSET, "text size") + data_address = _header_u32(header, PSX_EXE_DATA_ADDRESS_OFFSET, "data address") + data_size = _header_u32(header, PSX_EXE_DATA_SIZE_OFFSET, "data size") + bss_address = _header_u32(header, PSX_EXE_BSS_ADDRESS_OFFSET, "BSS address") + bss_size = _header_u32(header, PSX_EXE_BSS_SIZE_OFFSET, "BSS size") + sp_base = _header_u32(header, PSX_EXE_SP_BASE_OFFSET, "SP base") + sp_offset = _header_u32(header, PSX_EXE_SP_OFFSET_OFFSET, "SP offset") + + if text_size == 0: + raise PsxExeValidationError("PS-X EXE declared text size is zero") + if text_load_address % 4 or entry_pc % 4: + raise PsxExeValidationError("PS-X EXE text address or entry PC is not word-aligned") + _validate_psx_address_range(text_load_address, text_size, "text") + _validate_psx_address_range(data_address, data_size, "data") + _validate_psx_address_range(bss_address, bss_size, "BSS") + + payload_size = file_size - PSX_EXE_HEADER_SIZE + if text_size > payload_size: + raise PsxExeValidationError( + f"PS-X EXE declared text size {text_size} exceeds payload size {payload_size}" + ) + if not text_load_address <= entry_pc < text_load_address + text_size: + raise PsxExeValidationError("PS-X EXE entry PC is outside the declared text range") + + return PsxExeInfo( + path=path, + sha1=sha1, + file_size=file_size, + text_load_address=text_load_address, + text_size=text_size, + entry_pc=entry_pc, + initial_gp=initial_gp, + sp_base=sp_base, + sp_offset=sp_offset, + ) + + +def characterize_psx_exe( + executable_path: str | Path, manifest_path: str | Path +) -> PsxExeInfo: + """Validate a manifest-tracked extracted file as a PS-X EXE and return metadata.""" + relative_path = _relative_path_from_manifest_root(executable_path, manifest_path) + manifest_entry = _read_manifest_entry(manifest_path, relative_path) + header, file_size, sha1 = _read_psx_exe_and_hash(executable_path) + info = _parse_psx_exe_header( + header, + path=relative_path, + sha1=sha1, + file_size=file_size, + ) + if file_size != manifest_entry.size: + raise PsxExeValidationError( + f"executable size {file_size} does not match manifest size {manifest_entry.size}" + ) + if sha1 != manifest_entry.sha1: + raise PsxExeValidationError("executable SHA-1 does not match manifest") + return info + + +def _format_psx_exe_info(info: PsxExeInfo) -> str: + return "\n".join( + [ + f"path\t{info.path}", + f"sha1\t{info.sha1}", + f"file_size\t{info.file_size}", + f"header_size\t{PSX_EXE_HEADER_SIZE}", + f"text_load_address\t0x{info.text_load_address:08X}", + f"text_size\t{info.text_size}", + f"entry_pc\t0x{info.entry_pc:08X}", + f"initial_gp\t0x{info.initial_gp:08X}", + f"sp_base\t0x{info.sp_base:08X}", + f"sp_offset\t0x{info.sp_offset:08X}", + ] + ) + + +def _positive_depth(value: str) -> int: + try: + depth = int(value, 10) + except ValueError as exc: + raise argparse.ArgumentTypeError("must be an integer") from exc + if depth < 0: + raise argparse.ArgumentTypeError("must not be negative") + return depth + + +def _format_entry(entry: IsoEntry) -> str: + kind = "D" if entry.is_directory else "F" + extents = ",".join(f"{extent.lba}:{extent.size}" for extent in entry.extents) + return f"{kind}\t{entry.path}\t{entry.size}\t{extents}" + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Validate, list, extract, or characterize a MODE2/2352 ISO9660 filesystem." + ) + parser.add_argument( + "--max-depth", + type=_positive_depth, + default=DEFAULT_MAX_DIRECTORY_DEPTH, + help=f"maximum directory recursion depth (default: {DEFAULT_MAX_DIRECTORY_DEPTH})", + ) + commands = parser.add_subparsers(dest="command", required=True) + list_parser = commands.add_parser("list", help="validate and list the filesystem") + list_parser.add_argument("image", type=Path, help="MODE2/2352 image to inspect") + extract_parser = commands.add_parser( + "extract", help="validate and extract files into a fresh destination" + ) + extract_parser.add_argument("image", type=Path, help="MODE2/2352 image to extract") + extract_parser.add_argument( + "output", + type=Path, + help="new destination directory; an existing path is refused", + ) + exe_info_parser = commands.add_parser( + "psx-exe-info", help="validate a manifest-tracked extracted file as a PS-X EXE" + ) + exe_info_parser.add_argument("executable", type=Path, help="extracted PS-X EXE candidate") + exe_info_parser.add_argument("manifest", type=Path, help="manifest generated by extract") + args = parser.parse_args(argv) + + try: + if args.command == "list": + for entry in walk_image(args.image, max_depth=args.max_depth): + print(_format_entry(entry)) + return 0 + if args.command == "extract": + result = extract_image(args.image, args.output, max_depth=args.max_depth) + print( + f"extracted {result.file_count} files ({result.byte_count} bytes) " + f"to {result.output_root}" + ) + print(f"manifest: {result.manifest_path}") + return 0 + + info = characterize_psx_exe(args.executable, args.manifest) + except (IsoValidationError, PsxExeValidationError, OSError) as exc: + print(f"sf3_extract: error: {exc}", file=sys.stderr) + return 2 + + print(_format_psx_exe_info(info)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tests/test_sf3_extract.py b/tools/tests/test_sf3_extract.py new file mode 100644 index 0000000..5e99fa9 --- /dev/null +++ b/tools/tests/test_sf3_extract.py @@ -0,0 +1,656 @@ +"""Synthetic-only tests for ISO9660 extraction and PS-X EXE validation. + +These fixtures are assembled in temporary directories. They never inspect the +local disc image or any extracted game material. +""" + +from __future__ import annotations + +import hashlib +import importlib.machinery +import importlib.util +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +TOOL_PATH = Path(__file__).resolve().parents[1] / "sf3_extract" + + +def _load_tool() -> object: + loader = importlib.machinery.SourceFileLoader("sf3_extract_under_test", str(TOOL_PATH)) + spec = importlib.util.spec_from_loader(loader.name, loader) + if spec is None: + raise RuntimeError("could not create an import specification for sf3_extract") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + loader.exec_module(module) + return module + + +sf3_extract = _load_tool() + + +def _set_both_endian_u16(data: bytearray, offset: int, value: int) -> None: + data[offset : offset + 2] = value.to_bytes(2, "little") + data[offset + 2 : offset + 4] = value.to_bytes(2, "big") + + +def _set_both_endian_u32(data: bytearray, offset: int, value: int) -> None: + data[offset : offset + 4] = value.to_bytes(4, "little") + data[offset + 4 : offset + 8] = value.to_bytes(4, "big") + + +def _directory_record( + lba: int, + size: int, + identifier: bytes, + *, + flags: int = 0, + extended_attribute_blocks: int = 0, +) -> bytes: + if not identifier: + raise ValueError("synthetic records require a non-empty identifier") + padding = 1 if len(identifier) % 2 == 0 else 0 + record = bytearray(33 + len(identifier) + padding) + record[0] = len(record) + record[1] = extended_attribute_blocks + _set_both_endian_u32(record, 2, lba) + _set_both_endian_u32(record, 10, size) + record[25] = flags + _set_both_endian_u16(record, 28, 1) + record[32] = len(identifier) + record[33 : 33 + len(identifier)] = identifier + return bytes(record) + + +def _directory_block(records: list[bytes]) -> bytes: + block = bytearray(sf3_extract.LOGICAL_BLOCK_SIZE) + cursor = 0 + for record in records: + if cursor + len(record) > len(block): + raise ValueError("synthetic directory records exceed one logical block") + block[cursor : cursor + len(record)] = record + cursor += len(record) + return bytes(block) + + +def _mode2_sector(user_data: bytes) -> bytes: + if len(user_data) != sf3_extract.LOGICAL_BLOCK_SIZE: + raise ValueError("synthetic user data must occupy one logical block") + sector = bytearray(sf3_extract.RAW_SECTOR_SIZE) + sector[: len(sf3_extract.MODE2_SYNC)] = sf3_extract.MODE2_SYNC + sector[15] = 2 + sector[ + sf3_extract.USER_DATA_OFFSET : sf3_extract.USER_DATA_OFFSET + + sf3_extract.LOGICAL_BLOCK_SIZE + ] = user_data + return bytes(sector) + + +def _user_block(prefix: bytes) -> bytes: + if len(prefix) > sf3_extract.LOGICAL_BLOCK_SIZE: + raise ValueError("synthetic prefix exceeds one logical block") + return prefix.ljust(sf3_extract.LOGICAL_BLOCK_SIZE, b"\x00") + + +def _primary_volume_descriptor(volume_size: int, root_lba: int, root_size: int) -> bytes: + pvd = bytearray(sf3_extract.LOGICAL_BLOCK_SIZE) + pvd[0] = 1 + pvd[1:6] = b"CD001" + pvd[6] = 1 + _set_both_endian_u32(pvd, 80, volume_size) + _set_both_endian_u16(pvd, 120, 1) + _set_both_endian_u16(pvd, 124, 1) + _set_both_endian_u16(pvd, 128, sf3_extract.LOGICAL_BLOCK_SIZE) + root = _directory_record( + root_lba, + root_size, + b"\x00", + flags=sf3_extract.DIRECTORY_FLAG, + ) + pvd[156 : 156 + len(root)] = root + return bytes(pvd) + + +def _volume_descriptor_terminator() -> bytes: + descriptor = bytearray(sf3_extract.LOGICAL_BLOCK_SIZE) + descriptor[0] = sf3_extract.VOLUME_DESCRIPTOR_TERMINATOR + descriptor[1:6] = b"CD001" + descriptor[6] = 1 + return bytes(descriptor) + + +def _write_image( + path: Path, + *, + root_block: bytes, + directory_blocks: dict[int, bytes] | None = None, + user_blocks: dict[int, bytes] | None = None, + volume_size: int = 32, + root_lba: int = 20, +) -> None: + if len(root_block) != sf3_extract.LOGICAL_BLOCK_SIZE: + raise ValueError("synthetic root block must be one logical block") + blocks = {root_lba: root_block, **(directory_blocks or {})} + for lba, data in (user_blocks or {}).items(): + if lba in blocks: + raise ValueError("synthetic user data collides with a directory block") + blocks[lba] = data + sectors = [_mode2_sector(bytes(sf3_extract.LOGICAL_BLOCK_SIZE)) for _ in range(volume_size)] + sectors[sf3_extract.PVD_LBA] = _mode2_sector( + _primary_volume_descriptor(volume_size, root_lba, sf3_extract.LOGICAL_BLOCK_SIZE) + ) + sectors[sf3_extract.PVD_LBA + 1] = _mode2_sector(_volume_descriptor_terminator()) + for lba, data in blocks.items(): + if not 0 <= lba < volume_size: + raise ValueError("synthetic directory LBA is outside the image") + if len(data) != sf3_extract.LOGICAL_BLOCK_SIZE: + raise ValueError("synthetic directory block must be one logical block") + sectors[lba] = _mode2_sector(data) + path.write_bytes(b"".join(sectors)) + + +def _valid_image(path: Path, *, user_blocks: dict[int, bytes] | None = None) -> None: + root_lba = 20 + child_lba = 21 + root_block = _directory_block( + [ + _directory_record( + root_lba, + sf3_extract.LOGICAL_BLOCK_SIZE, + b"\x00", + flags=sf3_extract.DIRECTORY_FLAG, + ), + _directory_record( + root_lba, + sf3_extract.LOGICAL_BLOCK_SIZE, + b"\x01", + flags=sf3_extract.DIRECTORY_FLAG, + ), + _directory_record(22, 5, b"README.TXT;1"), + _directory_record( + child_lba, + sf3_extract.LOGICAL_BLOCK_SIZE, + b"DATA", + flags=sf3_extract.DIRECTORY_FLAG, + ), + ] + ) + child_block = _directory_block( + [ + _directory_record( + child_lba, + sf3_extract.LOGICAL_BLOCK_SIZE, + b"\x00", + flags=sf3_extract.DIRECTORY_FLAG, + ), + _directory_record( + root_lba, + sf3_extract.LOGICAL_BLOCK_SIZE, + b"\x01", + flags=sf3_extract.DIRECTORY_FLAG, + ), + _directory_record(23, 7, b"INNER.BIN;1"), + ] + ) + _write_image( + path, + root_block=root_block, + directory_blocks={child_lba: child_block}, + user_blocks=user_blocks, + ) + + +def _set_u32_le(data: bytearray, offset: int, value: int) -> None: + data[offset : offset + 4] = value.to_bytes(4, "little") + + +def _write_psx_exe_fixture( + root: Path, + *, + relative_path: str = "MAIN.EXE;1", + payload: bytes = b"\x00" * 16, + declared_text_size: int | None = None, + text_load_address: int = 0x80010000, + entry_pc: int | None = None, + initial_gp: int = 0x80020000, + sp_base: int = 0x801FFF00, + sp_offset: int = 0, + magic: bytes = sf3_extract.PSX_EXE_MAGIC, + manifest_sha1: str | None = None, +) -> tuple[Path, Path]: + root.mkdir(parents=True, exist_ok=True) + header = bytearray(sf3_extract.PSX_EXE_HEADER_SIZE) + header[: len(magic)] = magic + _set_u32_le( + header, + sf3_extract.PSX_EXE_INIT_PC_OFFSET, + text_load_address if entry_pc is None else entry_pc, + ) + _set_u32_le(header, sf3_extract.PSX_EXE_INIT_GP_OFFSET, initial_gp) + _set_u32_le(header, sf3_extract.PSX_EXE_TEXT_ADDRESS_OFFSET, text_load_address) + _set_u32_le( + header, + sf3_extract.PSX_EXE_TEXT_SIZE_OFFSET, + len(payload) if declared_text_size is None else declared_text_size, + ) + _set_u32_le(header, sf3_extract.PSX_EXE_SP_BASE_OFFSET, sp_base) + _set_u32_le(header, sf3_extract.PSX_EXE_SP_OFFSET_OFFSET, sp_offset) + + executable = root / relative_path + executable.parent.mkdir(parents=True, exist_ok=True) + contents = bytes(header) + payload + executable.write_bytes(contents) + digest = manifest_sha1 if manifest_sha1 is not None else hashlib.sha1(contents).hexdigest() + manifest = root / sf3_extract.MANIFEST_FILENAME + manifest.write_text( + sf3_extract.MANIFEST_HEADER + + f"{relative_path}\t24\t24:{len(contents)}\t{len(contents)}\t{digest}\n", + encoding="ascii", + ) + return executable, manifest + + +class Sf3ExtractTests(unittest.TestCase): + def synthetic_path(self) -> Path: + temporary_directory = tempfile.TemporaryDirectory() + self.addCleanup(temporary_directory.cleanup) + return Path(temporary_directory.name) / "synthetic-mode2.bin" + + def test_walks_nested_tree_in_deterministic_path_order(self) -> None: + image = self.synthetic_path() + _valid_image(image) + + entries = sf3_extract.walk_image(image) + + self.assertEqual( + [ + ( + entry.path, + entry.is_directory, + entry.size, + [(extent.lba, extent.size) for extent in entry.extents], + ) + for entry in entries + ], + [ + ("DATA", True, 2048, [(21, 2048)]), + ("DATA/INNER.BIN;1", False, 7, [(23, 7)]), + ("README.TXT;1", False, 5, [(22, 5)]), + ], + ) + + def test_list_command_uses_validated_walker(self) -> None: + image = self.synthetic_path() + _valid_image(image) + + completed = subprocess.run( + [sys.executable, str(TOOL_PATH), "list", str(image)], + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertEqual( + completed.stdout.splitlines(), + [ + "D\tDATA\t2048\t21:2048", + "F\tDATA/INNER.BIN;1\t7\t23:7", + "F\tREADME.TXT;1\t5\t22:5", + ], + ) + self.assertEqual(completed.stderr, "") + + def test_rejects_non_mode2_primary_volume_sector(self) -> None: + image = self.synthetic_path() + _valid_image(image) + with image.open("r+b") as handle: + handle.seek((sf3_extract.PVD_LBA * sf3_extract.RAW_SECTOR_SIZE) + 15) + handle.write(b"\x01") + + with self.assertRaisesRegex(sf3_extract.IsoValidationError, "not MODE2"): + sf3_extract.walk_image(image) + + def test_rejects_malformed_directory_record(self) -> None: + root_lba = 20 + root_block = bytearray( + _directory_block( + [ + _directory_record( + root_lba, + sf3_extract.LOGICAL_BLOCK_SIZE, + b"\x00", + flags=sf3_extract.DIRECTORY_FLAG, + ), + _directory_record( + root_lba, + sf3_extract.LOGICAL_BLOCK_SIZE, + b"\x01", + flags=sf3_extract.DIRECTORY_FLAG, + ), + ] + ) + ) + root_block[68] = 20 + image = self.synthetic_path() + _write_image(image, root_block=bytes(root_block)) + + with self.assertRaisesRegex(sf3_extract.IsoValidationError, "shorter than 34"): + sf3_extract.walk_image(image) + + def test_rejects_out_of_bounds_file_extent(self) -> None: + root_lba = 20 + root_block = _directory_block( + [ + _directory_record( + root_lba, + sf3_extract.LOGICAL_BLOCK_SIZE, + b"\x00", + flags=sf3_extract.DIRECTORY_FLAG, + ), + _directory_record( + root_lba, + sf3_extract.LOGICAL_BLOCK_SIZE, + b"\x01", + flags=sf3_extract.DIRECTORY_FLAG, + ), + _directory_record(32, 1, b"OUT.BIN;1"), + ] + ) + image = self.synthetic_path() + _write_image(image, root_block=root_block) + + with self.assertRaisesRegex(sf3_extract.IsoValidationError, "outside volume size"): + sf3_extract.walk_image(image) + + def test_rejects_unsafe_path_component(self) -> None: + root_lba = 20 + root_block = _directory_block( + [ + _directory_record( + root_lba, + sf3_extract.LOGICAL_BLOCK_SIZE, + b"\x00", + flags=sf3_extract.DIRECTORY_FLAG, + ), + _directory_record( + root_lba, + sf3_extract.LOGICAL_BLOCK_SIZE, + b"\x01", + flags=sf3_extract.DIRECTORY_FLAG, + ), + _directory_record(22, 1, b"../ESCAPE;1"), + ] + ) + image = self.synthetic_path() + _write_image(image, root_block=root_block) + + with self.assertRaisesRegex(sf3_extract.IsoValidationError, "unsafe path component"): + sf3_extract.walk_image(image) + + def test_rejects_recursive_directory_extent(self) -> None: + root_lba = 20 + child_lba = 21 + root_block = _directory_block( + [ + _directory_record( + root_lba, + sf3_extract.LOGICAL_BLOCK_SIZE, + b"\x00", + flags=sf3_extract.DIRECTORY_FLAG, + ), + _directory_record( + root_lba, + sf3_extract.LOGICAL_BLOCK_SIZE, + b"\x01", + flags=sf3_extract.DIRECTORY_FLAG, + ), + _directory_record( + child_lba, + sf3_extract.LOGICAL_BLOCK_SIZE, + b"DATA", + flags=sf3_extract.DIRECTORY_FLAG, + ), + ] + ) + child_block = _directory_block( + [ + _directory_record( + child_lba, + sf3_extract.LOGICAL_BLOCK_SIZE, + b"\x00", + flags=sf3_extract.DIRECTORY_FLAG, + ), + _directory_record( + root_lba, + sf3_extract.LOGICAL_BLOCK_SIZE, + b"\x01", + flags=sf3_extract.DIRECTORY_FLAG, + ), + _directory_record( + root_lba, + sf3_extract.LOGICAL_BLOCK_SIZE, + b"LOOP", + flags=sf3_extract.DIRECTORY_FLAG, + ), + ] + ) + image = self.synthetic_path() + _write_image(image, root_block=root_block, directory_blocks={child_lba: child_block}) + + with self.assertRaisesRegex(sf3_extract.IsoValidationError, "cycle or alias refused"): + sf3_extract.walk_image(image) + + def test_cli_refuses_corrupt_input_loudly(self) -> None: + image = self.synthetic_path() + _valid_image(image) + with image.open("r+b") as handle: + handle.seek((sf3_extract.PVD_LBA * sf3_extract.RAW_SECTOR_SIZE) + 15) + handle.write(b"\x01") + + completed = subprocess.run( + [sys.executable, str(TOOL_PATH), "list", str(image)], + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual(completed.returncode, 2) + self.assertIn("sf3_extract: error:", completed.stderr) + self.assertIn("not MODE2", completed.stderr) + self.assertEqual(completed.stdout, "") + + def test_extracts_files_and_writes_deterministic_manifest(self) -> None: + image = self.synthetic_path() + readme = b"hello" + inner = b"content" + _valid_image(image, user_blocks={22: _user_block(readme), 23: _user_block(inner)}) + output = image.parent / "extracted" + + result = sf3_extract.extract_image(image, output) + + self.assertEqual(result.output_root, output.resolve()) + self.assertEqual(result.manifest_path, output.resolve() / sf3_extract.MANIFEST_FILENAME) + self.assertEqual((result.file_count, result.byte_count), (2, len(readme) + len(inner))) + self.assertEqual((output / "README.TXT;1").read_bytes(), readme) + self.assertEqual((output / "DATA" / "INNER.BIN;1").read_bytes(), inner) + self.assertEqual( + result.manifest_path.read_text(encoding="ascii"), + "".join( + [ + sf3_extract.MANIFEST_HEADER, + "DATA/INNER.BIN;1\t23\t23:7\t7\t" + f"{hashlib.sha1(inner).hexdigest()}\n", + "README.TXT;1\t22\t22:5\t5\t" + f"{hashlib.sha1(readme).hexdigest()}\n", + ] + ), + ) + + def test_two_fresh_extractions_have_identical_manifests(self) -> None: + image = self.synthetic_path() + _valid_image( + image, + user_blocks={22: _user_block(b"hello"), 23: _user_block(b"content")}, + ) + first = image.parent / "first" + second = image.parent / "second" + + first_result = sf3_extract.extract_image(image, first) + second_result = sf3_extract.extract_image(image, second) + + self.assertEqual(first_result.file_count, second_result.file_count) + self.assertEqual( + first_result.manifest_path.read_bytes(), second_result.manifest_path.read_bytes() + ) + self.assertEqual( + (first / "README.TXT;1").read_bytes(), (second / "README.TXT;1").read_bytes() + ) + self.assertEqual( + (first / "DATA" / "INNER.BIN;1").read_bytes(), + (second / "DATA" / "INNER.BIN;1").read_bytes(), + ) + + def test_extract_copies_all_multi_extent_data(self) -> None: + root_lba = 20 + root_block = _directory_block( + [ + _directory_record( + root_lba, + sf3_extract.LOGICAL_BLOCK_SIZE, + b"\x00", + flags=sf3_extract.DIRECTORY_FLAG, + ), + _directory_record( + root_lba, + sf3_extract.LOGICAL_BLOCK_SIZE, + b"\x01", + flags=sf3_extract.DIRECTORY_FLAG, + ), + _directory_record(22, 3, b"SPLIT.BIN;1", flags=sf3_extract.MULTI_EXTENT_FLAG), + _directory_record(23, 2, b"SPLIT.BIN;1"), + ] + ) + image = self.synthetic_path() + _write_image( + image, + root_block=root_block, + user_blocks={22: _user_block(b"abc"), 23: _user_block(b"de")}, + ) + output = image.parent / "multi" + + result = sf3_extract.extract_image(image, output) + + self.assertEqual((result.file_count, result.byte_count), (1, 5)) + self.assertEqual((output / "SPLIT.BIN;1").read_bytes(), b"abcde") + self.assertIn("SPLIT.BIN;1\t22\t22:3,23:2\t5\t", result.manifest_path.read_text()) + + def test_extract_refuses_bad_file_sector_without_installing_output(self) -> None: + image = self.synthetic_path() + _valid_image(image) + with image.open("r+b") as handle: + handle.seek((22 * sf3_extract.RAW_SECTOR_SIZE) + 15) + handle.write(b"\x01") + output = image.parent / "failed" + + with self.assertRaisesRegex(sf3_extract.IsoValidationError, "not MODE2"): + sf3_extract.extract_image(image, output) + + self.assertFalse(output.exists()) + self.assertEqual(list(image.parent.glob(".failed.tmp-*")), []) + + def test_extract_refuses_existing_destination(self) -> None: + image = self.synthetic_path() + _valid_image(image) + output = image.parent / "existing" + output.mkdir() + marker = output / "keep" + marker.write_text("do not overwrite", encoding="ascii") + + with self.assertRaisesRegex(sf3_extract.IsoValidationError, "refusing to overwrite"): + sf3_extract.extract_image(image, output) + + self.assertEqual(marker.read_text(encoding="ascii"), "do not overwrite") + + def test_extract_command_uses_transactional_extractor(self) -> None: + image = self.synthetic_path() + _valid_image( + image, + user_blocks={22: _user_block(b"hello"), 23: _user_block(b"content")}, + ) + output = image.parent / "cli-extracted" + + completed = subprocess.run( + [sys.executable, str(TOOL_PATH), "extract", str(image), str(output)], + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertIn("extracted 2 files (12 bytes)", completed.stdout) + self.assertTrue((output / sf3_extract.MANIFEST_FILENAME).is_file()) + self.assertEqual(completed.stderr, "") + + def test_characterizes_manifest_tracked_psx_exe(self) -> None: + root = self.synthetic_path().parent / "psx-exe" + executable, manifest = _write_psx_exe_fixture(root) + + info = sf3_extract.characterize_psx_exe(executable, manifest) + + self.assertEqual(info.path, "MAIN.EXE;1") + self.assertEqual(info.file_size, sf3_extract.PSX_EXE_HEADER_SIZE + 16) + self.assertEqual(info.text_load_address, 0x80010000) + self.assertEqual(info.text_size, 16) + self.assertEqual(info.entry_pc, 0x80010000) + self.assertEqual(info.initial_gp, 0x80020000) + self.assertEqual(info.sp_base, 0x801FFF00) + self.assertEqual(info.sp_offset, 0) + self.assertEqual(info.sha1, hashlib.sha1(executable.read_bytes()).hexdigest()) + + def test_rejects_psx_exe_with_bad_magic(self) -> None: + root = self.synthetic_path().parent / "bad-magic" + executable, manifest = _write_psx_exe_fixture(root, magic=b"NOT EXE!") + + with self.assertRaisesRegex(sf3_extract.PsxExeValidationError, "magic is missing"): + sf3_extract.characterize_psx_exe(executable, manifest) + + def test_rejects_psx_exe_text_beyond_file_payload(self) -> None: + root = self.synthetic_path().parent / "bad-bounds" + executable, manifest = _write_psx_exe_fixture(root, declared_text_size=32) + + with self.assertRaisesRegex(sf3_extract.PsxExeValidationError, "exceeds payload size"): + sf3_extract.characterize_psx_exe(executable, manifest) + + def test_rejects_psx_exe_when_manifest_hash_differs(self) -> None: + root = self.synthetic_path().parent / "bad-hash" + executable, manifest = _write_psx_exe_fixture(root, manifest_sha1="0" * 40) + + with self.assertRaisesRegex(sf3_extract.PsxExeValidationError, "SHA-1 does not match"): + sf3_extract.characterize_psx_exe(executable, manifest) + + def test_psx_exe_info_command_uses_header_and_manifest_validation(self) -> None: + root = self.synthetic_path().parent / "psx-cli" + executable, manifest = _write_psx_exe_fixture(root) + + completed = subprocess.run( + [sys.executable, str(TOOL_PATH), "psx-exe-info", str(executable), str(manifest)], + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertIn("text_load_address\t0x80010000", completed.stdout) + self.assertIn("entry_pc\t0x80010000", completed.stdout) + self.assertIn("initial_gp\t0x80020000", completed.stdout) + self.assertIn("sp_base\t0x801FFF00", completed.stdout) + self.assertEqual(completed.stderr, "") + + +if __name__ == "__main__": + unittest.main()