| 1 | """Independent decoder for local Oak SQLite v1 committed state (stdlib-only).
|
| 2 |
|
| 3 | No Oak code or executable is imported or invoked. BLAKE3 and zstd are decoded
|
| 4 | by this repository's own pure-Python implementations (``pureblake3``,
|
| 5 | ``purezstd``), written from the public specifications, so a verified object is
|
| 6 | checked by code that shares nothing with the Rust ``blake3`` crate or libzstd
|
| 7 | that Oak links. ``OAK_BENCH_READER_BACKEND=native`` (or ``backend='native'``)
|
| 8 | opts into the optional ``blake3``/``zstandard`` packages for speed; they wrap
|
| 9 | the same upstream libraries Oak uses, so rows record which backend ran.
|
| 10 |
|
| 11 | Evidence contract (2026-09-28.2):
|
| 12 | - Scope: when scope paths are given, only entries on those paths (and the
|
| 13 | trees leading to them) are decoded. This is the same scope the Git
|
| 14 | committed-payload check reads with ``git ls-tree HEAD -- <scope>``, so the
|
| 15 | two readers can share an evidence class. Out-of-scope entries are never
|
| 16 | decoded, so repo-wide conditions the task does not own cannot fail Oak.
|
| 17 | - Reader resource and time budgets are the harness's limits, not properties
|
| 18 | of Oak's stored content: exceeding one is Unavailable (exit 77), never
|
| 19 | Invalid. Invalid (exit 1) is reserved for positively identified bad bytes.
|
| 20 | - Format pin: required tables/columns, a known schema migration and hash
|
| 21 | format, and at least one stored object of any kind (commit, tree, blob)
|
| 22 | verifying under the v1 rules. With the format pinned, every hash mismatch -
|
| 23 | HEAD commit, root tree, nested tree, blob, chunk - is Invalid (corruption).
|
| 24 | Only a store in which nothing verifies is an unrecognized format
|
| 25 | (Unavailable), indistinguishable from a future serialization.
|
| 26 | - Every materialized object is re-hashed: blobs (``blake3(plaintext)``),
|
| 27 | chunks and their blob reassembly, trees (canonical ``kind\\tname\\thash\\tmode``
|
| 28 | lines), and commits (v1 7-field preimage; legacy 6-field and the server's
|
| 29 | microsecond-truncated timestamps are accepted exactly as Oak verifies them).
|
| 30 | - Tree structure is validated independently of the hash: strictly ascending
|
| 31 | unique names, Oak's entry-name rules (no empty, ``.``/``..``, ``/``, NUL or
|
| 32 | control characters; ``.oak``/``.git`` are never stored), lowercase 64-hex
|
| 33 | object ids, and kind/mode pairs ``tree/tree`` and
|
| 34 | ``blob/{regular,executable,symlink}``.
|
| 35 | - Symlinks: unscoped reads materialize them (after every regular file, so no
|
| 36 | write passes through a created link); scoped reads report them in
|
| 37 | ``nonregular_paths`` so the workflow oracle fails them exactly as Git's
|
| 38 | 100644 mode check does.
|
| 39 | - Chunked storage: a blob with no complete ``blobs`` row is reassembled from
|
| 40 | ``blob_chunks``/``chunks`` (contiguous offsets, per-chunk hash, total size,
|
| 41 | whole-blob hash). ``deep=True`` also cross-checks the chunk mapping of
|
| 42 | complete blobs and walks the commit chain. ``complete=True`` makes absent
|
| 43 | content Invalid (a fresh full clone must hold every committed object);
|
| 44 | otherwise absent content is Unavailable (it may be legitimately unhydrated).
|
| 45 | """
|
| 46 | from __future__ import annotations
|
| 47 |
|
| 48 | import hashlib
|
| 49 | import importlib.metadata
|
| 50 | import json
|
| 51 | import os
|
| 52 | from pathlib import Path
|
| 53 | import re
|
| 54 | import sqlite3
|
| 55 | import sys
|
| 56 | import tempfile
|
| 57 | import time
|
| 58 |
|
| 59 | try: # Package import (harness) or standalone ``python -I reader.py``.
|
| 60 | from oakbench import pureblake3, purezstd
|
| 61 | except ImportError: # pragma: no cover - exercised by the subprocess tests
|
| 62 | sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 63 | from oakbench import pureblake3, purezstd
|
| 64 |
|
| 65 | READER_VERSION = 'oak-sqlite-v1-2026-09-29.1'
|
| 66 | # Newest Oak schema migration this reader is qualified against (oak 0.105.0).
|
| 67 | # A newer migration is an unrecognized format: unmeasured, never corrupt.
|
| 68 | KNOWN_MAX_MIGRATION = '0014_change_captures'
|
| 69 | MAX_OBJECT = 32 * 1024 * 1024
|
| 70 | MAX_TOTAL = 128 * 1024 * 1024
|
| 71 | MAX_ENTRIES = 8192
|
| 72 | MAX_DATABASE = 512 * 1024 * 1024
|
| 73 | MAX_HISTORY = 4096
|
| 74 | DEADLINE_S = 60
|
| 75 | # The deadline grows with the bytes actually verified, so large (or chunk-only)
|
| 76 | # payloads get headroom proportional to their size: pure BLAKE3 hashes at
|
| 77 | # ~1.7 MB/s and zstd decodes at ~30 MB/s on an M4, so 1.5 s/MiB is ~2.5x margin.
|
| 78 | DEADLINE_S_PER_MIB = 1.5
|
| 79 | HEX64 = re.compile('[0-9a-f]{64}')
|
| 80 | EMPTY_HASH = 'af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262'
|
| 81 | ZSTD_MAGIC = b'\x28\xb5\x2f\xfd'
|
| 82 | BLOB_MODES = ('regular', 'executable', 'symlink')
|
| 83 | RFC3339 = re.compile(r'(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d)(?:\.(\d{1,9}))?(Z|[+-]\d\d:\d\d)')
|
| 84 |
|
| 85 |
|
| 86 | class Unavailable(Exception):
|
| 87 | pass
|
| 88 |
|
| 89 |
|
| 90 | class Invalid(Exception):
|
| 91 | pass
|
| 92 |
|
| 93 |
|
| 94 | REQUIRED_COLUMNS = {
|
| 95 | 'metadata': {'key', 'value'},
|
| 96 | 'commits': {'hash', 'branch_name', 'parent_hash', 'merge_parent_hash', 'manifest_hash', 'author', 'message', 'timestamp'},
|
| 97 | 'blobs': {'hash', 'content', 'size', 'codec'},
|
| 98 | 'trees': {'hash', 'content'},
|
| 99 | }
|
| 100 | # Legacy per-entry tree rows. `oak maintenance compact` backfills every
|
| 101 | # trees.content from them and then DROPS the table by design, so it is
|
| 102 | # optional: when absent, every tree must carry content (a NULL is Invalid).
|
| 103 | LEGACY_TREE_COLUMNS = {'tree_entries': {'tree_hash', 'name', 'kind', 'hash', 'mode'}}
|
| 104 | CHUNK_COLUMNS = {
|
| 105 | 'blob_chunks': {'blob_hash', 'chunk_hash', 'chunk_index', 'offset', 'size'},
|
| 106 | 'chunks': {'hash', 'content', 'size'},
|
| 107 | }
|
| 108 |
|
| 109 |
|
| 110 | class Backend:
|
| 111 | """Hash and codec implementation actually used, recorded in every result."""
|
| 112 |
|
| 113 | def __init__(self, name=None):
|
| 114 | name = name or os.environ.get('OAK_BENCH_READER_BACKEND') or 'pure'
|
| 115 | if name not in ('pure', 'native'):
|
| 116 | raise Unavailable('unknown reader backend: ' + name)
|
| 117 | self.name = name
|
| 118 | if name == 'native':
|
| 119 | try:
|
| 120 | import blake3
|
| 121 | import zstandard
|
| 122 | except ImportError as exc:
|
| 123 | raise Unavailable('native reader backend requires blake3 and zstandard') from exc
|
| 124 | self._blake3, self._zstd = blake3, zstandard
|
| 125 | self.versions = {'blake3_version': importlib.metadata.version('blake3'),
|
| 126 | 'zstandard_version': importlib.metadata.version('zstandard')}
|
| 127 | else:
|
| 128 | self.versions = {'blake3_version': None, 'zstandard_version': None}
|
| 129 |
|
| 130 | def hexdigest(self, raw: bytes) -> str:
|
| 131 | if self.name == 'native':
|
| 132 | return self._blake3.blake3(raw).hexdigest()
|
| 133 | return pureblake3.hexdigest(raw)
|
| 134 |
|
| 135 | def decompress(self, content: bytes) -> bytes:
|
| 136 | if self.name == 'native':
|
| 137 | zstandard = self._zstd
|
| 138 | try:
|
| 139 | size = zstandard.frame_content_size(content)
|
| 140 | if zstandard.get_frame_parameters(content).window_size > MAX_OBJECT:
|
| 141 | raise Unavailable('decompression window exceeds reader bound')
|
| 142 | if size not in (-1, zstandard.CONTENTSIZE_UNKNOWN) and not 0 <= size <= MAX_OBJECT:
|
| 143 | raise Unavailable('decompressed object exceeds reader bound')
|
| 144 | return zstandard.ZstdDecompressor(max_window_size=MAX_OBJECT).decompress(
|
| 145 | content, max_output_size=MAX_OBJECT, allow_extra_data=False)
|
| 146 | except zstandard.ZstdError as exc:
|
| 147 | raise Invalid('invalid compressed object') from exc
|
| 148 | try:
|
| 149 | return purezstd.decompress(content, max_output=MAX_OBJECT, max_window=MAX_OBJECT)
|
| 150 | except purezstd.ZstdBoundError as exc:
|
| 151 | raise Unavailable(str(exc).replace('bound', 'reader bound')) from exc
|
| 152 | except purezstd.ZstdError as exc:
|
| 153 | raise Invalid('invalid compressed object: ' + str(exc)) from exc
|
| 154 |
|
| 155 |
|
| 156 | def _nonempty(path: Path) -> bool:
|
| 157 | try:
|
| 158 | return path.stat().st_size > 0
|
| 159 | except FileNotFoundError:
|
| 160 | return False
|
| 161 |
|
| 162 |
|
| 163 | def codec_sha256() -> str:
|
| 164 | """Identity of the pure codec modules the reader verified with."""
|
| 165 | digest = hashlib.sha256()
|
| 166 | for module in (pureblake3, purezstd):
|
| 167 | digest.update(Path(module.__file__).read_bytes())
|
| 168 | return digest.hexdigest()
|
| 169 |
|
| 170 |
|
| 171 | def normalize_scope(scope):
|
| 172 | """Validated repo-relative POSIX scope paths, or None for the whole tree."""
|
| 173 | if scope is None:
|
| 174 | return None
|
| 175 | result = []
|
| 176 | for item in scope:
|
| 177 | parts = item.split('/')
|
| 178 | if (not item or item.startswith('/') or any(part in ('', '.', '..', '.oak', '.git') for part in parts)
|
| 179 | or any(c in item for c in '\x00\n\r')):
|
| 180 | raise Unavailable('invalid reader scope path')
|
| 181 | result.append(item)
|
| 182 | return tuple(sorted(set(result)))
|
| 183 |
|
| 184 |
|
| 185 | def in_scope(relative: str, scope) -> tuple[bool, bool]:
|
| 186 | """(visit, materialize) for a repo-relative path under the scope."""
|
| 187 | if scope is None:
|
| 188 | return True, True
|
| 189 | for item in scope:
|
| 190 | if relative == item or relative.startswith(item + '/'):
|
| 191 | return True, True
|
| 192 | if item.startswith(relative + '/'):
|
| 193 | return True, False
|
| 194 | return False, False
|
| 195 |
|
| 196 |
|
| 197 | def entry_name_problem(name: str):
|
| 198 | """Oak's own entry-name rule (core tree.rs validate_entry_name), re-stated."""
|
| 199 | if not name:
|
| 200 | return 'empty filename'
|
| 201 | if name in ('.', '..'):
|
| 202 | return 'reserved name'
|
| 203 | if name in ('.oak', '.git'):
|
| 204 | return 'VCS metadata name'
|
| 205 | if '/' in name:
|
| 206 | return 'slash in filename'
|
| 207 | if any(ord(c) < 0x20 or 0x7f <= ord(c) <= 0x9f for c in name):
|
| 208 | return 'control character in filename'
|
| 209 | return None
|
| 210 |
|
| 211 |
|
| 212 | def rfc3339_tail_candidates(timestamp: str):
|
| 213 | """Oak's verifier restores 1..999 ns lost to microsecond storage; mirror it."""
|
| 214 | match = RFC3339.fullmatch(timestamp)
|
| 215 | if not match:
|
| 216 | return []
|
| 217 | base, fraction, zone = match.groups()
|
| 218 | nanos = int((fraction or '').ljust(9, '0') or '0')
|
| 219 | if nanos % 1000:
|
| 220 | return []
|
| 221 | return [f'{base}.{nanos + tail:09d}{zone}' for tail in range(1, 1000)]
|
| 222 |
|
| 223 |
|
| 224 | def commit_verifies(backend, commit_hash, fields):
|
| 225 | """fields = (branch, parent, merge_parent, manifest, author, message, timestamp)."""
|
| 226 | branch, parent, merge, manifest, author, message, timestamp = [v or '' for v in fields]
|
| 227 | def seven(ts):
|
| 228 | return '\n'.join((branch, parent, merge, manifest, author, message, ts)).encode()
|
| 229 | if backend.hexdigest(seven(timestamp)) == commit_hash:
|
| 230 | return 'v1'
|
| 231 | if not merge and backend.hexdigest('\n'.join((branch, parent, manifest, author, message, timestamp)).encode()) == commit_hash:
|
| 232 | return 'v1-legacy-6-field'
|
| 233 | for candidate in rfc3339_tail_candidates(timestamp):
|
| 234 | if backend.hexdigest(seven(candidate)) == commit_hash:
|
| 235 | return 'v1-microsecond-truncated'
|
| 236 | return None
|
| 237 |
|
| 238 |
|
| 239 | def read_committed_tree(repo: Path, destination, scope=None, max_blob_bytes=None, *,
|
| 240 | manifest=False, deep=False, complete=False, backend=None) -> dict:
|
| 241 | """Materialize (``destination``) and/or describe (``manifest``) Oak's committed HEAD.
|
| 242 |
|
| 243 | ``destination=None`` materializes nothing; the manifest is then the only
|
| 244 | output, which avoids filesystem aliasing (case folding, Unicode
|
| 245 | normalization) when the oracle must see exact committed names.
|
| 246 | """
|
| 247 | scope = normalize_scope(scope)
|
| 248 | backend = backend if isinstance(backend, Backend) else Backend(backend)
|
| 249 | repo = Path(repo)
|
| 250 | db_path = repo / '.oak' / 'oak.db'
|
| 251 | # The repository is subject-controlled state: a missing or linked database
|
| 252 | # is a measured failure, exactly as Git's ls-tree on a repo whose .git was
|
| 253 | # removed is (git_head_tree_unreadable). Never "unmeasured".
|
| 254 | if (repo.is_symlink() or (repo / '.oak').is_symlink() or db_path.is_symlink()
|
| 255 | or not db_path.is_file()):
|
| 256 | raise Invalid('committed state unreadable: local Oak database missing or linked')
|
| 257 | if destination is not None:
|
| 258 | destination = Path(destination)
|
| 259 | if destination.exists() or destination.is_symlink():
|
| 260 | raise Unavailable('reader destination must not exist')
|
| 261 | deadline = time.monotonic() + DEADLINE_S
|
| 262 |
|
| 263 | def database_snapshot():
|
| 264 | # A zero-byte -wal/-journal holds no frames (left by a closed reader
|
| 265 | # connection); only a non-empty one can change what the database means.
|
| 266 | if any(_nonempty(Path(str(db_path) + suffix)) for suffix in ('-wal', '-journal')):
|
| 267 | raise Unavailable('active SQLite journal; quiescent database required')
|
| 268 | with db_path.open('rb') as stream:
|
| 269 | content = stream.read(MAX_DATABASE + 1)
|
| 270 | if len(content) > MAX_DATABASE:
|
| 271 | raise Unavailable('database exceeds independent reader bound')
|
| 272 | return content
|
| 273 |
|
| 274 | # Snapshot only quiescent databases. immutable=1 is safe on this private copy,
|
| 275 | # never on a live WAL database; the original bytes are checked again below.
|
| 276 | snapshot = database_snapshot()
|
| 277 | database_sha256 = hashlib.sha256(snapshot).hexdigest()
|
| 278 | if destination is not None:
|
| 279 | destination.mkdir(parents=True)
|
| 280 | with tempfile.TemporaryDirectory(prefix='oakbench-reader-db-') as temp:
|
| 281 | snapshot_path = Path(temp) / 'snapshot.db'
|
| 282 | snapshot_path.write_bytes(snapshot)
|
| 283 | del snapshot
|
| 284 | reader = _SnapshotReader(snapshot_path, backend, deadline, scope, max_blob_bytes,
|
| 285 | destination, manifest, deep, complete)
|
| 286 | result = reader.run()
|
| 287 | if hashlib.sha256(database_snapshot()).hexdigest() != database_sha256:
|
| 288 | raise Unavailable('database changed during independent read')
|
| 289 | result.update({'database_sha256': database_sha256, 'reader_version': READER_VERSION,
|
| 290 | 'scope': list(scope) if scope is not None else None,
|
| 291 | 'reader_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
| 292 | 'reader_backend': backend.name, 'codec_sha256': codec_sha256(), **backend.versions})
|
| 293 | return result
|
| 294 |
|
| 295 |
|
| 296 | class _SnapshotReader:
|
| 297 | def __init__(self, path, backend, deadline, scope, max_blob_bytes, destination, manifest, deep, complete):
|
| 298 | self.path, self.backend, self.deadline = path, backend, deadline
|
| 299 | self.scope, self.max_blob_bytes = scope, max_blob_bytes
|
| 300 | self.destination, self.want_manifest = destination, manifest
|
| 301 | self.deep, self.complete = deep, complete
|
| 302 | self.total = 0
|
| 303 | self.entries_seen = 0
|
| 304 | self.nonregular, self.oversized, self.collisions = [], [], []
|
| 305 | self.entries = []
|
| 306 | self.symlinks = []
|
| 307 | self.chunked_blobs = 0
|
| 308 | self.chunk_crosschecks = 0
|
| 309 |
|
| 310 | def hashed(self, raw, expected):
|
| 311 | if not isinstance(expected, str) or not HEX64.fullmatch(expected):
|
| 312 | raise Invalid('invalid object hash')
|
| 313 | self.deadline += len(raw) / (1024 * 1024) * DEADLINE_S_PER_MIB
|
| 314 | if self.backend.hexdigest(raw) != expected:
|
| 315 | raise Invalid('object hash mismatch')
|
| 316 | return raw
|
| 317 |
|
| 318 | def decode(self, content, codec):
|
| 319 | if not isinstance(content, bytes):
|
| 320 | raise Invalid('stored object has invalid type')
|
| 321 | if len(content) > MAX_OBJECT:
|
| 322 | raise Unavailable('stored object exceeds reader bound')
|
| 323 | if codec == 0:
|
| 324 | return content
|
| 325 | if codec != 1:
|
| 326 | raise Unavailable('unsupported object codec')
|
| 327 | raw = self.backend.decompress(content)
|
| 328 | if len(raw) > MAX_OBJECT:
|
| 329 | raise Unavailable('decompressed object exceeds reader bound')
|
| 330 | return raw
|
| 331 |
|
| 332 | def run(self):
|
| 333 | with sqlite3.connect(self.path.as_uri() + '?mode=ro&immutable=1', uri=True, timeout=5) as db:
|
| 334 | self.db = db
|
| 335 | db.execute('PRAGMA query_only=ON')
|
| 336 | db.execute('PRAGMA trusted_schema=OFF')
|
| 337 | db.set_progress_handler(lambda: int(time.monotonic() > self.deadline), 1000)
|
| 338 | db.execute('BEGIN')
|
| 339 | try:
|
| 340 | return self._run()
|
| 341 | except sqlite3.OperationalError as exc:
|
| 342 | if 'interrupted' in str(exc):
|
| 343 | raise Unavailable('reader deadline exceeded') from exc
|
| 344 | raise
|
| 345 | finally:
|
| 346 | db.commit()
|
| 347 |
|
| 348 | def _columns(self, table):
|
| 349 | return {row[1] for row in self.db.execute(f'PRAGMA table_info({table})')}
|
| 350 |
|
| 351 | def _run(self):
|
| 352 | db = self.db
|
| 353 | for table, columns in REQUIRED_COLUMNS.items():
|
| 354 | if not columns <= self._columns(table):
|
| 355 | raise Unavailable('unrecognized Oak storage schema: ' + table)
|
| 356 | self.has_chunks = all(columns <= self._columns(table) for table, columns in CHUNK_COLUMNS.items())
|
| 357 | self.has_tree_entries = bool(db.execute(
|
| 358 | "SELECT 1 FROM sqlite_master WHERE type='table' AND name='tree_entries'").fetchone())
|
| 359 | if self.has_tree_entries and not LEGACY_TREE_COLUMNS['tree_entries'] <= self._columns('tree_entries'):
|
| 360 | raise Unavailable('unrecognized Oak storage schema: tree_entries')
|
| 361 | migrations = None
|
| 362 | if db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='schema_migrations'").fetchone():
|
| 363 | row = db.execute("SELECT max(version) FROM schema_migrations WHERE length(version)<=128").fetchone()
|
| 364 | migrations = row[0] if row else None
|
| 365 | if isinstance(migrations, str) and migrations > KNOWN_MAX_MIGRATION:
|
| 366 | raise Unavailable('unrecognized Oak schema migration: ' + migrations)
|
| 367 |
|
| 368 | def metadata(key):
|
| 369 | row = db.execute('SELECT value FROM metadata WHERE key=? AND length(value)<=4096', (key,)).fetchone()
|
| 370 | return row[0] if row else None
|
| 371 |
|
| 372 | if metadata('hash_format') not in (None, 'v1'):
|
| 373 | raise Unavailable('unsupported Oak hash format')
|
| 374 | head = metadata('head')
|
| 375 | branch = metadata('current_branch')
|
| 376 | if not head or not branch:
|
| 377 | # Nothing committed (or HEAD removed): subject state, measured failure.
|
| 378 | raise Invalid('no committed HEAD')
|
| 379 | commit = self._commit_row(head)
|
| 380 | if not commit:
|
| 381 | # The storage format is already pinned (schema columns and
|
| 382 | # migrations), so an Oak-written HEAD naming a missing commit row
|
| 383 | # is a dangling HEAD: an Oak failure, not unmeasured (QA R2-3).
|
| 384 | raise Invalid('committed HEAD names a missing commit row')
|
| 385 | if any(v is not None and (not isinstance(v, str) or len(v) > MAX_OBJECT) for v in commit):
|
| 386 | raise Invalid('committed HEAD object invalid')
|
| 387 | # Oak v1 stores the same RFC3339 timestamp text used in its hash preimage.
|
| 388 | # A preimage mismatch is an unrecognized format (fail closed), never a
|
| 389 | # published Oak correctness failure.
|
| 390 | if not HEX64.fullmatch(head):
|
| 391 | raise Invalid('invalid object hash')
|
| 392 | preimage = commit_verifies(self.backend, head, commit)
|
| 393 | if preimage is None:
|
| 394 | # Pinned format (another commit, tree or blob verifies): a HEAD
|
| 395 | # whose fields do not hash to its id is corruption, not drift.
|
| 396 | if self._format_recognized(exclude_commit=head):
|
| 397 | raise Invalid('committed HEAD commit hash mismatch')
|
| 398 | raise Unavailable('commit preimage format unrecognized: object hash mismatch')
|
| 399 | root_hash = commit[3]
|
| 400 | self._pin_blob_format()
|
| 401 | self.visit(root_hash, '', set())
|
| 402 | # Symlinks last: no regular write may pass through a created link.
|
| 403 | for relative, target in self.symlinks:
|
| 404 | os.symlink(target, self.destination / relative)
|
| 405 | history = self._history(head, commit) if self.deep else None
|
| 406 | result = {'head': head, 'branch': branch, 'root_tree': root_hash, 'commit_preimage': preimage,
|
| 407 | 'commit': {'parent': commit[1], 'merge_parent': commit[2], 'author': commit[4],
|
| 408 | 'message_sha256': hashlib.sha256((commit[5] or '').encode()).hexdigest(),
|
| 409 | 'timestamp': commit[6]},
|
| 410 | 'nonregular_paths': sorted(self.nonregular), 'oversized_paths': sorted(self.oversized),
|
| 411 | 'collision_paths': sorted(self.collisions), 'schema_migrations_max': migrations,
|
| 412 | 'chunked_blobs_reassembled': self.chunked_blobs,
|
| 413 | 'chunk_mappings_crosschecked': self.chunk_crosschecks}
|
| 414 | if history is not None:
|
| 415 | result['history'] = history
|
| 416 | if self.want_manifest:
|
| 417 | result['manifest'] = sorted(self.entries, key=lambda e: e['path'])
|
| 418 | return result
|
| 419 |
|
| 420 | def _commit_row(self, commit_hash):
|
| 421 | return self.db.execute(
|
| 422 | 'SELECT branch_name,parent_hash,merge_parent_hash,manifest_hash,author,message,timestamp FROM commits '
|
| 423 | 'WHERE hash=? AND length(CAST(branch_name AS BLOB))<=4096 AND coalesce(length(CAST(parent_hash AS BLOB)),0)<=64 '
|
| 424 | 'AND coalesce(length(CAST(merge_parent_hash AS BLOB)),0)<=64 AND length(CAST(manifest_hash AS BLOB))=64 '
|
| 425 | 'AND length(CAST(author AS BLOB))<=4096 AND coalesce(length(CAST(message AS BLOB)),0)<=1048576 '
|
| 426 | 'AND length(CAST(timestamp AS BLOB))<=128', (commit_hash,)).fetchone()
|
| 427 |
|
| 428 | def _history(self, head, commit):
|
| 429 | """Walk first and merge parents; every present commit must hash-verify.
|
| 430 |
|
| 431 | A parent absent from the local store is a shallow boundary (reported),
|
| 432 | not corruption: Oak clones may be depth-limited.
|
| 433 | """
|
| 434 | seen, frontier, missing = {head}, [commit], []
|
| 435 | verified = 1
|
| 436 | while frontier:
|
| 437 | if time.monotonic() > self.deadline or verified > MAX_HISTORY:
|
| 438 | raise Unavailable('history walk bound exceeded')
|
| 439 | current = frontier.pop()
|
| 440 | for parent in (current[1], current[2]):
|
| 441 | if not parent or parent in seen:
|
| 442 | continue
|
| 443 | seen.add(parent)
|
| 444 | if not HEX64.fullmatch(parent):
|
| 445 | raise Invalid('invalid parent hash')
|
| 446 | row = self._commit_row(parent)
|
| 447 | if row is None:
|
| 448 | missing.append(parent)
|
| 449 | continue
|
| 450 | if commit_verifies(self.backend, parent, row) is None:
|
| 451 | raise Invalid('ancestor commit hash mismatch: ' + parent)
|
| 452 | verified += 1
|
| 453 | frontier.append(row)
|
| 454 | return {'commits_verified': verified, 'missing_parents': sorted(missing)}
|
| 455 |
|
| 456 | def _pin_blob_format(self):
|
| 457 | # Blob-hash format pin: at least one of the smallest stored complete
|
| 458 | # blobs must verify under blake3(raw). If none does, the convention
|
| 459 | # changed (unmeasured); once pinned, in-scope blob mismatches are
|
| 460 | # corruption. (A repository whose every sampled blob is corrupt is
|
| 461 | # indistinguishable from a format change and fails closed.)
|
| 462 | candidates = self.db.execute('SELECT hash,content,codec FROM blobs WHERE content IS NOT NULL AND length(content)<=? '
|
| 463 | 'ORDER BY size LIMIT 8', (MAX_OBJECT,)).fetchall()
|
| 464 | checked = 0
|
| 465 | for object_hash, content, codec in candidates:
|
| 466 | try:
|
| 467 | self.hashed(self.decode(content, codec), object_hash)
|
| 468 | return
|
| 469 | except Invalid:
|
| 470 | checked += 1
|
| 471 | except Unavailable:
|
| 472 | continue # over a reader bound: says nothing about the format
|
| 473 | if checked and not (self._pin('commit') or self._pin('tree')):
|
| 474 | raise Unavailable('blob hash format unrecognized: no sampled blob verifies')
|
| 475 |
|
| 476 | # --- format pins -------------------------------------------------------
|
| 477 | # The storage format counts as recognized when the schema is pinned and at
|
| 478 | # least one stored object of ANY kind (commit, tree, blob) verifies under
|
| 479 | # the v1 rules, excluding the object under test. A mismatch on a
|
| 480 | # recognized format is corruption (Invalid); only a store in which nothing
|
| 481 | # verifies is indistinguishable from a format change (Unavailable).
|
| 482 | def _pin(self, kind, exclude=None):
|
| 483 | cache = self.__dict__.setdefault('_pins', {})
|
| 484 | key = (kind, exclude)
|
| 485 | if key in cache:
|
| 486 | return cache[key]
|
| 487 | ok = False
|
| 488 | if kind == 'commit':
|
| 489 | rows = self.db.execute('SELECT hash FROM commits WHERE hash IS NOT ? LIMIT 16', (exclude,)).fetchall()
|
| 490 | for (commit_hash,) in rows:
|
| 491 | row = self._commit_row(commit_hash)
|
| 492 | if row and isinstance(commit_hash, str) and commit_verifies(self.backend, commit_hash, row):
|
| 493 | ok = True
|
| 494 | break
|
| 495 | elif kind == 'tree':
|
| 496 | rows = self.db.execute('SELECT hash FROM trees WHERE hash IS NOT ? LIMIT 16', (exclude,)).fetchall()
|
| 497 | for (tree_hash,) in rows:
|
| 498 | try:
|
| 499 | self.hashed(self._tree_bytes(tree_hash), tree_hash)
|
| 500 | ok = True
|
| 501 | break
|
| 502 | except (Invalid, Unavailable):
|
| 503 | continue
|
| 504 | else:
|
| 505 | rows = self.db.execute('SELECT hash,content,codec FROM blobs WHERE content IS NOT NULL AND '
|
| 506 | 'length(content)<=? ORDER BY size LIMIT 8', (MAX_OBJECT,)).fetchall()
|
| 507 | for object_hash, content, codec in rows:
|
| 508 | try:
|
| 509 | self.hashed(self.decode(content, codec), object_hash)
|
| 510 | ok = True
|
| 511 | break
|
| 512 | except (Invalid, Unavailable):
|
| 513 | continue
|
| 514 | cache[key] = ok
|
| 515 | return ok
|
| 516 |
|
| 517 | def _format_recognized(self, exclude_commit=None, exclude_tree=None):
|
| 518 | return (self._pin('commit', exclude_commit) or self._pin('tree', exclude_tree) or self._pin('blob'))
|
| 519 |
|
| 520 | def _chunks(self, object_hash):
|
| 521 | if not self.has_chunks:
|
| 522 | return None
|
| 523 | rows = self.db.execute('SELECT chunk_index,chunk_hash,offset,size FROM blob_chunks WHERE blob_hash=? '
|
| 524 | 'ORDER BY chunk_index LIMIT 100001', (object_hash,)).fetchall()
|
| 525 | return rows or None
|
| 526 |
|
| 527 | def _assemble(self, object_hash, rows, expected_size):
|
| 528 | out = bytearray()
|
| 529 | for position, (index, chunk_hash, offset, size) in enumerate(rows):
|
| 530 | if index != position or offset != len(out) or type(size) is not int or size < 0:
|
| 531 | raise Invalid('non-contiguous chunk mapping')
|
| 532 | if len(out) + size > MAX_OBJECT:
|
| 533 | raise Unavailable('chunked blob exceeds reader bound')
|
| 534 | row = self.db.execute('SELECT content,size FROM chunks WHERE hash=? AND length(content)<=?',
|
| 535 | (chunk_hash, MAX_OBJECT)).fetchone()
|
| 536 | if row is None:
|
| 537 | if self.db.execute('SELECT 1 FROM chunks WHERE hash=?', (chunk_hash,)).fetchone():
|
| 538 | raise Unavailable('stored chunk exceeds reader bound')
|
| 539 | raise (Invalid if self.complete else Unavailable)('chunk content absent: ' + str(chunk_hash))
|
| 540 | content = row[0]
|
| 541 | if not isinstance(content, bytes):
|
| 542 | raise Invalid('stored chunk has invalid type')
|
| 543 | # Oak's chunk_decode: a zstd-looking chunk is plaintext only if the
|
| 544 | # decoded bytes hash to the chunk id; otherwise the raw bytes are.
|
| 545 | data = content
|
| 546 | if content[:4] == ZSTD_MAGIC and self.backend.hexdigest(content) != chunk_hash:
|
| 547 | data = self.backend.decompress(content)
|
| 548 | if len(data) != size:
|
| 549 | raise Invalid('chunk size mismatch')
|
| 550 | self.hashed(data, chunk_hash)
|
| 551 | out += data
|
| 552 | if expected_size is not None and len(out) != expected_size:
|
| 553 | raise Invalid('chunked blob size mismatch')
|
| 554 | return self.hashed(bytes(out), object_hash)
|
| 555 |
|
| 556 | def blob(self, object_hash):
|
| 557 | row = self.db.execute('SELECT content,size,codec FROM blobs WHERE hash=? AND length(content)<=?',
|
| 558 | (object_hash, MAX_OBJECT)).fetchone()
|
| 559 | if row is None:
|
| 560 | if self.db.execute('SELECT 1 FROM blobs WHERE hash=?', (object_hash,)).fetchone():
|
| 561 | raise Unavailable('stored blob exceeds reader bound')
|
| 562 | if object_hash == EMPTY_HASH:
|
| 563 | return b'' # Oak implies the empty blob's bytes from its hash.
|
| 564 | rows = self._chunks(object_hash)
|
| 565 | if rows is None:
|
| 566 | raise (Invalid if self.complete else Unavailable)(
|
| 567 | 'complete blob unavailable and no chunk mapping: ' + object_hash)
|
| 568 | self.chunked_blobs += 1
|
| 569 | return self._assemble(object_hash, rows, None)
|
| 570 | content, size, codec = row
|
| 571 | if type(size) is not int or size < 0:
|
| 572 | raise Invalid('invalid stored blob size')
|
| 573 | if size > MAX_OBJECT:
|
| 574 | raise Unavailable('blob size exceeds reader bound')
|
| 575 | raw = self.decode(content, codec)
|
| 576 | if len(raw) != size:
|
| 577 | raise Invalid('blob size mismatch')
|
| 578 | raw = self.hashed(raw, object_hash)
|
| 579 | if self.deep:
|
| 580 | rows = self._chunks(object_hash)
|
| 581 | if rows is not None:
|
| 582 | self._assemble(object_hash, rows, size)
|
| 583 | self.chunk_crosschecks += 1
|
| 584 | return raw
|
| 585 |
|
| 586 | def _tree_bytes(self, tree_hash):
|
| 587 | db = self.db
|
| 588 | row = db.execute('SELECT content FROM trees WHERE hash=? AND (content IS NULL OR length(content)<=?)',
|
| 589 | (tree_hash, MAX_OBJECT)).fetchone()
|
| 590 | if row is None:
|
| 591 | if db.execute('SELECT 1 FROM trees WHERE hash=?', (tree_hash,)).fetchone():
|
| 592 | raise Unavailable('committed tree exceeds reader bound')
|
| 593 | raise Invalid('committed tree missing')
|
| 594 | if row[0] is not None:
|
| 595 | return self.decode(row[0], 1)
|
| 596 | if not self.has_tree_entries:
|
| 597 | # Compacted store: content is the only representation of a tree.
|
| 598 | raise Invalid('committed tree content missing in compacted store')
|
| 599 | if db.execute('SELECT 1 FROM tree_entries WHERE tree_hash=? AND (length(CAST(name AS BLOB))>4096 OR '
|
| 600 | 'length(CAST(hash AS BLOB))!=64 OR length(CAST(kind AS BLOB))>4 OR length(CAST(mode AS BLOB))>10) '
|
| 601 | 'LIMIT 1', (tree_hash,)).fetchone():
|
| 602 | raise Unavailable('stored tree entry exceeds reader bound')
|
| 603 | stored = db.execute('SELECT kind,name,hash,mode FROM tree_entries WHERE tree_hash=? ORDER BY name LIMIT ?',
|
| 604 | (tree_hash, MAX_ENTRIES + 1)).fetchall()
|
| 605 | return '\n'.join('\t'.join((kind, name, obj, 'tree' if kind == 'tree' else mode))
|
| 606 | for kind, name, obj, mode in stored).encode()
|
| 607 |
|
| 608 | def _record(self, relative, kind, **fields):
|
| 609 | self.entries.append({'path': relative, 'kind': kind, **fields})
|
| 610 |
|
| 611 | def visit(self, tree_hash, relative, ancestors):
|
| 612 | if tree_hash in ancestors:
|
| 613 | raise Invalid('tree cycle')
|
| 614 | if len(ancestors) >= 64 or time.monotonic() > self.deadline:
|
| 615 | raise Unavailable('tree depth or time bound exceeded')
|
| 616 | if not isinstance(tree_hash, str) or not HEX64.fullmatch(tree_hash):
|
| 617 | raise Invalid('invalid object hash')
|
| 618 | if tree_hash == EMPTY_HASH:
|
| 619 | return
|
| 620 | raw = self._tree_bytes(tree_hash)
|
| 621 | if not ancestors:
|
| 622 | try:
|
| 623 | self.hashed(raw, tree_hash)
|
| 624 | except Invalid as exc:
|
| 625 | if self._format_recognized(exclude_tree=tree_hash):
|
| 626 | raise Invalid('root tree hash mismatch: ' + str(exc)) from exc
|
| 627 | raise Unavailable('root tree serialization unrecognized: ' + str(exc)) from exc
|
| 628 | else:
|
| 629 | self.hashed(raw, tree_hash)
|
| 630 | try:
|
| 631 | lines = raw.decode('utf-8').split('\n') if raw else []
|
| 632 | except UnicodeError as exc:
|
| 633 | raise Invalid('invalid tree encoding') from exc
|
| 634 | previous = None
|
| 635 | for line in lines:
|
| 636 | parts = line.split('\t')
|
| 637 | if len(parts) != 4:
|
| 638 | raise Invalid('invalid tree entry')
|
| 639 | kind, name, object_hash, mode = parts
|
| 640 | if entry_name_problem(name) or (previous is not None and name <= previous):
|
| 641 | raise Invalid('unsafe or unordered tree path: ' + (entry_name_problem(name) or 'order'))
|
| 642 | previous = name
|
| 643 | if not HEX64.fullmatch(object_hash):
|
| 644 | raise Invalid('invalid object hash in tree entry')
|
| 645 | path = name if not relative else relative + '/' + name
|
| 646 | visit_entry, _ = in_scope(path, self.scope)
|
| 647 | if not visit_entry:
|
| 648 | continue
|
| 649 | if not ((kind == 'tree' and mode == 'tree') or (kind == 'blob' and mode in BLOB_MODES)):
|
| 650 | if self.scope is not None:
|
| 651 | self.nonregular.append(path)
|
| 652 | continue
|
| 653 | raise Unavailable('unsupported tree entry kind or mode: %s/%s' % (kind, mode))
|
| 654 | self.entries_seen += 1
|
| 655 | if self.entries_seen > MAX_ENTRIES:
|
| 656 | raise Unavailable('tree entry bound exceeded')
|
| 657 | target = self.destination / path if self.destination is not None else None
|
| 658 | if target is not None and os.path.lexists(target):
|
| 659 | # Git fails the path set for colliding committed paths;
|
| 660 | # report it so the oracle fails the same way.
|
| 661 | self.collisions.append(path)
|
| 662 | continue
|
| 663 | if kind == 'tree':
|
| 664 | if target is not None:
|
| 665 | target.mkdir()
|
| 666 | if self.want_manifest:
|
| 667 | self._record(path, 'dir')
|
| 668 | self.visit(object_hash, path, ancestors | {tree_hash})
|
| 669 | continue
|
| 670 | if mode == 'symlink' and self.scope is not None:
|
| 671 | # Git's scoped check fails any non-100644 entry; report
|
| 672 | # it so the oracle fails the payload the same way.
|
| 673 | self.nonregular.append(path)
|
| 674 | continue
|
| 675 | if self.max_blob_bytes is not None and mode != 'symlink':
|
| 676 | stored = self.db.execute('SELECT size FROM blobs WHERE hash=?', (object_hash,)).fetchone()
|
| 677 | if stored and type(stored[0]) is int and stored[0] > self.max_blob_bytes:
|
| 678 | # The task's own size limit fails first, as in Git,
|
| 679 | # before any reader resource bound applies.
|
| 680 | self.oversized.append(path)
|
| 681 | continue
|
| 682 | content = self.blob(object_hash)
|
| 683 | self.total += len(content)
|
| 684 | if self.total > MAX_TOTAL:
|
| 685 | raise Unavailable('total payload bound exceeded')
|
| 686 | if mode == 'symlink':
|
| 687 | if b'\x00' in content or not content:
|
| 688 | raise Invalid('invalid symlink target')
|
| 689 | if self.want_manifest:
|
| 690 | self._record(path, 'symlink', target_hex=content.hex(), blob=object_hash)
|
| 691 | if target is not None:
|
| 692 | self.symlinks.append((path, content))
|
| 693 | continue
|
| 694 | if self.want_manifest:
|
| 695 | self._record(path, 'file', executable=mode == 'executable', size=len(content),
|
| 696 | sha256=hashlib.sha256(content).hexdigest(), blob=object_hash)
|
| 697 | if target is not None:
|
| 698 | target.write_bytes(content)
|
| 699 | target.chmod(0o755 if mode == 'executable' else 0o644)
|
| 700 |
|
| 701 |
|
| 702 | def committed_payloads(repo: Path, paths, *, verify_all: bool = False) -> tuple[dict, dict]:
|
| 703 | """{path: bytes} of committed regular files at HEAD for harness oracles.
|
| 704 |
|
| 705 | Paths absent from HEAD (or not regular files) are omitted. ``verify_all``
|
| 706 | first runs an unscoped deep read (every object and ancestor re-hashed,
|
| 707 | nothing materialized): Oak's missing ``fsck`` equivalent.
|
| 708 | Raises Invalid / Unavailable exactly like ``read_committed_tree``.
|
| 709 | """
|
| 710 | repo = Path(repo)
|
| 711 | meta = {}
|
| 712 | if verify_all:
|
| 713 | full = read_committed_tree(repo, None, deep=True)
|
| 714 | meta.update(head=full['head'], history=full.get('history'), verified_scope='all')
|
| 715 | scope = normalize_scope(list(paths))
|
| 716 | payloads = {}
|
| 717 | if scope:
|
| 718 | with tempfile.TemporaryDirectory(prefix='oakbench-committed-') as temporary:
|
| 719 | destination = Path(temporary) / 'tree'
|
| 720 | result = read_committed_tree(repo, destination, scope)
|
| 721 | meta.setdefault('head', result['head'])
|
| 722 | meta['reader_version'] = result['reader_version']
|
| 723 | meta['reader_backend'] = result['reader_backend']
|
| 724 | for relative in scope:
|
| 725 | target = destination / relative
|
| 726 | if target.is_file() and not target.is_symlink():
|
| 727 | payloads[relative] = target.read_bytes()
|
| 728 | return payloads, meta
|
| 729 |
|
| 730 |
|
| 731 | def main():
|
| 732 | try:
|
| 733 | args = sys.argv[1:]
|
| 734 | scope = None
|
| 735 | max_blob_bytes = None
|
| 736 | options = {'manifest': False, 'deep': False, 'complete': False}
|
| 737 | usage = ('usage: oak_sqlite_reader.py REPOSITORY NEW_DESTINATION|- [--manifest] [--deep] [--complete] '
|
| 738 | '[--max-blob-bytes N] [--scope PATH...]')
|
| 739 | if len(args) < 2:
|
| 740 | raise Unavailable(usage)
|
| 741 | rest = args[2:]
|
| 742 | while rest[:1] in (['--manifest'], ['--deep'], ['--complete']):
|
| 743 | options[rest[0][2:]] = True
|
| 744 | rest = rest[1:]
|
| 745 | if rest[:1] == ['--max-blob-bytes']:
|
| 746 | if len(rest) < 2 or not rest[1].isdigit():
|
| 747 | raise Unavailable(usage)
|
| 748 | max_blob_bytes = int(rest[1])
|
| 749 | rest = rest[2:]
|
| 750 | if rest[:1] == ['--scope']:
|
| 751 | scope = rest[1:]
|
| 752 | if not scope:
|
| 753 | raise Unavailable('empty reader scope')
|
| 754 | elif rest:
|
| 755 | raise Unavailable(usage)
|
| 756 | destination = None if args[1] == '-' else Path(args[1])
|
| 757 | result = read_committed_tree(Path(args[0]), destination, scope, max_blob_bytes, **options)
|
| 758 | code = 0
|
| 759 | except Unavailable as exc:
|
| 760 | result, code = {'unavailable': str(exc)}, 77
|
| 761 | except Invalid as exc:
|
| 762 | result, code = {'corrupt': True, 'reason': str(exc)}, 1
|
| 763 | except (OSError, ValueError, TypeError, sqlite3.Error) as exc:
|
| 764 | result, code = {'unavailable': 'reader instrument error: ' + str(exc)}, 77
|
| 765 | print(json.dumps(result, sort_keys=True))
|
| 766 | return code
|
| 767 |
|
| 768 |
|
| 769 | if __name__ == '__main__':
|
| 770 | raise SystemExit(main())
|