#!/usr/bin/env python3
"""Build and verify the deterministic xattr, ACL, and metabox G4 fixtures."""

from __future__ import annotations

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


SUPER = 1024
MAGIC = 0xE0F5E1E2
FEATURE_COMPAT_SB_CHKSUM = 0x00000001
FEATURE_COMPAT_SHARED_EA_IN_METABOX = 0x00000008
FEATURE_COMPAT_PLAIN_XATTR_PFX = 0x00000010
FEATURE_COMPAT_ISHARE_XATTRS = 0x00000020
FEATURE_INCOMPAT_FRAGMENTS = 0x00000020
FEATURE_INCOMPAT_XATTR_PREFIXES = 0x00000040
FEATURE_INCOMPAT_METABOX = 0x00000100
XATTR_INDEX_USER = 1
XATTR_INDEX_ACL_ACCESS = 2
XATTR_INDEX_ACL_DEFAULT = 3
XATTR_INDEX_TRUSTED = 4
XATTR_INDEX_SECURITY = 6
XATTR_LONG_PREFIX = 0x80
METABOX_NID_BIT = 1 << 63
LAYOUT_FLAT_PLAIN = 0
LAYOUT_COMPRESSED_FULL = 1
LAYOUT_FLAT_INLINE = 2
LAYOUT_COMPRESSED_COMPACT = 3
ACL_VERSION = 2
ACL_USER_OBJ = 0x01
ACL_USER = 0x02
ACL_GROUP_OBJ = 0x04
ACL_GROUP = 0x08
ACL_MASK = 0x10
ACL_OTHER = 0x20
ACL_UNDEFINED_ID = 0xFFFFFFFF
BLOCK_SIZE = 4096
METABOX_SIZE = 32768
METABOX_INODE_A = 16
METABOX_INODE_B = 32
METABOX_PREFIX_OFFSET = 8192
METABOX_XATTR_BASE = 4096
CRC32C_POLYNOMIAL = 0x82F63B78


class FixtureError(RuntimeError):
    pass


@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: int


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


@dataclass(frozen=True)
class XattrEntry:
    offset: int
    size: int
    name_index: int
    name: bytes
    value: bytes


def align(value: int, alignment: int) -> int:
    return (value + alignment - 1) & -alignment


def sha256_bytes(data: bytes | bytearray) -> str:
    return hashlib.sha256(data).hexdigest()


def sha256_path(path: Path) -> str:
    return sha256_bytes(path.read_bytes())


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


