Viewing files at commit 21316fe59317Commit detailsView latest

Browse files
scripts/oakbench/conformance_matrix.py 829 lines · 44.1 KB · python
1
"""Roundtrip conformance matrix: author -> commit -> push -> fresh clone (+ pull) -> compare.
2
3
Every behaviour has an immutable ID (ADR-0005). The expected state is what the
4
harness itself wrote to the filesystem (never a VCS answer). Each subject run
5
compares that expectation against:
6
7
- ``commit_read`` - the committed tree of the authoring repository, read by an
8
  independent instrument: Oak through ``oak_sqlite_reader`` (pure-Python
9
  BLAKE3/zstd, no Oak code), Git through its plumbing (``ls-tree``/``cat-file``);
10
  checked after every step, so intermediate commits (deletes, transitions) count;
11
- ``source_preserved`` - the authoring worktree is not rewritten by commit/push
12
  (checked after every step);
13
- ``clone_fs`` / ``clone_read`` / ``clone_status_clean`` - a fresh clone of the
14
  pushed branch: filesystem bytes, independently read committed state, and the
15
  subject's own claim that the fresh clone is clean;
16
- ``pull_fs`` / ``pull_read`` / ``pull_status_clean`` - multi-step behaviours
17
  only: a clone taken after step 1 and updated with ``pull``.
18
19
A Git differential is recorded on Oak rows: Oak's independently read committed
20
tree against Git's committed tree for the same authored fixture (paths, bytes,
21
executable bit, symlink targets). Records are diagnostic conformance evidence
22
(``evidence_class: diagnostic_conformance``), never timing rows.
23
"""
24
from __future__ import annotations
25
26
from dataclasses import dataclass
27
import hashlib
28
import json
29
import os
30
from pathlib import Path
31
import shutil
32
import socket
33
import stat
34
import subprocess
35
import tempfile
36
import time
37
38
from oakbench.environment import base_env
39
40
MATRIX_VERSION = "2026-09-28.1"
41
ORACLE = "roundtrip_independent_v1"
42
SCHEMA_VERSION = 2
43
HUGE_ENV = "OAK_BENCH_CONFORMANCE_HUGE"
44
COMMAND_TIMEOUT_S = 180
45
HUGE_TIMEOUT_S = 3600
46
LEGS = ("commit_read", "source_preserved", "clone_fs", "clone_read", "clone_status_clean",
47
        "pull_fs", "pull_read", "pull_status_clean")
48
49
50
class CapabilityUnavailable(Exception):
51
    pass
52
53
54
class LegFailure(AssertionError):
55
    pass
56
57
58
# --- fixture DSL ------------------------------------------------------------
59
# Each step is a tuple of operations applied in order to the authoring tree:
60
#   ("write", path, bytes[, executable]) ("symlink", path, target)
61
#   ("mkdir", path) ("remove", path) ("rename", src, dst) ("chmod", path, mode)
62
#   ("sparse", path, size, tail) ("inplace", path, bytes)  # same size, mtime kept
63
64
def apply_op(root: Path, op: tuple) -> None:
65
    kind = op[0]
66
    path = root / op[1]
67
    if kind == "write":
68
        path.parent.mkdir(parents=True, exist_ok=True)
69
        path.write_bytes(op[2])
70
        path.chmod(0o755 if len(op) > 3 and op[3] else 0o644)
71
    elif kind == "symlink":
72
        path.parent.mkdir(parents=True, exist_ok=True)
73
        os.symlink(op[2], path)
74
    elif kind == "mkdir":
75
        path.mkdir(parents=True, exist_ok=True)
76
    elif kind == "remove":
77
        if path.is_symlink() or path.is_file():
78
            path.unlink()
79
        else:
80
            shutil.rmtree(path)
81
    elif kind == "rename":
82
        target = root / op[2]
83
        target.parent.mkdir(parents=True, exist_ok=True)
84
        os.rename(path, target)
85
    elif kind == "chmod":
86
        path.chmod(op[2])
87
    elif kind == "sparse":
88
        path.parent.mkdir(parents=True, exist_ok=True)
89
        with path.open("wb") as stream:
90
            stream.truncate(op[2] - len(op[3]))
91
            stream.seek(op[2] - len(op[3]))
92
            stream.write(op[3])
93
    elif kind == "inplace":
94
        # Same size, same mtime: a stat-cache that trusts (size, mtime) misses it.
95
        before = path.stat()
96
        if len(op[2]) != before.st_size:
97
            raise ValueError("inplace edit must keep the size")
98
        with path.open("r+b") as stream:
99
            stream.write(op[2])
100
        os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns))
101
    else:
102
        raise ValueError(f"unknown fixture op {kind!r}")
103
104
105
@dataclass(frozen=True)
106
class Behaviour:
107
    id: str
108
    group: str
109
    summary: str
110
    steps: tuple
111
    requires: frozenset = frozenset()
112
    gated: bool = False
113
    prior_art: tuple = ()
114
    observe_dirs: bool = False
115
116
    def describe(self) -> dict:
117
        return {"behaviour": self.id, "group": self.group, "summary": self.summary,
118
                "steps": len(self.steps), "requires": sorted(self.requires), "gated": self.gated,
119
                "prior_art": list(self.prior_art)}
120
121
122
def _w(path, data, executable=False):
123
    return ("write", path, data if isinstance(data, bytes) else data.encode(), executable)
124
125
126
def _files(*names, payload=b"conformance payload\n"):
127
    return tuple(_w(name, str(index).encode() + b":" + payload) for index, name in enumerate(names))
128
129
130
def _stream(seed: bytes, size: int) -> bytes:
131
    """Deterministic incompressible bytes (SHA-256 counter stream)."""
132
    out = bytearray()
133
    counter = 0
134
    while len(out) < size:
135
        out += hashlib.sha256(seed + counter.to_bytes(8, "little")).digest()
136
        counter += 1
137
    return bytes(out[:size])
138
139
140
def _many_small():
141
    ops = []
142
    for index in range(1200):
143
        size = (index * 7919) % 65
144
        ops.append(_w(f"d{index % 30:02d}/f{index:05d}.txt", _stream(b"small%d" % index, size)))
145
    return tuple(ops)
