Viewing files at commit bd39a563b7f5Commit detailsView latest

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