Files
LisherSong--ps5-web-file-ma…/tests/make_fixtures.py
T
Songlx516 96286cb9f2 test(rar): 14 negative-path checks + rar fixture builder with rar/7z/placeholder fallback
tests/test_rar_extract.c — 14 checks, zero dependency on host having
rar or 7z installed:

  test_engine_dispatch (4):
    - not-a-RAR rejected: notar.rar (renamed basic.zip) -> FORMAT
    - junk blob rejected: junk.rar (1024 random bytes) -> FORMAT
    - missing source -> OPEN
    - null rar_path / dst -> INTERNAL
  test_format_translation (6):
    - rar_translate_error mapping table covers every dmc_unrar_return
      code rar_extract can emit, verified against fixture files
  test_limits_handoff (4):
    - default vs large limits profile passed through unchanged
    - max_entries / max_total_bytes / max_file_bytes / max_ratio

Uses tests/posix_compat.h (open/close/mkdirat renames) via gcc -include
for host builds -- never compiled into the PS5 payload.

tests/make_fixtures.py — adds rar_fixtures() that prefers host 'rar'
(non-free) and falls back to host '7z' (LGPL). If neither is present,
emits an 11-byte 'placeholder' basic.rar so the negative tests still
fire (basic.rar is positive-path, but is only read when a RAR writer
exists). Wired into the fn list in main().

tests/run-tests.sh:
  - Builds third_party/unrar/dmc_unrar.o with -DDMC_UNRAR_DISABLE_BE32TOH_BE64TOH=1
  - Builds rar_extract.o with -Ithird_party/unrar and posix_compat shim
  - Builds test_rar_extract.o against both rar_extract.o and zip_extract.o
    (the latter is required: rar_extract.c references zipx_status_string,
    zipx_default_limits, zipx_limits_profile for error/log/output)
  - Links test-rar-extract, then runs both test-zip-extract and
    test-rar-extract in sequence.

Fixtures committed:
  - basic.rar    -- 11-byte placeholder (host has no RAR writer)
  - notar.rar    -- basic.zip renamed, for format-rejection test
  - junk.rar     -- 1024 random bytes, for format-rejection test
  - All other .rar fixtures (encrypted, multi-volume, symlink, ...) are
    generated on demand when run on a host with rar/7z installed.

Final test count on a writer-less host: 69 ZIP checks + 14 RAR checks
= 83 total, 0 failures (re-verified post-commit).
2026-09-05 15:17:17 +08:00

299 lines
10 KiB
Python

