#!/usr/bin/env python3
"""Inspect and make assertion-driven EROFS manual-test fixtures."""

from __future__ import annotations

import argparse
import hashlib
import shutil
import struct
from dataclasses import dataclass
from pathlib import Path


SUPER = 1024
MAGIC = 0xE0F5E1E2
FEATURE_COMPAT_SB_CHKSUM = 0x00000001
FEATURE_INCOMPAT_COMPR_CFGS = 0x00000002
FEATURE_INCOMPAT_48BIT = 0x00000080
CRC32C_POLYNOMIAL = 0x82F63B78
CRC32C_INITIAL = 0xFFFFFFFF
KERNEL_CRC32C_SEED = 0x5045B54A


def crc32c(data: bytes | bytearray, initial: int = CRC32C_INITIAL) -> int:
    checksum = initial
    for byte in data:
        checksum ^= byte
        for _ in range(8):
            checksum = (checksum >> 1) ^ (
                CRC32C_POLYNOMIAL if checksum & 1 else 0
            )
    return checksum & 0xFFFFFFFF


@dataclass(frozen=True)
class Inode:
    nid: int
    offset: int
    inode_format: int
    inode_size: int
    xattr_size: int
    layout: int
    mode: int
    size: int
    start_block_low: int
    start_block_high: int
    start_block: int


@dataclass(frozen=True)
class DirectoryEntry:
    nid: int
    offset: int
    name_offset: int
    end_offset: int
    name: bytes