146
147
148
T3902 = "git t3902-quoted (unambiguous odd path names)"
149
T0050 = "git t0050-filesystem (case and Unicode-normalization filesystems)"
150
T3910 = "git t3910-mac-os-precompose (NFC/NFD names)"
151
T3700 = "git t3700-add (executable bit, core.filemode)"
152
T4129 = "git t4129-apply-samemode (mode-only changes)"
153
T4011 = "git t4011-diff-symlink (symlink targets as content)"
154
T2007 = "git t2007-checkout-symlink (directory replaced by symlink)"
155
T4114 = "git t4114-apply-typechange (file/symlink/directory type changes)"
156
T2021 = "git t2021-checkout-overwrite (file/directory replacement on checkout)"
157
T2003 = "git t2003-checkout-cache-mkdir (leading-path creation, symlinked parents)"
158
T0020 = "git t0020-crlf (byte-exact line endings when conversion is off)"
159
T0021 = "git t0021-conversion (content conversion must not alter stored bytes)"
160
T0027 = "git t0027-auto-crlf (LF/CRLF/mixed/no-final-newline matrix)"
161
T4012 = "git t4012-diff-binary (binary detection)"
162
T0010 = "git t0010-racy-git (same-size edits within timestamp granularity)"
163
T7001 = "git t7001-mv (renames, case-only renames)"
164
T4102 = "git t4102-apply-rename (moves across directories)"
165
DULWICH = "dulwich tests/compat (differential against real git; pattern only)"
166
167
LONG_COMPONENT = "l" * 251 + ".txt"
168
LONG_UNICODE_COMPONENT = "é" * 127  # 254 UTF-8 bytes
169
LONG_PATH = "/".join(f"segment-{index:02d}-" + "p" * 48 for index in range(12)) + "/leaf.txt"
170
DEEP_PATH = "/".join("d" for _ in range(40)) + "/bottom.txt"
171
172
BEHAVIOURS = (
173
    # --- paths ---------------------------------------------------------------
174
    Behaviour("path_space_v1", "paths", "Leading, trailing and inner spaces in files and directories.",
175
              (_files("space name", " leading space", "trailing space ", "dir with space/inner file"),),
176
              prior_art=(T3902,)),
177
    Behaviour("path_tab_v1", "paths", "Tab inside a file name.", (_files("tab\tname"),), prior_art=(T3902,)),
178
    Behaviour("path_newline_v1", "paths", "Newline and carriage return inside a file name.",
179
              (_files("line\nbreak", "carriage\rreturn"),), prior_art=(T3902,)),
180
    Behaviour("path_control_chars_v1", "paths", "Other C0/DEL control characters inside file names.",
181
              (_files("bell\x07name", "escape\x1b[0m", "delete\x7fname", "unit\x1fsep"),), prior_art=(T3902,)),
182
    Behaviour("path_leading_dash_v1", "paths", "Names that look like command-line options.",
183
              (_files("-leading", "--double-dash", "-n", "--", "dir/-inner"),), prior_art=(T3902,)),
184
    Behaviour("path_shell_metachars_v1", "paths", "Quotes, backslash, glob, shell and URL metacharacters.",
185
              (_files("quote\"name", "single'quote", "glob*star?[x]", "hash#bang!",
186
                      "$HOME", "semi;amp&pipe|", "percent%20", "colon:name", "brace{a,b}", "tilde~", "a=b"),),
187
              prior_art=(T3902,)),
188
    Behaviour("path_backslash_v1", "paths", "Backslash inside a POSIX file name is a literal byte, not a separator.",
189
              (_files("back\\slash", "trailing\\", "dir/in\\ner"),), requires=frozenset({"posix_backslash_names"}),
190
              prior_art=(T3902,)),
191
    Behaviour("path_unicode_nfc_v1", "paths", "Precomposed and non-Latin Unicode names, astral and format characters.",
192
              (_files("café", "日本語/文件", "λ-lambda", "emoji-\U0001F600",
193
                      "zwj-‍-joiner", "rtl-‮-override"),), prior_art=(T3910, T0050)),
194
    Behaviour("path_unicode_nfd_v1", "paths", "Decomposed (NFD) names must keep their exact code points.",
195
              (_files("café", "Å-ring"),), prior_art=(T3910, T0050)),
196
    Behaviour("path_unicode_nfc_nfd_pair_v1", "paths", "NFC and NFD spellings of one name as two distinct paths.",
197
              (_files("café", "café"),), requires=frozenset({"normalization_sensitive_fs"}),
198
              prior_art=(T3910, T0050)),
199
    Behaviour("path_non_utf8_v1", "paths", "A file name that is not valid UTF-8.",
200
              ((("write", os.fsdecode(b"latin1-\xe9.txt"), b"bytes name\n"),),),
201
              requires=frozenset({"non_utf8_names"}), prior_art=(T3902,)),
202
    Behaviour("path_long_component_v1", "paths", "255-byte path components (ASCII and multi-byte).",
203
              (_files(LONG_COMPONENT, LONG_UNICODE_COMPONENT),), requires=frozenset({"long_component_255"})),
204
    Behaviour("path_long_path_v1", "paths", "A ~700-byte relative path.", (_files(LONG_PATH),)),
205
    Behaviour("path_deep_tree_v1", "paths", "A file 40 directories deep.", (_files(DEEP_PATH),)),
206
    Behaviour("path_reserved_names_v1", "paths", "Windows-reserved and dot-heavy names that are legal on POSIX.",
207
              (_files("CON", "prn.txt", "aux.c", "NUL", "com1", "LPT9.log", "trailing-dot.", "..double-leading",
208
                      "...", ".hidden", "-", "_", "@", "clock$"),), prior_art=(T0050,)),
209
    Behaviour("path_case_only_rename_v1", "paths", "Case-only rename on a case-insensitive filesystem.",
210
              ((_w("File.txt", "case rename payload\n"),), (("rename", "File.txt", "file.txt"),)),
211
              requires=frozenset({"case_insensitive_fs"}), prior_art=(T7001, T0050)),
212
    Behaviour("path_case_distinct_v1", "paths", "Names differing only by case on a case-sensitive filesystem.",
213
              (_files("README", "readme", "ReadMe"),), requires=frozenset({"case_sensitive_fs"}),
214
              prior_art=(T0050,)),
215
    # --- modes ---------------------------------------------------------------
216
    Behaviour("mode_exec_new_v1", "modes", "New executable file next to a regular file with identical bytes.",
217
              ((_w("tool.sh", "#!/bin/sh\necho conformance\n", True), _w("copy.txt", "#!/bin/sh\necho conformance\n")),),
218
              requires=frozenset({"exec_bit"}), prior_art=(T3700,)),
219
    Behaviour("mode_exec_add_v1", "modes", "Executable bit added without a content change.",
220
              ((_w("run.sh", "#!/bin/sh\ntrue\n"),), (("chmod", "run.sh", 0o755),)),
221
              requires=frozenset({"exec_bit"}), prior_art=(T3700, T4129)),
222
    Behaviour("mode_exec_remove_v1", "modes", "Executable bit removed without a content change.",
223
              ((_w("run.sh", "#!/bin/sh\ntrue\n", True),), (("chmod", "run.sh", 0o644),)),
224
              requires=frozenset({"exec_bit"}), prior_art=(T3700, T4129)),
225
    Behaviour("mode_exec_and_content_v1", "modes", "Executable bit and content change in one commit.",
226
              ((_w("run.sh", "#!/bin/sh\ntrue\n"),), (_w("run.sh", "#!/bin/sh\nfalse\n", True),)),
227
              requires=frozenset({"exec_bit"}), prior_art=(T3700,)),
228
    # --- symlinks ------------------------------------------------------------
229
    Behaviour("symlink_relative_v1", "symlinks", "Relative links at several depths.",
230
              ((_w("target.txt", "link target\n"), ("symlink", "link", "target.txt"),
231
                ("symlink", "sub/uplink", "../target.txt"), ("symlink", "sub/deep/ln", "../../target.txt")),),
232
              requires=frozenset({"symlink"}), prior_art=(T4011,)),
233
    Behaviour("symlink_absolute_v1", "symlinks", "Absolute link targets are stored verbatim, never followed.",
234
              ((_w("keep.txt", "keep\n"), ("symlink", "abs", "/nonexistent-oak-conformance/absolute/target"),
235
                ("symlink", "abs-root", "/")),), requires=frozenset({"symlink"}), prior_art=(T4011,)),
236
    Behaviour("symlink_dangling_v1", "symlinks", "Dangling, self-referential and cyclic links.",
237
              ((_w("keep.txt", "keep\n"), ("symlink", "dangling", "absent-target"), ("symlink", "self", "self"),
238
                ("symlink", "loop-a", "loop-b"), ("symlink", "loop-b", "loop-a")),),
239
              requires=frozenset({"symlink"}), prior_art=(T4011,)),
240
    Behaviour("symlink_to_dir_v1", "symlinks", "Links to directories (including '.') are stored as links.",
241
              ((_w("real/file.txt", "inside real dir\n"), ("symlink", "alias", "real"), ("symlink", "here", ".")),),
242
              requires=frozenset({"symlink"}), prior_art=(T2003, T4011)),
243
    Behaviour("symlink_target_edge_v1", "symlinks", "Targets with spaces, Unicode, a newline and 1000 bytes.",
244
              ((_w("keep.txt", "keep\n"), ("symlink", "spaced", "target with space"),
245
                ("symlink", "unicode", "tärget-λ"), ("symlink", "newline", "nl\ntarget"),
246
                ("symlink", "long", "x" * 1000)),), requires=frozenset({"symlink"}), prior_art=(T4011,)),
247
    Behaviour("symlink_retarget_v1", "symlinks", "A link's target changes.",
248
              ((_w("a", "a\n"), _w("b", "b\n"), ("symlink", "l", "a")), (("remove", "l"), ("symlink", "l", "b"))),
249
              requires=frozenset({"symlink"}), prior_art=(T4011,)),
250
    Behaviour("symlink_to_file_v1", "symlinks", "A symlink replaced by a regular file.",
251
              ((_w("t", "t\n"), ("symlink", "x", "t")), (("remove", "x"), _w("x", "now a file\n"))),
252
              requires=frozenset({"symlink"}), prior_art=(T4114,)),
253
    Behaviour("file_to_symlink_v1", "symlinks", "A regular file replaced by a symlink.",
254
              ((_w("t", "t\n"), _w("x", "a file\n")), (("remove", "x"), ("symlink", "x", "t"))),
255
              requires=frozenset({"symlink"}), prior_art=(T4114,)),
256
    Behaviour("dir_to_symlink_v1", "symlinks", "A directory replaced by a symlink.",
257
              ((_w("p/inner.txt", "inner\n"), _w("elsewhere/e.txt", "e\n")),
258
               (("remove", "p"), ("symlink", "p", "elsewhere"))),
259
              requires=frozenset({"symlink"}), prior_art=(T2007, T4114)),
260
    Behaviour("symlink_to_dir_real_v1", "symlinks", "A symlink replaced by a real directory.",
261
              ((_w("elsewhere/e.txt", "e\n"), ("symlink", "p", "elsewhere")),
262
               (("remove", "p"), _w("p/inner.txt", "inner\n"))),
263
              requires=frozenset({"symlink"}), prior_art=(T2007, T4114)),
264
    # --- file <-> directory --------------------------------------------------
265
    Behaviour("file_to_dir_v1", "file_dir", "A file replaced by a directory of the same name.",
266
              ((_w("node", "file\n"), _w("keep", "k\n")), (("remove", "node"), _w("node/child.txt", "child\n"))),
267
              prior_art=(T2021, T4114)),
268
    Behaviour("dir_to_file_v1", "file_dir", "A directory tree replaced by a file of the same name.",
269
              ((_w("node/child.txt", "child\n"), _w("node/sub/g.txt", "g\n"), _w("keep", "k\n")),
270
               (("remove", "node"), _w("node", "file now\n"))), prior_art=(T2021, T4114)),
271
    Behaviour("file_to_deep_dir_v1", "file_dir", "A nested file replaced by a deeper directory chain.",
272
              ((_w("a/b", "b file\n"),), (("remove", "a/b"), _w("a/b/c/d.txt", "deep\n"))), prior_art=(T2021,)),
273
    # --- empty ---------------------------------------------------------------
274
    Behaviour("empty_file_v1", "empty", "Empty regular and executable files at several depths.",
275
              ((_w("empty", b""), _w("dir/empty", b""), _w("empty-exec", b"", True)),),
276
              requires=frozenset({"exec_bit"})),
277
    Behaviour("empty_dir_semantics_v1", "empty", "Empty directories are not content (recorded, never failed).",
278
              ((_w("keep.txt", "keep\n"), ("mkdir", "void"), ("mkdir", "nested/void/deeper")),), observe_dirs=True),
279
    Behaviour("dir_emptied_v1", "empty", "Deleting a directory's last file leaves no stale file.",
280
              ((_w("d/f.txt", "f\n"), _w("keep", "k\n")), (("remove", "d/f.txt"),)), observe_dirs=True),
281
    Behaviour("delete_all_v1", "empty", "Deleting every tracked file commits an empty tree.",
282
              ((_w("a", "a\n"), _w("b/c", "c\n")), (("remove", "a"), ("remove", "b")))),
283
    # --- content -------------------------------------------------------------
284
    Behaviour("bytes_all_values_v1", "content", "Every byte value, repeated.",
285
              ((_w("binary", bytes(range(256)) * 17),),), prior_art=(T4012,)),
286
    Behaviour("eol_lf_v1", "content", "LF line endings.", ((_w("lf.txt", b"one\ntwo\nthree\n"),),),
287
              prior_art=(T0020, T0027)),
288
    Behaviour("eol_crlf_v1", "content", "CRLF line endings, byte-exact.", ((_w("crlf.txt", b"one\r\ntwo\r\nthree\r\n"),),),
289
              prior_art=(T0020, T0027)),
290
    Behaviour("eol_cr_only_v1", "content", "Classic-Mac CR-only line endings.", ((_w("cr.txt", b"one\rtwo\rthree\r"),),),
291
              prior_art=(T0020,)),
292
    Behaviour("eol_mixed_v1", "content", "LF, CRLF and CR mixed in one file.",
293
              ((_w("mixed.txt", b"lf\ncrlf\r\ncr\rlf-again\n\r\n\n"),),), prior_art=(T0027,)),
294
    Behaviour("eol_no_final_newline_v1", "content", "Text without a final newline (LF and CRLF bodies).",
295
              ((_w("tail-lf.txt", b"a\nb"), _w("tail-crlf.txt", b"a\r\nb")),), prior_art=(T0027,)),
296
    Behaviour("eol_lf_to_crlf_v1", "content", "Changing only LF to CRLF is a committed change.",
297
              ((_w("eol.txt", b"x\ny\n"),), (_w("eol.txt", b"x\r\ny\r\n"),)), prior_art=(T0020, T0021)),
298
    Behaviour("whitespace_only_change_v1", "content", "Trailing-space and tab/space-only edits are committed.",
299
              ((_w("ws.txt", b"a\n\tb\n"),), (_w("ws.txt", b"a \n    b\n"),)), prior_art=(T0021,)),
300
    Behaviour("utf8_bom_v1", "content", "UTF-8 and UTF-16 byte-order marks are preserved.",
301
              ((_w("bom8.txt", b"\xef\xbb\xbfbom text\n"), _w("bom16.txt", "utf16 text\n".encode("utf-16-le"))),),
302
              prior_art=(T0021,)),
303
    Behaviour("binary_detection_v1", "content", "Binary-looking and text-looking payloads roundtrip byte-exact.",
304
              ((_w("nul-in-text.txt", b"text\x00more\n"), _w("latin1.txt", b"caf\xe9\n"),
305
                _w("late-nul.txt", b"a" * 9000 + b"\x00tail\n"), _w("image.png", b"\x89PNG\r\n\x1a\n" + _stream(b"png", 2048)),
306
                _w("high-bytes", bytes(range(128, 256)) * 8), _w("random.bin", _stream(b"random", 65536))),),
307
              prior_art=(T4012,)),
308
    Behaviour("binary_text_transition_v1", "content", "The same path changes from text to binary.",
309
              ((_w("blob", b"plain text\n"),), (_w("blob", _stream(b"bin", 4096)),)), prior_art=(T4012,)),
310
    Behaviour("modify_same_size_same_mtime_v1", "content",
311
              "Same-size in-place edit with the mtime restored must still be committed.",
312
              ((_w("racy.txt", b"aaaa-original\n"),), (("inplace", "racy.txt", b"bbbb-modified\n"),)),
313
              prior_art=(T0010,)),
314
    Behaviour("large_file_chunked_v1", "content", "A 10 MiB + 64 KiB incompressible file (chunked storage path).",
315
              ((_w("large.bin", _stream(b"large", 10 * 1024 * 1024 + 64 * 1024)),),)),
316
    Behaviour("huge_file_sparse_v1", "content", "A 1 GiB + 5 byte sparse file (gated).",
317
              ((("sparse", "huge.bin", 1024 * 1024 * 1024 + 5, b"tail\n"),),),
318
              requires=frozenset({"sparse_files"}), gated=True),
319
    # --- history -------------------------------------------------------------
320
    Behaviour("delete_readd_v1", "history", "Delete then re-add identical bytes at the same path.",
321
              ((_w("f.txt", "same\n"), _w("keep", "k\n")), (("remove", "f.txt"),), (_w("f.txt", "same\n"),))),
322
    Behaviour("rename_move_v1", "history", "Move a file to a new directory with a new name.",
323
              ((_w("old/name.txt", "moving payload\n"),), (("rename", "old/name.txt", "new/dir/name2.txt"),)),
324
              prior_art=(T4102, T7001)),
325
    Behaviour("shared_blob_across_modes_v1", "history",
326
              "One byte string as regular file, executable, duplicate and symlink target.",
327
              ((_w("a.txt", "abc"), _w("b.sh", "abc", True), _w("c.txt", "abc"), ("symlink", "link", "abc")),),
328
              requires=frozenset({"symlink", "exec_bit"})),
329
    Behaviour("many_small_files_v1", "history", "1,200 small files across 30 directories.", (_many_small(),)),
330
)
331
BY_ID = {behaviour.id: behaviour for behaviour in BEHAVIOURS}
332
assert len(BY_ID) == len(BEHAVIOURS), "behaviour IDs must be unique"
333
334
335
# --- capability probes ------------------------------------------------------
336
def probe_capabilities(workdir: Path) -> dict:
337
    workdir.mkdir(parents=True, exist_ok=True)
