Viewing files at commit b76ab8b42dfdCommit detailsView latest

Browse files
scripts/oakbench/oak_sqlite_reader.py 363 lines · 18.3 KB · python
1
"""Optional independent decoder for local Oak SQLite v1 committed trees.
2
3
No Oak code or executable is imported or invoked. Optional blake3/zstandard
4
packages belong to a pinned reader environment, not the base harness. Unknown
5
formats and missing instruments are unavailable evidence; corrupt objects fail.
6
7
Evidence contract (2026-09-27.1):
8
- Scope: when scope paths are given, only entries on those paths (and the
9
  trees leading to them) are decoded. This is the same scope the Git
10
  committed-payload check reads with ``git ls-tree HEAD -- <scope>``, so the
11
  two readers can share an evidence class. Out-of-scope entries are never
12
  decoded, so repo-wide conditions the task does not own cannot fail Oak.
13
- Reader resource and time budgets are the harness's limits, not properties
14
  of Oak's stored content: exceeding one is Unavailable (exit 77), never
15
  Invalid. Invalid (exit 1) is reserved for positively identified bad bytes.
16
- Format pin: required tables/columns must exist, and the HEAD commit
17
  preimage plus root tree serialization must verify. A mismatch there is an
18
  unrecognized format (Unavailable); once the root verifies, the same
19
  serialization is proven, so deeper hash mismatches are Invalid.
20
- In-scope non-regular entries (symlinks, unknown kinds) are reported in
21
  ``nonregular_paths`` instead of being refused, so the oracle can fail them
22
  exactly as Git's mode check does.
23
"""
24
from __future__ import annotations
25
import hashlib
26
import importlib.metadata
27
import json
28
from pathlib import Path
29
import re
30
import sqlite3
31
import sys
32
import time
33
import tempfile
34
35
READER_VERSION = 'oak-sqlite-v1-2026-09-27.3'
36
# Newest Oak schema migration this reader is qualified against (oak 0.105.0).
37
# A newer migration is an unrecognized format: unmeasured, never corrupt.
38
KNOWN_MAX_MIGRATION = '0014_change_captures'
39
MAX_OBJECT = 32 * 1024 * 1024
40
MAX_TOTAL = 128 * 1024 * 1024
41
MAX_ENTRIES = 8192
42
MAX_DATABASE = 512 * 1024 * 1024
43
44
class Unavailable(Exception):
45
    pass
46
47
class Invalid(Exception):
48
    pass
49
50
51
REQUIRED_COLUMNS = {
52
    'metadata': {'key', 'value'},
53
    'commits': {'hash', 'branch_name', 'parent_hash', 'merge_parent_hash', 'manifest_hash', 'author', 'message', 'timestamp'},
54
    'blobs': {'hash', 'content', 'size', 'codec'},
55
    'trees': {'hash', 'content'},
56
    'tree_entries': {'tree_hash', 'name', 'kind', 'hash', 'mode'},
57
}
58
59
60
def normalize_scope(scope):
61
    """Validated repo-relative POSIX scope paths, or None for the whole tree."""
62
    if scope is None:
63
        return None
64
    result = []
65
    for item in scope:
66
        parts = item.split('/')
67
        if (not item or item.startswith('/') or any(part in ('', '.', '..', '.oak', '.git') for part in parts)
68
                or any(c in item for c in '\\\x00\n\r')):
69
            raise Unavailable('invalid reader scope path')
70
        result.append(item)
71
    return tuple(sorted(set(result)))
72
73
74
def in_scope(relative: str, scope) -> tuple[bool, bool]:
75
    """(visit, materialize) for a repo-relative path under the scope."""
76
    if scope is None:
77
        return True, True
78
    for item in scope:
79
        if relative == item or relative.startswith(item + '/'):
80
            return True, True
81
        if item.startswith(relative + '/'):
82
            return True, False
83
    return False, False
