| 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 |
|