Viewing files at commit a3ea065926c9Commit detailsView latest

Browse files
scripts/oakbench/oak_sqlite_reader.py 770 lines · 37.2 KB · python
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())