84
85
86
def read_committed_tree(repo: Path, destination: Path, scope=None, max_blob_bytes=None) -> dict:
87
    scope = normalize_scope(scope)
88
    try:
89
        import blake3
90
        import zstandard
91
    except ImportError as exc:
92
        raise Unavailable('reader dependencies unavailable: blake3 and zstandard required') from exc
93
    db_path = repo / '.oak' / 'oak.db'
94
    # The repository is subject-controlled state: a missing or linked database
95
    # is a measured failure, exactly as Git's ls-tree on a repo whose .git was
96
    # removed is (git_head_tree_unreadable). Never "unmeasured".
97
    if (repo.is_symlink() or (repo / '.oak').is_symlink() or db_path.is_symlink()
98
            or not db_path.is_file()):
99
        raise Invalid('committed state unreadable: local Oak database missing or linked')
100
    if destination.exists() or destination.is_symlink():
101
        raise Unavailable('reader destination must not exist')
102
    deadline = time.monotonic() + 60
103
    destination.mkdir(parents=True)
104
105
    def hashed(raw, expected):
106
        if not isinstance(expected, str) or not re.fullmatch('[0-9a-f]{64}', expected):
107
            raise Invalid('invalid object hash')
108
        if blake3.blake3(raw).hexdigest() != expected:
109
            raise Invalid('object hash mismatch')
110
        return raw
111
112
    def decode(content, codec):
113
        if not isinstance(content, bytes):
114
            raise Invalid('stored object has invalid type')
115
        if len(content) > MAX_OBJECT:
116
            raise Unavailable('stored object exceeds reader bound')
117
        if codec == 0:
118
            return content
119
        if codec != 1:
120
            raise Unavailable('unsupported object codec')
121
        try:
122
            size = zstandard.frame_content_size(content)
123
            if zstandard.get_frame_parameters(content).window_size > MAX_OBJECT:
124
                raise Unavailable('decompression window exceeds reader bound')
125
            if size not in (-1, zstandard.CONTENTSIZE_UNKNOWN) and not 0 <= size <= MAX_OBJECT:
126
                raise Unavailable('decompressed object exceeds reader bound')
127
            raw = zstandard.ZstdDecompressor(max_window_size=MAX_OBJECT).decompress(
128
                content, max_output_size=MAX_OBJECT, allow_extra_data=False)
129
        except zstandard.ZstdError as exc:
130
            raise Invalid('invalid compressed object') from exc
131
        if len(raw) > MAX_OBJECT:
132
            raise Unavailable('decompressed object exceeds reader bound')
133
        return raw
134
135
    def database_snapshot():
136
        if any(Path(str(db_path) + suffix).exists() for suffix in ('-wal', '-journal')):
137
            raise Unavailable('active SQLite journal; quiescent database required')
138
        with db_path.open('rb') as stream:
139
            content = stream.read(MAX_DATABASE + 1)
140
        if len(content) > MAX_DATABASE:
141
            raise Unavailable('database exceeds independent reader bound')
142
        return content
143
144
    # Snapshot only quiescent databases. immutable=1 is safe on this private copy,
145
    # never on a live WAL database; the original bytes are checked again below.
146
    snapshot = database_snapshot()
147
    database_sha256 = hashlib.sha256(snapshot).hexdigest()
148
    with tempfile.TemporaryDirectory(prefix='oakbench-reader-db-') as temp:
149
        snapshot_path = Path(temp) / 'snapshot.db'
150
        snapshot_path.write_bytes(snapshot)
151
        del snapshot
152
        return _read_snapshot(snapshot_path, database_sha256, database_snapshot,
153
                              destination, deadline, hashed, decode, blake3, scope, max_blob_bytes)
154
155
156
def _read_snapshot(snapshot_path, database_sha256, database_snapshot, destination,
157
                   deadline, hashed, decode, blake3, scope=None, max_blob_bytes=None):
158
    total = 0
159
    entries_seen = 0