class ErofsImage:
    def __init__(self, data: bytes | bytearray, source: Path) -> None:
        self.data = bytearray(data)
        self.source = source
        self.validate_superblock()

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

    def clone(self) -> "ErofsImage":
        return ErofsImage(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:
        return 1 << self.block_bits

    @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:
        return self.u32(SUPER + 36)

    @property
    def declared_size(self) -> int:
        return self.blocks << self.block_bits

    @property
    def root_nid(self) -> int:
        return self.u16(SUPER + 14)

    @property
    def meta_offset(self) -> int:
        return self.u32(SUPER + 40) << self.block_bits

    @property
    def packed_nid(self) -> int:
        return self.u64(SUPER + 96)

    @property
    def checksum_end(self) -> int:
        return SUPER + self.block_size - SUPER

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

    def update_checksum(self) -> None:
        if not self.feature_compat & FEATURE_COMPAT_SB_CHKSUM:
            raise FixtureError(f"{self.source}: checksum feature is required")
        self.put_u32(SUPER + 4, 0)
        self.put_u32(SUPER + 4, self.calculated_checksum())
        if self.u32(SUPER + 4) != self.calculated_checksum():
            raise AssertionError("updated checksum does not verify")

    def validate_superblock(self) -> None:
        if len(self.data) < SUPER + 144:
            raise FixtureError(f"{self.source}: image is shorter than 1168 bytes")
        if self.u32(SUPER) != MAGIC:
            raise FixtureError(f"{self.source}: bad magic")
        if not 9 <= self.block_bits <= 16:
            raise FixtureError(f"{self.source}: invalid block bits")
        if self.blocks == 0 or self.declared_size > len(self.data):
            raise FixtureError(f"{self.source}: invalid declared size")
        if (
            self.feature_compat & FEATURE_COMPAT_SB_CHKSUM
            and self.u32(SUPER + 4) != self.calculated_checksum()
        ):
            raise FixtureError(f"{self.source}: bad checksum")

    def inode(self, nid: int) -> Inode:
        offset = self.meta_offset + (nid << 5)
        if offset > self.declared_size - 32:
            raise FixtureError(f"{self.source}: nid {nid} is outside the image")
        inode_format = self.u16(offset)
        inode_size = 64 if inode_format & 1 else 32
        count = self.u16(offset + 2)
        xattr_size = 0 if count == 0 else 12 + 4 * (count - 1)
        size = self.u64(offset + 8) if inode_size == 64 else self.u32(offset + 8)
        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=self.u32(offset + 16),
        )

    def inode_data(self, inode: Inode) -> bytes:
        if inode.layout == LAYOUT_FLAT_PLAIN:
            start = inode.start_block << self.block_bits
            end = start + inode.size
            if end > self.declared_size:
                raise FixtureError("plain inode data exceeds the declared image")
            return bytes(self.data[start:end])
        if inode.layout != LAYOUT_FLAT_INLINE:
            raise FixtureError(f"inode {inode.nid}: unsupported host layout {inode.layout}")
        full_size = inode.size // self.block_size * self.block_size
        if inode.size and inode.size % self.block_size == 0:
            full_size -= self.block_size
        full_start = inode.start_block << self.block_bits
        inline_start = inode.offset + inode.inode_size + inode.xattr_size
        full = self.data[full_start : full_start + full_size]
        tail = self.data[inline_start : inline_start + inode.size - full_size]
        if len(full) + len(tail) != inode.size:
            raise FixtureError("inline inode data is truncated")
        return bytes(full + tail)

    def directory_entries(self, inode: Inode) -> list[DirectoryEntry]:
        data = self.inode_data(inode)
        entries: list[DirectoryEntry] = []
        data_base = (
            inode.start_block << self.block_bits
            if inode.layout == LAYOUT_FLAT_PLAIN
            else inode.offset + inode.inode_size + inode.xattr_size
        )
        for block_start in range(0, len(data), self.block_size):
            block = data[block_start : block_start + self.block_size]
            if len(block) < 12:
                raise FixtureError("short directory block")
            first_name = struct.unpack_from("<H", block, 8)[0]
            if first_name < 12 or first_name % 12 or first_name > len(block):
                raise FixtureError("invalid directory first-name offset")
            count = first_name // 12
            for index in range(count):
                item = index * 12
                name_start = struct.unpack_from("<H", block, item + 8)[0]
                name_end = (
                    struct.unpack_from("<H", block, item + 20)[0]
                    if index + 1 < count
                    else len(block)
                )
                name = block[name_start:name_end].split(b"\0", 1)[0]
                entries.append(
                    DirectoryEntry(
                        name=name,
                        nid=struct.unpack_from("<Q", block, item)[0],
                        offset=data_base + block_start + item,
                    )
                )
        return entries

    def resolve_root(self, name: str) -> tuple[DirectoryEntry, Inode]:
        encoded = name.removeprefix("/").encode("ascii")
        root = self.inode(self.root_nid)
        for entry in self.directory_entries(root):
            if entry.name == encoded:
                return entry, self.inode(entry.nid)
        raise FixtureError(f"{self.source}: root entry not found: {name}")

    def inline_xattrs(self, inode: Inode) -> tuple[int, list[XattrEntry]]:
        if inode.xattr_size < 12:
            raise FixtureError(f"inode {inode.nid} has no xattr header")
        body = inode.offset + inode.inode_size
        shared_count = self.data[body + 4]
        cursor = body + 12 + shared_count * 4
        end = body + inode.xattr_size
        if cursor > end:
            raise FixtureError("shared xattr array exceeds inode body")
        entries = []
        while cursor < end:
            if cursor > end - 4:
                raise FixtureError("truncated inline xattr header")
            name_len = self.data[cursor]
            name_index = self.data[cursor + 1]
            value_len = self.u16(cursor + 2)
            size = align(4 + name_len + value_len, 4)
            if size > end - cursor:
                raise FixtureError("inline xattr entry exceeds inode body")
            entries.append(
                XattrEntry(
                    offset=cursor,
                    size=size,
                    name_index=name_index,
                    name=bytes(self.data[cursor + 4 : cursor + 4 + name_len]),
                    value=bytes(
                        self.data[
                            cursor + 4 + name_len : cursor + 4 + name_len + value_len
                        ]
                    ),
                )
            )
            cursor += size
        return shared_count, entries

    def shared_ids(self, inode: Inode) -> list[int]:
        body = inode.offset + inode.inode_size
        count = self.data[body + 4]
        return [self.u32(body + 12 + index * 4) for index in range(count)]

    def shared_xattr(self, shared_id: int) -> XattrEntry:
        offset = (self.u32(SUPER + 44) << self.block_bits) + shared_id * 4
        if offset > self.declared_size - 4:
            raise FixtureError(f"shared id {shared_id} starts outside the image")
        name_len = self.data[offset]
        value_len = self.u16(offset + 2)
        size = align(4 + name_len + value_len, 4)
        if size > self.declared_size - offset:
            raise FixtureError(f"shared id {shared_id} exceeds the image")
        return XattrEntry(
            offset=offset,
            size=size,
            name_index=self.data[offset + 1],
            name=bytes(self.data[offset + 4 : offset + 4 + name_len]),
            value=bytes(
                self.data[offset + 4 + name_len : offset + 4 + name_len + value_len]
            ),
        )

    def find_xattr(self, path: str, name: bytes) -> tuple[str, XattrEntry, int | None]:
        _, inode = self.resolve_root(path)
        _, inline = self.inline_xattrs(inode)
        for entry in inline:
            if entry.name == name:
                return "inline", entry, None
        for index, shared_id in enumerate(self.shared_ids(inode)):
            entry = self.shared_xattr(shared_id)
            if entry.name == name:
                return "shared", entry, index
        raise FixtureError(f"{path}: xattr {name!r} not found")

    def prefix_records(self) -> list[tuple[int, int, bytes]]:
        count = self.data[SUPER + 91]
        if count == 0:
            return []
        if self.feature_compat & FEATURE_COMPAT_PLAIN_XATTR_PFX:
            backing = bytes(self.data[: self.declared_size])
        else:
            if self.packed_nid == 0:
                raise FixtureError("prefix table has no packed backing")
            backing = self.inode_data(self.inode(self.packed_nid))
        cursor = self.u32(SUPER + 92) << 2
        records = []
        for _ in range(count):
            cursor = align(cursor, 4)
            if cursor > len(backing) - 3:
                raise FixtureError("prefix record header exceeds backing")
            length = struct.unpack_from("<H", backing, cursor)[0]
            if not 1 <= length <= 256 or cursor + 2 + length > len(backing):
                raise FixtureError("invalid prefix record")
            records.append((cursor, backing[cursor + 2], backing[cursor + 3 : cursor + 2 + length]))
            cursor += 2 + length
        return records

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


def xattr_entry(name_index: int, name: bytes, value: bytes) -> bytes:
    raw = struct.pack("<BBH", len(name), name_index, len(value)) + name + value
    return raw + bytes(align(len(raw), 4) - len(raw))


def acl_value(entries: list[tuple[int, int, int]]) -> bytes:
    return struct.pack("<I", ACL_VERSION) + b"".join(
        struct.pack("<HHI", tag, permissions, identifier)
        for tag, permissions, identifier in entries
    )