class ErofsImage:
    def __init__(self, data: bytearray, source: Path):
        self.data = data
        self.source = source
        if len(data) < SUPER + 128:
            raise ValueError(f"{source}: shorter than the EROFS superblock")

    @classmethod
    def load(cls, path: Path) -> "ErofsImage":
        return cls(bytearray(path.read_bytes()), path)

    def clone(self) -> "ErofsImage":
        return ErofsImage(bytearray(self.data), self.source)

    def u16(self, offset: int) -> int:
        return struct.unpack_from("<H", self.data, offset)[0]

    def u32(self, offset: int) -> int:
        return struct.unpack_from("<I", self.data, offset)[0]

    def u64(self, offset: int) -> int:
        return struct.unpack_from("<Q", self.data, offset)[0]

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

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

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

    @property
    def block_bits(self) -> int:
        return self.data[SUPER + 12]

    @property
    def block_size(self) -> int:
        if not 9 <= self.block_bits <= 16:
            raise ValueError(f"invalid block bits {self.block_bits}")
        return 1 << self.block_bits

    @property
    def checksum_end(self) -> int:
        span = self.block_size
        if span > SUPER:
            span -= SUPER
        end = SUPER + span
        if end > len(self.data):
            raise ValueError(
                f"checksum window ends at {end}, image size is {len(self.data)}"
            )
        return end

    @property
    def feature_compat(self) -> int:
        return self.u32(SUPER + 8)

    @property
    def feature_incompat(self) -> int:
        return self.u32(SUPER + 80)

    @property
    def blocks(self) -> int:
        blocks = self.u32(SUPER + 36)
        root8 = self.u64(SUPER + 112)
        if self.feature_incompat & FEATURE_INCOMPAT_48BIT and root8 != 0:
            blocks |= self.u16(SUPER + 14) << 32
        return blocks

    @property
    def root_nid(self) -> int:
        root8 = self.u64(SUPER + 112)
        if self.feature_incompat & FEATURE_INCOMPAT_48BIT and root8 != 0:
            return root8
        return self.u16(SUPER + 14)

    def calculated_checksum(self) -> int:
        window = bytearray(self.data[SUPER : self.checksum_end])
        struct.pack_into("<I", window, 4, 0)
        return crc32c(window)

    def kernel_calculated_checksum(self) -> int:
        return crc32c(
            self.data[SUPER + 8 : self.checksum_end], KERNEL_CRC32C_SEED
        )

    def checksum_valid(self) -> bool:
        if not self.feature_compat & FEATURE_COMPAT_SB_CHKSUM:
            return True
        return self.u32(SUPER + 4) == self.calculated_checksum()

    def kernel_checksum_valid(self) -> bool:
        if not self.feature_compat & FEATURE_COMPAT_SB_CHKSUM:
            return True
        return self.u32(SUPER + 4) == self.kernel_calculated_checksum()

    def update_checksum(self) -> None:
        if not self.feature_compat & FEATURE_COMPAT_SB_CHKSUM:
            raise ValueError("image does not advertise superblock checksum")
        self.put_u32(SUPER + 4, 0)
        self.put_u32(SUPER + 4, self.calculated_checksum())
        if not self.checksum_valid():
            raise AssertionError("updated checksum does not verify")
        if (
            self.u32(SUPER) == MAGIC
            and self.calculated_checksum() != self.kernel_calculated_checksum()
        ):
            raise AssertionError("canonical and kernel checksum forms differ")

    def validate_superblock(self) -> None:
        if self.u32(SUPER) != MAGIC:
            raise ValueError(f"unexpected magic {self.u32(SUPER):#010x}")
        if self.blocks == 0:
            raise ValueError("declared block count is zero")
        if not self.checksum_valid():
            raise ValueError("superblock checksum is invalid")

    def inode(self, nid: int) -> Inode:
        metadata = self.u32(SUPER + 40) << self.block_bits
        offset = metadata + (nid << 5)
        if offset > len(self.data) - 32:
            raise ValueError(f"nid {nid} maps outside the image at {offset}")
        inode_format = self.u16(offset)
        inode_size = 64 if inode_format & 1 else 32
        if offset > len(self.data) - inode_size:
            raise ValueError(f"nid {nid} extended inode is truncated")
        xattr_count = self.u16(offset + 2)
        xattr_size = 0 if xattr_count == 0 else 12 + 4 * (xattr_count - 1)
        size = self.u64(offset + 8) if inode_size == 64 else self.u32(offset + 8)
        start_block_low = self.u32(offset + 16)
        start_block_high = (
            self.u16(offset + 6)
            if inode_size == 64
            and self.feature_incompat & FEATURE_INCOMPAT_48BIT
            else 0
        )
        return Inode(
            nid=nid,
            offset=offset,
            inode_format=inode_format,
            inode_size=inode_size,
            xattr_size=xattr_size,
            layout=(inode_format >> 1) & 7,
            mode=self.u16(offset + 4),
            size=size,
            start_block_low=start_block_low,
            start_block_high=start_block_high,
            start_block=start_block_low | (start_block_high << 32),
        )

    def directory_entries(self, inode: Inode) -> list[DirectoryEntry]:
        if inode.size == 0 or inode.size > self.block_size:
            raise ValueError("helper resolves only one-block directories")
        if inode.layout == 2:
            data_offset = inode.offset + inode.inode_size + inode.xattr_size
        elif inode.layout == 0:
            data_offset = inode.start_block << self.block_bits
        else:
            raise ValueError(f"unsupported directory layout {inode.layout}")
        if data_offset > len(self.data) - inode.size:
            raise ValueError("directory data lies outside the image")
        first_name_offset = self.u16(data_offset + 8)
        if (
            first_name_offset < 12
            or first_name_offset % 12 != 0
            or first_name_offset >= inode.size
        ):
            raise ValueError("invalid first directory name offset")
        count = first_name_offset // 12
        entries = []
        previous = 0
        for index in range(count):
            entry_offset = data_offset + index * 12
            name_offset = self.u16(entry_offset + 8)
            end_offset = (
                self.u16(entry_offset + 20)
                if index + 1 < count
                else inode.size
            )
            if (
                name_offset < first_name_offset
                or name_offset <= previous
                or end_offset <= name_offset
                or end_offset > inode.size
            ):
                raise ValueError("invalid directory name offsets")
            slot = bytes(
                self.data[
                    data_offset + name_offset : data_offset + end_offset
                ]
            )
            name = slot.split(b"\0", 1)[0]
            if not name:
                raise ValueError("empty directory name")
            entries.append(
                DirectoryEntry(
                    nid=self.u64(entry_offset),
                    offset=entry_offset,
                    name_offset=name_offset,
                    end_offset=end_offset,
                    name=name,
                )
            )
            previous = name_offset
        return entries

    def resolve_root_entry(self, path: str) -> tuple[Inode, DirectoryEntry]:
        name = path.removeprefix("/").encode("ascii")
        if not name or b"/" in name:
            raise ValueError("helper accepts one root-level path component")
        root = self.inode(self.root_nid)
        for entry in self.directory_entries(root):
            if entry.name == name:
                return root, entry
        raise ValueError(f"path not found in root directory: {path}")

    def save(self, path: Path) -> None:
        path.write_bytes(self.data)


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


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


