#!/usr/bin/env python3
"""Generate, compare, and verify deterministic Pre15 G3 fixtures."""

from __future__ import annotations

import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import stat
import struct
import subprocess
import sys


TESTS_DIR = Path(__file__).resolve().parents[2]
if str(TESTS_DIR) not in sys.path:
    sys.path.insert(0, str(TESTS_DIR))

from erofs_fixture import ErofsImage, MAGIC, SUPER


FLAT_PLAIN = 0
HUGE_DIRECTORY_BLOCKS = (1 << 31) + 1
S_IFMT = 0o170000
FEATURE_COMPAT_SB_CHKSUM = 0x00000001
FEATURE_COMPAT_PLAIN_XATTR_PFX = 0x00000010
FEATURE_INCOMPAT_XATTR_PREFIXES = 0x00000040
FEATURE_INCOMPAT_METABOX = 0x00000100
EROFS_NAME_LEN = 255


class FixtureError(RuntimeError):
    pass


def sha256(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


def write_json(path: Path, value: object) -> None:
    with path.open("x", encoding="ascii") as output:
        json.dump(value, output, ensure_ascii=True, indent=2, sort_keys=True)
        output.write("\n")


def require_tools(*names: str) -> None:
    missing = [name for name in names if shutil.which(name) is None]
    if missing:
        raise FixtureError(f"missing tools: {', '.join(missing)}")


def run(argv: list[str], *, capture: bool = False) -> str:
    result = subprocess.run(
        argv,
        check=True,
        text=True,
        stdout=subprocess.PIPE if capture else None,
        stderr=subprocess.STDOUT if capture else None,
    )
    return result.stdout if capture else ""


def set_epoch(path: Path) -> None:
    for entry in sorted(path.rglob("*"), reverse=True):
        os.utime(entry, (0, 0), follow_symlinks=False)
    os.utime(path, (0, 0), follow_symlinks=False)


def deterministic_bytes(length: int) -> bytes:
    return bytes(
        ((index * 131 + 17) ^ (index >> 3)) & 0xFF
        for index in range(length)
    )


def put_u16(image: bytearray, offset: int, value: int) -> None:
    struct.pack_into("<H", image, offset, value)


def put_u32(image: bytearray, offset: int, value: int) -> None:
    struct.pack_into("<I", image, offset, value)


def put_u64(image: bytearray, offset: int, value: int) -> None:
    struct.pack_into("<Q", image, offset, value)


def u16(image: bytes | bytearray, offset: int) -> int:
    return struct.unpack_from("<H", image, offset)[0]


def u32(image: bytes | bytearray, offset: int) -> int:
    return struct.unpack_from("<I", image, offset)[0]


def u64(image: bytes | bytearray, offset: int) -> int:
    return struct.unpack_from("<Q", image, offset)[0]


def update_superblock_checksum(image: bytearray) -> None:
    erofs = ErofsImage(image, Path("generated"))
    erofs.update_checksum()


def write_image(path: Path, image: bytearray) -> None:
    update_superblock_checksum(image)
    path.write_bytes(image)


def build_image(
    artifact_dir: Path,
    fixture_dir: Path,
    uuid: str,
    image: str,
    source: str,
    *options: str,
) -> list[str]:
    argv = [
        "mkfs.erofs",
        "-d0",
        "-x-1",
        "-T0",
        "--all-time",
        "--all-root",
        "--workers=1",
        "-U",
        uuid,
        *options,
        str(artifact_dir / image),
        str(fixture_dir / source),
    ]
    run(argv)
    return argv


def inode_offset(image: bytes | bytearray, nid: int) -> int:
    block_bits = image[SUPER + 12]
    meta_blkaddr = u32(image, SUPER + 40)
    return (meta_blkaddr << block_bits) + (nid << 5)


def compact_inode(image: bytes | bytearray, nid: int) -> tuple[int, int, int]:
    offset = inode_offset(image, nid)
    inode_format = u16(image, offset)
    if inode_format & 1:
        raise FixtureError("expected compact inode")
    return offset, (inode_format >> 1) & 7, u32(image, offset + 8)


def inline_dir_entries(
    image: bytes | bytearray, nid: int
) -> tuple[int, int, int, list[tuple[int, int, int, bytes]]]:
    inode = inode_offset(image, nid)
    inode_format = u16(image, inode)
    inode_size = 64 if inode_format & 1 else 32
    layout = (inode_format >> 1) & 7
    size = u64(image, inode + 8) if inode_size == 64 else u32(image, inode + 8)
    if layout != 2 or u16(image, inode + 2) != 0:
        raise FixtureError("expected inline directory without xattrs")
    data = inode + inode_size
    first_nameoff = u16(image, data + 8)
    if first_nameoff < 12 or first_nameoff % 12:
        raise FixtureError("invalid first directory name offset")
    count = first_nameoff // 12
    entries = []
    for index in range(count):
        entry = data + index * 12
        child_nid, nameoff = struct.unpack_from("<QH", image, entry)
        endoff = u16(image, data + (index + 1) * 12 + 8) if index + 1 < count else size
        name = bytes(image[data + nameoff : data + endoff]).split(b"\0", 1)[0]
        if not name:
            raise FixtureError("empty directory name")
        entries.append((child_nid, nameoff, endoff, name))
    return inode, data, size, entries


def inode_info(
    image: bytes | bytearray, nid: int
) -> tuple[int, int, int, int, int, int]:
    offset = inode_offset(image, nid)
    inode_format = u16(image, offset)
    inode_size = 64 if inode_format & 1 else 32
    size = u64(image, offset + 8) if inode_size == 64 else u32(image, offset + 8)
    xattr_count = u16(image, offset + 2)
    xattr_size = 0 if xattr_count == 0 else 12 + 4 * (xattr_count - 1)
    layout = (inode_format >> 1) & 7
    return offset, inode_format, inode_size, xattr_size, layout, size


def directory_entries(image: bytes | bytearray, nid: int) -> list[tuple[bytes, int]]:
    offset, _, inode_size, xattr_size, layout, size = inode_info(image, nid)
    block_size = 1 << image[SUPER + 12]
    if not 0 < size <= block_size:
        raise FixtureError("directory must fit one block")
    if layout == 2:
        data = offset + inode_size + xattr_size
    elif layout == 0:
        data = u32(image, offset + 16) << image[SUPER + 12]
    else:
        raise FixtureError(f"unsupported directory layout {layout}")
    first_nameoff = u16(image, data + 8)
    if first_nameoff < 12 or first_nameoff % 12:
        raise FixtureError("invalid first directory name offset")
    count = first_nameoff // 12
    entries = []
    for index in range(count):
        entry = data + index * 12
        child = u64(image, entry)
        nameoff = u16(image, entry + 8)
        endoff = u16(image, entry + 20) if index + 1 < count else size
        name = bytes(image[data + nameoff : data + endoff]).split(b"\0", 1)[0]
        if not name:
            raise FixtureError("empty directory name")
        entries.append((name, child))
    return entries


def child_nid(image: bytes | bytearray, parent_nid: int, name: bytes) -> int:
    return next(nid for entry_name, nid in directory_entries(image, parent_nid) if entry_name == name)


def convert_inline_directory_to_plain(image: bytearray, nid: int) -> tuple[int, int]:
    offset, inode_format, inode_size, xattr_size, layout, size = inode_info(image, nid)
    if layout != 2 or inode_size != 64 or size <= 0:
        raise FixtureError("expected nonempty extended inline directory")
    block_size = 1 << image[SUPER + 12]
    tail_size = size % block_size
    if tail_size == 0:
        raise FixtureError("expected inline directory tail")
    raw_block = u32(image, offset + 16)
    full_size = size - tail_size
    inline_offset = offset + inode_size + xattr_size
    directory_data = bytes(
        image[raw_block * block_size : raw_block * block_size + full_size]
        + image[inline_offset : inline_offset + tail_size]
    )
    if len(directory_data) != size:
        raise FixtureError("directory data reconstruction failed")
    new_raw_block = (len(image) + block_size - 1) // block_size
    image.extend(b"\0" * (new_raw_block * block_size - len(image)))
    image.extend(directory_data)
    image.extend(b"\0" * (-len(image) % block_size))
    put_u16(image, offset, inode_format & ~(7 << 1))
    put_u32(image, offset + 16, new_raw_block)
    put_u32(image, SUPER + 36, len(image) // block_size)
    if inode_info(image, nid)[4] != FLAT_PLAIN:
        raise FixtureError("directory layout conversion failed")
    return new_raw_block, len(image) // block_size


def write_checksums(directory: Path) -> None:
    with (directory / "SHA256SUMS").open("x", encoding="ascii") as sums:
        for path in sorted(directory.glob("*.erofs")):
            sums.write(f"{sha256(path)}  {path.name}\n")


def make_metadata(fixture_dir: Path, artifact_dir: Path) -> list[list[str]]:
    require_tools("mkfs.erofs", "fsck.erofs", "dump.erofs")
    if fixture_dir.exists() or artifact_dir.exists():
        raise FixtureError("metadata source and artifact paths must be absent")
    for relative in (
        "inline",
        "special",
        "namei/alpha/bravo/charlie",
        "namei/alpha/sibling",
        "namei/wide",
        "pager",
        "nfs/basic/subdir",
        "nlink/subdir",
    ):
        (fixture_dir / relative).mkdir(parents=True, exist_ok=True)
    artifact_dir.mkdir(parents=True)
    (fixture_dir / "inline/inline.txt").write_text(
        "inline-tail-payload-0123456789\n", encoding="ascii"
    )
    (fixture_dir / "special/target").write_text("special target\n", encoding="ascii")
    os.symlink("target", fixture_dir / "special/link")
    os.mknod(
        fixture_dir / "special/char-large",
        stat.S_IFCHR | 0o666,
        os.makedev(2748, 344865),
    )
    os.mknod(
        fixture_dir / "special/block-large",
        stat.S_IFBLK | 0o666,
        os.makedev(2748, 344865),
    )
    os.mkfifo(fixture_dir / "special/fifo")
    (fixture_dir / "namei/alpha/bravo/charlie/payload.txt").write_text(
        "cold nested lookup payload\n", encoding="ascii"
    )
    (fixture_dir / "namei/alpha/bravo/repeat.txt").write_text(
        "repeat lookup payload\n", encoding="ascii"
    )
    (fixture_dir / "namei/alpha/sibling/marker.txt").write_text(
        "sibling marker\n", encoding="ascii"
    )
    for index in range(320):
        name = f"entry-{index:03d}-abcdefghijklmnopqrstuvwxyz.txt"
        (fixture_dir / "namei/wide" / name).write_text(
            f"wide entry {index:03d}\n", encoding="ascii"
        )
    (fixture_dir / "pager/pager.bin").write_bytes(deterministic_bytes(5 * 4096 + 731))
    (fixture_dir / "nfs/basic/regular.txt").write_text(
        "stable file handle payload\n", encoding="ascii"
    )
    (fixture_dir / "nfs/basic/subdir/child.txt").write_text("child\n", encoding="ascii")
    os.symlink("regular.txt", fixture_dir / "nfs/basic/link-to-regular")
    os.mkfifo(fixture_dir / "nfs/basic/test.fifo")
    (fixture_dir / "nlink/single.txt").write_text("single\n", encoding="ascii")
    (fixture_dir / "nlink/hard-a.txt").write_text("hardlinked\n", encoding="ascii")
    os.link(fixture_dir / "nlink/hard-a.txt", fixture_dir / "nlink/hard-b.txt")
    (fixture_dir / "nlink/subdir/child.txt").write_text("child\n", encoding="ascii")
    set_epoch(fixture_dir)

    commands = [
        build_image(artifact_dir, fixture_dir, "11111111-2222-3333-4444-555555555551", "inline.erofs", "inline"),
        build_image(artifact_dir, fixture_dir, "11111111-2222-3333-4444-555555555552", "special-compact.erofs", "special", "-E", "force-inode-compact"),
        build_image(artifact_dir, fixture_dir, "11111111-2222-3333-4444-555555555553", "special-extended.erofs", "special", "-E", "force-inode-extended"),
        build_image(artifact_dir, fixture_dir, "11111111-2222-3333-4444-555555555554", "namei-base.erofs", "namei"),
        build_image(artifact_dir, fixture_dir, "11111111-2222-3333-4444-555555555555", "pager-plain.erofs", "pager", "-E", "noinline_data"),
        build_image(artifact_dir, fixture_dir, "11111111-2222-3333-4444-555555555556", "pager-lz4.erofs", "pager", "-z", "lz4"),
        build_image(artifact_dir, fixture_dir, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeee1", "nfs-a.erofs", "nfs"),
        build_image(artifact_dir, fixture_dir, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeee2", "nfs-b.erofs", "nfs"),
        build_image(artifact_dir, fixture_dir, "11111111-2222-3333-4444-555555555557", "nlink.erofs", "nlink", "-E", "force-inode-compact"),
    ]

    inline = bytearray((artifact_dir / "inline.erofs").read_bytes())
    _, _, _, inline_root = inline_dir_entries(inline, u16(inline, SUPER + 14))
    inline_nid = next(nid for nid, _, _, name in inline_root if name == b"inline.txt")
    inline_inode, layout, inline_size = compact_inode(inline, inline_nid)
    if layout != 2 or u16(inline, inline_inode + 2) != 0:
        raise FixtureError("inline fixture has unexpected layout")
    inline_cross_xattr_icount = 695
    inline_xattr_size = 12 + 4 * (inline_cross_xattr_icount - 1)
    inline_data_off = inline_inode + 32 + inline_xattr_size
    block_size = 1 << inline[SUPER + 12]
    if inline_data_off % block_size + inline_size <= block_size:
        raise FixtureError("inline fixture does not cross the block boundary")
    inline_cross = bytearray(inline)
    put_u16(inline_cross, inline_inode + 2, inline_cross_xattr_icount)
    write_image(artifact_dir / "inline-cross-block.erofs", inline_cross)

    namei = bytearray((artifact_dir / "namei-base.erofs").read_bytes())
    root_nid = u16(namei, SUPER + 14)
    root_inode, root_data, _, root_entries = inline_dir_entries(namei, root_nid)
    wide_nid = next(nid for nid, _, _, name in root_entries if name == b"wide")
    wide_inode, wide_layout, _ = compact_inode(namei, wide_nid)
    if wide_layout != 2:
        raise FixtureError("wide directory is not inline")
    block_bits = namei[SUPER + 12]
    wide_block = u32(namei, wide_inode + 16) << block_bits
    wide_first_nameoff = u16(namei, wide_block + 8)
    wide_count = wide_first_nameoff // 12
    wide_last_nameoff = u16(namei, wide_block + (wide_count - 1) * 12 + 8)
    padding_nul = namei.index(0, wide_block + wide_last_nameoff, wide_block + (1 << block_bits))
    nonzero_padding = bytearray(namei)
    nonzero_padding[padding_nul + 1 : padding_nul + 9] = b"PAD!ERO!"
    write_image(artifact_dir / "namei-padding-nonzero.erofs", nonzero_padding)
    short_block = bytearray(namei)
    put_u32(short_block, root_inode + 8, 8)
    write_image(artifact_dir / "namei-corrupt-short.erofs", short_block)
    bad_nameoff = bytearray(namei)
    first_nameoff = u16(bad_nameoff, root_data + 8)
    put_u16(bad_nameoff, root_data + 20, first_nameoff)
    write_image(artifact_dir / "namei-corrupt-nameoff.erofs", bad_nameoff)
    bad_name = bytearray(namei)
    slash_offset = wide_block + wide_last_nameoff + 5
    if bad_name[slash_offset] in (0, ord("/")):
        raise FixtureError("name corruption target is unsuitable")
    bad_name[slash_offset] = ord("/")
    write_image(artifact_dir / "namei-corrupt-name.erofs", bad_name)

    nlink = bytearray((artifact_dir / "nlink.erofs").read_bytes())
    nlink_root_nid = u16(nlink, SUPER + 14)
    _, _, _, nlink_entries = inline_dir_entries(nlink, nlink_root_nid)
    single_nid = next(nid for nid, _, _, name in nlink_entries if name == b"single.txt")
    single_inode, _, _ = compact_inode(nlink, single_nid)
    nlink1 = bytearray(nlink)
    put_u16(nlink1, single_inode, u16(nlink1, single_inode) | 0x10)
    put_u16(nlink1, single_inode + 6, 0x1234)
    write_image(artifact_dir / "nlink1-patched.erofs", nlink1)

    with (artifact_dir / "fixture-evidence.txt").open("x", encoding="ascii") as output:
        output.write(
            f"inline_nid={inline_nid} inode_off={inline_inode} "
            f"inode_blockoff={inline_inode % block_size} "
            f"xattr_icount=0->{inline_cross_xattr_icount} "
            f"inline_data_blockoff={inline_data_off % block_size} "
            f"inline_size={inline_size}\n"
        )
        output.write(
            f"wide_nid={wide_nid} block={wide_block >> block_bits} "
            f"dirents={wide_count} last_nameoff={wide_last_nameoff} "
            f"padding_patch={padding_nul + 1 - wide_block}:"
            f"{padding_nul + 9 - wide_block}\n"
        )
        output.write(f"nlink1_nid={single_nid} i_format_bit4=1 i_nb=0x1234\n")
        for image_name in ("special-compact.erofs", "special-extended.erofs"):
            image = bytearray((artifact_dir / image_name).read_bytes())
            special_root = u16(image, SUPER + 14)
            _, _, _, entries = inline_dir_entries(image, special_root)
            output.write(image_name + "\n")
            for nid, _, _, name in entries:
                if name not in (b"char-large", b"block-large", b"fifo"):
                    continue
                offset = inode_offset(image, nid)
                output.write(
                    f" {name.decode()} nid={nid} inode_size="
                    f"{64 if u16(image, offset) & 1 else 32} "
                    f"raw_u={u32(image, offset + 16):#010x}\n"
                )
    write_checksums(artifact_dir)
    run(["fsck.erofs", "-d1", str(artifact_dir / "namei-padding-nonzero.erofs")])
    run(
        [
            "dump.erofs",
            "--cat",
            "--path=/wide/entry-079-abcdefghijklmnopqrstuvwxyz.txt",
            str(artifact_dir / "namei-padding-nonzero.erofs"),
        ]
    )
    return commands


def make_final(fixture_dir: Path, artifact_dir: Path) -> list[list[str]]:
    require_tools("mkfs.erofs")
    if fixture_dir.exists() or artifact_dir.exists():
        raise FixtureError("final source and artifact paths must be absent")
    for relative in ("fallback", "oversize", "extent", "large/huge"):
        (fixture_dir / relative).mkdir(parents=True, exist_ok=True)
    artifact_dir.mkdir(parents=True)
    (fixture_dir / "fallback/root.txt").write_text("48-bit fallback root\n", encoding="ascii")
    (fixture_dir / "oversize/big.dat").write_bytes(b"x")
    (fixture_dir / "extent/hole.dat").write_bytes(b"\0" * (1024 * 1024))
    (fixture_dir / "large/huge/anchor.txt").write_text(
        "large directory anchor\n", encoding="ascii"
    )
    for index in range(400):
        (fixture_dir / "large/huge" / f"entry-{index:03d}-abcdefghijklmnopqrstuvwxyz.txt").write_text(
            f"entry {index:03d}\n", encoding="ascii"
        )
    set_epoch(fixture_dir)
    commands = [
        build_image(artifact_dir, fixture_dir, "22222222-3333-4444-5555-666666666650", "fallback-base.erofs", "fallback", "-E", "force-inode-compact"),
        build_image(artifact_dir, fixture_dir, "22222222-3333-4444-5555-666666666651", "oversize-base.erofs", "oversize", "-E", "force-inode-extended"),
        build_image(artifact_dir, fixture_dir, "22222222-3333-4444-5555-666666666652", "extent-base.erofs", "extent", "-E", "legacy-compress,force-inode-extended", "-z", "lz4"),
        build_image(artifact_dir, fixture_dir, "22222222-3333-4444-5555-666666666653", "large-dir-base.erofs", "large", "-E", "force-inode-extended"),
    ]
    feature_incompat = SUPER + 80
    rootnid_2b = SUPER + 14
    blocks_lo = SUPER + 36
    rootnid_8b = SUPER + 112
    evidence = []
    fallback = bytearray((artifact_dir / "fallback-base.erofs").read_bytes())
    fallback_root = u16(fallback, rootnid_2b)
    fallback_blocks = u32(fallback, blocks_lo)
    if fallback_root == 0 or u64(fallback, rootnid_8b) != 0:
        raise FixtureError("unexpected fallback base root")
    put_u32(fallback, feature_incompat, u32(fallback, feature_incompat) | 0x80)
    put_u64(fallback, rootnid_8b, 0)
    write_image(artifact_dir / "fallback-48bit-root2.erofs", fallback)
    evidence.append(
        f"fallback rootnid_2b={fallback_root} rootnid_8b=0 "
        f"blocks_lo={fallback_blocks} union=0x{u16(fallback, rootnid_2b):04x}"
    )
    oversize = bytearray((artifact_dir / "oversize-base.erofs").read_bytes())
    oversize_root = u16(oversize, rootnid_2b)
    oversize_nid = child_nid(oversize, oversize_root, b"big.dat")
    oversize_off, oversize_format, oversize_isize, _, _, _ = inode_info(oversize, oversize_nid)
    if not oversize_format & 1 or oversize_isize != 64:
        raise FixtureError("oversize inode is not extended")
    put_u64(oversize, oversize_off + 8, 1 << 63)
    write_image(artifact_dir / "extended-size-bit63.erofs", oversize)
    evidence.append(
        f"oversize nid={oversize_nid} inode_off={oversize_off} "
        f"i_size=0x{u64(oversize, oversize_off + 8):016x}"
    )
    extent = bytearray((artifact_dir / "extent-base.erofs").read_bytes())
    extent_root = u16(extent, rootnid_2b)
    extent_nid = child_nid(extent, extent_root, b"hole.dat")
    extent_off, extent_format, extent_isize, extent_xattr, extent_layout, _ = inode_info(extent, extent_nid)
    if not extent_format & 1 or extent_isize != 64 or extent_layout != 1:
        raise FixtureError("extent inode has unexpected format")
    header = (extent_off + extent_isize + extent_xattr + 7) & ~7
    record = (header + 8 + 15) & ~15
    root_off = inode_offset(extent, extent_root)
    if header < root_off + 64 and root_off < record + 16:
        raise FixtureError("extent metadata overlaps root inode")
    hole_size = 5 * 1024 * 1024 * 1024 + 4096
    put_u64(extent, extent_off + 8, hole_size)
    struct.pack_into("<IHH", extent, header, 1, 0x1 | 0x4, 0)
    struct.pack_into("<IIII", extent, record, 0, 0, 0, 0)
    write_image(artifact_dir / "extent-hole-5g.erofs", extent)
    evidence.append(
        f"extent-hole nid={extent_nid} inode_off={extent_off} size={hole_size} "
        f"header={header} record={record} extents=1 recsize=16 plen=0 lstart=0"
    )
    large = bytearray((artifact_dir / "large-dir-base.erofs").read_bytes())
    large_root = u16(large, rootnid_2b)
    large_nid = child_nid(large, large_root, b"huge")
    large_off, large_format, large_isize, _, _, _ = inode_info(large, large_nid)
    if not large_format & 1 or large_isize != 64:
        raise FixtureError("large directory inode is not extended")
    large_raw_block, large_image_blocks = convert_inline_directory_to_plain(large, large_nid)
    large_size = HUGE_DIRECTORY_BLOCKS * (1 << large[SUPER + 12])
    put_u64(large, large_off + 8, large_size)
    write_image(artifact_dir / "large-dir-intmax.erofs", large)
    evidence.append(
        f"large-dir nid={large_nid} inode_off={large_off} size={large_size} "
        f"blocks={HUGE_DIRECTORY_BLOCKS} last_block={HUGE_DIRECTORY_BLOCKS - 1} "
        f"layout=flat-plain raw_block={large_raw_block} image_blocks={large_image_blocks}"
    )
    (artifact_dir / "fixture-evidence.txt").write_text(
        "\n".join(evidence) + "\n", encoding="ascii"
    )
    write_checksums(artifact_dir)
    return commands


def make_vfs_source(source: Path) -> None:
    testdir = source / "testdir"
    (testdir / "subdir").mkdir(parents=True)
    for index in range(5):
        (testdir / f"dir-{index:02d}").mkdir()
    (source / "parent/child/grandchild").mkdir(parents=True)
    (testdir / "file.txt").write_text("repo22 G3 file payload\n", encoding="ascii")
    for index in range(12):
        (testdir / f"regular-{index:02d}.txt").write_text(f"regular {index:02d}\n", encoding="ascii")
    for name in ("File.txt", "FILE.TXT", "FiLe.TxT"):
        (testdir / name).write_text(f"case variant {name}\n", encoding="ascii")
    for index in range(1, 4):
        (testdir / f"file{index}.txt").write_text(f"cache file {index}\n", encoding="ascii")
    (testdir / "subdir/child.txt").write_text("child\n", encoding="ascii")
    (testdir / "script.sh").write_text("#!/bin/sh\nexit 0\n", encoding="ascii")
    (testdir / "rootonly.txt").write_text("restricted\n", encoding="ascii")
    (testdir / "writeonly.txt").write_text("write only mode\n", encoding="ascii")
    (testdir / "noexec.txt").write_text("not executable\n", encoding="ascii")
    (testdir / ("long-" + "x" * 250)).write_text("255 byte name\n", encoding="ascii")
    long_target = "subdir/" + "y" * 112
    (testdir / "subdir" / ("y" * 112)).write_text("long target\n", encoding="ascii")
    os.symlink("file.txt", testdir / "shortlink")
    os.symlink("subdir/child.txt", testdir / "relative-link")
    os.symlink("missing-target", testdir / "broken-link")
    os.symlink(long_target, testdir / "long-link")
    (source / "parent/file.txt").write_text("parent file\n", encoding="ascii")
    (source / "parent/child/grandchild/marker.txt").write_text("grandchild\n", encoding="ascii")
    (source / "pager.bin").write_bytes(deterministic_bytes(5 * 4096 + 731))
    os.chmod(testdir / "script.sh", 0o755)
    os.chmod(testdir / "rootonly.txt", 0o600)
    os.chmod(testdir / "writeonly.txt", 0o200)
    os.chmod(testdir / "noexec.txt", 0o644)
    set_epoch(source)


def directory_data_offset(image: ErofsImage, inode: object) -> int:
    if inode.layout == 2:
        return inode.offset + inode.inode_size + inode.xattr_size
    if inode.layout == FLAT_PLAIN:
        return inode.start_block << image.block_bits
    raise FixtureError(f"unsupported directory layout {inode.layout}")


def limited_directory_entries(image: ErofsImage, inode: object) -> list[object]:
    if inode.size == 0 or inode.size > image.block_size:
        raise FixtureError("G3 qualifier accepts only one-block path components")
    return image.directory_entries(inode)


def resolve(image: ErofsImage, path: str) -> object:
    inode = image.inode(image.root_nid)
    for component in path.strip("/").split("/"):
        if not component:
            continue
        entry = next(
            (
                item
                for item in limited_directory_entries(image, inode)
                if item.name.decode("ascii") == component
            ),
            None,
        )
        if entry is None:
            raise FixtureError(f"path not found: {path}")
        inode = image.inode(entry.nid)
    return inode


def validate_vfs_image(image_path: Path, source: Path) -> list[str]:
    image = ErofsImage.load(image_path)
    image.validate_superblock()
    evidence = [
        f"image={image_path.name} provider_bytes={len(image.data)} "
        f"block_size={image.block_size} blocks={image.blocks} "
        f"root_nid={image.root_nid} checksum_valid={image.checksum_valid()}"
    ]
    paths = [
        "/testdir",
        "/testdir/file.txt",
        "/testdir/script.sh",
        "/testdir/rootonly.txt",
        "/testdir/writeonly.txt",
        "/testdir/shortlink",
        "/testdir/long-link",
        "/pager.bin",
        "/parent/child/grandchild",
    ]
    for path in paths:
        source_path = source / path.lstrip("/")
        source_stat = source_path.lstat()
        inode = resolve(image, path)
        if inode.mode & S_IFMT != stat.S_IFMT(source_stat.st_mode):
            raise FixtureError(f"{path}: inode type differs from source")
        if inode.mode & 0o7777 != source_stat.st_mode & 0o7777:
            raise FixtureError(f"{path}: inode permissions differ from source")
        expected_size = None
        if source_path.is_symlink():
            expected_size = len(os.readlink(source_path).encode("ascii"))
        elif source_path.is_file():
            expected_size = source_stat.st_size
        if expected_size is not None and inode.size != expected_size:
            raise FixtureError(f"{path}: size {inode.size}, expected {expected_size}")
        evidence.append(
            f"path={path} nid={inode.nid} inode_offset={inode.offset} "
            f"layout={inode.layout} mode={inode.mode:#07o} size={inode.size} "
            f"start_block={inode.start_block}"
        )
    source_names = sorted(item.name for item in (source / "testdir").iterdir())
    testdir = resolve(image, "/testdir")
    image_names = sorted(
        entry.name.decode("ascii")
        for entry in limited_directory_entries(image, testdir)
        if entry.name not in (b".", b"..")
    )
    if source_names != image_names:
        raise FixtureError("/testdir names differ from source")
    evidence.append(
        f"path=/testdir real_entries={len(image_names)} "
        f"directory_size={testdir.size} layout={testdir.layout}"
    )
    return evidence


def write_source_hashes(source: Path, output: Path) -> None:
    with output.open("x", encoding="ascii") as sums:
        for path in sorted(source.rglob("*")):
            if path.is_file() and not path.is_symlink():
                sums.write(f"{sha256(path)}  {path.relative_to(source)}\n")


def make_vfs(output: Path) -> list[list[str]]:
    require_tools("mkfs.erofs")
    if output.exists():
        raise FixtureError(f"output already exists: {output}")
    source = output / "source"
    output.mkdir(parents=True)
    make_vfs_source(source)
    plain = output / "vfs-plain.erofs"
    lz4 = output / "vfs-lz4.erofs"
    common = ["mkfs.erofs", "-d0", "-T0", "--all-time", "--all-root", "--workers=1"]
    commands = [
        common + ["-x-1", "-E", "noinline_data", "-U", "33333333-4444-5555-6666-777777777771", str(plain), str(source)],
        common + ["-z", "lz4", "-U", "33333333-4444-5555-6666-777777777772", str(lz4), str(source)],
    ]
    for command in commands:
        run(command)
    evidence = validate_vfs_image(plain, source) + validate_vfs_image(lz4, source)
    (output / "fixture-evidence.txt").write_text("\n".join(evidence) + "\n", encoding="ascii")
    (output / "expected-testdir.txt").write_text(
        "\n".join(sorted(item.name for item in (source / "testdir").iterdir())) + "\n",
        encoding="ascii",
    )
    write_source_hashes(source, output / "SOURCE-SHA256SUMS")
    with (output / "IMAGE-SHA256SUMS").open("x", encoding="ascii") as sums:
        for path in (lz4, plain):
            sums.write(f"{sha256(path)}  {path.name}\n")
    return commands


def make_large_prefix(source: Path, output: Path, evidence_path: Path) -> None:
    image = ErofsImage.load(source)
    image.validate_superblock()
    huge = resolve(image, "/huge")
    expected_size = HUGE_DIRECTORY_BLOCKS * image.block_size
    if huge.inode_size != 64 or huge.layout != FLAT_PLAIN:
        raise FixtureError("TC153 directory is not extended FLAT_PLAIN/Layout0")
    if huge.size != expected_size:
        raise FixtureError(f"TC153 directory size {huge.size}, expected {expected_size}")
    if huge.start_block == 0:
        raise FixtureError("TC153 directory has no plain-data start block")
    provider_blocks = huge.start_block + HUGE_DIRECTORY_BLOCKS
    if provider_blocks > 0xFFFFFFFF:
        raise FixtureError("TC153 sparse provider does not fit blocks_lo")
    image.put_u32(SUPER + 36, provider_blocks)
    image.update_checksum()
    image.validate_superblock()
    output.parent.mkdir(parents=True, exist_ok=True)
    image.save(output)
    checked = ErofsImage.load(output)
    checked_huge = resolve(checked, "/huge")
    if checked.blocks != provider_blocks or checked_huge.layout != FLAT_PLAIN or checked_huge.size != expected_size:
        raise FixtureError("TC153 sparse prefix self-check failed")
    provider_bytes = provider_blocks * checked.block_size
    evidence_path.write_text(
        "tc153-sparse-prefix "
        f"nid={checked_huge.nid} inode_offset={checked_huge.offset} "
        f"layout=flat-plain start_block={checked_huge.start_block} "
        f"directory_blocks={HUGE_DIRECTORY_BLOCKS} last_block={HUGE_DIRECTORY_BLOCKS - 1} "
        f"blocks_lo={provider_blocks} provider_bytes={provider_bytes} "
        f"prefix_bytes={len(checked.data)} checksum_valid={checked.checksum_valid()} "
        f"sha256={sha256(output)}\n",
        encoding="ascii",
    )


def path_type(mode: int) -> str:
    if stat.S_ISREG(mode):
        return "file"
    if stat.S_ISDIR(mode):
        return "directory"
    if stat.S_ISLNK(mode):
        return "symlink"
    if stat.S_ISFIFO(mode):
        return "fifo"
    if stat.S_ISCHR(mode):
        return "char"
    if stat.S_ISBLK(mode):
        return "block"
    raise FixtureError(f"unsupported source type: {mode:#o}")


def tree_inventory(root: Path) -> dict[str, object]:
    if not root.is_dir():
        raise FixtureError(f"inventory root is absent: {root}")
    paths = [root] + sorted(root.rglob("*"))
    hardlinks: dict[tuple[int, int], list[str]] = {}
    for path in paths:
        info = path.lstat()
        if stat.S_ISREG(info.st_mode) and info.st_nlink > 1:
            hardlinks.setdefault((info.st_dev, info.st_ino), []).append(
                "." if path == root else path.relative_to(root).as_posix()
            )
    hardlink_names = {
        key: ",".join(sorted(names)) for key, names in hardlinks.items()
    }
    entries = []
    for path in paths:
        info = path.lstat()
        relative = "." if path == root else path.relative_to(root).as_posix()
        item: dict[str, object] = {
            "path": relative,
            "type": path_type(info.st_mode),
            "mode": info.st_mode & 0o7777,
        }
        if stat.S_ISREG(info.st_mode):
            item.update(size=info.st_size, sha256=sha256(path))
            if info.st_nlink > 1:
                item["hardlink_group"] = hardlink_names[(info.st_dev, info.st_ino)]
        elif stat.S_ISLNK(info.st_mode):
            item["target"] = os.readlink(path)
        elif stat.S_ISCHR(info.st_mode) or stat.S_ISBLK(info.st_mode):
            item.update(major=os.major(info.st_rdev), minor=os.minor(info.st_rdev))
        entries.append(item)
    encoded = json.dumps(entries, ensure_ascii=True, separators=(",", ":"), sort_keys=True).encode("ascii")
    return {"sha256": hashlib.sha256(encoded).hexdigest(), "entries": entries}


def compare_set(
    left_source: Path,
    left_artifacts: Path,
    right_source: Path,
    right_artifacts: Path,
    output: Path,
) -> None:
    inventories = {
        "left_source": tree_inventory(left_source),
        "left_artifacts": tree_inventory(left_artifacts),
        "right_source": tree_inventory(right_source),
        "right_artifacts": tree_inventory(right_artifacts),
    }
    comparison = {
        name: {"sha256": value["sha256"], "entries": len(value["entries"])}
        for name, value in inventories.items()
    }
    comparison["source_equal"] = inventories["left_source"] == inventories["right_source"]
    comparison["artifacts_equal"] = inventories["left_artifacts"] == inventories["right_artifacts"]
    comparison["status"] = (
        "PASS" if comparison["source_equal"] and comparison["artifacts_equal"] else "RUNNER_FAIL"
    )
    if comparison["status"] != "PASS":
        comparison["differences"] = {
            "source": sorted(
                set(json.dumps(item, sort_keys=True) for item in inventories["left_source"]["entries"])
                ^ set(json.dumps(item, sort_keys=True) for item in inventories["right_source"]["entries"])
            )[:40],
            "artifacts": sorted(
                set(json.dumps(item, sort_keys=True) for item in inventories["left_artifacts"]["entries"])
                ^ set(json.dumps(item, sort_keys=True) for item in inventories["right_artifacts"]["entries"])
            )[:40],
        }
    write_json(output, comparison)
    if comparison["status"] != "PASS":
        raise FixtureError("stable and archived fixture trees differ")


def verify_checksum_file(directory: Path, filename: str) -> None:
    for line in (directory / filename).read_text(encoding="ascii").splitlines():
        digest, relative = line.split("  ", 1)
        if sha256(directory / relative) != digest:
            raise FixtureError(f"checksum mismatch: {directory / relative}")


def make_expected_wide(fixture_dir: Path, artifact_dir: Path) -> None:
    names = sorted(path.name for path in (fixture_dir / "namei/wide").iterdir() if path.is_file())
    if len(names) != 320:
        raise FixtureError(f"wide directory contains {len(names)} files")
    (artifact_dir / "expected-wide.txt").write_text("\n".join(names) + "\n", encoding="ascii")


def make_all(output: Path) -> None:
    if output.exists():
        raise FixtureError(f"output already exists: {output}")
    output.mkdir(parents=True)
    commands = {
        "vfs": make_vfs(output / "vfs"),
        "metadata": make_metadata(output / "metadata-source", output / "metadata"),
        "final": make_final(output / "final-source", output / "final"),
    }
    make_expected_wide(output / "metadata-source", output / "metadata")
    make_large_prefix(
        output / "final/large-dir-intmax.erofs",
        output / "final/large-dir-intmax-sparse-prefix.erofs",
        output / "final/tc153-sparse-evidence.txt",
    )
    (output / "final/TC153-SPARSE-SHA256").write_text(
        f"{sha256(output / 'final/large-dir-intmax-sparse-prefix.erofs')}  "
        "large-dir-intmax-sparse-prefix.erofs\n",
        encoding="ascii",
    )
    verify_checksum_file(output / "vfs", "IMAGE-SHA256SUMS")
    verify_checksum_file(output / "vfs/source", "../SOURCE-SHA256SUMS")
    verify_checksum_file(output / "metadata", "SHA256SUMS")
    verify_checksum_file(output / "final", "SHA256SUMS")
    output_text = str(output)
    canonical_commands = {
        name: [
            [
                "$OUTPUT" + argument[len(output_text) :]
                if argument == output_text or argument.startswith(output_text + os.sep)
                else argument
                for argument in argv
            ]
            for argv in command_set
        ]
        for name, command_set in commands.items()
    }
    manifest = {
        "schema_version": 1,
        "generator": "tests/pre15/fixtures/g3.py",
        "generator_sha256": sha256(Path(__file__)),
        "mkfs_version": run(["mkfs.erofs", "-V"], capture=True).splitlines()[0],
        "commands": canonical_commands,
        "sets": {
            "vfs": tree_inventory(output / "vfs"),
            "metadata_source": tree_inventory(output / "metadata-source"),
            "metadata": tree_inventory(output / "metadata"),
            "final_source": tree_inventory(output / "final-source"),
            "final": tree_inventory(output / "final"),
        },
    }
    write_json(output / "G3-MANIFEST.json", manifest)


def verify_all(output: Path) -> None:
    manifest = json.loads((output / "G3-MANIFEST.json").read_text(encoding="ascii"))
    roots = {
        "vfs": output / "vfs",
        "metadata_source": output / "metadata-source",
        "metadata": output / "metadata",
        "final_source": output / "final-source",
        "final": output / "final",
    }
    for name, root in roots.items():
        if tree_inventory(root) != manifest["sets"][name]:
            raise FixtureError(f"G3 manifest mismatch: {name}")
    verify_checksum_file(output / "vfs", "IMAGE-SHA256SUMS")
    verify_checksum_file(output / "vfs/source", "../SOURCE-SHA256SUMS")
    verify_checksum_file(output / "metadata", "SHA256SUMS")
    verify_checksum_file(output / "final", "SHA256SUMS")


def source_constant(path: Path, name: str) -> int:
    for line in path.read_text(encoding="utf-8").splitlines():
        fields = line.split()
        if len(fields) >= 3 and fields[0] == "#define" and fields[1] == name:
            return int(fields[2], 0)
    raise FixtureError(f"constant not found: {path}:{name}")


def validate_xattr_sources(freebsd_root: Path, linux_root: Path) -> dict[str, str]:
    constants = (
        "EROFS_FEATURE_COMPAT_SB_CHKSUM",
        "EROFS_FEATURE_COMPAT_PLAIN_XATTR_PFX",
        "EROFS_FEATURE_INCOMPAT_XATTR_PREFIXES",
        "EROFS_FEATURE_INCOMPAT_METABOX",
    )
    for name in constants:
        freebsd = source_constant(freebsd_root / "erofs_fs.h", name)
        linux = source_constant(linux_root / "erofs_fs.h", name)
        if freebsd != linux:
            raise FixtureError(f"FreeBSD/Linux constant mismatch: {name}")
    return {
        "freebsd_erofs_fs_sha256": sha256(freebsd_root / "erofs_fs.h"),
        "freebsd_xattr_sha256": sha256(freebsd_root / "xattr.c"),
        "linux_erofs_fs_sha256": sha256(linux_root / "erofs_fs.h"),
        "linux_xattr_sha256": sha256(linux_root / "xattr.c"),
    }


def make_contract_image(
    path: Path,
    feature_compat: int,
    feature_incompat: int,
    prefix_start: int,
    prefix_count: int,
    packed_nid: int,
    metabox_nid: int,
    primary: bytes,
) -> None:
    data = bytearray(4096)
    put_u32(data, SUPER, MAGIC)
    put_u32(data, SUPER + 8, feature_compat | FEATURE_COMPAT_SB_CHKSUM)
    data[SUPER + 12] = 12
    put_u16(data, SUPER + 14, 1)
    put_u32(data, SUPER + 36, 1)
    put_u32(data, SUPER + 40, 0)
    put_u32(data, SUPER + 80, feature_incompat)
    data[SUPER + 91] = prefix_count
    put_u32(data, SUPER + 92, prefix_start // 4)
    put_u64(data, SUPER + 96, packed_nid)
    put_u64(data, SUPER + 128, metabox_nid)
    if prefix_start + len(primary) <= len(data):
        data[prefix_start : prefix_start + len(primary)] = primary
    write_image(path, data)


def select_prefix_backing(image: ErofsImage, carriers: dict[str, bytes]) -> tuple[str, bytes]:
    plain = bool(image.feature_compat & FEATURE_COMPAT_PLAIN_XATTR_PFX)
    metabox = bool(image.feature_incompat & FEATURE_INCOMPAT_METABOX)
    packed_nid = image.u64(SUPER + 96)
    if plain:
        return "primary", bytes(image.data)
    if metabox:
        return "metabox", carriers["metabox"]
    if packed_nid:
        return "packed", carriers["packed"]
    return "primary-legacy", bytes(image.data)


def parse_prefix(backing: bytes, offset: int) -> tuple[int, bytes]:
    aligned = (offset + 3) & ~3
    if aligned > len(backing) - 2:
        raise FixtureError("EINTEGRITY: prefix length is outside backing")
    raw_length = struct.unpack_from("<H", backing, aligned)[0]
    length = 65536 if raw_length == 0 else raw_length
    if length < 1 or length > EROFS_NAME_LEN + 1:
        raise FixtureError("EINTEGRITY: invalid prefix length")
    if aligned + 2 + length > len(backing):
        raise FixtureError("EINTEGRITY: prefix payload is outside backing")
    payload = backing[aligned + 2 : aligned + 2 + length]
    return payload[0], payload[1:]


def make_xattr_legacy(
    spec_path: Path, output: Path, freebsd_root: Path, linux_root: Path
) -> None:
    if output.exists():
        raise FixtureError(f"output already exists: {output}")
    spec = json.loads(spec_path.read_text(encoding="ascii"))
    output.mkdir(parents=True)
    source_hashes = validate_xattr_sources(freebsd_root, linux_root)
    prefix_start = spec["prefix_start"]
    payload = bytes([spec["base_index"]]) + spec["infix"].encode("ascii")
    record = struct.pack("<H", len(payload)) + payload
    carrier = bytearray(prefix_start + len(record))
    carrier[prefix_start : prefix_start + len(record)] = record
    carriers = {"packed": bytes(carrier), "metabox": bytes(carrier)}
    (output / "packed.bin").write_bytes(carriers["packed"])
    (output / "metabox.bin").write_bytes(carriers["metabox"])
    results = {}
    for item in spec["positive"]:
        compat = FEATURE_COMPAT_PLAIN_XATTR_PFX if item["plain"] else 0
        incompat = FEATURE_INCOMPAT_XATTR_PREFIXES
        if item["carrier"] == "metabox":
            incompat |= FEATURE_INCOMPAT_METABOX
        packed_nid = 7 if item["carrier"] == "packed" else 0
        metabox_nid = 9 if item["carrier"] == "metabox" else 0
        primary = record if item["carrier"] in ("primary", "primary-legacy") else b""
        image_path = output / f"{item['name']}.erofs"
        make_contract_image(
            image_path,
            compat,
            incompat,
            prefix_start,
            1,
            packed_nid,
            metabox_nid,
            primary,
        )
        image = ErofsImage.load(image_path)
        image.validate_superblock()
        selected, backing = select_prefix_backing(image, carriers)
        base_index, infix = parse_prefix(backing, prefix_start)
        if selected != item["carrier"] or base_index != spec["base_index"] or infix.decode("ascii") != spec["infix"]:
            raise FixtureError(f"positive xattr oracle failed: {item['name']}")
        results[item["name"]] = {
            "carrier": selected,
            "base_index": base_index,
            "infix": infix.decode("ascii"),
            "checksum_valid": image.checksum_valid(),
            "image_sha256": sha256(image_path),
        }
    for item in spec["negative"]:
        negative_record = record
        negative_start = prefix_start
        if item["field"] == "record_length":
            negative_record = struct.pack("<H", item["value"]) + payload
        elif item["field"] == "prefix_start":
            negative_start = item["value"]
        else:
            raise FixtureError(f"unknown negative field: {item['field']}")
        image_path = output / f"{item['name']}.erofs"
        make_contract_image(
            image_path,
            0,
            FEATURE_INCOMPAT_XATTR_PREFIXES,
            negative_start,
            1,
            0,
            0,
            negative_record,
        )
        image = ErofsImage.load(image_path)
        image.validate_superblock()
        try:
            _, backing = select_prefix_backing(image, carriers)
            parse_prefix(backing, negative_start)
        except FixtureError as error:
            errno_name = str(error).split(":", 1)[0]
        else:
            raise FixtureError(f"negative xattr oracle unexpectedly passed: {item['name']}")
        if errno_name != item["expected_errno"]:
            raise FixtureError(
                f"{item['name']}: errno {errno_name}, expected {item['expected_errno']}"
            )
        results[item["name"]] = {
            "field": item["field"],
            "expected_errno": errno_name,
            "checksum_valid": image.checksum_valid(),
            "image_sha256": sha256(image_path),
        }
    manifest = {
        "schema_version": 1,
        "generator": "tests/pre15/fixtures/g3.py",
        "generator_sha256": sha256(Path(__file__)),
        "spec_sha256": sha256(spec_path),
        "source_hashes": source_hashes,
        "results": results,
    }
    write_json(output / "fixture-manifest.json", manifest)


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    subparsers = parser.add_subparsers(dest="command", required=True)
    make_all_parser = subparsers.add_parser("make-all")
    make_all_parser.add_argument("--output", type=Path, required=True)
    verify_parser = subparsers.add_parser("verify")
    verify_parser.add_argument("--output", type=Path, required=True)
    vfs_parser = subparsers.add_parser("make-vfs")
    vfs_parser.add_argument("--output", type=Path, required=True)
    metadata_parser = subparsers.add_parser("make-metadata")
    metadata_parser.add_argument("--source", type=Path, required=True)
    metadata_parser.add_argument("--output", type=Path, required=True)
    final_parser = subparsers.add_parser("make-final")
    final_parser.add_argument("--source", type=Path, required=True)
    final_parser.add_argument("--output", type=Path, required=True)
    large_parser = subparsers.add_parser("make-large-prefix")
    large_parser.add_argument("--source", type=Path, required=True)
    large_parser.add_argument("--output", type=Path, required=True)
    large_parser.add_argument("--evidence", type=Path, required=True)
    compare_parser = subparsers.add_parser("compare-set")
    compare_parser.add_argument("--left-source", type=Path, required=True)
    compare_parser.add_argument("--left-artifacts", type=Path, required=True)
    compare_parser.add_argument("--right-source", type=Path, required=True)
    compare_parser.add_argument("--right-artifacts", type=Path, required=True)
    compare_parser.add_argument("--output", type=Path, required=True)
    xattr_parser = subparsers.add_parser("make-xattr-legacy")
    xattr_parser.add_argument("--spec", type=Path, required=True)
    xattr_parser.add_argument("--output", type=Path, required=True)
    xattr_parser.add_argument("--freebsd-root", type=Path, required=True)
    xattr_parser.add_argument("--linux-root", type=Path, required=True)
    return parser.parse_args(argv)


def main(argv: list[str] | None = None) -> None:
    os.umask(0o022)
    args = parse_args(argv)
    if args.command == "make-all":
        make_all(args.output)
    elif args.command == "verify":
        verify_all(args.output)
    elif args.command == "make-vfs":
        make_vfs(args.output)
    elif args.command == "make-metadata":
        make_metadata(args.source, args.output)
        make_expected_wide(args.source, args.output)
    elif args.command == "make-final":
        make_final(args.source, args.output)
    elif args.command == "make-large-prefix":
        make_large_prefix(args.source, args.output, args.evidence)
    elif args.command == "compare-set":
        compare_set(
            args.left_source,
            args.left_artifacts,
            args.right_source,
            args.right_artifacts,
            args.output,
        )
    else:
        make_xattr_legacy(args.spec, args.output, args.freebsd_root, args.linux_root)


if __name__ == "__main__":
    try:
        main()
    except (FixtureError, OSError, subprocess.CalledProcessError, ValueError) as error:
        print(f"fixture error: {error}", file=sys.stderr)
        raise SystemExit(1) from error