def canonical_acl(*named_users: int) -> bytes:
    entries = [(ACL_USER_OBJ, 7, ACL_UNDEFINED_ID)]
    entries.extend((ACL_USER, 5, identifier) for identifier in named_users)
    entries.extend(
        [
            (ACL_GROUP_OBJ, 5, ACL_UNDEFINED_ID),
            (ACL_MASK, 5, ACL_UNDEFINED_ID),
            (ACL_OTHER, 1, ACL_UNDEFINED_ID),
        ]
    )
    return acl_value(entries)


def prefix_record(base_index: int, infix: bytes) -> bytes:
    payload = bytes([base_index]) + infix
    return struct.pack("<H", len(payload)) + payload


def add_aligned(blob: bytearray, offset: int, payload: bytes) -> int:
    start = align(offset, 4)
    blob[start : start + len(payload)] = payload
    return start


def synthetic_inode(content: bytes, shared_ids: list[int], item_value: bytes) -> bytes:
    header = bytearray(12 + 4 * len(shared_ids))
    header[4] = len(shared_ids)
    for index, shared_id in enumerate(shared_ids):
        struct.pack_into("<I", header, 12 + index * 4, shared_id)
    body = header + xattr_entry(XATTR_INDEX_USER, b"item", item_value)
    body.extend(bytes(align(len(body), 4) - len(body)))
    icount = 1 + (len(body) - 12) // 4
    inode = bytearray(32)
    struct.pack_into("<HHHHIII", inode, 0, 4, icount, stat.S_IFREG | 0o644, 1, len(content), 0, 0)
    return bytes(inode + body + content)


def make_metabox_payload() -> tuple[bytes, dict[str, object]]:
    payload = bytearray(METABOX_SIZE)
    shared_offset = METABOX_XATTR_BASE
    shared_records = []
    for name_index, name, value in (
        (XATTR_INDEX_USER, b"metaboxshared", b"nonzero-base"),
        (XATTR_LONG_PREFIX | 0, b"setting", b"long-prefix-value"),
        (XATTR_INDEX_USER, b"shared-prefix-key", b"shared-value"),
    ):
        raw = xattr_entry(name_index, name, value)
        start = add_aligned(payload, shared_offset, raw)
        shared_id = (start - METABOX_XATTR_BASE) // 4
        shared_records.append((shared_id, start, raw))
        shared_offset = start + len(raw)

    prefix_cursor = METABOX_PREFIX_OFFSET
    prefix_fields = []
    for base_index, infix in (
        (XATTR_INDEX_USER, b"repo22.application.component."),
        (XATTR_INDEX_TRUSTED, b"repo22.trusted.deep."),
    ):
        raw = prefix_record(base_index, infix)
        start = add_aligned(payload, prefix_cursor, raw)
        prefix_fields.append((start, base_index, infix))
        prefix_cursor = start + len(raw)

    inode_a = synthetic_inode(
        b"metabox-file-a\n",
        [shared_records[0][0], shared_records[1][0], shared_records[2][0]],
        b"value-000",
    )
    inode_b = synthetic_inode(
        b"metabox-file-b\n",
        [shared_records[0][0]],
        b"value-001",
    )
    offset_a = METABOX_INODE_A << 5
    offset_b = METABOX_INODE_B << 5
    if offset_a + len(inode_a) >= offset_b:
        raise AssertionError("synthetic metabox inodes overlap")
    payload[offset_a : offset_a + len(inode_a)] = inode_a
    payload[offset_b : offset_b + len(inode_b)] = inode_b

    manifest = {
        "size": len(payload),
        "sha256": sha256_bytes(payload),
        "inode_a": {"nid": METABOX_INODE_A, "offset": offset_a},
        "inode_b": {"nid": METABOX_INODE_B, "offset": offset_b},
        "xattr_base": METABOX_XATTR_BASE,
        "shared": [
            {
                "id": shared_id,
                "offset": offset,
                "sha256": sha256_bytes(raw),
            }
            for shared_id, offset, raw in shared_records
        ],
        "prefix_start": METABOX_PREFIX_OFFSET // 4,
        "prefixes": [
            {"offset": offset, "base_index": base_index, "infix": infix.decode("ascii")}
            for offset, base_index, infix in prefix_fields
        ],
    }
    return bytes(payload), manifest


def write_file(path: Path, data: bytes = b"") -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_bytes(data)


def set_user_xattr(path: Path, name: str, value: bytes) -> None:
    os.setxattr(path, f"user.{name}", value)


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