160
    nonregular = []
161
    oversized = []
162
    collisions = []
163
    with sqlite3.connect(snapshot_path.as_uri() + '?mode=ro&immutable=1', uri=True, timeout=5) as db:
164
        db.execute('PRAGMA query_only=ON')
165
        db.execute('PRAGMA trusted_schema=OFF')
166
        db.set_progress_handler(lambda: int(time.monotonic() > deadline), 1000)
167
        db.execute('BEGIN')
168
        for table, columns in REQUIRED_COLUMNS.items():
169
            present = {row[1] for row in db.execute(f'PRAGMA table_info({table})')}
170
            if not columns <= present:
171
                raise Unavailable('unrecognized Oak storage schema: ' + table)
172
        migrations = None
173
        if db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='schema_migrations'").fetchone():
174
            row = db.execute("SELECT max(version) FROM schema_migrations WHERE length(version)<=128").fetchone()
175
            migrations = row[0] if row else None
176
        if isinstance(migrations, str) and migrations > KNOWN_MAX_MIGRATION:
177
            raise Unavailable('unrecognized Oak schema migration: ' + migrations)
178
179
        def metadata(key):
180
            row = db.execute('SELECT value FROM metadata WHERE key=? AND length(value)<=4096', (key,)).fetchone()
181
            return row[0] if row else None
182
183
        if metadata('hash_format') not in (None, 'v1'):
184
            raise Unavailable('unsupported Oak hash format')
185
        head = metadata('head')
186
        branch = metadata('current_branch')
187
        if not head or not branch:
188
            # Nothing committed (or HEAD removed): subject state, measured failure.
189
            raise Invalid('no committed HEAD')
190
        commit = db.execute('SELECT branch_name,parent_hash,merge_parent_hash,manifest_hash,author,message,timestamp FROM commits WHERE hash=? AND length(CAST(branch_name AS BLOB))<=4096 AND coalesce(length(CAST(parent_hash AS BLOB)),0)<=64 AND coalesce(length(CAST(merge_parent_hash AS BLOB)),0)<=64 AND length(CAST(manifest_hash AS BLOB))=64 AND length(CAST(author AS BLOB))<=4096 AND coalesce(length(CAST(message AS BLOB)),0)<=1048576 AND length(CAST(timestamp AS BLOB))<=128', (head,)).fetchone()
191
        if not commit:
192
            # The storage format is already pinned (schema columns and
193
            # migrations), so an Oak-written HEAD naming a missing commit row
194
            # is a dangling HEAD: an Oak failure, not unmeasured (QA R2-3).
195
            raise Invalid('committed HEAD names a missing commit row')
196
        if any(v is not None and (not isinstance(v, str) or len(v) > MAX_OBJECT) for v in commit):
197
            raise Invalid('committed HEAD object invalid')
198
        # Oak v1 stores the same RFC3339 timestamp text used in its hash preimage.
199
        # A preimage mismatch is an unrecognized format (fail closed), never a
200
        # published Oak correctness failure.
201
        try:
202
            hashed('\n'.join(v or '' for v in commit).encode(), head)
203
        except Invalid as exc:
204
            raise Unavailable('commit preimage format unrecognized: ' + str(exc)) from exc
205
        root_hash = commit[3]
206
        # Blob-hash format pin: at least one of the smallest stored complete
207
        # blobs must verify under blake3(raw). If none does, the convention
208
        # changed (unmeasured); once pinned, in-scope blob mismatches are
209
        # corruption. (A repository whose every sampled blob is corrupt is
210
        # indistinguishable from a format change and fails closed.)
211
        candidates = db.execute('SELECT hash,content,codec FROM blobs WHERE content IS NOT NULL AND length(content)<=? '
212
                                'ORDER BY size LIMIT 8', (MAX_OBJECT,)).fetchall()
213
        if candidates:
214
            pinned = False
215
            for object_hash, content, codec in candidates:
216
                try:
217
                    hashed(decode(content, codec), object_hash)
218
                    pinned = True
219
                    break
220
                except (Invalid, Unavailable):
221
                    continue
222
            if not pinned:
223
                raise Unavailable('blob hash format unrecognized: no sampled blob verifies')
224
        def blob(object_hash):
225
            row = db.execute('SELECT content,size,codec FROM blobs WHERE hash=? AND length(content)<=?', (object_hash, MAX_OBJECT)).fetchone()
226
            if row is None:
227
                if db.execute('SELECT 1 FROM blobs WHERE hash=?', (object_hash,)).fetchone():
228
                    raise Unavailable('stored blob exceeds reader bound')
229
                # Chunk-only hydration is not yet qualified by this reader.
230
                raise Unavailable('complete blob unavailable; chunk-only storage unsupported')
231
            content, size, codec = row
232
            if type(size) is not int or size < 0:
233
                raise Invalid('invalid stored blob size')
234
            if size > MAX_OBJECT:
235
                raise Unavailable('blob size exceeds reader bound')
236
            raw = decode(content, codec)
237
            if len(raw) != size:
238
                raise Invalid('blob size mismatch')
239
            return hashed(raw, object_hash)
240
241
        def visit(tree_hash, relative, ancestors):
242
            nonlocal total, entries_seen
243
            if tree_hash in ancestors:
244
                raise Invalid('tree cycle')
245
            if len(ancestors) >= 32 or time.monotonic() > deadline:
246
                raise Unavailable('tree depth or time bound exceeded')
247
            if tree_hash == blake3.blake3(b'').hexdigest():
248
                return
249
            row = db.execute('SELECT content FROM trees WHERE hash=? AND (content IS NULL OR length(content)<=?)', (tree_hash, MAX_OBJECT)).fetchone()
250
            if row is None:
251
                if db.execute('SELECT 1 FROM trees WHERE hash=?', (tree_hash,)).fetchone():
252
                    raise Unavailable('committed tree exceeds reader bound')
253
                raise Invalid('committed tree missing')
254
            if row[0] is None:
255
                if db.execute('SELECT 1 FROM tree_entries WHERE tree_hash=? AND (length(CAST(name AS BLOB))>4096 OR length(CAST(hash AS BLOB))!=64 OR length(CAST(kind AS BLOB))>4 OR length(CAST(mode AS BLOB))>10) LIMIT 1', (tree_hash,)).fetchone():
256
                    raise Unavailable('stored tree entry exceeds reader bound')
257
                stored = db.execute('SELECT kind,name,hash,mode FROM tree_entries WHERE tree_hash=? ORDER BY name LIMIT ?', (tree_hash, MAX_ENTRIES + 1)).fetchall()
258
                raw = '\n'.join('\t'.join((kind, name, obj, 'tree' if kind == 'tree' else mode)) for kind, name, obj, mode in stored).encode()
259
            else:
260
                raw = decode(row[0], 1)
261
            if not ancestors:
262
                try:
263
                    hashed(raw, tree_hash)
264
                except Invalid as exc:
265
                    raise Unavailable('root tree serialization unrecognized: ' + str(exc)) from exc
266
            else:
267
                hashed(raw, tree_hash)
268
            try:
269
                lines = raw.decode('utf-8').split('\n') if raw else []
270
            except UnicodeError as exc:
271
                raise Invalid('invalid tree encoding') from exc
272
            previous = None
273
            for line in lines:
274
                parts = line.split('\t')
275
                if len(parts) != 4:
276
                    raise Invalid('invalid tree entry')
277
                kind, name, object_hash, mode = parts
278
                if (not name or name in ('.', '..', '.oak', '.git') or any(c in name for c in '/\\\x00\n\r')
279
                        or (previous is not None and name <= previous)):
280
                    raise Invalid('unsafe or unordered tree path')
281
                previous = name
282
                path = relative / name