def save_checked(image: ErofsImage, path: Path) -> None:
    image.update_checksum()
    image.validate_superblock()
    image.save(path)


def assert_same_length(original: ErofsImage, changed: ErofsImage) -> None:
    if len(original.data) != len(changed.data):
        raise AssertionError("fixture mutation changed provider length")


def make_error_fixtures(args: argparse.Namespace) -> None:
    output = args.output
    if output.exists():
        raise ValueError(f"output already exists: {output}")
    output.mkdir(parents=True)
    plain = ErofsImage.load(args.plain)
    compressed = ErofsImage.load(args.compressed)
    deflate = ErofsImage.load(args.deflate)
    plain.validate_superblock()
    compressed.validate_superblock()
    deflate.validate_superblock()
    shutil.copy2(args.plain, output / "valid-plain.erofs")
    shutil.copy2(args.compressed, output / "valid-lz4.erofs")
    shutil.copy2(args.deflate, output / "valid-deflate-level1.erofs")
    evidence = []

    bad_crc = plain.clone()
    patch_offset = SUPER + 64
    old_byte = bad_crc.data[patch_offset]
    bad_crc.data[patch_offset] ^= 0x01
    assert_same_length(plain, bad_crc)
    if bad_crc.checksum_valid():
        raise AssertionError("bad checksum fixture still verifies")
    bad_crc.save(output / "bad-super-crc.erofs")
    evidence.append(
        f"bad-super-crc patch_offset={patch_offset} checksum_offset={SUPER + 4} "
        f"old={old_byte:#04x} new={bad_crc.data[patch_offset]:#04x} "
        f"coverage={SUPER}:{plain.checksum_end} recomputed=no "
        f"provider_length={len(plain.data)}"
    )

    bad_checksum_field = plain.clone()
    old_checksum = bad_checksum_field.u32(SUPER + 4)
    bad_checksum_field.put_u32(SUPER + 4, old_checksum ^ 0x00000001)
    assert_same_length(plain, bad_checksum_field)
    if bad_checksum_field.checksum_valid():
        raise AssertionError("altered checksum field still verifies")
    bad_checksum_field.save(output / "bad-checksum-field.erofs")
    evidence.append(
        f"bad-checksum-field field_offset={SUPER + 4} "
        f"old={old_checksum:#010x} new={old_checksum ^ 1:#010x} "
        f"coverage={SUPER}:{plain.checksum_end} recomputed=no "
        f"provider_length={len(plain.data)}"
    )

    bad_magic = plain.clone()
    if bad_magic.u32(SUPER) != MAGIC:
        raise AssertionError("unexpected source magic")
    bad_magic.put_u32(SUPER, 0x21444142)
    assert_same_length(plain, bad_magic)
    if bad_magic.checksum_valid() or not bad_magic.kernel_checksum_valid():
        raise AssertionError("bad magic checksum qualification failed")
    bad_magic.save(output / "bad-magic.erofs")
    evidence.append(
        f"bad-magic patch_offset={SUPER} old={MAGIC:#010x} new=0x21444142 "
        f"crc=unchanged canonical_valid=no kernel_suffix_valid=yes "
        f"provider_length={len(plain.data)}"
    )

    bad_block_size = plain.clone()
    old_block_bits = bad_block_size.block_bits
    bad_block_size.data[SUPER + 12] = 13
    bad_block_size.update_checksum()
    assert_same_length(plain, bad_block_size)
    bad_block_size.save(output / "bad-block-size.erofs")
    evidence.append(
        f"bad-block-size field_offset={SUPER + 12} old={old_block_bits} new=13 "
        f"coverage={SUPER}:{bad_block_size.checksum_end} crc=recomputed "
        f"provider_length={len(plain.data)}"
    )

    bad_root = plain.clone()
    if bad_root.feature_incompat & FEATURE_INCOMPAT_48BIT:
        raise AssertionError("plain source unexpectedly has 48-bit feature")
    metadata_offset = bad_root.u32(SUPER + 40) << bad_root.block_bits
    invalid_root = (
        ((bad_root.blocks << bad_root.block_bits) - metadata_offset) // 32
        + 1024
    )
    bad_root.put_u32(
        SUPER + 80, bad_root.feature_incompat | FEATURE_INCOMPAT_48BIT
    )
    bad_root.put_u16(SUPER + 14, 0)
    bad_root.put_u64(SUPER + 112, invalid_root)
    save_checked(bad_root, output / "bad-root-nid.erofs")
    assert_same_length(plain, bad_root)
    evidence.append(
        f"bad-root-nid feature_offset={SUPER + 80} "
        f"blocks_hi_offset={SUPER + 14} rootnid_8b_offset={SUPER + 112} "
        f"old_root={plain.root_nid} new_root={invalid_root} "
        f"crc=recomputed provider_length={len(plain.data)}"
    )

    unsupported_feature = plain.clone()
    old_incompat = unsupported_feature.feature_incompat
    unsupported_feature.put_u32(SUPER + 80, old_incompat | 0x80000000)
    save_checked(unsupported_feature, output / "unsupported-feature.erofs")
    assert_same_length(plain, unsupported_feature)
    evidence.append(
        f"unsupported-feature field_offset={SUPER + 80} "
        f"old={old_incompat:#010x} new={old_incompat | 0x80000000:#010x} "
        f"crc=recomputed provider_length={len(plain.data)}"
    )

    short_path = output / "truncated-before-super.erofs"
    short_path.write_bytes(plain.data[: SUPER - 1])
    evidence.append(
        f"truncated-before-super source_length={len(plain.data)} "
        f"provider_length={SUPER - 1} required_super_offset={SUPER}"
    )
    empty_path = output / "empty-provider.erofs"
    empty_path.write_bytes(b"")
    evidence.append(
        f"empty-provider source_length={len(plain.data)} provider_length=0"
    )

    bad_dirent = plain.clone()
    _, entry = bad_dirent.resolve_root_entry("/bad-entry.txt")
    invalid_nid = (bad_dirent.blocks << bad_dirent.block_bits) // 32 + 1024
    bad_dirent.put_u64(entry.offset, invalid_nid)
    save_checked(bad_dirent, output / "bad-dirent-nid.erofs")
    assert_same_length(plain, bad_dirent)
    evidence.append(
        f"bad-dirent-nid dirent_offset={entry.offset} old_nid={entry.nid} "
        f"new_nid={invalid_nid} crc=recomputed "
        f"provider_length={len(plain.data)}"
    )

    bad_inode = plain.clone()
    _, entry = bad_inode.resolve_root_entry("/inode-target.txt")
    inode = bad_inode.inode(entry.nid)
    if inode.inode_format & 0x8000:
        raise AssertionError("reserved i_format bit already set")
    bad_inode.put_u16(inode.offset, inode.inode_format | 0x8000)
    save_checked(bad_inode, output / "bad-inode-format.erofs")
    assert_same_length(plain, bad_inode)
    evidence.append(
        f"bad-inode-format nid={inode.nid} inode_offset={inode.offset} "
        f"i_format={inode.inode_format:#06x}->{inode.inode_format | 0x8000:#06x} "
        f"crc=recomputed provider_length={len(plain.data)}"
    )

    out_of_bounds = plain.clone()
    _, entry = out_of_bounds.resolve_root_entry("/plain.bin")
    inode = out_of_bounds.inode(entry.nid)
    if inode.layout != 0 or inode.size == 0:
        raise AssertionError("plain.bin is not nonempty FLAT_PLAIN")
    out_of_bounds.put_u32(inode.offset + 16, out_of_bounds.blocks)
    save_checked(out_of_bounds, output / "oob-start-block.erofs")
    assert_same_length(plain, out_of_bounds)
    evidence.append(
        f"oob-start-block nid={inode.nid} field_offset={inode.offset + 16} "
        f"old={inode.start_block} new={out_of_bounds.blocks} crc=recomputed "
        f"provider_length={len(plain.data)}"
    )

    future = compressed.clone()
    if not future.feature_incompat & FEATURE_INCOMPAT_COMPR_CFGS:
        raise AssertionError("compressed image has no compression config feature")
    available_offset = SUPER + 82
    old_algorithms = future.u16(available_offset)
    if old_algorithms & 0x8000:
        raise AssertionError("future algorithm bit already set")
    future.put_u16(available_offset, old_algorithms | 0x8000)
    save_checked(future, output / "future-algorithm.erofs")
    assert_same_length(compressed, future)
    evidence.append(
        f"future-algorithm field_offset={available_offset} "
        f"old={old_algorithms:#06x} new={old_algorithms | 0x8000:#06x} "
        f"crc=recomputed provider_length={len(compressed.data)}"
    )

    compressed_corrupt = compressed.clone()
    corrupt_offset = args.compressed_offset + 32
    corrupt_length = 64
    if args.compressed_length < 32 + corrupt_length:
        raise ValueError("compressed extent is too short for targeted mutation")
    if corrupt_offset + corrupt_length > (
        args.compressed_offset + args.compressed_length
    ):
        raise ValueError("compressed mutation exceeds selected extent")
    if corrupt_offset < compressed_corrupt.checksum_end:
        raise ValueError("compressed corruption overlaps checksum window")
    if corrupt_offset + corrupt_length > len(compressed_corrupt.data):
        raise ValueError("compressed corruption exceeds provider")
    old_bytes = bytes(
        compressed_corrupt.data[corrupt_offset : corrupt_offset + corrupt_length]
    )
    if old_bytes == bytes(corrupt_length):
        raise ValueError("compressed mutation would not change bytes")
    compressed_corrupt.data[
        corrupt_offset : corrupt_offset + corrupt_length
    ] = bytes(corrupt_length)
    if not compressed_corrupt.checksum_valid():
        raise AssertionError("payload mutation changed superblock checksum")
    assert_same_length(compressed, compressed_corrupt)
    compressed_corrupt.save(output / "compressed-stream-corrupt.erofs")
    evidence.append(
        f"compressed-stream-corrupt extent_offset={args.compressed_offset} "
        f"extent_length={args.compressed_length} patch_offset={corrupt_offset} "
        f"patch_length={corrupt_length} provider_length={len(compressed.data)} "
        "crc=unchanged-valid"
    )

    raw_corrupt = plain.clone()
    _, entry = raw_corrupt.resolve_root_entry("/plain.bin")
    inode = raw_corrupt.inode(entry.nid)
    if inode.layout != 0 or inode.size <= 257:
        raise ValueError("plain.bin is not a qualifying FLAT_PLAIN target")
    raw_offset = (inode.start_block << raw_corrupt.block_bits) + 257
    if raw_offset < raw_corrupt.checksum_end or raw_offset >= len(raw_corrupt.data):
        raise ValueError("raw payload patch offset is invalid")
    old_byte = raw_corrupt.data[raw_offset]
    raw_corrupt.data[raw_offset] ^= 0x80
    assert_same_length(plain, raw_corrupt)
    if not raw_corrupt.checksum_valid():
        raise AssertionError("raw payload mutation changed superblock checksum")
    raw_corrupt.save(output / "raw-data-corrupt.erofs")
    evidence.append(
        f"raw-data-corrupt path=/plain.bin nid={inode.nid} "
        f"start_block={inode.start_block} file_offset=257 "
        f"patch_offset={raw_offset} old={old_byte:#04x} "
        f"new={raw_corrupt.data[raw_offset]:#04x} crc=unchanged-valid "
        f"provider_length={len(plain.data)}"
    )

    if plain.feature_incompat & FEATURE_INCOMPAT_48BIT:
        raise AssertionError("plain source unexpectedly has 48-bit feature")
    if plain.root_nid == 0:
        raise AssertionError("48-bit fixture needs a nonzero root nid")
    blocks_lo = plain.u32(SUPER + 36)
    blocks_hi = 1
    total_blocks = blocks_lo | (blocks_hi << 32)
    provider_length = total_blocks << plain.block_bits

    large = plain.clone()
    large.put_u32(SUPER + 80, large.feature_incompat | FEATURE_INCOMPAT_48BIT)
    large.put_u16(SUPER + 14, blocks_hi)
    large.put_u64(SUPER + 112, plain.root_nid)
    save_checked(large, output / "48bit-statfs-prefix.erofs")
    assert_same_length(plain, large)
    evidence.append(
        f"48bit-statfs-prefix feature_offset={SUPER + 80} "
        f"blocks_lo_offset={SUPER + 36} blocks_lo={blocks_lo} "
        f"blocks_hi_offset={SUPER + 14} blocks_hi={blocks_hi} "
        f"rootnid_8b_offset={SUPER + 112} rootnid_8b={plain.root_nid} "
        f"total_blocks={total_blocks} required_provider_length={provider_length} "
        f"prefix_length={len(plain.data)} crc=recomputed"
    )

    high = large.clone()
    _, high_entry = high.resolve_root_entry("/high-offset.txt")
    high_inode = high.inode(high_entry.nid)
    if high_inode.inode_size != 64 or high_inode.layout != 0 or high_inode.size == 0:
        raise ValueError("high-offset.txt is not a nonempty extended FLAT_PLAIN file")
    if high_inode.start_block_high != 0:
        raise ValueError("high-offset.txt already has a high start block")
    low_data_offset = high_inode.start_block_low << high.block_bits
    low_data = bytes(
        high.data[low_data_offset : low_data_offset + high_inode.size]
    )
    if low_data == bytes(high_inode.size):
        raise ValueError("high-offset.txt low payload is already zero")
    high.data[low_data_offset : low_data_offset + high_inode.size] = bytes(
        high_inode.size
    )
    high.put_u16(high_inode.offset + 6, 1)
    save_checked(high, output / "48bit-high-file-prefix.erofs")
    assert_same_length(plain, high)
    high_inode = high.inode(high_entry.nid)
    high_data_offset = high_inode.start_block << high.block_bits
    if high_data_offset + high_inode.size > provider_length:
        raise ValueError("high-offset target exceeds declared provider")
    evidence.append(
        f"48bit-high-file-prefix path=/high-offset.txt nid={high_inode.nid} "
        f"inode_offset={high_inode.offset} startblk_hi_offset={high_inode.offset + 6} "
        f"startblk_lo={high_inode.start_block_low} startblk_hi=1 "
        f"start_block={high_inode.start_block} data_offset={high_data_offset} "
        f"low_decoy_offset={low_data_offset} low_decoy=zero "
        f"file_size={high_inode.size} required_provider_length={provider_length} "
        f"prefix_length={len(plain.data)} crc=recomputed"
    )

    (output / "fixture-evidence.txt").write_text(
        "\n".join(evidence) + "\n", encoding="ascii"
    )
    write_checksums(output)