def make_sources(output: Path) -> dict[str, object]:
    source_root = output / "source"
    basic = source_root / "basic"
    carrier = source_root / "carrier"
    basic.mkdir(parents=True)
    carrier.mkdir(parents=True)
    metabox_payload, metabox_manifest = make_metabox_payload()

    basic_files: dict[str, dict[str, bytes]] = {
        "inline-user": {
            "comment": b"inline-user-value\x00tail",
            "special.chars": b"special-value",
        },
        "inline-multi": {
            "attr1": b"one",
            "attr2": b"two\x00binary",
            "attr3": bytes(range(32)),
        },
        "inline-trusted": {"admin": b"trusted-inline-value"},
        "inline-security": {
            "capability": bytes.fromhex("0100000200000000aabbccdd"),
            "selinux": b"system_u:object_r:repo22_t:s0\x00",
        },
        "corrupt-inline": {"bad": b"bad-format-target"},
        "user-subset": {
            "comment": b"subset-comment",
            "author": b"repo22",
            "checksum": b"sha256:0123456789abcdef",
            "com.repo22.app.setting": b"enabled",
        },
    }
    for name, attrs in basic_files.items():
        path = basic / name
        write_file(path, f"{name}\n".encode("ascii"))
        for xattr_name, value in attrs.items():
            set_user_xattr(path, xattr_name, value)

    for name in ("shared-a", "shared-b", "shared-multi"):
        path = basic / name
        write_file(path, f"{name}\n".encode("ascii"))
        set_user_xattr(path, "shared_key", b"shared-value\x00exact")
        set_user_xattr(path, "shared_comment", b"shared-comment")
        set_user_xattr(path, "shared_binary", bytes(range(16)))
    set_user_xattr(basic / "shared-a", "local", b"in-bounds-local")

    for name in ("trusted-shared-a", "trusted-shared-b", "trusted-shared-c"):
        path = basic / name
        write_file(path, f"{name}\n".encode("ascii"))
        set_user_xattr(path, "config", b"trusted-shared-value")

    for name in ("security-shared-a", "security-shared-b", "security-shared-c"):
        path = basic / name
        write_file(path, f"{name}\n".encode("ascii"))
        set_user_xattr(path, "selinux", b"system_u:object_r:shared_repo22_t:s0\x00")

    for index in range(4):
        path = basic / f"prefix-user-{index}"
        write_file(path, f"prefix user {index}\n".encode("ascii"))
        set_user_xattr(
            path,
            "repo22.application.component.setting",
            f"prefix-value-{index}".encode("ascii"),
        )
    for index in range(3):
        path = basic / f"prefix-trusted-{index}"
        write_file(path, f"prefix trusted {index}\n".encode("ascii"))
        set_user_xattr(
            path,
            "repo22.trusted.deep.setting",
            f"trusted-prefix-value-{index}".encode("ascii"),
        )

    acl_values = {
        "acl-unordered": canonical_acl(3002, 2002),
        "acl-header": struct.pack("<I", ACL_VERSION),
        "acl-duplicate": canonical_acl(2002, 2002),
        "acl-phase": acl_value(
            [
                (ACL_USER_OBJ, 7, ACL_UNDEFINED_ID),
                (ACL_GROUP_OBJ, 5, ACL_UNDEFINED_ID),
                (ACL_USER, 5, 2002),
                (ACL_MASK, 5, ACL_UNDEFINED_ID),
                (ACL_OTHER, 1, ACL_UNDEFINED_ID),
            ]
        ),
        "acl-perm": acl_value(
            [
                (ACL_USER_OBJ, 8, ACL_UNDEFINED_ID),
                (ACL_GROUP_OBJ, 5, ACL_UNDEFINED_ID),
                (ACL_OTHER, 1, ACL_UNDEFINED_ID),
            ]
        ),
    }
    for name, value in acl_values.items():
        path = basic / name
        write_file(path)
        set_user_xattr(path, "acl_access_placeholder", value)

    fifo = basic / "fifo-node"
    write_file(fifo)
    fifo.chmod(0o640)
    set_user_xattr(fifo, "acl_access_placeholder", canonical_acl(2002))
    set_user_xattr(fifo, "fifo_note", b"fifo-readonly-xattr")

    write_file(basic / "metabox-carrier.bin", metabox_payload)
    write_file(basic / "metabox-a", b"primary placeholder a\n")
    write_file(basic / "metabox-b", b"primary placeholder b\n")

    write_file(carrier / "metabox-carrier.bin", metabox_payload)
    write_file(carrier / "metabox-a", b"primary placeholder a\n")
    write_file(carrier / "metabox-b", b"primary placeholder b\n")
    set_user_xattr(
        carrier / "metabox-a",
        "repo22.application.component.seed",
        b"prefix-seed",
    )

    normalize_times(source_root)
    return {"metabox_payload": metabox_manifest}


def source_inventory(root: Path) -> dict[str, object]:
    entries = []
    for path in sorted([root, *root.rglob("*")]):
        info = path.lstat()
        relative = path.relative_to(root).as_posix() or "."
        item: dict[str, object] = {
            "path": relative,
            "mode": stat.S_IFMT(info.st_mode) | stat.S_IMODE(info.st_mode),
            "uid": info.st_uid,
            "gid": info.st_gid,
            "size": info.st_size,
        }
        if path.is_file():
            item["content_sha256"] = sha256_path(path)
        names = sorted(os.listxattr(path, follow_symlinks=False))
        item["xattrs"] = {
            name: os.getxattr(path, name, follow_symlinks=False).hex()
            for name in names
        }
        entries.append(item)
    canonical = json.dumps(entries, separators=(",", ":"), sort_keys=True).encode("ascii")
    return {"sha256": sha256_bytes(canonical), "entries": entries}


def tool_version() -> str:
    result = subprocess.run(
        ["mkfs.erofs", "-V"],
        check=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
    )
    version = result.stdout.strip()
    if "erofs-utils) 1.8.6" not in version:
        raise FixtureError(f"mkfs.erofs 1.8.6 is required, got: {version}")
    return version


def run_mkfs(source: Path, image: Path, uuid: str, *extra: str) -> list[str]:
    command = [
        "mkfs.erofs",
        "-d0",
        "-T0",
        "--all-time",
        "--all-root",
        "--workers=1",
        "--sort=path",
        "-U",
        uuid,
        "--xattr-prefix=user.repo22.application.component.",
        "--xattr-prefix=user.repo22.trusted.deep.",
        *extra,
        str(image),
        str(source),
    ]
    subprocess.run(command, check=True)
    return command


