diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a02f50f0..c9bd076c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,7 +6,7 @@ see exactly what a file is, flag anomalies, and edit or repair its structure. Closer to readelf / 010 Editor / radare2's format layer than to exiftool, with some optional audio analysis (BPM/key via librosa). -v1.8.6 · ~56k source LOC · ~52k test LOC · one hard dependency (`mutagen`); +v1.8.7 · ~56k source LOC · ~52k test LOC · one hard dependency (`mutagen`); everything heavier is an optional, lazily imported extra, so `import acidcat` pulls only the stdlib core. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c639605..e27feebd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,57 @@ All notable changes to acidcat. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project will adopt [Semantic Versioning](https://semver.org/spec/v2.0.0.html) at 1.0. +## [1.8.7] - 2026-09-28 + +Bug fixes only, found by running acidcat over a curated library of real files +from every format it reads. No new features and no changes in behaviour +beyond the fixes. + +### Fixed + +- **`acidcat inspect` crashed on every SoundFont**, and so did `inspect + --full`, `--json` and `od`: the preset and instrument tree has no byte + position, and the renderers formatted its offset as a number. A test now + runs every render mode over every seed format. +- **Every valid RF64 failed `validate`.** RF64 writes 0xFFFFFFFF in its + 32-bit size fields and keeps the real sizes in ds64; the checker read the + placeholders literally, reported the data chunk as overrunning the file, + and proposed an 88-byte RIFF size (repair refused to write it). RF64 is now + declined with a note, as `write` already declines it. +- **`validate` advertised a fix `repair` would refuse** on a file whose audio + chunk the walk cannot reach (a wrong size earlier in the file). It now + explains the misread and marks nothing repairable. +- **A WAV's RF64 reservation was reported as damage.** The 28-byte JUNK a + writer puts first is filled on purpose: with the ds64 sizes (sometimes + stale, the file having grown), with a quote Ableton Live writes there, or + with the RIFF size over such a quote. It is now read as the reservation, + and the quote names Ableton Live as the writer in `audit`. +- **Bytes appended past a WAV's RIFF end were read as chunks.** 2 MB of + appended zeros became 262,144 empty chunks and opening the file took + seconds. The walk now stops at the first id that is not four ASCII + characters; the bytes are still reported as trailing data. +- **Zip-based formats were reported as polyglots** (.xpn, .labx, + .multisample): the archive's own end record read as an appended zip, its + members as smuggled files. An SF3's Ogg samples were flagged the same way. +- **A Max for Live instrument was a "magic mismatch".** The four bytes at 8 + are the device type: `aaaa` for an audio effect, `iiii` for an instrument. +- **A `.vitalskin` was read as a Vital preset**, every theme key flagged as + an unknown one. +- **A cut BRSTM was reported as whole.** The header's file size at 0x08 is + now read, and a file shorter than it declares says the stream is cut. +- **A SigMF recording opened by its `.sigmf-meta` was reported as broken.** +- **Every 12-bit AIFF and WAV was reported as damaged**: the size checks + rounded 12 bits down to one byte per sample. +- **Two walkers found wrong by McGill's public test files**: an AU whose + data runs past the end of the file, and an AIFF APPL chunk without a + fitting name. +- **76 X68000 PDX banks with a short slot table, and 16 cut MDX modules, + were not recognised.** +- **`constraints.repair` raised on a multi-track MP4** instead of declining + it, and MP4 timestamps used a call Python 3.12 deprecates. +- **`audit` called a console ROM `[unknown]`**; it now names the ROM and + suggests `acidcat extract`. + ## [1.8.6] - 2026-09-25 ### Fixed diff --git a/docs/formats/ableton-anatomy.html b/docs/formats/ableton-anatomy.html index c876e1bc..ecd1ba8c 100644 --- a/docs/formats/ableton-anatomy.html +++ b/docs/formats/ableton-anatomy.html @@ -338,12 +338,12 @@
magic"ampf"header12 byteschainid + u32 lengthpayloadMax patcher JSON
-

A Max for Live device is an Ableton Max Patch Format container: the ASCII magic ampf, a version word, and then -- before the chunk chain begins -- a four-byte marker, aaaa. Only at offset 12 does the chain start: four-byte id, little-endian length, payload. The ptch chunk carries the Max patcher, a short binary preamble followed by JSON.

+

A Max for Live device is an Ableton Max Patch Format container: the ASCII magic ampf, a version word, and then -- before the chunk chain begins -- a four-byte device type: aaaa for an audio effect, iiii for an instrument. Only at offset 12 does the chain start: four-byte id, little-endian length, payload. The ptch chunk carries the Max patcher, a short binary preamble followed by JSON.

layout
-
0x00  "ampf"  u32:version  "aaaa"      <- 12-byte header
+      
0x00  "ampf"  u32:version  "aaaa"      <- device type; 12-byte header
 0x0C  "meta"  u32:len      ...
 0x18  "ptch"  u32:len      mx@c ... { patcher JSON }
