Files
erofs-freebsd-out-tree/tests/g4_fixtures.py
T
2026-08-18 09:20:44 +02:00

1278 lines
46 KiB
Python
Executable File

#!/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