def validate_full_directory_block(
    image: ErofsImage, offset: int
) -> list[DirectoryEntry]:
    first_name_offset = image.u16(offset + 8)
    if first_name_offset < 12 or first_name_offset % 12 != 0:
        raise ValueError("invalid first name offset in full directory block")
    count = first_name_offset // 12
    entries = []
    previous = 0
    for index in range(count):
        entry_offset = offset + index * 12
        name_offset = image.u16(entry_offset + 8)
        end_offset = (
            image.u16(entry_offset + 20)
            if index + 1 < count
            else image.block_size
        )
        if (
            name_offset < first_name_offset
            or name_offset <= previous
            or end_offset <= name_offset
            or end_offset > image.block_size
        ):
            raise ValueError("invalid full-block directory offsets")
        name = bytes(
            image.data[offset + name_offset : offset + end_offset]
        ).split(b"\0", 1)[0]
        if not name:
            raise ValueError("empty full-block directory name")
        entries.append(
            DirectoryEntry(
                nid=image.u64(entry_offset),
                offset=entry_offset,
                name_offset=name_offset,
                end_offset=end_offset,
                name=name,
            )
        )
        previous = name_offset
    return entries


def make_directory_fixtures(args: argparse.Namespace) -> None:
    output = args.output
    if output.exists():
        raise ValueError(f"output already exists: {output}")
    output.mkdir(parents=True)
    base = ErofsImage.load(args.base)
    base.validate_superblock()
    shutil.copy2(args.base, output / "namei-base.erofs")
    _, wide_entry = base.resolve_root_entry("/wide")
    wide = base.inode(wide_entry.nid)
    if wide.layout != 2 or wide.size <= base.block_size:
        raise ValueError("wide must be a multi-block FLAT_INLINE directory")
    wide_block = wide.start_block << base.block_bits
    entries = validate_full_directory_block(base, wide_block)
    last = entries[-1]
    slot_start = wide_block + last.name_offset
    slot_end = wide_block + last.end_offset
    padding_nul = base.data.index(0, slot_start, slot_end)
    if padding_nul + 9 > wide_block + base.block_size:
        raise ValueError("not enough final-name padding to patch")
    evidence = [
        f"wide_nid={wide.nid} inode_offset={wide.offset} block_offset={wide_block} "
        f"dirents={len(entries)} final_name={last.name.decode('ascii')} "
        f"padding_patch={padding_nul + 1}:{padding_nul + 9}"
    ]

    nonzero = base.clone()
    nonzero.data[padding_nul + 1 : padding_nul + 9] = b"PAD!ERO!"
    save_checked(nonzero, output / "namei-padding-nonzero.erofs")

    short = base.clone()
    root = short.inode(short.root_nid)
    if root.inode_size != 32:
        raise ValueError("expected compact root inode")
    short.put_u32(root.offset + 8, 8)
    save_checked(short, output / "namei-corrupt-short.erofs")
    evidence.append(f"short-root inode_offset={root.offset} size=8")

    bad_offset = base.clone()
    root = bad_offset.inode(bad_offset.root_nid)
    root_entries = bad_offset.directory_entries(root)
    if len(root_entries) < 2:
        raise ValueError("root needs at least two entries")
    bad_offset.put_u16(
        root_entries[1].offset + 8, root_entries[0].name_offset
    )
    save_checked(bad_offset, output / "namei-corrupt-nameoff.erofs")
    evidence.append(
        f"bad-nameoff field_offset={root_entries[1].offset + 8} "
        f"new={root_entries[0].name_offset}"
    )

    bad_name = base.clone()
    slash_offset = slot_start + 5
    if bad_name.data[slash_offset] in (0, ord("/")):
        raise ValueError("chosen name byte cannot be patched")
    bad_name.data[slash_offset] = ord("/")
    save_checked(bad_name, output / "namei-corrupt-name.erofs")
    evidence.append(f"bad-name patch_offset={slash_offset} new=0x2f")

    (output / "fixture-evidence.txt").write_text(
        "\n".join(evidence) + "\n", encoding="ascii"
    )
    write_checksums(output)