class TransformLog:
    def __init__(self) -> None:
        self.records: list[dict[str, object]] = []

    def bytes(self, image: ErofsImage, field: str, offset: int, after: bytes) -> None:
        before = bytes(image.data[offset : offset + len(after)])
        if len(before) != len(after):
            raise FixtureError(f"{field}: patch exceeds image")
        image.data[offset : offset + len(after)] = after
        self.records.append(
            {
                "field": field,
                "offset": offset,
                "size": len(after),
                "before_hex": before.hex(),
                "after_hex": after.hex(),
            }
        )

    def u8(self, image: ErofsImage, field: str, offset: int, value: int) -> None:
        self.bytes(image, field, offset, bytes([value]))

    def u16(self, image: ErofsImage, field: str, offset: int, value: int) -> None:
        self.bytes(image, field, offset, struct.pack("<H", value))

    def u32(self, image: ErofsImage, field: str, offset: int, value: int) -> None:
        self.bytes(image, field, offset, struct.pack("<I", value))

    def u64(self, image: ErofsImage, field: str, offset: int, value: int) -> None:
        self.bytes(image, field, offset, struct.pack("<Q", value))


def find_prefix_id(image: ErofsImage, infix: bytes) -> int:
    for prefix_id, (_, _, record_infix) in enumerate(image.prefix_records()):
        if record_infix == infix:
            return prefix_id
    raise FixtureError(f"prefix not found: {infix!r}")


def patch_namespace_entries(image: ErofsImage, log: TransformLog) -> None:
    for path, name, expected_storage, new_index in (
        ("/inline-trusted", b"admin", "inline", XATTR_INDEX_TRUSTED),
        ("/inline-security", b"capability", "inline", XATTR_INDEX_SECURITY),
        ("/inline-security", b"selinux", "inline", XATTR_INDEX_SECURITY),
        ("/trusted-shared-a", b"config", "shared", XATTR_INDEX_TRUSTED),
        ("/security-shared-a", b"selinux", "shared", XATTR_INDEX_SECURITY),
    ):
        storage, entry, _ = image.find_xattr(path, name)
        if storage != expected_storage or entry.name_index != XATTR_INDEX_USER:
            raise FixtureError(f"{path}:{name!r} has unexpected storage/index")
        log.u8(image, f"{path}:{name.decode()}.e_name_index", entry.offset + 1, new_index)

    trusted_prefix_id = find_prefix_id(image, b"repo22.trusted.deep.")
    packed = image.inode(image.packed_nid)
    packed_data = image.inode_data(packed)
    record_offset = image.prefix_records()[trusted_prefix_id][0]
    if packed.layout != LAYOUT_FLAT_INLINE:
        raise FixtureError("expected inline packed prefix carrier")
    physical = packed.offset + packed.inode_size + packed.xattr_size + record_offset + 2
    if packed_data[record_offset + 2] != XATTR_INDEX_USER:
        raise FixtureError("trusted prefix base is not the user placeholder")
    log.u8(image, "packed_prefix.trusted.base_index", physical, XATTR_INDEX_TRUSTED)


def replace_acl_entry(image: ErofsImage, path: str, value: bytes, log: TransformLog) -> None:
    _, inode = image.resolve_root(path)
    shared_count, entries = image.inline_xattrs(inode)
    placeholders = [entry for entry in entries if entry.name == b"acl_access_placeholder"]
    if shared_count != 0 or len(placeholders) != 1:
        raise FixtureError(f"{path}: ACL placeholder is not one inline xattr")
    body = bytearray(12)
    for entry in entries:
        if entry.name == b"acl_access_placeholder":
            body.extend(xattr_entry(XATTR_INDEX_ACL_ACCESS, b"", value))
        else:
            body.extend(xattr_entry(entry.name_index, entry.name, entry.value))
    body.extend(bytes(align(len(body), 4) - len(body)))
    if len(body) > inode.xattr_size:
        raise FixtureError(f"{path}: rebuilt ACL body grew")
    padded = body + bytes(inode.xattr_size - len(body))
    log.bytes(image, f"{path}.xattr_body", inode.offset + inode.inode_size, padded)
    icount = 1 + (len(body) - 12) // 4
    log.u16(image, f"{path}.i_xattr_icount", inode.offset + 2, icount)


def patch_acls(image: ErofsImage, log: TransformLog) -> None:
    acl_values = {
        "/acl-unordered": canonical_acl(3002, 2002),
        "/acl-header": struct.pack("<I", ACL_VERSION),
        "/acl-duplicate": canonical_acl(2002, 2002),
        "/acl-phase": acl_value(
            [
                (ACL_USER_OBJ, 7, ACL_UNDEFINED_ID),
                (ACL_GROUP_OBJ, 5, ACL_UNDEFINED_ID),
                (ACL_USER, 5, 2002),
                (ACL_MASK, 5, ACL_UNDEFINED_ID),
                (ACL_OTHER, 1, ACL_UNDEFINED_ID),
            ]
        ),
        "/acl-perm": acl_value(
            [
                (ACL_USER_OBJ, 8, ACL_UNDEFINED_ID),
                (ACL_GROUP_OBJ, 5, ACL_UNDEFINED_ID),
                (ACL_OTHER, 1, ACL_UNDEFINED_ID),
            ]
        ),
        "/fifo-node": canonical_acl(2002),
    }
    for path, value in acl_values.items():
        replace_acl_entry(image, path, value, log)
    _, fifo = image.resolve_root("/fifo-node")
    log.u16(image, "/fifo-node.i_mode", fifo.offset + 4, stat.S_IFIFO | 0o640)


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


def transformed_basic(base: Path) -> tuple[ErofsImage, TransformLog]:
    image = ErofsImage.load(base)
    log = TransformLog()
    patch_namespace_entries(image, log)
    patch_acls(image, log)
    image.update_checksum()
    return image, log


