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