338
    caps = {}
339
    with tempfile.TemporaryDirectory(prefix="caps-", dir=workdir) as temporary:
340
        root = Path(temporary)
341
        try:
342
            (root / "t").write_text("t")
343
            (root / "l").symlink_to("t")
344
            caps["symlink"] = (root / "l").is_symlink()
345
        except (OSError, NotImplementedError):
346
            caps["symlink"] = False
347
        probe = root / "x"
348
        probe.write_text("x")
349
        probe.chmod(0o755)
350
        caps["exec_bit"] = bool(probe.stat().st_mode & 0o100) and os.name == "posix"
351
        (root / "CaseProbe").write_text("c")
352
        insensitive = (root / "caseprobe").exists()
353
        caps["case_insensitive_fs"] = insensitive
354
        caps["case_sensitive_fs"] = not insensitive
355
        (root / "café").write_text("n")
356
        caps["normalization_sensitive_fs"] = not (root / "café").exists()
357
        try:
358
            (root / os.fsdecode(b"\xe9-probe")).write_text("b")
359
            caps["non_utf8_names"] = True
360
        except (OSError, UnicodeError):
361
            caps["non_utf8_names"] = False
362
        try:
363
            (root / LONG_COMPONENT).write_text("l")
364
            (root / LONG_UNICODE_COMPONENT).write_text("l")