283
                visit_entry, _ = in_scope(path.as_posix(), scope)
284
                if not visit_entry:
285
                    continue
286
                entries_seen += 1
287
                if entries_seen > MAX_ENTRIES:
288
                    raise Unavailable('tree entry bound exceeded')
289
                if (destination / path).exists() or (destination / path).is_symlink():
290
                    # Git fails the path set for colliding committed paths;
291
                    # report it so the oracle fails the same way.
292
                    collisions.append(path.as_posix())
293
                    continue
294
                if kind == 'tree' and mode == 'tree':
295
                    (destination / path).mkdir()
296
                    visit(object_hash, path, ancestors | {tree_hash})
297
                elif kind == 'blob' and mode in ('regular', 'executable'):
298
                    if max_blob_bytes is not None:
299
                        stored = db.execute('SELECT size FROM blobs WHERE hash=?', (object_hash,)).fetchone()
300
                        if stored and type(stored[0]) is int and stored[0] > max_blob_bytes:
301
                            # The task's own size limit fails first, as in Git,
302
                            # before any reader resource bound applies.
303
                            oversized.append(path.as_posix())
304
                            continue
305
                    content = blob(object_hash)
306
                    total += len(content)
307
                    if total > MAX_TOTAL:
308
                        raise Unavailable('total payload bound exceeded')
309
                    target = destination / path
310
                    target.write_bytes(content)
311
                    target.chmod(0o755 if mode == 'executable' else 0o644)
312
                elif scope is not None:
313
                    # Git's scoped check fails any non-100644 entry; report
314
                    # it so the oracle fails the payload the same way.
315
                    nonregular.append(path.as_posix())
316
                else:
317
                    raise Unavailable('unsupported tree entry kind or mode')
318
        visit(root_hash, Path(), set())
319
        db.commit()
320
        if hashlib.sha256(database_snapshot()).hexdigest() != database_sha256:
321
            raise Unavailable('database changed during independent read')
322
    return {'head': head, 'database_sha256': database_sha256, 'reader_version': READER_VERSION,
323
            'scope': list(scope) if scope is not None else None, 'nonregular_paths': sorted(nonregular),
324
            'oversized_paths': sorted(oversized), 'collision_paths': sorted(collisions),
325
            'schema_migrations_max': migrations,
326
            'reader_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
327
            'blake3_version': importlib.metadata.version('blake3'),
328
            'zstandard_version': importlib.metadata.version('zstandard')}
329
330
331
def main():
332
    try:
333
        args = sys.argv[1:]
334
        scope = None
335
        max_blob_bytes = None
336
        usage = 'usage: oak_sqlite_reader.py REPOSITORY NEW_DESTINATION [--max-blob-bytes N] [--scope PATH...]'
337
        if len(args) < 2:
338
            raise Unavailable(usage)
339
        rest = args[2:]
340
        if rest[:1] == ['--max-blob-bytes']:
341
            if len(rest) < 2 or not rest[1].isdigit():
342
                raise Unavailable(usage)
343
            max_blob_bytes = int(rest[1])
344
            rest = rest[2:]
345
        if rest[:1] == ['--scope']:
346
            scope = rest[1:]
347
            if not scope:
348
                raise Unavailable('empty reader scope')
349
        elif rest:
350
            raise Unavailable(usage)
351
        result = read_committed_tree(Path(args[0]), Path(args[1]), scope, max_blob_bytes)
352
        code = 0
353
    except Unavailable as exc:
354
        result, code = {'unavailable': str(exc)}, 77
355
    except Invalid as exc:
356
        result, code = {'corrupt': True, 'reason': str(exc)}, 1
357
    except (OSError, ValueError, TypeError, sqlite3.Error) as exc:
358
        result, code = {'unavailable': 'reader instrument error: ' + str(exc)}, 77
359
    print(json.dumps(result, sort_keys=True))
360
    return code
361
362
if __name__ == '__main__':
363
    raise SystemExit(main())