Files
d5563d2e12 fix: keep the truncated tail instead of dropping it in png/isobmff strips (#182)
* fix: keep the truncated tail instead of dropping it in png/isobmff strips

strip_png stopped walking at the first chunk it could not parse and
never copied the remainder; strip_isobmff rebuilt only the boxes that
parsed. A truncated IDAT/mdat — the actual coded image — was dropped
from the output, the run reported "already clean", the exit code was
0: a user with a recoverable (viewable) image ended with an
unopenable husk and the tool saying it was fine (#170).

Both strippers now keep the unparseable tail verbatim and append a
"kept N bytes of truncated ... tail" action — a real report, so the
run is never mistaken for a no-op clean and every original byte
survives (truncated-image-capable readers can still open the output).

* refactor: surface parser walk end; stop false truncation reports

- _parse_isobmff_boxes returns (boxes, scanned_end), so strip_isobmff no longer re-walks parsed boxes to recover the stop offset.

- Fewer than 8 trailing bytes is trailing junk, not truncation: kept verbatim without a 'file truncated' action.

- Tests: drop unused noqa (RUF100) and unpack (RUF059), simplify the PNG tail assertion to byte-equality, cover the trailing-junk case.

---------

Co-authored-by: yzxcj797 <yzxcj797@users.noreply.github.com>
Co-authored-by: Guillaume Meyer (The Opinionated Man) <1385518+guillaumemeyer@users.noreply.github.com>
2026-08-19 16:21:55 -07:00

392 lines
14 KiB
Python

#!/usr/bin/env python3
"""AI/C2PA provenance metadata for audio and video containers.
Extends the file-cleaners layer (image_meta.py for PNG/JPEG/..., container_meta.py
for SVG/PDF/DOCX/...) to MP4/MOV/M4A/M4V (ISOBMFF), WAV, and MP3. Generative
audio/video tools embed provenance the same way image generators do -- C2PA
manifests and XMP in ISOBMFF boxes, generator tags in RIFF chunks and ID3v2
frames -- so this reuses the existing ISOBMFF box walker from image_meta.py
(the same mechanism already proven for AVIF/HEIC) rather than duplicating it.
Metadata only: waveform/pixel data is never touched, matching every other
cleaner in this project. A box/chunk/frame is either kept byte-identical or
dropped whole -- nothing here does a partial in-place rewrite of a box's
payload, so a container can never come out semantically mangled.
Known scope limits (documented, not silently mishandled):
- MP4/MOV: legacy QuickTime files with no top-level `ftyp` box are not
detected by signature (rare in practice; modern encoders always write one).
- MP3: ID3v2.2 (3-byte frame IDs, pre-iTunes era) tags are detected but not
decomposed into frames -- stripping falls back to a whole-tag drop, which
is always safe. ID3v1 (fixed 128-byte trailer at EOF) is not handled.
"""
from __future__ import annotations
import struct
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from common import classify_finding_confidence, safe_write_bytes
from image_meta import (
AI_META_HINTS,
XMP_UUID, # noqa: F401 -- re-exported for callers that want the raw constant
_build_isobmff_box,
_contains_any,
_isobmff_free_box,
_parse_isobmff_boxes,
inspect_isobmff,
strip_isobmff,
)
AV_EXTS = {".mp4", ".mov", ".m4a", ".m4v", ".wav", ".mp3"}
@dataclass
class AVInspectReport:
path: str
format: str # mp4 | wav | mp3 | unknown
has_c2pa: bool
has_ai_metadata: bool
findings: list[str] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"path": self.path,
"format": self.format,
"has_c2pa": self.has_c2pa,
"has_ai_metadata": self.has_ai_metadata,
"findings": self.findings,
"findings_confidence": [classify_finding_confidence(f) for f in self.findings],
"notes": self.notes,
}
def detect_av_format(data: bytes) -> str:
"""Sniff MP4/MOV/M4A/M4V (ISOBMFF), WAV, or MP3 from magic bytes."""
if len(data) >= 12 and data[4:8] == b"ftyp":
return "mp4"
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WAVE":
return "wav"
if len(data) >= 3 and data[:3] == b"ID3":
return "mp3"
if len(data) >= 2 and data[0] == 0xFF and (data[1] & 0xE0) == 0xE0:
return "mp3" # MPEG frame sync with no ID3v2 header (rare but valid)
return "unknown"
def _classify_c2pa(hits: list[str]) -> bool:
return any(h.lower() in ("c2pa", "contentcredentials", "jumb", "contentauth") for h in hits)
# ---------------------------------------------------------------------------
# MP4 / MOV / M4A / M4V (ISOBMFF)
# ---------------------------------------------------------------------------
#
# Top-level C2PA (jumb/c2pa box) and XMP (uuid box) detection/stripping reuse
# inspect_isobmff() / strip_isobmff() from image_meta.py unchanged -- that is
# exactly the mechanism the C2PA spec defines for ISOBMFF-family containers,
# already proven correct for AVIF/HEIC. moov/udta (QuickTime "user data",
# where generator/tool tags commonly live) is MP4-specific and handled here.
def _inspect_moov_udta(data: bytes) -> tuple[bool, bool, list[str]]:
has_c2pa = False
has_ai = False
findings: list[str] = []
for fourcc, payload, _size, _hdr in _parse_isobmff_boxes(data)[0]:
if fourcc != b"moov":
continue
for s_fourcc, s_payload, _s_size, _s_hdr in _parse_isobmff_boxes(payload)[0]:
if s_fourcc != b"udta":
continue
hits = _contains_any(s_payload, AI_META_HINTS)
if hits:
has_ai = True
if _classify_c2pa(hits):
has_c2pa = True
findings.append(f"MP4 moov/udta box: {', '.join(hits[:8])}")
return has_c2pa, has_ai, findings
def _strip_moov_udta(data: bytes, *, strip_all_metadata: bool) -> tuple[bytes, list[str]]:
actions: list[str] = []
out = bytearray()
for fourcc, payload, _size, hdr in _parse_isobmff_boxes(data)[0]:
if fourcc != b"moov":
out.extend(_build_isobmff_box(fourcc, payload, hdr))
continue
new_moov = bytearray()
for s_fourcc, s_payload, s_size, s_hdr in _parse_isobmff_boxes(payload)[0]:
if s_fourcc == b"udta" and (
strip_all_metadata or _contains_any(s_payload, AI_META_HINTS)
):
actions.append("drop moov/udta box (generator/user-data tags)")
new_moov.extend(_isobmff_free_box(s_size, s_hdr))
continue
new_moov.extend(_build_isobmff_box(s_fourcc, s_payload, s_hdr))
out.extend(_build_isobmff_box(b"moov", bytes(new_moov), hdr))
return bytes(out), actions
def _inspect_mp4(data: bytes) -> tuple[bool, bool, list[str]]:
has_c2pa, has_ai, findings = inspect_isobmff(data, fmt="mp4")
udta_c2pa, udta_ai, udta_findings = _inspect_moov_udta(data)
return has_c2pa or udta_c2pa, has_ai or udta_ai, findings + udta_findings
def _strip_mp4(data: bytes, *, strip_all_metadata: bool) -> tuple[bytes, list[str]]:
cleaned, actions = strip_isobmff(data, fmt="mp4", strip_all_metadata=strip_all_metadata)
cleaned, udta_actions = _strip_moov_udta(cleaned, strip_all_metadata=strip_all_metadata)
actions = [a for a in actions if not a.startswith("no MP4 metadata")] + udta_actions
if not actions:
actions = ["no MP4 metadata boxes removed (already clean or none matched)"]
return cleaned, actions
# ---------------------------------------------------------------------------
# ID3v2 (shared by MP3 files and WAV's optional `id3 ` chunk)
# ---------------------------------------------------------------------------
def _id3v2_size(data: bytes, offset: int) -> int:
b0, b1, b2, b3 = data[offset], data[offset + 1], data[offset + 2], data[offset + 3]
return ((b0 & 0x7F) << 21) | ((b1 & 0x7F) << 14) | ((b2 & 0x7F) << 7) | (b3 & 0x7F)
def _id3v2_size_bytes(n: int) -> bytes:
return bytes([(n >> 21) & 0x7F, (n >> 14) & 0x7F, (n >> 7) & 0x7F, n & 0x7F])
def _parse_id3v2_frames(data: bytes) -> tuple[int, int, list[tuple[bytes, bytes]]] | None:
"""Parse an ID3v2 tag at the start of *data*.
Returns (tag_total_size, major_version, frames); frames is a list of
(frame_id, frame_payload) for v2.3/v2.4 tags (4-byte frame IDs). v2.2
tags (3-byte frame IDs) are detected but returned with an empty frame
list -- callers fall back to whole-tag byte-scanning and whole-tag drop.
"""
if len(data) < 10 or data[:3] != b"ID3":
return None
major = data[3]
tag_size = _id3v2_size(data, 6)
total = 10 + tag_size
if total > len(data):
return None
if major < 3:
return total, major, []
frames: list[tuple[bytes, bytes]] = []
pos = 10
while pos + 10 <= total:
frame_id = data[pos : pos + 4]
if frame_id == b"\x00\x00\x00\x00":
break # padding
frame_size = (
_id3v2_size(data, pos + 4)
if major == 4
else struct.unpack(">I", data[pos + 4 : pos + 8])[0]
)
frame_start = pos + 10
frame_end = frame_start + frame_size
if frame_size < 0 or frame_end > total:
break
frames.append((frame_id, data[frame_start:frame_end]))
pos = frame_end
return total, major, frames
def _inspect_id3v2(data: bytes) -> tuple[bool, bool, list[str]]:
parsed = _parse_id3v2_frames(data)
if parsed is None:
return False, False, []
total, major, frames = parsed
findings: list[str] = []
has_ai = False
has_c2pa = False
if not frames:
hits = _contains_any(data[:total], AI_META_HINTS)
if hits:
has_ai = True
has_c2pa = _classify_c2pa(hits)
findings.append(f"ID3v2.{major} tag: {', '.join(hits[:8])}")
return has_c2pa, has_ai, findings
for frame_id, payload in frames:
hits = _contains_any(payload, AI_META_HINTS)
if hits:
has_ai = True
if _classify_c2pa(hits):
has_c2pa = True
label = frame_id.decode("latin-1", errors="replace")
findings.append(f"ID3v2 frame {label}: {', '.join(hits[:8])}")
return has_c2pa, has_ai, findings
def _strip_id3v2(data: bytes, *, strip_all_metadata: bool) -> tuple[bytes, list[str]]:
parsed = _parse_id3v2_frames(data)
if parsed is None:
return data, []
total, major, frames = parsed
rest = data[total:]
if not frames:
# v2.2 (undecomposed) or an empty v2.3/2.4 tag: only a whole-tag drop
# is safe here, since frame boundaries were never decoded.
if not strip_all_metadata and not _contains_any(data[:total], AI_META_HINTS):
return data, ["no ID3v2 tag removed (no AI/C2PA markers found)"]
return rest, [f"drop ID3v2.{major} tag ({total} bytes)"]
if strip_all_metadata:
return rest, [f"drop ID3v2.{major} tag ({total} bytes)"]
kept = bytearray()
actions: list[str] = []
for frame_id, payload in frames:
hits = _contains_any(payload, AI_META_HINTS)
if hits:
label = frame_id.decode("latin-1", errors="replace")
actions.append(f"drop ID3v2 frame {label}: {', '.join(hits[:8])}")
continue
size_bytes = (
_id3v2_size_bytes(len(payload)) if major == 4 else struct.pack(">I", len(payload))
)
kept.extend(frame_id + size_bytes + b"\x00\x00" + payload)
if not actions:
return data, ["no ID3v2 frames removed (already clean or none matched)"]
header = bytes([ord("I"), ord("D"), ord("3"), major, 0, 0]) + _id3v2_size_bytes(len(kept))
return header + bytes(kept) + rest, actions
# ---------------------------------------------------------------------------
# WAV (RIFF)
# ---------------------------------------------------------------------------
def _inspect_wav(data: bytes) -> tuple[bool, bool, list[str]]:
findings: list[str] = []
has_ai = False
has_c2pa = False
pos = 12 # past "RIFF" + size(4) + "WAVE"
while pos + 8 <= len(data):
cid = data[pos : pos + 4]
csize = struct.unpack("<I", data[pos + 4 : pos + 8])[0]
cstart = pos + 8
cend = cstart + csize
if cend > len(data):
break
payload = data[cstart:cend]
if cid == b"LIST" and payload[:4] == b"INFO":
hits = _contains_any(payload, AI_META_HINTS)
if hits:
has_ai = True
if _classify_c2pa(hits):
has_c2pa = True
findings.append(f"WAV LIST INFO chunk: {', '.join(hits[:8])}")
elif cid in (b"id3 ", b"ID3 "):
c2pa, ai, sub_findings = _inspect_id3v2(payload)
if ai:
has_ai = True
has_c2pa = has_c2pa or c2pa
findings.extend(f"WAV id3 chunk / {f}" for f in sub_findings)
pos = cend + (csize & 1) # chunks are word-aligned
return has_c2pa, has_ai, findings
def _strip_wav(data: bytes, *, strip_all_metadata: bool) -> tuple[bytes, list[str]]:
actions: list[str] = []
out = bytearray(data[:12])
pos = 12
while pos + 8 <= len(data):
cid = data[pos : pos + 4]
csize = struct.unpack("<I", data[pos + 4 : pos + 8])[0]
cstart = pos + 8
cend = cstart + csize
if cend > len(data):
out.extend(data[pos:])
pos = len(data)
break
payload = data[cstart:cend]
pad = csize & 1
chunk_total = data[pos : cend + pad]
drop = False
is_info = cid == b"LIST" and payload[:4] == b"INFO"
is_id3 = cid in (b"id3 ", b"ID3 ")
if (is_info or is_id3) and (strip_all_metadata or _contains_any(payload, AI_META_HINTS)):
actions.append(f"drop WAV {'LIST INFO' if is_info else 'id3'} chunk")
drop = True
if not drop:
out.extend(chunk_total)
pos = cend + pad
struct.pack_into("<I", out, 4, len(out) - 8)
if not actions:
actions.append("no WAV metadata chunks removed (already clean or none matched)")
return bytes(out), actions
# ---------------------------------------------------------------------------
# Unified inspect / clean
# ---------------------------------------------------------------------------
def inspect_av(path: Path) -> AVInspectReport:
data = path.read_bytes()
fmt = detect_av_format(data)
if fmt == "mp4":
has_c2pa, has_ai, findings = _inspect_mp4(data)
elif fmt == "wav":
has_c2pa, has_ai, findings = _inspect_wav(data)
elif fmt == "mp3":
has_c2pa, has_ai, findings = _inspect_id3v2(data)
else:
has_c2pa, has_ai, findings = False, False, ["unsupported format (MP4/MOV/M4A/WAV/MP3)"]
notes: list[str] = []
if fmt == "unknown":
notes.append("format not fully inspected; only MP4/MOV/M4A/WAV/MP3 are supported")
return AVInspectReport(
path=str(path),
format=fmt,
has_c2pa=has_c2pa,
has_ai_metadata=has_ai,
findings=findings,
notes=notes,
)
def clean_av(path: Path, dest: Path, *, strip_all_metadata: bool = True) -> dict[str, Any]:
data = path.read_bytes()
fmt = detect_av_format(data)
if fmt == "mp4":
cleaned, actions = _strip_mp4(data, strip_all_metadata=strip_all_metadata)
elif fmt == "wav":
cleaned, actions = _strip_wav(data, strip_all_metadata=strip_all_metadata)
elif fmt == "mp3":
cleaned, actions = _strip_id3v2(data, strip_all_metadata=strip_all_metadata)
else:
raise ValueError(f"unsupported audio/video format for cleaning: {fmt}")
safe_write_bytes(dest, cleaned)
after = inspect_av(dest)
return {
"input": str(path),
"output": str(dest),
"format": fmt,
"actions": actions,
"bytes_in": len(data),
"bytes_out": len(cleaned),
"still_has_c2pa": after.has_c2pa,
"still_has_ai_metadata": after.has_ai_metadata,
"post_findings": after.findings,
}