#!/usr/bin/env python3
"""Generate the ZIP fixtures used by test_zip_extract."""
import os
import shutil
import stat
import struct
import sys
import time as _time
import zipfile
import zlib
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "fixtures")
# Force every ZIP entry's date_time to a fixed value (1980-01-01 00:00:00)
# so generated archives are byte-stable across runs. Without this fix,
# Python 3.13's zipfile.writestr() passes time.localtime(time.time())[:6]
# into ZipInfo(...) when the caller supplies a string arcname, which makes
# every fixture differ each run and the git diff stat balloons on every
# "regenerate fixtures" pass. We swap the zipfile module's `time` symbol
# for a fake that always returns the same struct_time; the fake also stubs
# `time.time` since zipfile.writestr chains localtime(time.time()).
_FIXED_DT = (1980, 1, 1, 0, 0, 0)
zipfile.time = type("_FakeTimeMod", (), {
"localtime": staticmethod(lambda *_a, **_k: _time.struct_time(_FIXED_DT + (0, 1, 0))),
"time": staticmethod(lambda *_a, **_k: 0.0),
})()
def fresh():
if os.path.isdir(OUT):
shutil.rmtree(OUT)
os.makedirs(OUT)
def path(name):
return os.path.join(OUT, name)
def basic():
with zipfile.ZipFile(path("basic.zip"), "w") as zf:
zf.writestr("root.txt", "root content")
zf.writestr("dir/nested.txt", "nested content")
zf.writestr("dir/deep/deeper.txt", "deeper content")
zi = zipfile.ZipInfo("empty_dir/")
zi.external_attr = (stat.S_IFDIR | 0o755) << 16
zi.create_system = 3
zf.writestr(zi, b"")
def stored():
with zipfile.ZipFile(path("stored.zip"), "w", zipfile.ZIP_STORED) as zf:
zf.writestr("stored.txt", "stored content" * 100)
def unicode_names():
with zipfile.ZipFile(path("unicode.zip"), "w") as zf:
zf.writestr("中文目录/文件.txt", "unicode content")
zf.writestr("emoji-\U0001f600.txt", "emoji content")
def zip64():
"""A genuine ZIP64 archive. Python's zipfile only emits zip64 fields when
sizes exceed 4 GiB (impractical for a fixture), so the layout is written by
hand: 32-bit size fields are set to 0xFFFFFFFF and the real values live in
the zip64 extra fields and the zip64 end-of-central-directory record."""
name = "big.bin"
data = bytes(range(256)) * 16 # 4096 bytes, deterministic
name_b = name.encode("utf-8")
crc = zlib.crc32(data) & 0xFFFFFFFF
size = len(data)
def extra_local():
return struct.pack("<HHQQ", 0x0001, 16, size, size)
def extra_central(offset):
return struct.pack("<HHQQQ", 0x0001, 24, size, size, offset)
out = bytearray()
# Local file header (stored, sizes deferred to zip64 extra field).
local_offset = 0
el = extra_local()
out += struct.pack("<IHHHHHIIIHH", 0x04034B50, 45, 0, 0, 0, 0, crc,
0xFFFFFFFF, 0xFFFFFFFF, len(name_b), len(el))
out += name_b + el + data
# Central directory header.
cd_offset = len(out)
ec = extra_central(local_offset)
out += struct.pack("<IHHHHHHIIIHHHHHII", 0x02014B50, 45, 45, 0, 0, 0, 0,
crc, 0xFFFFFFFF, 0xFFFFFFFF, len(name_b), len(ec), 0,
0, 0, 0, 0xFFFFFFFF)
out += name_b + ec
cd_size = len(out) - cd_offset
# ZIP64 end of central directory record.
zip64_eocd_offset = len(out)
out += struct.pack("<IQHHIIQQQQ", 0x06064B50, 44, 45, 45, 0, 0, 1, 1,
cd_size, cd_offset)
# ZIP64 end of central directory locator.
out += struct.pack("<IIQI", 0x07064B50, 0, zip64_eocd_offset, 1)
# End of central directory record (offsets deferred to zip64).
out += struct.pack("<IHHHHIIH", 0x06054B50, 0, 0, 1, 1, 0xFFFFFFFF,
0xFFFFFFFF, 0)
with open(path("zip64.zip"), "wb") as fh:
fh.write(bytes(out))
def traversal():
with zipfile.ZipFile(path("traversal.zip"), "w") as zf:
zf.writestr("ok.txt", "ok")
zf.writestr("../evil.txt", "evil")
def traversal_backslash():
with zipfile.ZipFile(path("traversal_bs.zip"), "w") as zf:
zf.writestr("ok.txt", "ok")
zf.writestr("..\\evil.txt", "evil")
def absolute():
with zipfile.ZipFile(path("absolute.zip"), "w") as zf:
zf.writestr("/tmp/evil.txt", "evil")
def drive_letter():
with zipfile.ZipFile(path("drive.zip"), "w") as zf:
zf.writestr("C:/evil.txt", "evil")
def duplicate():
with zipfile.ZipFile(path("duplicate.zip"), "w") as zf:
zf.writestr("a.txt", "first")
zf.writestr("a.txt", "second")
def file_dir_clash():
with zipfile.ZipFile(path("clash.zip"), "w") as zf:
zf.writestr("a", "file")
zf.writestr("a/b.txt", "child")
def symlink_entry():
with zipfile.ZipFile(path("symlink.zip"), "w") as zf:
zf.writestr("ok.txt", "ok")
zi = zipfile.ZipInfo("link")
zi.create_system = 3
zi.external_attr = (stat.S_IFLNK | 0o777) << 16
zf.writestr(zi, "/etc/passwd")
def fifo_entry():
with zipfile.ZipFile(path("fifo.zip"), "w") as zf:
zi = zipfile.ZipInfo("pipe")
zi.create_system = 3
zi.external_attr = (stat.S_IFIFO | 0o644) << 16
zf.writestr(zi, b"")
def encrypted():
with zipfile.ZipFile(path("encrypted.zip"), "w") as zf:
zf.writestr("secret.txt", "secret")
data = bytearray(open(path("encrypted.zip"), "rb").read())
idx = data.find(b"PK\x03\x04")
if idx < 0:
raise SystemExit("local header not found")
data[idx + 6] |= 0x01 # general purpose bit 0 = encrypted
cd = data.find(b"PK\x01\x02")
data[cd + 8] |= 0x01
open(path("encrypted.zip"), "wb").write(bytes(data))
def bad_crc():
with zipfile.ZipFile(path("bad_crc.zip"), "w") as zf:
zf.writestr("data.bin", "x" * 4096)
data = bytearray(open(path("bad_crc.zip"), "rb").read())
cd = data.find(b"PK\x01\x02")
if cd < 0:
raise SystemExit("central directory not found")
data[cd + 16:cd + 20] = b"\xde\xad\xbe\xef"
open(path("bad_crc.zip"), "wb").write(bytes(data))
def truncated():
with zipfile.ZipFile(path("full.zip"), "w") as zf:
zf.writestr("data.txt", "y" * 4096)
data = open(path("full.zip"), "rb").read()
open(path("truncated.zip"), "wb").write(data[:-40])
def not_a_zip():
open(path("notazip.zip"), "wb").write(b"this is definitely not a zip file")
def bomb():
with zipfile.ZipFile(path("bomb.zip"), "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("bomb.bin", "A" * (4 * 1024 * 1024))
def medium_bomb():
"""1 MiB of 0..255 cycled, which deflate squeezes to ~4.4 KiB
(ratio ~238). Sits between the default cap (200) and the large cap
(1000) so the default profile rejects it and the large profile
accepts it. Used by the large-profile host test."""
with zipfile.ZipFile(path("medium_bomb.zip"), "w",
zipfile.ZIP_DEFLATED) as zf:
zf.writestr("medium.bin", bytes(range(256)) * 4096)
def many_files():
with zipfile.ZipFile(path("many.zip"), "w", zipfile.ZIP_DEFLATED) as zf:
for i in range(500):
zf.writestr("many/f%03d.txt" % i, "%d" % i)
def conflict_source():
"""A zip whose top level collides with an existing destination layout."""
with zipfile.ZipFile(path("conflict.zip"), "w") as zf:
zf.writestr("shared.txt", "from zip")
zf.writestr("shareddir/inner.txt", "inner from zip")
zf.writestr("shareddir/added.txt", "added from zip")
def rar_fixtures():
"""Generate RAR fixtures if a rar/7z writer is available.
We intentionally do not depend on a rar binary being installed in the
host test environment; when neither `rar` nor `7z` is present we leave
1-byte placeholders so that tests/test_rar_extract.c can still hit its
"not a real archive" branches. See docs/HANDOVER.md for the manual
fixture procedure used in CI on a developer workstation that has WinRAR.
"""
import shutil
import subprocess
import tempfile
candidates = []
for cmd in ("rar", "7z", "7za"):
if shutil.which(cmd):
candidates.append(cmd)
staging_dir = tempfile.mkdtemp(prefix="wfm-rar-fixtures-")
try:
# Build a small directory we can compress into a RAR.
staging_root = os.path.join(staging_dir, "stage")
os.makedirs(staging_root)
with open(os.path.join(staging_root, "root.txt"), "wb") as f:
f.write(b"rar root content\n")
nested = os.path.join(staging_root, "dir")
os.makedirs(nested)
with open(os.path.join(nested, "nested.txt"), "wb") as f:
f.write(b"rar nested content\n")
out = path("basic.rar")
ok = False
for cmd in candidates:
args = [cmd, "a", "-r", "-ep1", out,
os.path.join(staging_root, "root.txt"),
os.path.join(staging_root, "dir")]
# 7z uses -t7z / -rr differently; for the purposes of a smoke
# fixture we only need any small valid RAR.
try:
rc = subprocess.call(args, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
if rc == 0 and os.path.exists(out) and os.path.getsize(out) > 16:
print("rar_fixtures: built %s via %s" % (out, cmd))
ok = True
break
except Exception:
pass
if not ok:
# Placeholder: the test suite only needs a file that dmc_unrar
# will reject. 8 bytes is far too small to be a valid archive.
with open(out, "wb") as f:
f.write(b"placeholder")
finally:
shutil.rmtree(staging_dir, ignore_errors=True)
def main():
fresh()
for fn in (basic, stored, unicode_names, zip64, traversal,
traversal_backslash, absolute, drive_letter, duplicate,
file_dir_clash, symlink_entry, fifo_entry, encrypted, bad_crc,
truncated, not_a_zip, bomb, medium_bomb, many_files,
conflict_source, rar_fixtures):
fn()
print("fixtures written to %s" % OUT)
return 0
if __name__ == "__main__":
sys.exit(main())