def inspect_image(args: argparse.Namespace) -> None:
    image = ErofsImage.load(args.image)
    print(f"image={args.image}")
    print(f"provider_bytes={len(image.data)}")
    print(f"magic={image.u32(SUPER):#010x} offset={SUPER}")
    print(f"block_size={image.block_size} block_bits={image.block_bits}")
    print(f"blocks={image.blocks} blocks_lo_offset={SUPER + 36}")
    print(f"root_nid={image.root_nid}")
    print(f"feature_compat={image.feature_compat:#010x} offset={SUPER + 8}")
    print(f"feature_incompat={image.feature_incompat:#010x} offset={SUPER + 80}")
    print(
        f"checksum={image.u32(SUPER + 4):#010x} offset={SUPER + 4} "
        f"calculated={image.calculated_checksum():#010x} "
        f"coverage={SUPER}:{image.checksum_end} valid={image.checksum_valid()}"
    )
    print(
        f"kernel_calculated={image.kernel_calculated_checksum():#010x} "
        f"kernel_seed={KERNEL_CRC32C_SEED:#010x} "
        f"kernel_coverage={SUPER + 8}:{image.checksum_end} "
        f"kernel_valid={image.kernel_checksum_valid()} "
        f"equivalent={image.calculated_checksum() == image.kernel_calculated_checksum()}"
    )
    if args.path:
        _, entry = image.resolve_root_entry(args.path)
        inode = image.inode(entry.nid)
        print(
            f"path={args.path} nid={inode.nid} inode_offset={inode.offset} "
            f"i_format={inode.inode_format:#06x} layout={inode.layout} "
            f"size={inode.size} start_block_low={inode.start_block_low} "
            f"start_block_high={inode.start_block_high} "
            f"start_block={inode.start_block}"
        )


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    subparsers = parser.add_subparsers(dest="command", required=True)

    inspect_parser = subparsers.add_parser("inspect")
    inspect_parser.add_argument("image", type=Path)
    inspect_parser.add_argument("--path")
    inspect_parser.set_defaults(function=inspect_image)

    error_parser = subparsers.add_parser("make-error-fixtures")
    error_parser.add_argument("--plain", type=Path, required=True)
    error_parser.add_argument("--compressed", type=Path, required=True)
    error_parser.add_argument("--deflate", type=Path, required=True)
    error_parser.add_argument("--compressed-offset", type=int, required=True)
    error_parser.add_argument("--compressed-length", type=int, required=True)
    error_parser.add_argument("--output", type=Path, required=True)
    error_parser.set_defaults(function=make_error_fixtures)

    directory_parser = subparsers.add_parser("make-directory-fixtures")
    directory_parser.add_argument("--base", type=Path, required=True)
    directory_parser.add_argument("--output", type=Path, required=True)
    directory_parser.set_defaults(function=make_directory_fixtures)
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    args.function(args)


if __name__ == "__main__":
    main()