365
            caps["long_component_255"] = True
366
        except OSError:
367
            caps["long_component_255"] = False
368
        try:
369
            sparse = root / "sparse"
370
            with sparse.open("wb") as stream:
371
                stream.truncate(64 * 1024 * 1024)
372
            caps["sparse_files"] = sparse.stat().st_blocks * 512 < 1024 * 1024
373
        except OSError:
374
            caps["sparse_files"] = False
375
        caps["posix_backslash_names"] = os.name == "posix"
376
        free = shutil.disk_usage(root).free
377
        caps["free_bytes"] = free
378
    return caps
379
380
381
# --- manifests --------------------------------------------------------------
382
def _sha256_file(path: Path) -> tuple[str, int]:
383
    digest = hashlib.sha256()
384
    size = 0
385
    with path.open("rb") as stream:
386
        for block in iter(lambda: stream.read(1024 * 1024), b""):
387
            digest.update(block)
388
            size += len(block)
389
    return digest.hexdigest(), size
390
391
392
def fs_manifest(root: Path, dirs: list | None = None) -> dict:
393
    """Exact path -> entry projection of a worktree, never following links.
394
395
    Empty directories are not content; pass ``dirs`` to collect every
396
    directory path as an observation. Root ``.git``/``.oak`` are excluded.
397
    """