-
The marker is not a chunk, and mistaking it for one is loud. Reading aaaa as an id makes the following four bytes -- the ASCII meta -- decode as a length of 1,635,018,093 bytes, and the whole chain collapses to one impossible chunk. The chain starts at 12, not 8.
+
The device type is not a chunk, and mistaking it for one is loud. Reading aaaa as an id makes the following four bytes -- the ASCII meta -- decode as a length of 1,635,018,093 bytes, and the whole chain collapses to one impossible chunk. The chain starts at 12, not 8.
The length field cannot be trusted. A chunk claiming more bytes than remain must stop the walk rather than read past the end. The ptch chunk ends exactly on the last byte of the file, so a chain that stops short means something is missing.
diff --git a/pyproject.toml b/pyproject.toml index 4bdb3e28..be7bbce7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "acidcat" -version = "1.8.6" +version = "1.8.7" description = "Binary analysis and reverse-engineering for audio file formats and music-production hardware" readme = {file = "README.md", content-type = "text/markdown"} license = {text = "MIT"} diff --git a/src/acidcat/__init__.py b/src/acidcat/__init__.py index bc205830..494caeea 100644 --- a/src/acidcat/__init__.py +++ b/src/acidcat/__init__.py @@ -46,7 +46,7 @@ See docs/format_internals.md for the formats acidcat walks. """ -__version__ = "1.8.6" +__version__ = "1.8.7" # dissection namespaces from acidcat.core import probe # noqa: E402,F401 diff --git a/src/acidcat/commands/audit.py b/src/acidcat/commands/audit.py index fabaca5a..3bf8246c 100644 --- a/src/acidcat/commands/audit.py +++ b/src/acidcat/commands/audit.py @@ -278,6 +278,17 @@ def _run_one(args): print(f"acidcat audit: {path}: {e}", file=sys.stderr) return 2 size = os.path.getsize(path) + # a format acidcat recognises but only extracts from (a console ROM) is + # not "unknown", and `extract` is the verb that gets at its audio + extract_only = None + if not scanned: + from acidcat.core.infra import sniff + from acidcat.core.walk import _EXTRACT_ONLY + what = _EXTRACT_ONLY.get(sniff.sniff(path)) + if what: + extract_only = what.split(" ", 1)[1] + label = label or extract_only + todo = "extract" if extract_only else "locate" if chosen_format(args) == "json": out = { @@ -329,8 +340,9 @@ def _run_one(args): if not scanned: print(" FORENSICS not scanned -- no walker for this format") - print(" try: acidcat locate " + - os.path.basename(path) + " (finds embedded audio regardless)") + print(f" try: acidcat {todo} " + os.path.basename(path) + + (" (recovers its samples)" if extract_only + else " (finds embedded audio regardless)")) elif not other: print(" FORENSICS nothing else flagged") else: @@ -378,6 +390,6 @@ def _run_one(args): # "clean" is a claim about checks that ran. With no walker they did not, # and the honest answer is that this verb had nothing to say -- `locate` # still finds embedded containers in a format we cannot walk. - bits.append("clean" if scanned else "not analyzable -- no walker; try acidcat locate") + bits.append("clean" if scanned else f"not analyzable -- no walker; try acidcat {todo}") print(f"\n VERDICT: {', '.join(bits) if bits else 'no structural fixes; review findings'}") return _code(scanned, vios, findings, integ) diff --git a/src/acidcat/commands/inspect.py b/src/acidcat/commands/inspect.py index dcea569f..c0b1ea66 100644 --- a/src/acidcat/commands/inspect.py +++ b/src/acidcat/commands/inspect.py @@ -225,6 +225,12 @@ def _render_anomalies(findings, args): print(f" {tag} {off} {f['rule']:16} {f['message']}") +def _at(offset): + """a chunk's offset for the table; a node the walk does not place + (an SF2 preset, a tree node) has none""" + return "-" if offset is None else f"0x{offset:08x}" + + def _render_table(filepath, fmt_label, chunks, file_warns, args, total=None, shown_as=None): file_size = os.path.getsize(filepath) @@ -241,8 +247,9 @@ def _render_table(filepath, fmt_label, chunks, file_warns, args, total=None, for i, c in enumerate(chunks): idx = p("dim", f"[{c.get('_idx', i):>2}]") cid = p("id", f"{c['id']:<5}") - off = p("dim", f"0x{c['offset']:08x}") - print(f" {idx} {cid} {off} {c['size']:<11,} {c['summary']}") + off = p("dim", _at(c["offset"])) + size = "-" if c["size"] is None else f"{c['size']:,}" + print(f" {idx} {cid} {off} {size:<11} {c['summary']}") if not args.quiet: for c in chunks: @@ -250,7 +257,7 @@ def _render_table(filepath, fmt_label, chunks, file_warns, args, total=None, continue print() hdr_id = p("id", c["id"].strip()) - hdr_meta = p("dim", f"@ 0x{c['offset']:08x} ({c['size']} bytes)") + hdr_meta = p("dim", f"@ {_at(c['offset'])} ({c['size'] if c['size'] is not None else '-'} bytes)") print(f"{hdr_id} {hdr_meta}") for fl in c["fields"]: note = p("dim", f" {fl['note']}") if fl["note"] else "" @@ -261,7 +268,8 @@ def _render_table(filepath, fmt_label, chunks, file_warns, args, total=None, if o is not None else " ") off_col = p("dim", off_col) val = p("val", f"{fl['value']!s:<14}") - if args.show_hex and fl["off"] is not None: + if args.show_hex and fl["off"] is not None and ( + c.get("payload_base") is not None or c["offset"] is not None): # field offsets are measured from the chunk's payload base. # RIFF/AIFF/RF64/MThd all have an 8-byte id+size header, so # that is the default; formats with a different header (FLAC @@ -329,18 +337,20 @@ def _full_chunk(chunk, filepath): payload base, the raw region bytes as hex (capped), and every field's absolute byte offset. `acidcat explore` needs nothing but this JSON.""" c = {k: v for k, v in chunk.items() if k != "_idx"} - pb = chunk.get("payload_base", chunk["offset"] + 8) + pb = chunk.get("payload_base") + if pb is None and chunk["offset"] is not None: + pb = chunk["offset"] + 8 c["payload_base"] = pb fields = [] for f in chunk["fields"]: f2 = _public_field(f) # absolute file offset, so a field maps to raw[abs - offset] - f2["abs"] = pb + f["off"] if f["off"] is not None else None + f2["abs"] = pb + f["off"] if f["off"] is not None and pb is not None else None fields.append(f2) c["fields"] = fields # only carry raw bytes for chunks that actually have positioned fields; # audio-data regions are huge and have nothing to highlight. - if any(f["off"] is not None for f in chunk["fields"]): + if chunk["offset"] is not None and any(f["off"] is not None for f in chunk["fields"]): n = min(chunk["size"], _FULL_RAW_CAP) with open(filepath, "rb") as fh: fh.seek(chunk["offset"]) @@ -649,7 +659,9 @@ def run(args): # tuned on trackers broke silently on RIFF. --full has # always emitted the absolute offsets; plain --json now # does too, at no extra cost. - pb = c.get("payload_base", c["offset"] + 8) + pb = c.get("payload_base") + if pb is None and c["offset"] is not None: + pb = c["offset"] + 8 oc["payload_base"] = pb fields = [] for f in c.get("fields", []): diff --git a/src/acidcat/commands/od.py b/src/acidcat/commands/od.py index 66ad99f6..d8537b66 100644 --- a/src/acidcat/commands/od.py +++ b/src/acidcat/commands/od.py @@ -333,7 +333,11 @@ def dim(t): print(header) for c in chunks: - base = c.get("payload_base", c["offset"] + 8) + if c.get("offset") is None: + continue # a tree node (an SF2 preset) has no bytes to dump + base = c.get("payload_base") + if base is None: + base = c["offset"] + 8 summary = c.get("summary", "") title = _c("1;37", f"{str(c['id'])!r} @ 0x{c['offset']:08x} {c['size']:,} bytes", on) print("\n" + title + (dim(" " + summary) if summary else "")) diff --git a/src/acidcat/core/forensics/anomalies.py b/src/acidcat/core/forensics/anomalies.py index db5f2f8c..9537c620 100644 --- a/src/acidcat/core/forensics/anomalies.py +++ b/src/acidcat/core/forensics/anomalies.py @@ -486,10 +486,13 @@ def scan(filepath, fmt_label=None, chunks=None, warns=None): # size-based check above cannot see it). Scans the last 64K+ from the end. if not any(f["rule"] == "polyglot" for f in findings): with open(filepath, "rb") as f: + is_zip = f.read(4) == b"PK\x03\x04" f.seek(max(0, size - 66000)) tail = f.read() idx = tail.rfind(b"PK\x05\x06") - if idx >= 0 and len(tail) - idx >= 22: + # a file that IS a zip (.xpn, .labx, .multisample) ends with its own + # end record; that is the archive's index, not one appended to it + if idx >= 0 and len(tail) - idx >= 22 and not is_zip: findings.append({"severity": "alert", "offset": (size - len(tail)) + idx, "rule": "polyglot", "message": "possible polyglot: appended ZIP archive " @@ -748,8 +751,13 @@ def scan(filepath, fmt_label=None, chunks=None, warns=None): # branched on the label, and it made the guard depend on the label being # non-empty: a walker that returned "" got a spurious embedded-Ogg # finding on every ordinary Ogg file. + # an archive's members are whole files by design (a .multisample zone + # is a WAV in a zip), and an SF3 stores every sample as an Ogg stream wanted = [c for c in _EMBEDDED_MEDIA - if not (c[0] == b"OggS" and fmt_id in _OGG_IDS)] + if not (c[0] == b"OggS" and fmt_id in _OGG_IDS) + and not (c[0] == b"OggS" and fmt_id == "sf2")] + if own == b"PK\x03\x04": + wanted = [] hits = _find_embedded(filepath, wanted, own) if wanted else {} for magic, _form_at, _form, name in wanted: at = hits.get(magic) diff --git a/src/acidcat/core/forensics/provenance.py b/src/acidcat/core/forensics/provenance.py index ecb8133a..2fe981f9 100644 --- a/src/acidcat/core/forensics/provenance.py +++ b/src/acidcat/core/forensics/provenance.py @@ -210,6 +210,25 @@ def _ffmpeg_rf64_junk(chunks): return None +def _ableton_junk_quote(chunks): + """Ableton Live writes a 28-character quote ("The sleeper must awaken", + "Why r u using a hex editor?") into the RF64 reservation of the WAVs it + renders; measured in two unrelated Live projects. A later tool may write + the RIFF size over its first 8 bytes, and the tail still names Live. The + walker has decoded the text; this reads its summary.""" + first = chunks[0] if chunks else {} + if str(first.get("id", "")).strip() != "JUNK": + return None + s = first.get("summary", "") + if s.startswith("RF64 reservation (ds64) holding text"): + basis = "a quote in the 28-byte RF64 reservation" + elif "written over a text" in s: + basis = "the tail of a quote under a later tool's RF64 sizes" + else: + return None + return {"tool": "Ableton Live", "confidence": "likely", "basis": basis} + + def _structural(label, chunks, data): out = [] if "MP3" in label or "MPEG" in label: @@ -232,6 +251,9 @@ def _structural(label, chunks, data): ff = _ffmpeg_rf64_junk(chunks) if ff: out.append(ff) + live = _ableton_junk_quote(chunks) + if live: + out.append(live) return out diff --git a/src/acidcat/core/formats/mdx.py b/src/acidcat/core/formats/mdx.py index d585dff1..26a87f13 100644 --- a/src/acidcat/core/formats/mdx.py +++ b/src/acidcat/core/formats/mdx.py @@ -254,23 +254,20 @@ def looks_like_mdx(raw, filesize=None): Everything before the offset table is variable-length text, so the only thing that can identify an MDX is whether the arithmetic lands: a title - terminator, a NUL, and a table that resolves to a legal channel count with - every offset inside the file. - - `filesize` matters. The offsets routinely point past the few kilobytes a - sniffer reads, so checking them against len(raw) rejects almost every real - tune when handed a truncated head -- which is exactly what happened, and - the bound has to be the FILE's length rather than the buffer's. + terminator, a NUL, and a table that resolves to a legal channel count. + Offsets that run past the end mark a cut file, not a different format; + `filesize` is kept for callers and no longer consulted. """ h = parse_header(raw) if not h["ok"]: # a packed module is an MDX whose body cannot be walked, not a file # that is something else return bool(h.get("packer")) - n = len(raw) if filesize is None else filesize - if not 0 <= h["voice_abs"] <= n: - return False - return all(0 <= a <= n for a in h["mml_abs"]) + # Offsets past the end of the file do not make it something else: a cut + # rip keeps a whole header (terminator, bank name, a 9- or 16-channel + # table) and the walker reports each offset that dangles. 16 real modules + # are cut like that, and no other file in the corpus has such a header. + return True def voice_count(raw, h): diff --git a/src/acidcat/core/formats/pdx.py b/src/acidcat/core/formats/pdx.py index 70356012..dce8eb5a 100644 --- a/src/acidcat/core/formats/pdx.py +++ b/src/acidcat/core/formats/pdx.py @@ -26,6 +26,11 @@ Measured over 3,418 real banks, every table size that resolves is a multiple of 768. +Some writers emit a SHORT table: only the slots they fill, so the first +sample begins before 768 (26 slots is common). It is accepted only when the +short table accounts for the file exactly, samples end to end to the last +byte; 76 real banks do, and none of 333,922 other files does. + Slots may point at the SAME bytes. 947 duplicate slot pairs across the corpus, and every one of them is an exact duplicate -- same offset, same length -- rather than a window into another sample. A bank aliases a sample to several @@ -69,7 +74,7 @@ def parse_table(raw, filesize): """ h = {"ok": False, "why": "", "table_size": 0, "banks": 0, "slots": [], "used": 0, "packer": "", "data_start": 0, - "padded": False} + "padded": False, "short": False} if filesize < BANK + SLOT: h["why"] = "file is smaller than one %d-byte bank table" % BANK return h @@ -94,8 +99,12 @@ def parse_table(raw, filesize): if h["packer"]: h["why"] = ("the bank is packed with %s; the slot table " "belongs to the unpacked form" % h["packer"]) - else: - h["why"] = "a slot points outside the file" + return h + short = _short_table(raw, filesize) + if short: + h.update(short) + return h + h["why"] = "a slot points outside the file" return h low = min(o for o, _s in live) @@ -125,6 +134,41 @@ def parse_table(raw, filesize): return h +def _short_table(raw, filesize): + """A table shorter than one bank, or None. + + Some writers emit only as many slots as they fill: the first sample then + begins where those slots end, before 768, and reading a whole bank reads + sample data as slots. Accepted only when the short table accounts for the + file exactly: every live slot inside it, the samples laid end to end, and + the last one ending at the end of the file (or one byte before it, which + a handful of real banks do). That is what separates a bank from any other + run of bytes, since the format has no magic.""" + first = None + for i in range(0, min(len(raw), BANK) - SLOT + 1, SLOT): + o, n = struct.unpack_from(">II", raw, i) + if o or n: + first = o + break + if first is None or first % SLOT or not 2 * SLOT <= first < BANK: + return None + k = first // SLOT + rows = [struct.unpack_from(">II", raw, i * SLOT) for i in range(k)] + live = sorted(set((o, n) for o, n in rows if o or n)) + if len(live) < 2 or live[0][0] != first: + return None + if any(n == 0 or o < first or o + n > filesize for o, n in live): + return None + if any(a[0] + a[1] != b[0] for a, b in zip(live, live[1:])): + return None + end = live[-1][0] + live[-1][1] + if filesize - end not in (0, 1): + return None + return {"ok": True, "why": "", "table_size": first, "banks": 1, "slots": rows, + "used": sum(1 for o, n in rows if o or n), "data_start": first, + "short": True} + + def looks_like_pdx(raw, filesize): """Structural identification. The format has no magic, so this is it.""" return parse_table(raw, filesize)["ok"] diff --git a/src/acidcat/core/grammar/helpers.py b/src/acidcat/core/grammar/helpers.py index 2f4a0799..ef704556 100644 --- a/src/acidcat/core/grammar/helpers.py +++ b/src/acidcat/core/grammar/helpers.py @@ -86,8 +86,8 @@ def _wav_fmt_relations(local, ctx): rate = local.get("sample_rate") avg = local.get("avg_bytes_per_sec") warns = [] - if tag == 1 and ch and bits and align != ch * bits // 8: - warns.append(f"block_align {align} != channels*bits/8 = {ch * bits // 8}") + if tag == 1 and ch and bits and align != ch * ((bits + 7) // 8): + warns.append(f"block_align {align} != channels*ceil(bits/8) = {ch * ((bits + 7) // 8)}") if tag == 1 and rate and align and avg != rate * align: warns.append(f"avg_bytes_per_sec {avg} != sample_rate*block_align = {rate * align}") return warns diff --git a/src/acidcat/core/infra/sniff.py b/src/acidcat/core/infra/sniff.py index 9b41b2cf..ba7d64ad 100644 --- a/src/acidcat/core/infra/sniff.py +++ b/src/acidcat/core/infra/sniff.py @@ -280,6 +280,7 @@ def sniff_bytes(head): _VITAL_WINDOW = 64 * 1024 _VITAL_KEY = b'"synth_version"' +_VITAL_BODY = b'"settings"' def _is_vital(filepath): @@ -298,11 +299,18 @@ def _is_vital(filepath): the sniffer stricter than the parser and left `inspect` unable to reach any real preset. """ + # A .vitalskin (Vital's UI theme) also carries synth_version, but it is a + # table of colours and sizes with no `settings`: read as a preset, every key + # came back as an unknown one, "carrier" alerts included. + if str(filepath).lower().endswith(".vitalskin"): + return False try: with open(filepath, "rb") as fh: head = fh.read(_VITAL_WINDOW) if _VITAL_KEY in head: - return True + size = fh.seek(0, 2) + # the whole file was read and holds no preset body + return size > _VITAL_WINDOW or _VITAL_BODY in head size = fh.seek(0, 2) if size <= _VITAL_WINDOW: return False # the head already was the file diff --git a/src/acidcat/core/primitives/zipio.py b/src/acidcat/core/primitives/zipio.py index 7d3e4022..deb96e2b 100644 --- a/src/acidcat/core/primitives/zipio.py +++ b/src/acidcat/core/primitives/zipio.py @@ -33,3 +33,32 @@ def zip_data_offset(z, zi): n = int.from_bytes(hdr[26:28], "little") # file name length m = int.from_bytes(hdr[28:30], "little") # extra field length return zi.header_offset + 30 + n + m + + +_EOCD = b"PK\x05\x06" + + +def zip_directory_extent(z, size): + """(offset, length) of the central directory plus the end record, or None. + + A zip-backed format ends with its own directory; a walker that places only + the entries leaves those bytes "past the container end", and the forensic + scan then reports the file's own index as an appended archive. Located from + the end record, which states the directory's size, rather than trusting its + stated offset: data prepended to the archive shifts the one and not the other. + """ + tail_len = min(size, 22 + 0xFFFF) + try: + z.fp.seek(size - tail_len) + tail = z.fp.read(tail_len) + except OSError: + return None + at = tail.rfind(_EOCD) + if at < 0 or len(tail) - at < 22: + return None + cd_size = int.from_bytes(tail[at + 12:at + 16], "little") + eocd = size - tail_len + at + start = eocd - cd_size + if start < 0: + return None + return start, size - start diff --git a/src/acidcat/core/walk/ableton.py b/src/acidcat/core/walk/ableton.py index d731e146..e7e4c3b8 100644 --- a/src/acidcat/core/walk/ableton.py +++ b/src/acidcat/core/walk/ableton.py @@ -417,10 +417,14 @@ def inspect_ableton_xml(filepath, fmt_id="als"): # ── Max for Live ────────────────────────────────────────────────────────── -# 'ampf' magic, a u32 version, then a 4-byte marker -- 'aaaa' in every specimen -# seen -- and only then the chunk chain. Reading the marker as a chunk id makes -# its next 4 bytes look like a 1.6 GB length, which is how this was caught. +# 'ampf' magic, a u32 version, then a 4-byte device type, and only then the +# chunk chain. Reading the type as a chunk id makes its next 4 bytes look like +# a 1.6 GB length, which is how this was caught. The type was taken for a +# constant 'aaaa' until a Max instrument said 'iiii': every audio effect +# measured carries 'aaaa' and every instrument 'iiii', each from the preset +# folder Live files that kind under. _AMXD_HEADER = 12 +_AMXD_KINDS = {b"aaaa": "audio effect", b"iiii": "instrument"} _AMXD_MAX_CHUNKS = 64 @@ -433,8 +437,10 @@ def inspect_amxd(filepath): if raw[:4] != b"ampf": warns.append("missing 'ampf' magic") marker = raw[8:12] - if marker != b"aaaa": - warns.append(f"marker at offset 8 is {marker!r}, expected b'aaaa'") + kind = _AMXD_KINDS.get(marker) + if kind is None: + warns.append(f"device type at offset 8 is {marker!r}; the measured " + f"ones are b'aaaa' (audio effect) and b'iiii' (instrument)") chunks = [{ "id": "ampf", "offset": 0, "size": min(size, _AMXD_HEADER), @@ -447,8 +453,8 @@ def inspect_amxd(filepath): _f(0x00, 4, "magic", raw[:4].decode("ascii", "replace")), _f(0x04, 4, "version", struct.unpack_from("= 8 else "?"), - _f(0x08, 4, "marker", marker.decode("ascii", "replace"), - "constant separator before the chunk chain"), + _f(0x08, 4, "device_type", marker.decode("ascii", "replace"), + kind or "not a type measured"), ], "warnings": [], "payload_base": 0, }] diff --git a/src/acidcat/core/walk/aiff.py b/src/acidcat/core/walk/aiff.py index c7fdfce3..2e5c37d4 100644 --- a/src/acidcat/core/walk/aiff.py +++ b/src/acidcat/core/walk/aiff.py @@ -100,7 +100,8 @@ def _aiff_ssnd(b, ctx, size, avail=None): comp = ctx.get("compression", "NONE") uncompressed = comp in ("NONE", "none", "sowt", "twos", "raw ") if frames and ch and bits and uncompressed: - expected = frames * ch * (bits // 8) + # a sample point is padded to whole bytes: 12-bit is stored in two + expected = frames * ch * ((bits + 7) // 8) if audio_bytes >= 0 and abs(audio_bytes - expected) > max(16, expected * 0.01): warns.append( f"SSND holds {audio_bytes:,} audio bytes but COMM frames " @@ -277,8 +278,13 @@ def _aiff_appl(b): fields.append(_f(0x00, 4, "signature", sig)) if sig in ("pdos", "stoc") and len(b) > 4: nlen = b[4] - name = b[5:5 + nlen].decode("ascii", errors="replace") - fields.append(_f(0x04, 1 + nlen, "name", name, "pstring")) + if 5 + nlen <= len(b): + name = b[5:5 + nlen].decode("ascii", errors="replace") + fields.append(_f(0x04, 1 + nlen, "name", name, "pstring")) + else: + # the length byte claims more than the chunk holds: not a pstring + warns.append("APPL '%s' data does not start with a pstring " + "that fits the chunk" % sig) fields.append(_f(None, 0, "data", f"{len(b) - 4:,} bytes")) return f"app '{sig}', {len(b) - 4:,} bytes", fields, warns @@ -351,7 +357,7 @@ def inspect_aiff(filepath, form_type, ctx=None): fr, ch, bits = ctx.get("frames"), ctx.get("channels"), ctx.get("bits") comp = ctx.get("compression") if fr and ch and bits and (comp is None or comp in _AIFC_UNCOMPRESSED) \ - and fr * ch * (bits // 8) > file_size: + and fr * ch * ((bits + 7) // 8) > file_size: entry["warnings"].append( f"num_sample_frames {fr:,} implies more audio than the " f"{file_size:,}-byte file holds; duration is not trustworthy" diff --git a/src/acidcat/core/walk/au.py b/src/acidcat/core/walk/au.py index 697e8925..1092e70b 100644 --- a/src/acidcat/core/walk/au.py +++ b/src/acidcat/core/walk/au.py @@ -213,7 +213,8 @@ def inspect_au(filepath): f"play as noise if fed to a PCM player") if data_offset + eff_size > file_size: w.append(f"audio runs past the end of the file " - f"(@0x{data_offset:x} + {eff_size:,})") + f"(@0x{data_offset:x} + {eff_size:,}); " + f"{avail:,} bytes are there") dfields = [ _f(None, 0, "encoding", name), _f(None, 0, "sample_rate", rate if rate else "unknown"), @@ -223,8 +224,9 @@ def inspect_au(filepath): dfields.append(_f(None, 0, "bits_per_sample", bits)) if secs is not None: dfields.append(_f(None, 0, "duration", f"{secs:.3f} s")) + # a cut file's audio chunk owns what is there, not what was declared chunks.append({ - "id": "data", "offset": data_offset, "size": eff_size, + "id": "data", "offset": data_offset, "size": min(eff_size, avail), "summary": (f"{name} @ {rate} Hz, {eff_size:,} B" + (f", {secs:.2f} s" if secs is not None else "")), "fields": dfields, "warnings": w, "payload_base": data_offset, diff --git a/src/acidcat/core/walk/mp4.py b/src/acidcat/core/walk/mp4.py index 77678483..32389bad 100644 --- a/src/acidcat/core/walk/mp4.py +++ b/src/acidcat/core/walk/mp4.py @@ -363,7 +363,7 @@ def _stamp(unix_time): raising: the field is only as trustworthy as the writer.""" import datetime try: - return datetime.datetime.utcfromtimestamp(unix_time).strftime( + return datetime.datetime.fromtimestamp(unix_time, datetime.timezone.utc).strftime( "%Y-%m-%d %H:%M:%S UTC") except (OSError, OverflowError, ValueError): return str(unix_time) diff --git a/src/acidcat/core/walk/multisample.py b/src/acidcat/core/walk/multisample.py index 806188a9..63f4ebab 100644 --- a/src/acidcat/core/walk/multisample.py +++ b/src/acidcat/core/walk/multisample.py @@ -16,7 +16,7 @@ import zipfile import zlib -from acidcat.core.primitives.zipio import zip_data_offset +from acidcat.core.primitives.zipio import zip_data_offset, zip_directory_extent from acidcat.core.walk.base import _f _ZONE_CAP = 48 # don't flood the view on big kits @@ -156,4 +156,9 @@ def inspect_multisample(filepath): chunks.append({"id": "zone", "offset": 0, "size": 0, "summary": f"... {len(samples) - _ZONE_CAP} more zone(s)", "fields": [], "warnings": [], "payload_base": 0}) + cd = zip_directory_extent(z, size) + if cd: + chunks.append({"id": "zip_directory", "offset": cd[0], "size": cd[1], + "summary": f"zip central directory, {len(names)} entries", + "fields": [], "warnings": [], "payload_base": cd[0]}) return chunks, warns diff --git a/src/acidcat/core/walk/sigmf.py b/src/acidcat/core/walk/sigmf.py index 8bb9b97b..ab66e53e 100644 --- a/src/acidcat/core/walk/sigmf.py +++ b/src/acidcat/core/walk/sigmf.py @@ -325,11 +325,33 @@ def inspect_sigmf(path, deep=False): warns.append(f"listing the first {_ANNOTATION_CAP} of " f"{len(annotations)} annotations") + if str(path).lower().endswith(".sigmf-meta") and os.path.isfile(meta_path): + return _as_sidecar(chunks, meta_path, data_path), warns samples, _ = _samples_chunk(data_path, data_size, geo, dt, deep) chunks.append(samples) return chunks, warns +def _as_sidecar(chunks, meta_path, data_path): + """The walk seen from the .sigmf-meta: its own bytes are the JSON. The + captures and annotations locate bytes of the .sigmf-data, so here they are + listed but not placed, and their pointers are not followed into this file.""" + where = os.path.basename(data_path) if os.path.isfile(data_path) else "the .sigmf-data" + meta_size = os.path.getsize(meta_path) + for c in chunks: + if c["id"] == "global": + continue + c.update(offset=0, size=0, payload_base=0) + c["summary"] += f" (in {where})" + for f in c["fields"]: + f.pop("xref", None) + chunks.append({"id": "sidecar", "offset": 0, "size": meta_size, "payload_base": 0, + "summary": f"SigMF metadata, {meta_size:,} bytes of JSON; " + f"the samples are in {where}", + "fields": [], "warnings": []}) + return chunks + + def inspect_iq(path, deep=False): size = os.path.getsize(path) ext = os.path.splitext(path.lower())[1] diff --git a/src/acidcat/core/walk/streams.py b/src/acidcat/core/walk/streams.py index 10a34daf..05016d8f 100644 --- a/src/acidcat/core/walk/streams.py +++ b/src/acidcat/core/walk/streams.py @@ -24,6 +24,8 @@ import os +import struct + from acidcat.core.primitives.notes import coverage from acidcat.core.walk.base import _f @@ -121,8 +123,10 @@ def inspect_brstm(filepath, deep=False): audio = h.get("audio_off", 0) body = max(0, size - audio) + declared = struct.unpack_from(">I", raw, 0x08)[0] fields = _stream("DSP-ADPCM", h["channels"], h["rate"], h["samples"], [ _f(0x00, 4, "magic", "RSTM"), + _f(0x08, 4, "file_size", declared, "bytes, the whole stream", enc=">I"), _f(None, 0, "blocks", format(h.get("blocks", 0), ","), "%s bytes each" % format(h.get("block_size", 0), ",")), _f(None, 0, "audioOffset", "0x%08X" % audio, @@ -132,6 +136,10 @@ def inspect_brstm(filepath, deep=False): ]) if audio > size: warns.append("the header points at audio beyond the end of the file") + elif declared > size: + warns.append( + "the header declares %s bytes and the file holds %s; the stream " + "is cut" % (format(declared, ","), format(size, ","))) return [ {"id": "header", "offset": 0, "size": min(audio, size), "summary": "Nintendo BRSTM, DSP-ADPCM, %d ch at %s Hz" diff --git a/src/acidcat/core/walk/wav.py b/src/acidcat/core/walk/wav.py index 91c92a3f..4c8e1148 100644 --- a/src/acidcat/core/walk/wav.py +++ b/src/acidcat/core/walk/wav.py @@ -58,8 +58,8 @@ def _parse_fmt(b, ctx): ctx.update({"format_tag": tag, "channels": ch, "sample_rate": rate, "block_align": align, "bits": bits}) - if tag == 1 and ch and bits and align != ch * bits // 8: - warns.append(f"block_align {align} != channels*bits/8 = {ch * bits // 8}") + if tag == 1 and ch and bits and align != ch * ((bits + 7) // 8): + warns.append(f"block_align {align} != channels*ceil(bits/8) = {ch * ((bits + 7) // 8)}") if tag == 1 and rate and align and avg != rate * align: warns.append(f"avg_bytes_per_sec {avg} != sample_rate*block_align = {rate * align}") # physically implausible but structurally valid values -- a crafted-file @@ -769,6 +769,62 @@ def _parse_padding(b, ctx): """JUNK / FLLR / PAD, through the reader every container shares.""" return parse_padding(b) + +def _printable(b): + return all(0x20 <= x < 0x7F for x in b) + + +def _parse_ds64_reservation(b, riff_size): + """A 28-byte JUNK straight after the RIFF header, or None. + + 28 bytes is exactly a ds64 payload (riffSize, dataSize and sampleCount as + u64, then a u32 table length), and EBU Tech 3306 has a writer put a JUNK of + that size first so the file can become RF64 past 4 GB by renaming it. Read + as padding, every one that is not zero was reported as something + overwritten in place, but writers fill it three ways on purpose: + + - the ds64 values themselves, already true for this file; + - 28 characters of text: Ableton Live writes a quote here ("The sleeper + must awaken", "Why r u using a hex editor?"), on the renders it makes; + - the RIFF size written over the first 8 bytes of such a text, which is a + later tool preparing the ds64 in a file Live wrote. + Anything else is left to the shared padding reader.""" + if len(b) < 28 or not b.strip(b"\x00"): + return None + r64, d64, n64 = struct.unpack_from("= riff_size + 8 + and str(cid).startswith("hex:")): + # appended bytes, not chunks: a chunk id is four ASCII + # characters. Reading on turned 2 MB of appended zeros into + # 262,144 empty "chunks"; they stay past the container end, + # where the forensic scan reports them as trailing data. + break if size > avail and not (cid == "data" and size in _STREAM_SENTINELS): file_warns.append( f"chunk {cid!r} at 0x{offset:08x} claims {size:,} bytes " @@ -1268,6 +1331,10 @@ def inspect_wav(filepath, ctx=None): ctx["data_bytes"] = min(size, avail) entry["summary"], entry["fields"], entry["warnings"] = \ _parse_data(payload, ctx, size, avail) + elif (cid == "JUNK" and offset == 12 + and _parse_ds64_reservation(payload, riff_size)): + entry["summary"], entry["fields"], entry["warnings"] = \ + _parse_ds64_reservation(payload, riff_size) elif parser: try: entry["summary"], entry["fields"], entry["warnings"] = \ diff --git a/src/acidcat/core/write/repairers.py b/src/acidcat/core/write/repairers.py index ebf9ce21..73856817 100644 --- a/src/acidcat/core/write/repairers.py +++ b/src/acidcat/core/write/repairers.py @@ -75,6 +75,18 @@ def _orphan_violation(node, n_bytes): f"Recomputing the size here would orphan them.")) +def _lost_audio_violation(node): + want = _IFF_AUDIO.get(node.form_type, b"data").decode("latin-1") + return Violation( + SIZE, f"{node.form_type.decode('latin-1', 'replace')}/{want}", + "declared_size", None, None, + witness="", # NOT repairable: the walk lost the audio + detail=(f"the {want} chunk is in the file but the chunk walk never " + f"reaches it: a size before it is wrong, so every chunk after " + f"that one is misread. Recomputing the container size would " + f"cut the audio off.")) + + def _iff_violation(change): """Map a structure.recompute change to a Violation. A top-level (master) size is witnessed by end-of-file; a nested size by its container's parsed @@ -90,16 +102,26 @@ def _iff_violation(change): class IffRepairer(Repairer): label = "IFF" + # RF64 keeps its real sizes in the ds64 chunk and writes 0xFFFFFFFF in the + # 32-bit fields. The structure model reads those fields literally, so every + # valid RF64 failed validation ("data overruns the file") and repair + # proposed an 88-byte RIFF size. Declined, as the tag editor declines it. + _DS64_NOTE = ("RF64 keeps its sizes in the ds64 chunk; the 32-bit size " + "fields are placeholders, and checking them is not modelled") def applies(self, data): return structure.is_iff(data) + def _is_rf64(self, data): + return bytes(data[:4]) == b"RF64" + def _report(self, data, opts): node = structure.parse(data) orphan = _orphaned_audio(node) changes = structure.recompute(node, normalize_pad=not (opts or {}).get("keep_pad")) label = node.form_type.decode("latin-1", "replace") violations = [_iff_violation(c) for c in changes] + want = _IFF_AUDIO.get(node.form_type) if orphan: # The master-size change is the destructive one, so it must stop # advertising itself as repairable -- otherwise validate and audit @@ -108,13 +130,24 @@ def _report(self, data, opts): # violation, reported first, explains why. violations = [replace(v, witness="") for v in violations] violations.insert(0, _orphan_violation(node, orphan)) + elif want and _iff_audio(node) is None and want in bytes(data): + # apply() refuses exactly this file (audio present, not in the + # tree); analyze must not advertise a fix it will refuse. Checked + # after the orphan case, whose overrunning chunk also sits outside + # the tree and has the more specific explanation. + violations = [replace(v, witness="") for v in violations] + violations.insert(0, _lost_audio_violation(node)) return node, violations, label, orphan def analyze(self, data, opts=None): + if self._is_rf64(data): + return Report("WAVE", note=self._DS64_NOTE) _node, violations, label, _orphan = self._report(data, opts) return Report(label, violations) def apply(self, data, opts=None): + if self._is_rf64(data): + return data, Report("WAVE", note=self._DS64_NOTE) node, violations, label, orphan = self._report(data, opts) if orphan: want = _IFF_AUDIO.get(node.form_type, b"data").decode("latin-1") @@ -175,9 +208,11 @@ def analyze(self, data, opts=None): return self._run(data, patch=False)[1] def apply(self, data, opts=None): - before = self._mdat(data) + # locate mdat only when there is a rewrite to guard: finding it first + # raised on a multi-track file _run would have reported as out of scope new_data, report = self._run(data) - if report.violations and self._mdat(new_data) != before: + if (report.violations and new_data is not data + and self._mdat(new_data) != self._mdat(data)): raise AudioGuardError("mdat payload would change") return new_data, report diff --git a/src/acidcat/tui_app/app.py b/src/acidcat/tui_app/app.py index 2cc55871..2d1c5b73 100644 --- a/src/acidcat/tui_app/app.py +++ b/src/acidcat/tui_app/app.py @@ -3352,7 +3352,7 @@ def _do_play(self, off, length): rate, ch, bits, floating = self._audio_params() self.action_stop_play(quiet=True) self._play = play.play_bytes(data, rate=rate, ch=ch, bits=bits, floating=floating) - secs = len(data) / max(1, rate * ch * (bits // 8)) + secs = len(data) / max(1, rate * ch * ((bits + 7) // 8)) self.notify(f"playing {len(data):,} bytes as {rate} Hz {ch}ch {bits}-bit " f"(~{secs:.1f}s) -- . to stop") diff --git a/tests/test_ableton.py b/tests/test_ableton.py index f663b51f..4738c872 100644 --- a/tests/test_ableton.py +++ b/tests/test_ableton.py @@ -372,7 +372,7 @@ def test_xml_walker_reads_the_creator(tmp_path): # ── Max for Live ────────────────────────────────────────────────────────── def test_amxd_chunk_chain(tmp_path): - """The chain starts at 12, after a constant 'aaaa' marker. Treating that + """The chain starts at 12, after the 4-byte device type. Treating that marker as a chunk id makes its next 4 bytes read as a 1.6 GB length -- which is exactly how the first version of this walker failed on a real device.""" p = tmp_path / "d.amxd" @@ -385,6 +385,23 @@ def test_amxd_chunk_chain(tmp_path): assert "JSON at +4" in chunks[2]["summary"] +def test_amxd_device_type_is_named_not_flagged(tmp_path): + """The 'aaaa' was never a constant: it is the device type. A real Max + instrument says 'iiii' and was reported as a magic mismatch.""" + chain = (b"meta" + struct.pack("') + z.writestr("a.wav", wav) + p = tmp_path / name + p.write_bytes(buf.getvalue()) + return p + + +def test_a_file_that_is_a_zip_is_not_an_appended_zip(tmp_path): + """.xpn, .labx and .multisample are zips from byte 0. Their end record is + their own index, and a stored WAV member is a whole file by design; every + real one was reported as a polyglot carrying embedded media.""" + rules = {f["rule"] for f in anomalies.scan(str(_zip_with_wav(tmp_path, "kit.multisample")))} + assert not rules & {"polyglot", "embedded_standalone_media", "trailing_data"} + + +def test_a_zip_appended_to_a_wav_is_still_a_polyglot(tmp_path): + """The control: the exemption is for a file that starts as a zip.""" + host = _wav(_FMT, _chunk(b"data", b"\x00\x01" * 64)) + p = tmp_path / "carrier.wav" + p.write_bytes(host + _zip_with_wav(tmp_path, "inner.zip").read_bytes()) + assert "polyglot" in {f["rule"] for f in anomalies.scan(str(p))} + + +def test_appended_zeros_are_trailing_data_not_chunks(tmp_path): + """The walker read every 8 bytes past the RIFF end as a chunk: 2 MB of + appended zeros became 262,144 empty chunks, and building the Document took + seconds. A chunk id is four ASCII characters, so the walk stops at the + first one that is not; the bytes stay past the container end, where the + scan still names them.""" + host = _wav(_FMT, _chunk(b"data", b"\x00\x01" * 64)) + p = tmp_path / "padded.wav" + p.write_bytes(host + bytes(2 * 1024 * 1024)) + _label, chunks, _w = walk_file(str(p)) + assert [c["id"].strip() for c in chunks] == ["fmt", "data"] + assert "trailing_data" in {f["rule"] for f in anomalies.scan(str(p))} diff --git a/tests/test_audit.py b/tests/test_audit.py index 73bd2dbe..820f26ed 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -133,3 +133,19 @@ def test_audit_survives_a_data_chunk_that_overruns_the_file(tmp_path, capsys): assert out.strip(), "audit produced no report at all" assert "VERDICT" in out assert rc in (0, 1) + + +def test_a_rom_is_named_and_pointed_at_extract(tmp_path, capsys): + """A SNES ROM sniffs as one and `extract` recovers its samples, yet audit + printed it as [unknown] and suggested `locate`. It names what it recognised + and the verb that gets at the audio; exit 2 still says nothing was checked.""" + rom = bytearray(0x8000) + rom[0x7FC0 + 0x15] = 0x20 # LoROM map mode + rom[0x7FC0 + 0x1C:0x7FC0 + 0x20] = bytes((0x34, 0x12, 0xCB, 0xED)) # complement, checksum + p = tmp_path / "cart.sfc" + p.write_bytes(bytes(rom)) + rc = audit.run(SimpleNamespace(input=str(p), signal=False, output_format="table", + json=False)) + out = capsys.readouterr().out + assert "[SNES ROM]" in out and "acidcat extract" in out and "[unknown]" not in out + assert rc == 2 diff --git a/tests/test_chunk_geometry.py b/tests/test_chunk_geometry.py index bead6ece..f1d341b2 100644 --- a/tests/test_chunk_geometry.py +++ b/tests/test_chunk_geometry.py @@ -50,7 +50,7 @@ import pytest from acidcat.core.infra import geometry -from acidcat.core.walk import walk_file +from acidcat.core.walk import Unsupported, walk_file _MAX = 8_000_000 @@ -170,6 +170,10 @@ def _hunt(): continue try: label, chunks, warns = walk_file(path, deep=False) + except Unsupported: + # a format recognised but deliberately not walked (a ROM + # says to use `extract`): a refusal is an answer, not a crash + continue except Exception as exc: # noqa: BLE001 (recorded, asserted below) HUNT_RAISED.append((path, repr(exc))) continue diff --git a/tests/test_mdx.py b/tests/test_mdx.py index 26ff4c6f..cca9f39f 100644 --- a/tests/test_mdx.py +++ b/tests/test_mdx.py @@ -93,16 +93,21 @@ def test_identification_needs_more_than_the_shared_sniff_head(tmp_path): assert sniff.sniff(str(p)) == "mdx" -def test_offsets_are_bounded_by_the_FILE_not_the_buffer(): - """The same bug from the other side. Offsets routinely point past the few - kilobytes a sniffer reads, so checking them against len(buffer) rejects - almost every real tune.""" +def test_offsets_past_the_end_do_not_unmake_an_mdx(tmp_path): + """Offsets routinely point past the few kilobytes a sniffer reads, and a + cut rip's offsets point past the end of the file itself. Neither makes it + a different format: the header identifies it, and the walker reports each + offset that dangles. 16 real modules are cut like that.""" blob = _mdx(title="B" * 300, mml_len=4096) head = blob[:512] assert mdxmod.looks_like_mdx(head, len(blob)) is True - assert mdxmod.looks_like_mdx(head) is False, ( - "without a file size the truncated head cannot resolve, which is " - "exactly why the size has to be passed") + assert mdxmod.looks_like_mdx(head) is True + p = tmp_path / "cut.mdx" + p.write_bytes(head) + assert sniff.sniff(str(p)) == "mdx" + from acidcat.core.walk import walk_file + _label, _chunks, warns = walk_file(str(p)) + assert any("past the end" in str(w) for w in warns) @pytest.mark.parametrize("blob", [ diff --git a/tests/test_mp4.py b/tests/test_mp4.py index d0584364..eedf1369 100644 --- a/tests/test_mp4.py +++ b/tests/test_mp4.py @@ -397,3 +397,14 @@ def test_a_malformed_box_claims_only_the_bytes_that_remain(): assert bad[0]["declared"] == 0x47400030 assert bad[0]["size"] == len(full) - bad[0]["offset"] assert bad[0]["offset"] + bad[0]["size"] <= len(full) + + +def test_a_box_timestamp_renders_without_the_deprecated_utc_call(): + """utcfromtimestamp is deprecated from Python 3.12 and warned on every MP4 + with a creation time; a real one in the hunt corpus failed the sweep.""" + import warnings + from acidcat.core.walk.mp4 import _stamp + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert _stamp(946080000) == "1999-12-25 00:00:00 UTC" + assert _stamp(2 ** 40) == str(2 ** 40) diff --git a/tests/test_mp4repair.py b/tests/test_mp4repair.py index ba0e18d3..d2ee7387 100644 --- a/tests/test_mp4repair.py +++ b/tests/test_mp4repair.py @@ -209,3 +209,17 @@ def test_out_of_scope_is_still_a_quiet_note_not_damage(tmp_path, capsys): p.write_bytes(_box(b"ftyp", b"M4A \x00\x00\x00\x00") + tree + _box(b"mdat", payload)) assert main(["validate", str(p)]) == 0 + + +def test_repair_declines_a_multitrack_file_without_raising(): + """`constraints.repair` promises a report, never an exception. The MP4 + repairer located mdat BEFORE its guarded run, and locating it raised on a + multi-track file, so a real two-track video escaped as Mp4RepairError.""" + from acidcat.core.write import constraints + sizes, runs, payload, start, good = _make_multichunk() + stbl = _stsz(sizes) + _stsc(runs) + _stco(good) + _stco(good) + tree = _box(b"moov", _box(b"trak", _box(b"mdia", + _box(b"minf", _box(b"stbl", stbl))))) + data = _box(b"ftyp", b"M4A \x00\x00\x00\x00") + tree + _box(b"mdat", payload) + new_data, _report = constraints.repair(data) + assert new_data == data diff --git a/tests/test_pdx.py b/tests/test_pdx.py index 58768b77..3c814a9a 100644 --- a/tests/test_pdx.py +++ b/tests/test_pdx.py @@ -264,3 +264,41 @@ def test_real_corpus_walks_completely(): assert seen >= 100, "only %d banks identified; that tests almost nothing" % seen assert covered_fully >= seen - 20, ( "%d of %d banks were not fully accounted for" % (seen - covered_fully, seen)) + + +def _short_bank(lengths, slots=None, tail=b""): + """A bank whose table holds only `slots` rows (default: one per sample), + with the samples laid end to end right after it.""" + slots = slots or len(lengths) + table_end = slots * pdxmod.SLOT + rows, at = [], table_end + for n in lengths: + rows.append((at, n)) + at += n + rows += [(0, 0)] * (slots - len(rows)) + table = b"".join(struct.pack(">II", o, n) for o, n in rows) + return table + b"".join(bytes([0x11] * n) for n in lengths) + tail + + +def test_a_table_shorter_than_one_bank_is_accepted_when_it_accounts_for_the_file(tmp_path): + """Some writers emit only the slots they fill. Seen on 76 real banks: the + first sample begins where those slots end, and the samples run end to end + to the last byte of the file.""" + raw = _short_bank([400, 300, 500], slots=26) + h = pdxmod.parse_table(raw, len(raw)) + assert h["ok"] and h["short"] and h["table_size"] == 26 * 8 + assert h["used"] == 3 and len(h["slots"]) == 26 + p = tmp_path / "short.pdx" + p.write_bytes(raw) + assert sniff.sniff(str(p)) == "pdx" + + +def test_a_short_table_with_a_gap_or_a_tail_is_not_a_bank(): + gapped = _short_bank([400, 300]) + rows = bytearray(gapped) + struct.pack_into(">I", rows, 8, 16 + 400 + 20) # second sample 20 bytes late + assert not pdxmod.parse_table(bytes(rows), len(rows))["ok"] + tailed = _short_bank([400, 300], tail=bytes(64)) # unaccounted bytes after + assert not pdxmod.parse_table(tailed, len(tailed))["ok"] + one = _short_bank([400]) # one sample proves too little + assert not pdxmod.parse_table(one, len(one))["ok"] diff --git a/tests/test_render_every_seed.py b/tests/test_render_every_seed.py new file mode 100644 index 00000000..fa855dbe --- /dev/null +++ b/tests/test_render_every_seed.py @@ -0,0 +1,38 @@ +"""Every render path, on every seed, finishes without an internal error. + +1.8.6 shipped `acidcat inspect` crashing on every SoundFont: the SF2 walker +added a preset and instrument tree whose nodes have no byte position, and the +table, --full and od all formatted `offset` as a number. The walk tests passed; +nothing ran the renderers over a walk that holds an unplaced node. This does, +for every format, in every mode the CLI offers for one file.""" +import pytest + +import seeds +from acidcat.cli import main + +MODES = (["inspect"], ["inspect", "-q"], ["inspect", "--hex"], ["inspect", "--json"], + ["inspect", "--full"], ["inspect", "--pretty"], ["od"]) + + +@pytest.mark.parametrize("fmt", sorted(seeds.SEEDS)) +def test_every_mode_renders_the_seed(fmt, tmp_path, capsys): + path = tmp_path / ("seed" + seeds.suffix(fmt)) + path.write_bytes(seeds.build(fmt)) + for mode in MODES: + try: + rc = main([*mode, str(path)]) + except SystemExit as e: + rc = e.code + out = capsys.readouterr() + assert rc in (0, 1, None), f"{' '.join(mode)} exited {rc}: {out.err.strip()[-300:]}" + assert "internal error" not in out.err, f"{' '.join(mode)}: {out.err.strip()[-300:]}" + + +def test_the_seed_set_includes_an_unplaced_node(tmp_path): + """The control: without a seed whose walk holds a node with no offset, the + test above could not have caught the SoundFont crash.""" + from acidcat.core.walk import walk_file + path = tmp_path / ("probe" + seeds.suffix("sf2")) + path.write_bytes(seeds.build("sf2")) + _l, chunks, _w = walk_file(str(path)) + assert any(c.get("offset") is None for c in chunks) diff --git a/tests/test_rf64_check.py b/tests/test_rf64_check.py new file mode 100644 index 00000000..1b45e2ae --- /dev/null +++ b/tests/test_rf64_check.py @@ -0,0 +1,59 @@ +"""A valid RF64 is not a broken RIFF. + +RF64 writes 0xFFFFFFFF in the 32-bit RIFF and data size fields and keeps the +real sizes in the ds64 chunk. The IFF structure model read those fields +literally, so every valid RF64 failed validation with "the data chunk overruns +the file" and repair proposed an 88-byte RIFF size. Found on a file libsndfile +wrote. The checker now declines RF64 with a note, as the tag editor does.""" +import struct + +from acidcat.core.write import constraints + +SENTINEL = 0xFFFFFFFF + + +def _rf64(n_audio=64): + fmt = struct.pack("hIh", 1, 16, 16) + bytes(10) + b"NONE" + b"\x0enot compressed" + b"\x00" + ssnd = struct.pack(">II", 0, 0) + bytes(32) + body = (b"AIFC" + b"COMM" + struct.pack(">I", 18) + comm + + b"SSND" + struct.pack(">I", len(ssnd)) + ssnd) + data = b"FORM" + struct.pack(">I", len(body)) + body + report = constraints.analyze(data) + assert report.violations + assert not any(v.repairable for v in report.violations) + assert "never reaches it" in report.violations[0].detail diff --git a/tests/test_secondary_path_hunt.py b/tests/test_secondary_path_hunt.py index 64e15c1a..cb5c6f8c 100644 --- a/tests/test_secondary_path_hunt.py +++ b/tests/test_secondary_path_hunt.py @@ -103,6 +103,7 @@ def test_no_real_file_crashes_the_repair_path(): degrade internally, never raise on a real file. Output is discarded; the file is never modified.""" from acidcat.core.write import constraints + from acidcat.core.write.repairers import AudioGuardError def run(path, fid): try: @@ -111,7 +112,13 @@ def run(path, fid): except OSError: return constraints.analyze(data) - constraints.repair(data) + try: + constraints.repair(data) + except AudioGuardError: + # the guard refusing to write is the designed answer for a file + # whose audio it cannot locate (the repair verb reports it as + # "nothing written"); first seen on a malformed AIFC from McGill + pass tried, crashes = _sweep(_REPAIRABLE, run) if not tried: diff --git a/tests/test_sigmf.py b/tests/test_sigmf.py index 734009da..3595e7ce 100644 --- a/tests/test_sigmf.py +++ b/tests/test_sigmf.py @@ -6,6 +6,7 @@ format is open and headerless, so no real capture is needed to exercise it.""" import hashlib import json +import os import struct from acidcat.core.infra.sniff import sniff @@ -37,6 +38,11 @@ def _make_pair(tmp_path, extra_global=None, captures=None, annotations=None): return str(p), data +def _data(meta): + """the byte regions live in the data file; walk that half to place them""" + return meta[:-len(".sigmf-meta")] + ".sigmf-data" + + def test_sigmf_sniffs_both_pair_members(tmp_path): meta, _ = _make_pair(tmp_path) assert sniff(meta) == "sigmf" @@ -58,7 +64,7 @@ def test_sigmf_captures_are_byte_regions(tmp_path): meta, _ = _make_pair(tmp_path, captures=[ {"core:sample_start": 0, "core:frequency": 8_428_190_000}, {"core:sample_start": 4, "core:frequency": 8_500_000_000}]) - chunks, _ = sigmf.inspect_sigmf(meta) + chunks, _ = sigmf.inspect_sigmf(_data(meta)) caps = [c for c in chunks if c["id"].startswith("capture")] assert caps[0]["offset"] == 0 and caps[0]["size"] == 16 # samples 0..4, 4 B each assert caps[1]["offset"] == 16 and caps[1]["size"] == 16 @@ -69,7 +75,7 @@ def test_sigmf_captures_are_byte_regions(tmp_path): def test_sigmf_annotation_region(tmp_path): meta, _ = _make_pair(tmp_path, annotations=[ {"core:sample_start": 2, "core:sample_count": 3, "core:label": "burst"}]) - chunks, _ = sigmf.inspect_sigmf(meta) + chunks, _ = sigmf.inspect_sigmf(_data(meta)) ann = next(c for c in chunks if c["id"].startswith("annotation")) assert ann["offset"] == 8 and ann["size"] == 12 # 3 samples * 4 B assert "burst" in ann["summary"] @@ -77,7 +83,7 @@ def test_sigmf_annotation_region(tmp_path): def test_sigmf_deep_verifies_sha_and_stats(tmp_path): meta, _ = _make_pair(tmp_path) - chunks, warns = sigmf.inspect_sigmf(meta, deep=True) + chunks, warns = sigmf.inspect_sigmf(_data(meta), deep=True) g = next(c for c in chunks if c["id"] == "global") assert next(x for x in g["fields"] if x["name"] == "sha512")["note"] == "verified" samp = next(c for c in chunks if c["id"] == "samples") @@ -155,3 +161,22 @@ def test_malformed_json_types_degrade(tmp_path): meta.write_text(js) chunks, warns = sigmf.inspect_sigmf(str(meta)) # must not raise assert chunks # always a degraded result + + +def test_the_meta_half_places_only_its_own_bytes(tmp_path): + """Opened by its .sigmf-meta, the walk laid the data file's regions over + the JSON: a real 1,045-byte sidecar came back with three invalid chunks + and every byte unaccounted for. Its own bytes are the JSON; the captures + and annotations are listed but point into the other file.""" + meta, _ = _make_pair(tmp_path, annotations=[ + {"core:sample_start": 2, "core:sample_count": 3, "core:label": "burst"}]) + chunks, warns = sigmf.inspect_sigmf(meta) + assert warns == [] + size = os.path.getsize(meta) + side = next(c for c in chunks if c["id"] == "sidecar") + assert (side["offset"], side["size"]) == (0, size) + for c in chunks: + if c["id"] != "sidecar": + assert c["size"] == 0, c["id"] + assert all("xref" not in f for f in c["fields"]), c["id"] + assert "(in cap.sigmf-data)" in next(c for c in chunks if c["id"] == "annotation[0]")["summary"] diff --git a/tests/test_streams.py b/tests/test_streams.py index 3e626fd9..2e87192c 100644 --- a/tests/test_streams.py +++ b/tests/test_streams.py @@ -210,3 +210,18 @@ def test_codec_refusals_reach_extract_as_sampleerror(tmp_path): p.write_bytes(MAGIC + struct.pack(">II", 32000, 5) + bytes(0x200)) with pytest.raises(SampleError): list(iter_samples(str(p), fmt="hps")) + + +def test_a_cut_brstm_says_it_is_cut(tmp_path): + """The RSTM header declares the whole file's size at 0x08. FFmpeg's test + suite carries a stream cut to 200 KB of a declared 9.1 MB, and the walker + reported it as whole because it never read that field.""" + data = bytearray(_brstm()) + struct.pack_into(">I", data, 0x08, len(data)) + chunks, warns = streams.inspect_brstm(_write(tmp_path, "whole.brstm", bytes(data))) + assert not warns + assert [f["value"] for f in chunks[0]["fields"] if f["name"] == "file_size"] == [len(data)] + struct.pack_into(">I", data, 0x08, len(data) * 45) + _chunks, warns = streams.inspect_brstm(_write(tmp_path, "cut.brstm", bytes(data))) + assert len(warns) == 1 + assert "the stream is cut" in str(warns[0]) diff --git a/tests/test_tui_fundamentals.py b/tests/test_tui_fundamentals.py index 8333f757..c0298af5 100644 --- a/tests/test_tui_fundamentals.py +++ b/tests/test_tui_fundamentals.py @@ -306,21 +306,21 @@ def test_the_footer_only_advertises_keys_that_work(keysweep): # ── mounting must not scale with a hostile chunk count ───────────── -def _null_tailed_wav(path, tail_bytes): - """A valid WAV with a run of nulls appended. +def _many_chunk_wav(path, n_chunks): + """A valid WAV holding n_chunks empty JUNK chunks inside its RIFF. - The deep walker reads each 8-byte run of zeros as a zero-size chunk, so - this is the cheapest way to make a file with an enormous chunk count. It is - not contrived: a truncated or zero-padded file from a failed transfer looks - exactly like this, and opening damaged files is the tool's purpose. + The cheapest real file with an enormous chunk count. It was built from + nulls appended past the RIFF end, which the walker used to read as + zero-size chunks; appended bytes are trailing data, not chunks, so the walk + now stops there, and the count has to come from chunks the RIFF declares. """ import struct pcm = b"\x00\x01" * 200 body = (b"WAVE" + b"fmt " + struct.pack("