Viewing files at commit 21316fe59317Commit detailsView latest

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