398
    result = {}
399
400
    def visit(directory: Path, prefix: str) -> None:
401
        with os.scandir(directory) as iterator:
402
            children = sorted(iterator, key=lambda entry: entry.name)
403
        for entry in children:
404
            if not prefix and entry.name in (".git", ".oak"):
405
                continue
406
            relative = prefix + entry.name
407
            mode = entry.stat(follow_symlinks=False).st_mode
408
            if stat.S_ISLNK(mode):
409
                result[relative] = {"kind": "symlink", "target": os.readlink(entry.path)}
410
            elif stat.S_ISDIR(mode):
411
                if dirs is not None:
412
                    dirs.append(relative)
413
                visit(Path(entry.path), relative + "/")
414
            elif stat.S_ISREG(mode):
415
                digest, size = _sha256_file(Path(entry.path))
416
                result[relative] = {"kind": "file", "size": size, "sha256": digest,
417
                                    "executable": bool(mode & 0o111)}
418
            else:
419
                result[relative] = {"kind": "special"}
420
    visit(root, "")
421
    return result
422
423
424
def reader_projection(entries: list) -> dict:
425
    result = {}
426
    for entry in entries:
427
        if entry["kind"] == "file":
428
            result[entry["path"]] = {"kind": "file", "size": entry["size"], "sha256": entry["sha256"],
429
                                     "executable": entry["executable"]}
430
        elif entry["kind"] == "symlink":
431
            result[entry["path"]] = {"kind": "symlink",
432
                                     "target": os.fsdecode(bytes.fromhex(entry["target_hex"]))}
433
    return result
434
435
436
def diff_manifests(expected: dict, actual: dict, limit: int = 8) -> str | None:
437
    if expected == actual:
438
        return None
439
    missing = sorted(expected.keys() - actual.keys())
440
    extra = sorted(actual.keys() - expected.keys())
441
    changed = sorted(key for key in expected.keys() & actual.keys() if expected[key] != actual[key])
442
    parts = []
443
    for label, items in (("missing", missing), ("extra", extra), ("changed", changed)):
444
        if items:
445
            parts.append(f"{label}={items[:limit]!r}" + (f"(+{len(items) - limit})" if len(items) > limit else ""))
446
    return "; ".join(parts)
447
448
449
# --- subjects ---------------------------------------------------------------
450
class Runner:
451
    def __init__(self, binary: str, env: dict, timeout: float):
452
        self.binary, self.env, self.timeout = binary, env, timeout
453
        self.commands = []
454
455
    def run(self, cwd: Path, *args, check=True, timeout=None) -> subprocess.CompletedProcess:
456
        started = time.monotonic()
457
        try:
458
            process = subprocess.run([self.binary, *args], cwd=cwd, env=self.env, stdin=subprocess.DEVNULL,
459
                                     capture_output=True, timeout=timeout or self.timeout)
460
        except subprocess.TimeoutExpired as exc:
461
            self.commands.append({"argv": [str(a) for a in args], "returncode": None, "timeout": True})
462
            raise LegFailure(f"command timed out: {args[:3]!r}") from exc
463
        self.commands.append({
464
            "argv": [str(a) for a in args], "returncode": process.returncode,
465
            "seconds": round(time.monotonic() - started, 3),
466
            "stderr": process.stderr.decode("utf-8", "backslashreplace")[-600:]})