def add_metabox_fields(
    image: ErofsImage,
    log: TransformLog,
    carrier_nid: int,
    clear_packed: bool,
) -> None:
    if image.u32(SUPER + 40) != 0:
        raise FixtureError("metabox conversion expects metadata in block zero")
    metadata_copy = bytes(image.data[: image.block_size])
    relocated_offset = align(len(image.data), image.block_size)
    image.data.extend(bytes(relocated_offset - len(image.data)))
    image.data.extend(bytes(image.block_size))
    image.data[relocated_offset : relocated_offset + image.block_size] = metadata_copy
    log.u32(image, "super.blocks_lo", SUPER + 36, len(image.data) // image.block_size)
    log.u32(image, "super.meta_blkaddr", SUPER + 40, relocated_offset // image.block_size)
    log.u32(
        image,
        "super.feature_compat",
        SUPER + 8,
        image.feature_compat | FEATURE_COMPAT_SHARED_EA_IN_METABOX,
    )
    log.u8(image, "super.sb_extslots", SUPER + 13, 1)
    log.u32(
        image,
        "super.feature_incompat",
        SUPER + 80,
        image.feature_incompat | FEATURE_INCOMPAT_METABOX | FEATURE_INCOMPAT_XATTR_PREFIXES,
    )
    log.u32(image, "super.xattr_blkaddr", SUPER + 44, METABOX_XATTR_BASE // BLOCK_SIZE)
    log.u8(image, "super.xattr_prefix_count", SUPER + 91, 2)
    log.u32(image, "super.xattr_prefix_start", SUPER + 92, METABOX_PREFIX_OFFSET // 4)
    log.u64(image, "super.metabox_nid", SUPER + 128, carrier_nid)
    if clear_packed:
        log.u64(image, "super.packed_nid", SUPER + 96, 0)
    for path, nid in (("/metabox-a", METABOX_INODE_A), ("/metabox-b", METABOX_INODE_B)):
        entry, _ = image.resolve_root(path)
        log.u64(image, f"{path}.dirent_nid", entry.offset, METABOX_NID_BIT | nid)


def make_primary_prefix(image: ErofsImage, log: TransformLog) -> None:
    packed_data = image.inode_data(image.inode(image.packed_nid))
    start = align(len(image.data), image.block_size)
    if len(image.data) < start:
        image.data.extend(bytes(start - len(image.data)))
    image.data.extend(packed_data)
    image.data.extend(bytes(align(len(image.data), image.block_size) - len(image.data)))
    new_blocks = len(image.data) // image.block_size
    log.u32(image, "super.blocks_lo", SUPER + 36, new_blocks)
    log.u32(
        image,
        "super.feature_compat",
        SUPER + 8,
        image.feature_compat | FEATURE_COMPAT_PLAIN_XATTR_PFX,
    )
    log.u32(image, "super.xattr_prefix_start", SUPER + 92, start // 4)
    log.u64(image, "super.packed_nid", SUPER + 96, 0)


def save_variant(
    output: Path,
    name: str,
    image: ErofsImage,
    log: TransformLog,
    transforms: dict[str, object],
    base_name: str,
    semantics: dict[str, object] | None = None,
) -> None:
    path = output / "images" / name
    checksum_and_save(image, path)
    transforms[name] = {
        "base": base_name,
        "image_sha256": sha256_path(path),
        "provider_size": path.stat().st_size,
        "declared_size": image.declared_size,
        "fields": log.records,
        "semantics": semantics or {},
    }


def make_variants(output: Path, base_commands: dict[str, list[str]]) -> dict[str, object]:
    bases = output / "bases"
    images = output / "images"
    images.mkdir()
    transforms: dict[str, object] = {}

    basic, basic_log = transformed_basic(bases / "basic-base.erofs")
    save_variant(output, "basic.erofs", basic.clone(), basic_log, transforms, "basic-base.erofs")

    primary = basic.clone()
    primary_log = TransformLog()
    make_primary_prefix(primary, primary_log)
    save_variant(output, "prefix-primary.erofs", primary, primary_log, transforms, "basic.erofs")

    metabox_plain = ErofsImage.load(bases / "carrier-plain-base.erofs")
    metabox_plain_log = TransformLog()
    _, carrier_inode = metabox_plain.resolve_root("/metabox-carrier.bin")
    if carrier_inode.layout != LAYOUT_FLAT_PLAIN or carrier_inode.size != METABOX_SIZE:
        raise FixtureError("plain metabox carrier has the wrong layout or size")
    add_metabox_fields(metabox_plain, metabox_plain_log, carrier_inode.nid, True)
    save_variant(
        output,
        "metabox-plain.erofs",
        metabox_plain,
        metabox_plain_log,
        transforms,
        "carrier-plain-base.erofs",
        {"carrier_nid": carrier_inode.nid, "carrier_layout": carrier_inode.layout},
    )

    compressed = ErofsImage.load(bases / "carrier-compressed-base.erofs")
    compressed_log = TransformLog()
    _, compressed_carrier = compressed.resolve_root("/metabox-carrier.bin")
    if compressed_carrier.layout not in (LAYOUT_COMPRESSED_FULL, LAYOUT_COMPRESSED_COMPACT):
        raise FixtureError("compressed metabox carrier is not compressed")
    add_metabox_fields(compressed, compressed_log, compressed_carrier.nid, True)
    save_variant(
        output,
        "metabox-compressed.erofs",
        compressed,
        compressed_log,
        transforms,
        "carrier-compressed-base.erofs",
        {"carrier_nid": compressed_carrier.nid, "carrier_layout": compressed_carrier.layout},
    )

    fragment = ErofsImage.load(bases / "carrier-fragment-base.erofs")
    fragment_log = TransformLog()
    _, fragment_carrier = fragment.resolve_root("/metabox-carrier.bin")
    if fragment_carrier.layout not in (LAYOUT_COMPRESSED_FULL, LAYOUT_COMPRESSED_COMPACT):
        raise FixtureError("fragment metabox carrier is not compressed")
    add_metabox_fields(fragment, fragment_log, fragment_carrier.nid, False)
    _, fragment_carrier = fragment.resolve_root("/metabox-carrier.bin")
    header_offset = align(
        fragment_carrier.offset + fragment_carrier.inode_size + fragment_carrier.xattr_size,
        8,
    )
    fragment_header = fragment.u64(header_offset)
    if not fragment_header & METABOX_NID_BIT:
        raise FixtureError("metabox carrier is not a whole-file fragment")
    fragment_offset = fragment_header ^ METABOX_NID_BIT
    packed_inode = fragment.inode(fragment.packed_nid)
    if fragment_offset + fragment_carrier.size > packed_inode.size:
        raise FixtureError("positive fragment range exceeds packed inode")
    fragment_semantics = {
        "carrier_nid": fragment_carrier.nid,
        "carrier_layout": fragment_carrier.layout,
        "fragment_header_offset": header_offset,
        "fragment_header": fragment_header,
        "fragment_offset": fragment_offset,
        "carrier_size": fragment_carrier.size,
        "packed_nid": fragment.packed_nid,
        "packed_size": packed_inode.size,
    }
    save_variant(
        output,
        "metabox-fragment.erofs",
        fragment,
        fragment_log,
        transforms,
        "carrier-fragment-base.erofs",
        fragment_semantics,
    )

    bad_inline = basic.clone()
    bad_inline_log = TransformLog()
    storage, entry, _ = bad_inline.find_xattr("/corrupt-inline", b"bad")
    if storage != "inline":
        raise FixtureError("corrupt-inline target is not inline")
    bad_inline_log.u16(bad_inline, "/corrupt-inline.e_value_size", entry.offset + 2, 0xFFFF)
    save_variant(output, "bad-inline-entry.erofs", bad_inline, bad_inline_log, transforms, "basic.erofs")

    bad_shared = basic.clone()
    bad_shared_log = TransformLog()
    storage, shared_entry, _ = bad_shared.find_xattr("/shared-a", b"shared_key")
    if storage != "shared":
        raise FixtureError("shared corruption target is not shared")
    bad_shared_log.u16(bad_shared, "shared_key.e_value_size", shared_entry.offset + 2, 0xFFFF)
    save_variant(output, "bad-shared-entry.erofs", bad_shared, bad_shared_log, transforms, "basic.erofs")

    shared_oob = basic.clone()
    shared_oob_log = TransformLog()
    _, shared_inode = shared_oob.resolve_root("/shared-a")
    target_id = None
    target_array_index = None
    for array_index, shared_id in enumerate(shared_oob.shared_ids(shared_inode)):
        if shared_oob.shared_xattr(shared_id).name == b"shared_key":
            target_id = shared_id
            target_array_index = array_index
            break
    if target_id is None or target_array_index is None:
        raise FixtureError("shared OOB target ID not found")
    declared_end = shared_oob.declared_size
    sentinel = xattr_entry(XATTR_INDEX_USER, b"shared_key", b"sentinel-must-not-leak")
    shared_oob.data.extend(bytes(BLOCK_SIZE))
    shared_oob.data[declared_end : declared_end + len(sentinel)] = sentinel
    body = shared_inode.offset + shared_inode.inode_size
    shared_oob_log.u32(
        shared_oob,
        "/shared-a.shared_key_id",
        body + 12 + target_array_index * 4,
        declared_end // 4,
    )
    save_variant(
        output,
        "bad-shared-declared-bounds.erofs",
        shared_oob,
        shared_oob_log,
        transforms,
        "basic.erofs",
        {"sentinel_offset": declared_end, "old_shared_id": target_id},
    )

    prefix_oob = basic.clone()
    prefix_oob_log = TransformLog()
    prefix_declared_end = prefix_oob.declared_size
    sentinel_prefix = prefix_record(XATTR_INDEX_USER, b"sentinel-never-visible.")
    prefix_oob.data.extend(bytes(BLOCK_SIZE))
    prefix_oob.data[prefix_declared_end : prefix_declared_end + len(sentinel_prefix)] = sentinel_prefix
    prefix_oob_log.u32(
        prefix_oob,
        "super.feature_compat",
        SUPER + 8,
        prefix_oob.feature_compat | FEATURE_COMPAT_PLAIN_XATTR_PFX,
    )
    prefix_oob_log.u8(prefix_oob, "super.xattr_prefix_count", SUPER + 91, 1)
    prefix_oob_log.u32(
        prefix_oob,
        "super.xattr_prefix_start",
        SUPER + 92,
        prefix_declared_end // 4,
    )
    prefix_oob_log.u64(prefix_oob, "super.packed_nid", SUPER + 96, 0)
    save_variant(
        output,
        "bad-prefix-declared-bounds.erofs",
        prefix_oob,
        prefix_oob_log,
        transforms,
        "basic.erofs",
        {"sentinel_offset": prefix_declared_end},
    )

    truncated = basic.clone()
    truncated_log = TransformLog()
    _, truncated_carrier = truncated.resolve_root("/metabox-carrier.bin")
    truncated_log.u32(
        truncated,
        "super.feature_incompat",
        SUPER + 80,
        truncated.feature_incompat | FEATURE_INCOMPAT_METABOX,
    )
    truncated_log.u64(truncated, "super.metabox_nid", SUPER + 128, truncated_carrier.nid)
    save_variant(output, "bad-metabox-truncated-extension.erofs", truncated, truncated_log, transforms, "basic.erofs")

    ishare = basic.clone()
    ishare_log = TransformLog()
    ishare_log.u32(
        ishare,
        "super.feature_compat",
        SUPER + 8,
        ishare.feature_compat | FEATURE_COMPAT_ISHARE_XATTRS,
    )
    ishare_log.u8(
        ishare,
        "super.ishare_xattr_prefix_id",
        SUPER + 105,
        ishare.data[SUPER + 91],
    )
    save_variant(output, "bad-ishare-prefix-id.erofs", ishare, ishare_log, transforms, "basic.erofs")

    positive_fragment_path = output / "images" / "metabox-fragment.erofs"
    positive_fragment = ErofsImage.load(positive_fragment_path)
    positive_semantics = transforms["metabox-fragment.erofs"]["semantics"]

    self_loop = positive_fragment.clone()
    self_loop_log = TransformLog()
    self_loop_log.u64(
        self_loop,
        "super.packed_nid",
        SUPER + 96,
        int(positive_semantics["carrier_nid"]),
    )
    save_variant(output, "bad-fragment-self-loop.erofs", self_loop, self_loop_log, transforms, "metabox-fragment.erofs")

    range_bad = positive_fragment.clone()
    range_log = TransformLog()
    range_log.u64(
        range_bad,
        "metabox.fragment_header",
        int(positive_semantics["fragment_header_offset"]),
        METABOX_NID_BIT | int(positive_semantics["packed_size"]),
    )
    save_variant(output, "bad-fragment-range.erofs", range_bad, range_log, transforms, "metabox-fragment.erofs")

    recursive_metabox = positive_fragment.clone()
    recursive_metabox_log = TransformLog()
    recursive_metabox_log.u64(
        recursive_metabox,
        "super.metabox_nid",
        SUPER + 128,
        METABOX_NID_BIT | int(positive_semantics["carrier_nid"]),
    )
    save_variant(
        output,
        "bad-metabox-recursive-nid.erofs",
        recursive_metabox,
        recursive_metabox_log,
        transforms,
        "metabox-fragment.erofs",
    )

    recursive_packed = positive_fragment.clone()
    recursive_packed_log = TransformLog()
    recursive_packed_log.u64(
        recursive_packed,
        "super.packed_nid",
        SUPER + 96,
        METABOX_NID_BIT | int(positive_semantics["packed_nid"]),
    )
    save_variant(
        output,
        "bad-packed-recursive-nid.erofs",
        recursive_packed,
        recursive_packed_log,
        transforms,
        "metabox-fragment.erofs",
    )

    return {"base_commands": base_commands, "images": transforms}


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


def verify_output(output: Path) -> None:
    manifest = json.loads((output / "fixture-manifest.json").read_text(encoding="ascii"))
    inventory = source_inventory(output / "source")
    if inventory["sha256"] != manifest["source"]["sha256"]:
        raise FixtureError("source inventory hash changed")
    for name, item in manifest["transforms"]["images"].items():
        path = output / "images" / name
        if sha256_path(path) != item["image_sha256"]:
            raise FixtureError(f"{name}: image hash changed")
        data = path.read_bytes()
        for field in item["fields"]:
            offset = field["offset"]
            size = field["size"]
            if data[offset : offset + size].hex() != field["after_hex"]:
                raise FixtureError(f"{name}: field check failed: {field['field']}")
        image = ErofsImage.load(path)
        if image.declared_size != item["declared_size"]:
            raise FixtureError(f"{name}: declared size changed")
        if len(data) != item["provider_size"]:
            raise FixtureError(f"{name}: provider size changed")
    checksums = {}
    for line in (output / "IMAGE-SHA256SUMS").read_text(encoding="ascii").splitlines():
        digest, relative = line.split("  ", 1)
        checksums[relative] = digest
    for relative, digest in checksums.items():
        if sha256_path(output / relative) != digest:
            raise FixtureError(f"checksum list mismatch: {relative}")


def make(output: Path) -> None:
    if shutil.which("mkfs.erofs") is None:
        raise FixtureError("mkfs.erofs is required")
    if output.exists():
        raise FixtureError(f"output already exists: {output}")
    output.mkdir(parents=True)
    version = tool_version()
    source_details = make_sources(output)
    source = source_inventory(output / "source")
    bases = output / "bases"
    bases.mkdir()
    base_commands = {
        "basic-base.erofs": run_mkfs(
            output / "source" / "basic",
            bases / "basic-base.erofs",
            "44444444-4444-4444-8444-444444444404",
        ),
        "carrier-plain-base.erofs": run_mkfs(
            output / "source" / "carrier",
            bases / "carrier-plain-base.erofs",
            "44444444-4444-4444-8444-444444444434",
        ),
        "carrier-compressed-base.erofs": run_mkfs(
            output / "source" / "carrier",
            bases / "carrier-compressed-base.erofs",
            "44444444-4444-4444-8444-444444444440",
            "-zlz4",
            "-C4096",
            "-Elegacy-compress",
        ),
        "carrier-fragment-base.erofs": run_mkfs(
            output / "source" / "carrier",
            bases / "carrier-fragment-base.erofs",
            "44444444-4444-4444-8444-444444444442",
            "-zlz4",
            "-C4096",
            "-Eall-fragments",
        ),
    }
    transforms = make_variants(output, base_commands)
    write_checksums(output)
    manifest = {
        "generator": "g4_fixtures.py",
        "mkfs_version": version,
        "source": source,
        "source_details": source_details,
        "transforms": transforms,
    }
    (output / "fixture-manifest.json").write_text(
        json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="ascii"
    )
    (output / "SOURCE-SHA256").write_text(f"{source['sha256']}  source-inventory\n", encoding="ascii")
    (output / "mkfs-version.txt").write_text(version + "\n", encoding="ascii")
    verify_output(output)
    print(f"source_sha256={source['sha256']}")
    print((output / "IMAGE-SHA256SUMS").read_text(encoding="ascii"), end="")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    subparsers = parser.add_subparsers(dest="command", required=True)
    make_parser = subparsers.add_parser("make")
    make_parser.add_argument("--output", required=True, type=Path)
    verify_parser = subparsers.add_parser("verify")
    verify_parser.add_argument("--output", required=True, type=Path)
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    if args.command == "make":
        make(args.output)
    else:
        verify_output(args.output)


if __name__ == "__main__":
    try:
        main()
    except (FixtureError, OSError, subprocess.CalledProcessError) as error:
        raise SystemExit(f"g4_fixtures.py: {error}") from error
