Files
erofs-freebsd-out-tree/tests/pre15/gates/P15-019.sh
T
2026-08-18 09:20:44 +02:00

965 lines
37 KiB
Bash
Executable File

#!/bin/sh
set -eu
umask 022
gate_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
root=$(CDPATH= cd -- "$gate_dir/../../../.." && pwd -P)
input=$gate_dir/P15-019-input.json
freebsd_src=${FREEBSD_SRC:-/work/build/freebsd-src}
utils_src=${EROFS_UTILS_SRC:-/work/build/erofs-utils-main}
base=
output=
while test "$#" -gt 0; do
case "$1" in
--base)
test "$#" -ge 2 || { printf '%s\n' '--base requires a commit' >&2; exit 2; }
base=$2
shift 2
;;
--output)
test "$#" -ge 2 || { printf '%s\n' '--output requires a directory' >&2; exit 2; }
output=$2
shift 2
;;
*)
printf 'unknown argument: %s\n' "$1" >&2
exit 2
;;
esac
done
test -n "$base" || { printf '%s\n' '--base is required' >&2; exit 2; }
test -n "$output" || { printf '%s\n' '--output is required' >&2; exit 2; }
test -f "$input" || { printf 'missing input: %s\n' "$input" >&2; exit 2; }
test -d "$freebsd_src/sys" || { printf 'missing FreeBSD source: %s\n' "$freebsd_src" >&2; exit 2; }
test -d "$utils_src/lib" || { printf 'missing erofs-utils source: %s\n' "$utils_src" >&2; exit 2; }
for tool in dump.erofs fsck.erofs git mkfs.erofs python3 sha256sum timeout; do
command -v "$tool" >/dev/null 2>&1 || {
printf 'missing required host tool: %s\n' "$tool" >&2
exit 2
}
done
case "$output" in
/*) ;;
*) output=$PWD/$output ;;
esac
test ! -e "$output" || { printf 'refusing existing output: %s\n' "$output" >&2; exit 2; }
mkdir -p "$output/host"
python3 -B - "$root" "$input" "$base" "$output/host" "$freebsd_src" "$utils_src" <<'PY'
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import stat
import struct
import subprocess
import sys
import tempfile
import traceback
from typing import Any
ROOT = Path(sys.argv[1])
INPUT = Path(sys.argv[2])
REQUESTED_BASE = sys.argv[3]
OUTPUT = Path(sys.argv[4])
FREEBSD_SRC = Path(sys.argv[5])
UTILS_SRC = Path(sys.argv[6])
SPEC = json.loads(INPUT.read_text(encoding="ascii"))
DUT = ROOT / "repo-pre-15"
SUPER = 1024
MAGIC = 0xE0F5E1E2
CRC32C_POLY = 0x82F63B78
FEATURE_COMPAT_SB_CHKSUM = 0x00000001
FEATURE_INCOMPAT_CHUNKED_FILE = 0x00000004
EROFS_FT_REG_FILE = 1
EROFS_FT_SYMLINK = 7
S_IFMT = 0o170000
S_IFREG = 0o100000
S_IFLNK = 0o120000
CHUNK_FORMAT_INDEXES = 0x0020
LAYOUT_NAMES = {
0: "plain",
1: "compressed-full",
2: "inline",
3: "compressed-compact",
4: "chunk",
}
class GateStop(Exception):
pass
class InfraBlocked(Exception):
pass
def sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def sha256_path(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="ascii")
def git(path: Path, *args: str) -> str:
completed = subprocess.run(
["git", "-C", str(path), *args],
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
if completed.returncode != 0:
raise InfraBlocked(f"git {' '.join(args)} failed: {completed.stdout.strip()}")
return completed.stdout.strip()
COMMANDS: list[dict[str, Any]] = []
def display_arg(value: str, work: Path) -> str:
replacements = (
(str(work), "OWNED_WORK"),
(str(OUTPUT), "GATE_OUTPUT"),
(str(ROOT), "ROOT"),
)
for old, new in replacements:
value = value.replace(old, new)
return value
def run(argv: list[str], work: Path, timeout: int = 30) -> subprocess.CompletedProcess[str]:
try:
completed = subprocess.run(
argv,
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=timeout,
)
except subprocess.TimeoutExpired as error:
raise InfraBlocked(f"command timed out: {' '.join(argv)}") from error
COMMANDS.append(
{
"argv": [display_arg(item, work) for item in argv],
"exit": completed.returncode,
"stdout_sha256": sha256_bytes(completed.stdout.encode("utf-8")),
}
)
return completed
def run_required(argv: list[str], work: Path, timeout: int = 30) -> str:
completed = run(argv, work, timeout)
if completed.returncode != 0:
raise InfraBlocked(
f"command failed ({completed.returncode}): {' '.join(argv)}: "
f"{completed.stdout.strip()}"
)
return completed.stdout
def crc32c(data: bytes | bytearray, seed: int = 0xFFFFFFFF) -> int:
value = seed
for byte in data:
value ^= byte
for _ in range(8):
value = (value >> 1) ^ (CRC32C_POLY if value & 1 else 0)
return value & 0xFFFFFFFF
class Inode:
def __init__(
self,
nid: int,
offset: int,
inode_format: int,
inode_size: int,
xattr_size: int,
layout: int,
mode: int,
size: int,
start_block: int,
):
self.nid = nid
self.offset = offset
self.inode_format = inode_format
self.inode_size = inode_size
self.xattr_size = xattr_size
self.layout = layout
self.mode = mode
self.size = size
self.start_block = start_block
class DirectoryEntry:
def __init__(self, nid: int, offset: int, file_type: int, name: bytes):
self.nid = nid
self.offset = offset
self.file_type = file_type
self.name = name
class Image:
def __init__(self, data: bytes | bytearray):
self.data = bytearray(data)
if len(self.data) < SUPER + 128 or self.u32(SUPER) != MAGIC:
raise GateStop("fixture is not an EROFS image")
self.block_bits = self.data[SUPER + 12]
if self.block_bits < 9 or self.block_bits > 16:
raise GateStop("fixture has invalid block bits")
self.block_size = 1 << self.block_bits
self.meta_blkaddr = self.u32(SUPER + 40)
self.feature_compat = self.u32(SUPER + 8)
self.feature_incompat = self.u32(SUPER + 80)
self.blocks = self.u32(SUPER + 36)
self.root_nid = self.u16(SUPER + 14)
self.packed_nid = self.u64(SUPER + 96)
if self.blocks == 0 or self.blocks << self.block_bits > len(self.data):
raise GateStop("fixture primary image bounds are invalid")
self.verify_checksum()
@classmethod
def load(cls, path: Path) -> "Image":
return cls(path.read_bytes())
def u16(self, offset: int) -> int:
if offset < 0 or offset + 2 > len(self.data):
raise GateStop("u16 read outside image")
return struct.unpack_from("<H", self.data, offset)[0]
def u32(self, offset: int) -> int:
if offset < 0 or offset + 4 > len(self.data):
raise GateStop("u32 read outside image")
return struct.unpack_from("<I", self.data, offset)[0]
def u64(self, offset: int) -> int:
if offset < 0 or offset + 8 > len(self.data):
raise GateStop("u64 read outside image")
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 checksum_end(self) -> int:
span = self.block_size - SUPER if self.block_size > SUPER else self.block_size
end = SUPER + span
if end > len(self.data):
raise GateStop("checksum span exceeds image")
return end
def calculated_checksum(self) -> int:
block = bytearray(self.data[SUPER : self.checksum_end])
block[4:8] = bytes(4)
return crc32c(block)
def verify_checksum(self) -> None:
if self.feature_compat & FEATURE_COMPAT_SB_CHKSUM:
if self.u32(SUPER + 4) != self.calculated_checksum():
raise GateStop("fixture checksum is invalid")
def update_checksum(self) -> None:
if not self.feature_compat & FEATURE_COMPAT_SB_CHKSUM:
raise GateStop("fixture does not advertise a checksum")
self.put_u32(SUPER + 4, 0)
self.put_u32(SUPER + 4, self.calculated_checksum())
self.verify_checksum()
def inode(self, nid: int) -> Inode:
offset = (self.meta_blkaddr << self.block_bits) + (nid << 5)
if offset > len(self.data) - 32:
raise GateStop(f"nid {nid} lies outside the primary image")
inode_format = self.u16(offset)
inode_size = 64 if inode_format & 1 else 32
if offset > len(self.data) - inode_size:
raise GateStop(f"nid {nid} 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)
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 uncompressed_data(self, inode: Inode, blob: bytes | None = None) -> bytes:
if inode.layout == 2:
offset = inode.offset + inode.inode_size + inode.xattr_size
if (offset & (self.block_size - 1)) + inode.size > self.block_size:
raise GateStop("inline fixture crosses its metadata block")
source = self.data
elif inode.layout == 0:
offset = inode.start_block << self.block_bits
source = self.data
elif inode.layout == 4:
if blob is None:
raise GateStop("chunk fixture has no external blob")
chunk_format = self.u16(inode.offset + 16)
entry_size = 8 if chunk_format & CHUNK_FORMAT_INDEXES else 4
index_base = (inode.offset + inode.inode_size + inode.xattr_size + entry_size - 1) & ~(entry_size - 1)
output = bytearray()
logical = 0
chunk_size = 1 << (self.block_bits + (chunk_format & 0x1F))
while logical < inode.size:
index = logical // chunk_size
position = index_base + index * entry_size
if entry_size == 8:
high, device_id, low = struct.unpack_from("<HHI", self.data, position)
block = low | high << 32
if device_id != 1:
raise GateStop(f"chunk fixture device id is {device_id}, not 1")
source = blob
else:
block = self.u32(position)
source = self.data
count = min(chunk_size, inode.size - logical)
offset = block << self.block_bits
if offset > len(source) or count > len(source) - offset:
raise GateStop("chunk payload exceeds its device")
output.extend(source[offset : offset + count])
logical += count
return bytes(output)
else:
raise GateStop(f"layout {inode.layout} is not uncompressed")
if offset > len(source) or inode.size > len(source) - offset:
raise GateStop("uncompressed payload exceeds its image")
return bytes(source[offset : offset + inode.size])
def directory_entries(self, inode: Inode) -> list[DirectoryEntry]:
data = self.uncompressed_data(inode)
if len(data) < 12:
raise GateStop("root directory is too short")
first_name = struct.unpack_from("<H", data, 8)[0]
if first_name < 12 or first_name % 12 != 0 or first_name >= len(data):
raise GateStop("root directory has an invalid first name offset")
count = first_name // 12
entries = []
for index in range(count):
slot = index * 12
name_start = struct.unpack_from("<H", data, slot + 8)[0]
name_end = (
struct.unpack_from("<H", data, slot + 20)[0]
if index + 1 < count
else len(data)
)
if name_start >= name_end or name_end > len(data):
raise GateStop("root directory name bounds are invalid")
name = data[name_start:name_end].split(b"\0", 1)[0]
data_offset = (
inode.offset + inode.inode_size + inode.xattr_size
if inode.layout == 2
else inode.start_block << self.block_bits
)
entries.append(
DirectoryEntry(
nid=struct.unpack_from("<Q", data, slot)[0],
offset=data_offset + slot,
file_type=data[slot + 10],
name=name,
)
)
return entries
def resolve(self, name: bytes = b"link") -> tuple[Inode, DirectoryEntry, Inode]:
root = self.inode(self.root_nid)
entry = next((item for item in self.directory_entries(root) if item.name == name), None)
if entry is None:
raise GateStop("fixture root has no /link entry")
return root, entry, self.inode(entry.nid)
def lz4_decode_exact(source: bytes, output_size: int) -> bytes:
ip = 0
output = bytearray()
while ip < len(source):
token = source[ip]
ip += 1
literal_length = token >> 4
if literal_length == 15:
while True:
if ip >= len(source):
raise ValueError("truncated literal length")
value = source[ip]
ip += 1
literal_length += value
if value != 255:
break
if ip + literal_length > len(source):
raise ValueError("truncated literals")
output.extend(source[ip : ip + literal_length])
ip += literal_length
if ip == len(source):
break
if ip + 2 > len(source):
raise ValueError("truncated match offset")
offset = source[ip] | source[ip + 1] << 8
ip += 2
if offset == 0 or offset > len(output):
raise ValueError("invalid match offset")
match_length = token & 15
if match_length == 15:
while True:
if ip >= len(source):
raise ValueError("truncated match length")
value = source[ip]
ip += 1
match_length += value
if value != 255:
break
for _ in range(match_length + 4):
output.append(output[-offset])
if len(output) > output_size:
raise ValueError("decoded output exceeds inode size")
if ip != len(source) or len(output) != output_size:
raise ValueError("raw LZ4 size mismatch")
return bytes(output)
def read_compressed_inline(image: Image, inode: Inode) -> tuple[bytes, dict[str, int]]:
header = (inode.offset + inode.inode_size + inode.xattr_size + 7) & ~7
if header > len(image.data) - 8:
raise GateStop("compressed map header exceeds image")
raw0, advise, algorithm, clusterbits = struct.unpack_from("<IHBB", image.data, header)
idata_size = raw0 >> 16
if inode.layout != 3 or not advise & 0x0008 or idata_size == 0:
raise GateStop("compressed fixture is not a compact inline-pcluster inode")
block_end = min((inode.offset | (image.block_size - 1)) + 1, len(image.data))
matches = []
for position in range(header + 8, block_end - idata_size + 1):
encoded = bytes(image.data[position : position + idata_size])
try:
decoded = lz4_decode_exact(encoded, inode.size)
except ValueError:
continue
matches.append((position, decoded))
if len(matches) != 1:
raise GateStop(f"compressed fixture has {len(matches)} independent decode candidates")
return matches[0][1], {
"map_header": header,
"advise": advise,
"algorithm": algorithm & 0x0F,
"clusterbits": clusterbits,
"idata_size": idata_size,
"encoded_offset": matches[0][0],
}
def read_fragment(image: Image, inode: Inode) -> tuple[bytes, dict[str, int]]:
header = (inode.offset + inode.inode_size + inode.xattr_size + 7) & ~7
raw = image.u64(header)
if inode.layout != 1 or raw & (1 << 63) == 0:
raise GateStop("fragment fixture lacks the whole-fragment header")
if image.packed_nid == 0 or image.packed_nid == inode.nid:
raise GateStop("fragment fixture has an invalid packed nid")
fragment_offset = raw ^ (1 << 63)
packed = image.inode(image.packed_nid)
packed_data = image.uncompressed_data(packed)
if fragment_offset > len(packed_data) or inode.size > len(packed_data) - fragment_offset:
raise GateStop("fragment target exceeds packed inode")
return bytes(packed_data[fragment_offset : fragment_offset + inode.size]), {
"map_header": header,
"fragment_offset": fragment_offset,
"packed_nid": image.packed_nid,
"packed_layout": packed.layout,
"packed_size": packed.size,
}
def target_bytes(length: int, nul_position: str | None = None) -> bytes:
pattern = b"p15-019-safe-target/"
target = bytearray((pattern * ((length + len(pattern) - 1) // len(pattern)))[:length])
if nul_position == "first":
target[0] = 0
elif nul_position == "middle":
target[length // 2] = 0
elif nul_position == "last":
target[-1] = 0
return bytes(target)
def protected_state() -> dict[str, Any]:
protected = SPEC["protected"]
base = Path(protected["base_image"])
base_stat = base.stat() if base.exists() else None
pid_path = Path("/proc") / str(protected["pid"])
command = None
if (pid_path / "cmdline").exists():
command = (pid_path / "cmdline").read_bytes().replace(b"\0", b" ").decode("utf-8", "replace").strip()
port_hex = f"{protected['port']:04X}"
listeners = 0
for table in (Path("/proc/net/tcp"), Path("/proc/net/tcp6")):
if not table.exists():
continue
for line in table.read_text(encoding="ascii").splitlines()[1:]:
fields = line.split()
if len(fields) >= 4 and fields[1].endswith(f":{port_hex}") and fields[3] == "0A":
listeners += 1
return {
"base": None if base_stat is None else {
"device": base_stat.st_dev,
"inode": base_stat.st_ino,
"mtime_ns": base_stat.st_mtime_ns,
"size": base_stat.st_size,
},
"pid": protected["pid"],
"pid_command_sha256": None if command is None else sha256_bytes(command.encode("utf-8")),
"port": protected["port"],
"port_listener_count": listeners,
}
def verify_identities() -> dict[str, Any]:
required = SPEC["required_base"]
resolved = git(ROOT, "rev-parse", REQUESTED_BASE)
if resolved != required:
raise GateStop(f"requested base {resolved} is not required base {required}")
source_dirty = git(ROOT, "diff", "--name-only", required, "--", "repo-pre-15/src")
if source_dirty:
raise GateStop(f"production source differs from frozen base: {source_dirty}")
source_hashes = {}
for relative, expected in SPEC["source_sha256"].items():
content = subprocess.run(
["git", "-C", str(ROOT), "show", f"{required}:{relative}"],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if content.returncode != 0:
raise InfraBlocked(f"cannot read frozen source {relative}")
actual = sha256_bytes(content.stdout)
if actual != expected:
raise GateStop(f"frozen source hash differs for {relative}")
source_hashes[relative] = actual
if git(FREEBSD_SRC, "rev-parse", "HEAD") != SPEC["freebsd"]["head"]:
raise GateStop("FreeBSD source HEAD differs")
for relative, expected in SPEC["freebsd"]["sha256"].items():
if sha256_path(FREEBSD_SRC / relative) != expected:
raise GateStop(f"FreeBSD source hash differs for {relative}")
if git(UTILS_SRC, "rev-parse", "HEAD") != SPEC["utils"]["head"]:
raise GateStop("erofs-utils source HEAD differs")
for relative, expected in SPEC["utils"]["sha256"].items():
actual = sha256_path(UTILS_SRC / relative)
if actual != expected:
raise GateStop(f"erofs-utils source hash differs for {relative}")
for name, tool in SPEC["tools"].items():
path = Path(tool["path"])
if sha256_path(path) != tool["sha256"]:
raise GateStop(f"tool hash differs for {name}")
version = subprocess.run(
[SPEC["tools"]["mkfs.erofs"]["path"], "-V"],
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
).stdout.splitlines()[0]
if version != SPEC["tools"]["mkfs.erofs"]["version"]:
raise GateStop(f"mkfs version differs: {version}")
return {"base": resolved, "source_sha256": source_hashes, "mkfs_version": version}
def verify_semantics() -> dict[str, Any]:
linux = (ROOT / "src-linux/inode.c").read_text(encoding="utf-8")
freebsd_inode = (DUT / "src/inode.c").read_text(encoding="utf-8")
freebsd_data = (DUT / "src/data.c").read_text(encoding="utf-8")
freebsd_vnops = (DUT / "src/erofs_vnops.c").read_text(encoding="utf-8")
lookup = (FREEBSD_SRC / "sys/kern/vfs_lookup.c").read_text(encoding="utf-8")
errno_h = (FREEBSD_SRC / "sys/sys/errno.h").read_text(encoding="utf-8")
syslimits = (FREEBSD_SRC / "sys/sys/syslimits.h").read_text(encoding="utf-8")
linux_markers = (
"vi->datalayout == EROFS_INODE_FLAT_INLINE",
"kmemdup_nul(bptr + ofs, inode->i_size, GFP_KERNEL)",
"!inode->i_size || strlen(link) != inode->i_size",
"return -EFSCORRUPTED",
".get_link = page_get_link",
".get_link = simple_get_link",
)
freebsd_markers = (
"return (erofs_read_uio(MTOE(vp->v_mount), VTOE(vp), uio));",
"return (erofs_readlink_target(ap->a_vp, ap->a_uio));",
".vop_readlink = erofs_readlink",
)
if not all(marker in linux for marker in linux_markers):
raise GateStop("Linux fast/page symlink path anchors changed")
if not all(marker in freebsd_data + freebsd_vnops for marker in freebsd_markers):
raise GateStop("FreeBSD vnode/readlink path anchors changed")
if "erofs_validate_symlink" in freebsd_inode + freebsd_data + freebsd_vnops:
raise GateStop("P15-019 production implementation already exists at the gate base")
if "aiov.iov_len = MAXPATHLEN;" not in lookup or "linklen == 0" not in lookup:
raise GateStop("FreeBSD namei readlink boundary anchors changed")
expected_errno = SPEC["errno"]
for name, value in expected_errno.items():
if re.search(rf"#define\s+{name}\s+{value}\b", errno_h) is None:
raise GateStop(f"FreeBSD errno {name} is not {value}")
maxpath = SPEC["freebsd"]["maxpathlen"]
if re.search(rf"#define\s+PATH_MAX\s+{maxpath}\b", syslimits) is None:
raise GateStop("FreeBSD PATH_MAX differs")
if re.search(r"return\s*\(\s*-E[A-Z0-9_]+", freebsd_inode + freebsd_data + freebsd_vnops):
raise GateStop("FreeBSD EROFS source contains Linux negative errno returns")
return {
"linux": {
"fast_inline_validation": True,
"noninline_get_link": "page_get_link",
"errno_convention": "negative Linux errno",
},
"freebsd": {
"entry": "VOP_READLINK",
"path": "vnode -> erofs_readlink_target -> map/read -> GEOM or compressed backing",
"maxpathlen": maxpath,
"empty_namei_baseline": "ENOENT after zero-byte VOP_READLINK success",
"errno_convention": "positive FreeBSD errno",
},
"candidate": {
"empty": "EINTEGRITY",
"embedded_nul": "EINTEGRITY before uiomove",
"oversize": "ENAMETOOLONG before target I/O",
"normal": "exact byte target; no trailing NUL required",
},
}
def make_source(source: Path, payload: bytes, native_symlink: bool) -> None:
source.mkdir()
source.chmod(0o755)
link = source / "link"
if native_symlink:
os.symlink(payload.decode("ascii"), link)
else:
link.write_bytes(payload)
link.chmod(0o644)
os.utime(link, (0, 0), follow_symlinks=False)
os.utime(source, (0, 0), follow_symlinks=False)
def mkfs_case(
layout: str,
case_id: str,
payload: bytes,
native_symlink: bool,
work: Path,
fixtures: Path,
) -> tuple[Path, Path | None, list[int]]:
source = work / f"source-{layout}-{case_id}"
make_source(source, payload, native_symlink)
image_path = fixtures / f"{layout}-{case_id}.erofs"
blob_path = fixtures / f"{layout}-{case_id}.blob" if layout == "chunk" else None
options: list[str] = []
if layout == "plain":
options = ["-E^inline_data"]
elif layout == "chunk":
assert blob_path is not None
blob_path.write_bytes(b"")
options = ["--chunksize=4096", f"--blobdev={blob_path}"]
elif layout == "compressed":
options = ["-zlz4", "-C4096", "-Eztailpacking"]
elif layout == "fragment":
options = ["-zlz4", "-C4096", "-Eall-fragments"]
command = [
SPEC["tools"]["mkfs.erofs"]["path"],
"-T0",
"--all-time",
"--all-root",
"--workers=1",
"--sort=path",
"-x-1",
f"-U{SPEC['uuid']}",
*options,
str(image_path),
str(source),
]
run_required(command, work)
before = image_path.read_bytes()
image = Image(before)
_, entry, inode = image.resolve()
changed = []
if stat.S_IFMT(inode.mode) == S_IFREG:
image.put_u16(inode.offset + 4, S_IFLNK | 0o777)
image.data[entry.offset + 10] = EROFS_FT_SYMLINK
elif stat.S_IFMT(inode.mode) != S_IFLNK or entry.file_type != EROFS_FT_SYMLINK:
raise GateStop(f"{layout}/{case_id}: source inode type is unexpected")
if case_id == "empty":
if inode.inode_size == 64:
image.put_u64(inode.offset + 8, 0)
else:
image.put_u32(inode.offset + 8, 0)
image.update_checksum()
after = bytes(image.data)
for offset, (old, new) in enumerate(zip(before, after)):
if old != new:
changed.append(offset)
allowed = set(range(SUPER + 4, SUPER + 8))
allowed.update(range(inode.offset + 4, inode.offset + 6))
allowed.add(entry.offset + 10)
if case_id == "empty":
allowed.update(range(inode.offset + 8, inode.offset + (16 if inode.inode_size == 64 else 12)))
unexpected = sorted(set(changed) - allowed)
if unexpected:
raise GateStop(f"{layout}/{case_id}: transform changed unexpected offsets {unexpected}")
image_path.write_bytes(after)
return image_path, blob_path, changed
def expected_case(layout: str, case_id: str) -> tuple[int, bytes, str | None, str]:
short = SPEC["layouts"][layout]["short_length"]
if case_id == "normal-short":
return short, target_bytes(short), None, "PASS"
if case_id == "normal-max":
limit = SPEC["freebsd"]["maxpathlen"]
return limit, target_bytes(limit), None, "PASS"
if case_id == "empty":
return 0, target_bytes(short), None, "EINTEGRITY"
if case_id.startswith("nul-"):
position = case_id.removeprefix("nul-")
limit = SPEC["freebsd"]["maxpathlen"]
return limit, target_bytes(limit, position), position, "EINTEGRITY"
if case_id == "too-long":
length = SPEC["freebsd"]["maxpathlen"] + 1
return length, target_bytes(length), None, "ENAMETOOLONG"
raise AssertionError(case_id)
def independent_target(image: Image, inode: Inode, blob: bytes | None) -> tuple[bytes, dict[str, Any]]:
if inode.layout in (0, 2, 4):
return image.uncompressed_data(inode, blob), {}
if inode.layout == 3:
return read_compressed_inline(image, inode)
if inode.layout == 1:
return read_fragment(image, inode)
raise GateStop(f"unsupported target layout {inode.layout}")
def oracle(image: Image, inode: Inode, blob: bytes | None) -> tuple[str, bytes | None, dict[str, Any]]:
if inode.size == 0:
return "EINTEGRITY", None, {"point": "symlink.empty"}
if inode.size > SPEC["freebsd"]["maxpathlen"]:
return "ENAMETOOLONG", None, {"point": "symlink.maxpathlen"}
target, details = independent_target(image, inode, blob)
nul = target.find(b"\0")
if nul >= 0:
return "EINTEGRITY", target, {"point": "symlink.embedded-nul", "nul_offset": nul, **details}
return "PASS", target, {"point": "symlink.valid", **details}
def fixture_set_hash(fixtures: Path) -> tuple[str, dict[str, str]]:
hashes = {}
for path in sorted(item for item in fixtures.iterdir() if item.is_file()):
if path.name == "SHA256SUMS":
continue
hashes[path.name] = sha256_path(path)
lines = "".join(f"{digest} {name}\n" for name, digest in hashes.items())
(fixtures / "SHA256SUMS").write_text(lines, encoding="ascii")
return sha256_bytes(lines.encode("ascii")), hashes
def execute() -> dict[str, Any]:
if SPEC.get("schema") != 1 or SPEC.get("candidate") != "P15-019" or SPEC.get("gate") != "G03":
raise InfraBlocked("input identity is invalid")
identity = verify_identities()
semantics = verify_semantics()
write_json(OUTPUT / "semantics.json", semantics)
fixtures = OUTPUT / "fixtures"
fixtures.mkdir()
cases = (
"normal-short",
"normal-max",
"empty",
"nul-first",
"nul-middle",
"nul-last",
"too-long",
)
records = []
with tempfile.TemporaryDirectory(prefix=".p15-019-work-", dir=OUTPUT) as raw_work:
work = Path(raw_work)
for layout, layout_spec in sorted(SPEC["layouts"].items()):
for case_id in cases:
expected_size, payload, nul_position, expected = expected_case(layout, case_id)
native = layout == "inline" and nul_position is None
image_path, blob_path, changed = mkfs_case(
layout, case_id, payload, native, work, fixtures
)
image = Image.load(image_path)
_, entry, inode = image.resolve()
if inode.layout != layout_spec["expected_layout"]:
raise GateStop(
f"{layout}/{case_id}: layout {inode.layout} != {layout_spec['expected_layout']}"
)
if stat.S_IFMT(inode.mode) != S_IFLNK or entry.file_type != EROFS_FT_SYMLINK:
raise GateStop(f"{layout}/{case_id}: transformed inode is not a symlink")
if inode.size != expected_size:
raise GateStop(f"{layout}/{case_id}: inode size {inode.size} != {expected_size}")
blob = blob_path.read_bytes() if blob_path is not None else None
observed, target, details = oracle(image, inode, blob)
if observed != expected:
raise GateStop(f"{layout}/{case_id}: oracle {observed} != {expected}")
if expected != "EINTEGRITY" or case_id == "empty":
if case_id != "empty" and target is not None and target != payload:
raise GateStop(f"{layout}/{case_id}: target bytes differ from source")
if case_id.startswith("nul-"):
expected_nul = {"first": 0, "middle": len(payload) // 2, "last": len(payload) - 1}[nul_position]
if details.get("nul_offset") != expected_nul:
raise GateStop(f"{layout}/{case_id}: NUL offset differs")
dump = run(
[SPEC["tools"]["dump.erofs"]["path"], "--path=/link", str(image_path)],
work,
)
dump_layout = re.search(r"Layout:\s+(\d+)", dump.stdout)
dump_mode = "symlink file" in dump.stdout
if dump.returncode != 0 or dump_layout is None or int(dump_layout.group(1)) != inode.layout or not dump_mode:
raise GateStop(f"{layout}/{case_id}: dump.erofs cross-check failed")
fsck_argv = [SPEC["tools"]["fsck.erofs"]["path"], "-d0"]
if blob_path is not None:
fsck_argv.append(f"--device={blob_path}")
fsck_argv.append(str(image_path))
fsck = run(fsck_argv, work)
extracted = False
if expected == "PASS":
extract = work / f"extract-{layout}-{case_id}"
extract.mkdir()
extract_argv = [
SPEC["tools"]["fsck.erofs"]["path"],
f"--extract={extract}",
"--no-preserve",
]
if blob_path is not None:
extract_argv.append(f"--device={blob_path}")
extract_argv.append(str(image_path))
run_required(extract_argv, work)
extracted_link = extract / "link"
if not extracted_link.is_symlink() or os.readlink(extracted_link).encode("ascii") != payload:
raise GateStop(f"{layout}/{case_id}: fsck extraction target differs")
extracted = True
records.append(
{
"case": case_id,
"changed_offsets": changed,
"dump_cross_check": "PASS",
"expected": expected,
"fsck_check_exit": fsck.returncode,
"fsck_extract": extracted,
"image": image_path.name,
"inode": {
"layout": inode.layout,
"layout_name": LAYOUT_NAMES[inode.layout],
"mode": oct(inode.mode),
"nid": inode.nid,
"size": inode.size,
},
"layout": layout,
"oracle": details,
"source_payload_sha256": sha256_bytes(payload),
"target_sha256": None if target is None else sha256_bytes(target),
}
)
fixture_sha256, hashes = fixture_set_hash(fixtures)
expected_fixture = SPEC.get("expected_fixture_set_sha256", "")
if expected_fixture and fixture_sha256 != expected_fixture:
raise GateStop(
f"fixture set hash {fixture_sha256} != frozen {expected_fixture}"
)
write_json(OUTPUT / "cases.json", records)
write_json(OUTPUT / "commands.json", COMMANDS)
by_layout = {
layout: {
record["case"]: record["expected"]
for record in records
if record["layout"] == layout
}
for layout in sorted(SPEC["layouts"])
}
required = set(cases)
if any(set(result) != required for result in by_layout.values()):
raise GateStop("one or more layouts lack a complete oracle")
return {
"candidate": "P15-019",
"case_count": len(records),
"decision": "GO",
"fixture_file_count": len(hashes),
"fixture_set_sha256": fixture_sha256,
"gate": "G03",
"identity": identity,
"layout_results": by_layout,
"qemu": "NOT_RUN: Stage0 host fixture/oracle is complete",
"source_modified": False,
}
before_protected = protected_state()
status = "INFRA_BLOCKED"
exit_code = 2
result: dict[str, Any]
try:
result = execute()
status = "GO"
exit_code = 0
except GateStop as error:
status = "STOP"
exit_code = 1
result = {"candidate": "P15-019", "decision": status, "reason": str(error)}
except (InfraBlocked, OSError, ValueError, KeyError, json.JSONDecodeError) as error:
result = {"candidate": "P15-019", "decision": status, "reason": str(error)}
except Exception as error:
result = {
"candidate": "P15-019",
"decision": status,
"reason": f"unexpected gate error: {error}",
"traceback": traceback.format_exc(),
}
after_protected = protected_state()
cleanup = {
"owned_temp_remaining": sorted(path.name for path in OUTPUT.glob(".p15-019-work-*")),
"protected_before": before_protected,
"protected_after": after_protected,
"protected_unchanged": before_protected == after_protected,
}
write_json(OUTPUT / "cleanup.json", cleanup)
if cleanup["owned_temp_remaining"] or not cleanup["protected_unchanged"]:
status = "INFRA_BLOCKED"
exit_code = 2
result = {
"candidate": "P15-019",
"decision": status,
"reason": "cleanup or protected-resource identity changed",
}
result["cleanup_sha256"] = sha256_path(OUTPUT / "cleanup.json")
result["input_sha256"] = sha256_path(INPUT)
write_json(OUTPUT / "result.json", result)
print(f"P15-019 G03: {status}")
if "fixture_set_sha256" in result:
print(f"fixture_set_sha256={result['fixture_set_sha256']}")
if "reason" in result:
print(f"reason={result['reason']}")
sys.exit(exit_code)
PY