467
        if check and process.returncode != 0:
468
            raise LegFailure(f"{Path(self.binary).name} {' '.join(map(str, args[:2]))} exited "
469
                             f"{process.returncode}: {process.stderr.decode('utf-8', 'backslashreplace').strip()[-300:]}")
470
        return process
471
472
473
class GitSubject:
474
    kind = "git"
475
    reader_label = "git_plumbing_committed_tree"
476
477
    def __init__(self, runner: Runner, workdir: Path):
478
        self.git, self.workdir = runner, workdir
479
480
    CONFIG = ("core.autocrlf=false", "core.precomposeunicode=false", "core.filemode=true",
481
              "core.symlinks=true", "core.quotepath=false")
482
483
    def init(self, repo: Path):
484
        self.git.run(repo, "init", "-q", "-b", "main")
485
        for item in self.CONFIG:
486
            key, value = item.split("=")
487
            self.git.run(repo, "config", key, value)
488
        self.remote = repo.parent / "remote.git"
489
        self.git.run(repo.parent, "init", "-q", "--bare", str(self.remote))
490
        self.git.run(repo, "remote", "add", "origin", str(self.remote))
491
492
    def commit(self, repo: Path, message: str):
493
        self.git.run(repo, "add", "--all")
494
        self.git.run(repo, "commit", "-q", "--no-verify", "--allow-empty-message", "-m", message)
495
496
    def push(self, repo: Path):
497
        self.git.run(repo, "push", "-q", "origin", "HEAD:refs/heads/main")
498
499
    def clone(self, repo: Path, destination: Path):
500
        config = [argument for item in self.CONFIG for argument in ("--config", item)]
501
        self.git.run(destination.parent, "clone", "-q", *config, "--branch", "main", str(self.remote),
502
                     str(destination))
503
504
    def pull(self, repo: Path):
505
        self.git.run(repo, "pull", "-q", "--ff-only", "origin", "main")
506
507
    def status_clean(self, repo: Path):
508
        output = self.git.run(repo, "status", "--porcelain=v1", "-z", "--untracked-files=all").stdout
509
        if output:
510
            raise LegFailure("git status reports changes: " + output.decode("utf-8", "backslashreplace")[:300])
511
512
    def committed(self, repo: Path) -> tuple[dict, dict]:
513
        listing = self.git.run(repo, "ls-tree", "-r", "-z", "--full-tree", "HEAD", check=False)
514
        if listing.returncode != 0:
515
            head = self.git.run(repo, "rev-parse", "--verify", "-q", "HEAD", check=False)
516
            if head.returncode != 0:
517
                raise LegFailure("no committed HEAD")
518
            raise LegFailure("git ls-tree failed")
519
        entries = []
520
        for record in listing.stdout.split(b"\0"):
521
            if not record:
522
                continue
523
            meta, path = record.split(b"\t", 1)
524
            mode, kind, object_id = meta.split(b" ")
525
            entries.append((mode.decode(), kind.decode(), object_id.decode(), os.fsdecode(path)))
526
        manifest = {}
527
        if entries:
528
            process = subprocess.run([self.git.binary, "cat-file", "--batch"], cwd=repo, env=self.git.env,
529
                                     input=b"".join(e[2].encode() + b"\n" for e in entries),
530
                                     capture_output=True, timeout=self.git.timeout)
531
            if process.returncode != 0:
532
                raise LegFailure("git cat-file --batch failed")
533
            data, offset = process.stdout, 0
534
            for mode, kind, object_id, path in entries:
535
                end = data.index(b"\n", offset)
536
                header = data[offset:end].split(b" ")
537
                size = int(header[2])
538
                payload = data[end + 1:end + 1 + size]
539
                offset = end + 2 + size
540
                if mode == "120000":
541
                    manifest[path] = {"kind": "symlink", "target": os.fsdecode(payload)}
542
                elif mode in ("100644", "100755"):
543
                    manifest[path] = {"kind": "file", "size": size, "sha256": hashlib.sha256(payload).hexdigest(),
544
                                      "executable": mode == "100755"}
545
                else:
546
                    manifest[path] = {"kind": "special:" + mode}
547
        head = self.git.run(repo, "rev-parse", "HEAD").stdout.decode().strip()
548
        return manifest, {"head": head, "reader": self.reader_label}
549
550
551
class OakSubject:
552
    kind = "oak"
553
    reader_label = "independent_oak_sqlite_reader"
554
555
    def __init__(self, runner: Runner, workdir: Path, server):
556
        self.oak, self.workdir, self.server = runner, workdir, server
557
        self.branch = None
558
559
    def init(self, repo: Path):
560
        self.oak.run(repo, "init", ".")
561
        self.spec = self.server.new_repo_spec()
562
563
    def commit(self, repo: Path, message: str):
564
        self.oak.run(repo, "commit", "--no-verify")
565
        if self.branch is None:
566
            self.branch = json.loads(self.oak.run(repo, "status", "--json").stdout)["branch"]
567
568
    def push(self, repo: Path):
569
        self.oak.run(repo, "push", "--repo", self.spec, "--remote", self.server.url)
570
571
    def clone(self, repo: Path, destination: Path):
572
        self.oak.run(destination.parent, "clone", self.spec, str(destination), "--remote", self.server.url,
573
                     "--branch", self.branch)
574
575
    def pull(self, repo: Path):
576
        self.oak.run(repo, "pull")
577
578
    def status_clean(self, repo: Path):
579
        status = json.loads(self.oak.run(repo, "status", "--json").stdout)
580
        dirty = list(status.get("changes") or []) + list((status.get("working_changes") or {}).get("changes") or [])
581
        if dirty:
582
            raise LegFailure("oak status reports changes: " + json.dumps(dirty)[:300])
583
584
    def committed(self, repo: Path) -> tuple[dict, dict]:
585
        from oakbench import oak_sqlite_reader as reader
586
        try:
587
            result = reader.read_committed_tree(repo, None, manifest=True, deep=True, complete=True)
588
        except reader.Invalid as exc:
589
            raise LegFailure("independent reader: stored state invalid: " + str(exc)) from exc
590
        except reader.Unavailable as exc:
591
            raise CapabilityUnavailable("independent reader unavailable: " + str(exc)) from exc
592
        info = {"head": result["head"], "reader": self.reader_label, "reader_version": result["reader_version"],
593
                "reader_backend": result["reader_backend"], "history": result.get("history"),
594
                "chunked_blobs_reassembled": result["chunked_blobs_reassembled"],
595
                "chunk_mappings_crosschecked": result["chunk_mappings_crosschecked"]}
596
        return reader_projection(result["manifest"]), info
597
598
599
class LoopbackOakServer:
600
    """One ``oak serve`` on 127.0.0.1 per matrix run; only this PID is ever stopped."""
601
602
    def __init__(self, binary: str, root: Path, env: dict):
603
        self.binary, self.root, self.env = binary, root, env
604
        self.process = None
605
        self.counter = 0
606
607
    def __enter__(self):
608
        self.root.mkdir(parents=True, exist_ok=True)
609
        with socket.socket() as listener:
610
            listener.bind(("127.0.0.1", 0))
611
            self.port = listener.getsockname()[1]
612
        self.url = f"http://127.0.0.1:{self.port}"
613
        self.process = subprocess.Popen([self.binary, "serve", "--dir", str(self.root), "--host", "127.0.0.1",
614
                                         "--port", str(self.port)], cwd=self.root, env=self.env,
615
                                        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
616
                                        start_new_session=os.name == "posix")
617
        deadline = time.monotonic() + 20
618
        while time.monotonic() < deadline:
619
            if self.process.poll() is not None:
620
                raise CapabilityUnavailable("oak serve exited before accepting connections")
621
            try:
622
                with socket.create_connection(("127.0.0.1", self.port), timeout=0.2):
623
                    return self
624
            except OSError:
625
                time.sleep(0.05)
626
        self.__exit__(None, None, None)
627
        raise CapabilityUnavailable("oak serve did not accept connections")
628
629
    def new_repo_spec(self) -> str:
630
        self.counter += 1
631
        return f"conformance/r{self.counter}-{os.getpid()}"
632
633
    def __exit__(self, *exc):
634
        if self.process is not None and self.process.poll() is None:
635
            self.process.terminate()
636
            try:
637
                self.process.wait(timeout=10)
638
            except subprocess.TimeoutExpired:
639
                self.process.kill()
640
                self.process.wait(timeout=10)
641
642
643
# --- behaviour execution ----------------------------------------------------
644
def _leg(legs: dict, name: str, function) -> None:
645
    try:
646
        detail = function()
647
        legs[name] = {"status": "fail", "detail": detail} if detail else {"status": "pass"}
648
    except CapabilityUnavailable as exc:
649
        legs[name] = {"status": "unmeasured", "detail": str(exc)}
650
    except LegFailure as exc:
651
        legs[name] = {"status": "fail", "detail": str(exc)}
652
653
654
def run_behaviour(subject, behaviour: Behaviour, root: Path, observations: dict) -> tuple[dict, dict | None]:
655
    """Execute one behaviour; returns (legs, committed-manifest-of-final-state)."""
656
    legs = {}
657
    source = root / "src"
658
    source.mkdir()
659
    subject.init(source)
660
    step_manifests = []
661
    early_clone = root / "early"
662
    final_committed = None
663
    for index, step in enumerate(behaviour.steps):
664
        for op in step:
665
            apply_op(source, op)
666
        expected = fs_manifest(source)
667
        step_manifests.append(expected)
668
        label = f"commit_read@{index + 1}" if len(behaviour.steps) > 1 else "commit_read"
669
        try:
670
            subject.commit(source, f"conformance {behaviour.id} step {index + 1}")
671
            subject.push(source)
672
        except LegFailure as exc:
673
            legs["commit_push" + (f"@{index + 1}" if len(behaviour.steps) > 1 else "")] = {
674
                "status": "fail", "detail": str(exc)}
675
            return legs, None
676
        committed_holder = {}
677
678
        def check_commit(expected=expected):
679
            manifest, info = subject.committed(source)
680
            committed_holder["manifest"], committed_holder["info"] = manifest, info
681
            return diff_manifests(expected, manifest)
682
        _leg(legs, label, check_commit)
683
        suffix = f"@{index + 1}" if len(behaviour.steps) > 1 else ""
684
        _leg(legs, "source_preserved" + suffix, lambda expected=expected: diff_manifests(expected, fs_manifest(source)))
685
        final_committed = committed_holder.get("manifest")
686
        if committed_holder.get("info"):
687
            observations.setdefault("committed_reader", committed_holder["info"])
688
        if index == 0 and len(behaviour.steps) > 1:
689
            try:
690
                subject.clone(source, early_clone)
691
            except LegFailure as exc:
692
                legs["pull_fs"] = {"status": "fail", "detail": "clone for pull leg failed: " + str(exc)}
693
    expected = step_manifests[-1]
694
    clone = root / "clone"
695
    try:
696
        subject.clone(source, clone)
697
    except LegFailure as exc:
698
        legs["clone_fs"] = {"status": "fail", "detail": str(exc)}
699
    else:
700
        dirs = [] if behaviour.observe_dirs else None
701
        _leg(legs, "clone_fs", lambda: diff_manifests(expected, fs_manifest(clone, dirs)))
702
        if dirs is not None:
703
            observations["clone_dirs"] = dirs
704
        def check_clone_read():
705
            manifest, info = subject.committed(clone)
706
            observations["clone_reader"] = info
707
            return diff_manifests(expected, manifest)
708
        _leg(legs, "clone_read", check_clone_read)
709
        _leg(legs, "clone_status_clean", lambda: subject.status_clean(clone))
710
    if len(behaviour.steps) > 1 and "pull_fs" not in legs:
711
        try:
712
            subject.pull(early_clone)
713
        except LegFailure as exc:
714
            legs["pull_fs"] = {"status": "fail", "detail": str(exc)}
715
        else:
716
            dirs = [] if behaviour.observe_dirs else None
717
            _leg(legs, "pull_fs", lambda: diff_manifests(expected, fs_manifest(early_clone, dirs)))
718
            if dirs is not None:
719
                observations["pull_dirs"] = dirs
720
            _leg(legs, "pull_read", lambda: diff_manifests(expected, subject.committed(early_clone)[0]))
721
            _leg(legs, "pull_status_clean", lambda: subject.status_clean(early_clone))
722
    return legs, final_committed
723
724
725
def _binary_identity(binary: str, kind: str = "oak") -> dict:
726
    resolved = shutil.which(binary)
727
    if resolved is None:
728
        return {}
729
    extra = {}
730
    if kind == "git":
731
        # Same rule as the timed lanes (ADR-0011): run the real Git, never the xcrun shim.
732
        from oakbench.subjects import is_xcrun_git_shim, resolve_real_git
733
        if is_xcrun_git_shim(resolved):
734
            real = resolve_real_git()
735
            extra = {"git_requested_bin": resolved, "git_shim_resolved": real is not None}
736
            resolved = str(real) if real else resolved
737
    resolved = str(Path(resolved).resolve())
738
    return {"binary": resolved, "binary_sha256": hashlib.sha256(Path(resolved).read_bytes()).hexdigest(), **extra}
739
740
741
def base_row(kind: str, behaviour: Behaviour) -> dict:
742
    return {"conformance_schema_version": SCHEMA_VERSION, "evidence_class": "diagnostic_conformance",
743
            "matrix_version": MATRIX_VERSION, "oracle": ORACLE, "subject": kind, "scenario": behaviour.id,
744
            "behaviour_group": behaviour.group, "summary": behaviour.summary, "fixture_version": 1,
745
            "prior_art": list(behaviour.prior_art), "wall_ms": None, "tokens": None, "integrity_ok": None}
746
747
748
def run_matrix(workdir: Path, subjects: dict, behaviours=None, include_gated: bool | None = None,
749
               progress=None) -> list[dict]:
750
    """Run behaviours for {kind: binary}; git first so Oak rows carry the differential."""
751
    workdir = Path(workdir).resolve()
752
    workdir.mkdir(parents=True, exist_ok=True)
753
    if include_gated is None:
754
        include_gated = os.environ.get(HUGE_ENV) == "1"
755
    selected = [BY_ID[name] for name in behaviours] if behaviours else list(BEHAVIOURS)
756
    caps = probe_capabilities(workdir)
757
    rows = []
758
    git_committed = {}
759
    order = [kind for kind in ("git", "oak") if kind in subjects]
760
    for kind in order:
761
        identity = _binary_identity(subjects[kind], kind)
762
        home = workdir / f"home-{kind}"
763
        home.mkdir(exist_ok=True)
764
        env = base_env(home=home)
765
        env["OAK_NO_UPDATE_CHECK"] = "1"
766
        if not identity:
767
            rows += [{**base_row(kind, b), "returncode": 77, "skip_reason": f"binary unavailable: {subjects[kind]}"}
768
                     for b in selected]
769
            continue
770
        version = subprocess.run([identity["binary"], "--version"], capture_output=True, env=env,
771
                                 timeout=30).stdout.decode("utf-8", "replace").strip()
772
        server = LoopbackOakServer(identity["binary"], workdir / "oak-serve-data", env) if kind == "oak" else None
773
        try:
774
            if server is not None:
775
                server.__enter__()
776
            for behaviour in selected:
777
                row = {**base_row(kind, behaviour), **identity, "version": version,
778
                       "capabilities": {k: v for k, v in caps.items() if k in behaviour.requires}}
779
                missing = sorted(cap for cap in behaviour.requires if not caps.get(cap))
780
                if behaviour.gated and not include_gated:
781
                    row.update(returncode=77, skip_reason=f"gated: set {HUGE_ENV}=1 to run")
782
                elif missing:
783
                    row.update(returncode=77, skip_reason="filesystem capability unavailable: " + ",".join(missing))
784
                elif behaviour.gated and caps.get("free_bytes", 0) < 8 * 1024 ** 3:
785
                    row.update(returncode=77, skip_reason="gated behaviour needs >= 8 GiB free disk")
786
                else:
787
                    runner = Runner(identity["binary"], env, HUGE_TIMEOUT_S if behaviour.gated else COMMAND_TIMEOUT_S)
788
                    subject = GitSubject(runner, workdir) if kind == "git" else OakSubject(runner, workdir, server)
789
                    observations = {}
790
                    started = time.monotonic()
791
                    with tempfile.TemporaryDirectory(prefix=f"{kind}-{behaviour.id}-", dir=workdir) as temporary:
792
                        try:
793
                            legs, committed = run_behaviour(subject, behaviour, Path(temporary), observations)
794
                        except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc:
795
                            legs, committed = {"harness": {"status": "error", "detail": repr(exc)}}, None
796
                    row["legs"] = legs
797
                    row["observations"] = observations
798
                    row["commands"] = runner.commands
799
                    row["harness_seconds"] = round(time.monotonic() - started, 2)
800
                    statuses = {leg["status"] for leg in legs.values()}
801
                    failed = sorted(name for name, leg in legs.items() if leg["status"] in ("fail", "error"))
802
                    row["failed_legs"] = failed
803
                    if "error" in statuses:
804
                        row.update(returncode=2, failure="harness error: " + legs["harness"]["detail"])
805
                    elif failed:
806
                        first = legs[failed[0]]
807
                        row.update(returncode=1, integrity_ok=False, failure=f"{failed[0]}: {first.get('detail')}")
808
                    elif "unmeasured" in statuses:
809
                        row.update(returncode=77, skip_reason="leg unmeasured: " + "; ".join(
810
                            f"{name}: {leg['detail']}" for name, leg in legs.items() if leg["status"] == "unmeasured"))
811
                    else:
812
                        row.update(returncode=0, integrity_ok=True)
813
                    if kind == "git" and committed is not None:
814
                        git_committed[behaviour.id] = committed
815
                    if kind == "oak":
816
                        reference = git_committed.get(behaviour.id)
817
                        if reference is None or committed is None:
818
                            row["git_differential"] = "unavailable"
819
                        else:
820
                            difference = diff_manifests(reference, committed)
821
                            row["git_differential"] = "identical" if difference is None else "differs: " + difference
822
                rows.append(row)
823
                if progress:
824
                    progress(row)
825
        finally:
826
            if server is not None:
827
                server.__exit__(None, None, None)
828
    return rows
829