Viewing files at commit 290f11640be1Commit detailsView latest

Browse files
scripts/oakbench/oak_sqlite_reader.py 362 lines · 18.2 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.2'
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
            # A HEAD whose row this reader cannot find may be a changed lookup
193
            # format; fail closed as unrecognized rather than as corrupt.
194
            raise Unavailable('committed HEAD commit row not found by this reader')
195
        if any(v is not None and (not isinstance(v, str) or len(v) > MAX_OBJECT) for v in commit):
196
            raise Invalid('committed HEAD object invalid')
197
        # Oak v1 stores the same RFC3339 timestamp text used in its hash preimage.
198
        # A preimage mismatch is an unrecognized format (fail closed), never a
199
        # published Oak correctness failure.
200
        try:
201
            hashed('\n'.join(v or '' for v in commit).encode(), head)
202
        except Invalid as exc:
203
            raise Unavailable('commit preimage format unrecognized: ' + str(exc)) from exc
204
        root_hash = commit[3]
205
        # Blob-hash format pin: at least one of the smallest stored complete
206
        # blobs must verify under blake3(raw). If none does, the convention
207
        # changed (unmeasured); once pinned, in-scope blob mismatches are
208
        # corruption. (A repository whose every sampled blob is corrupt is
209
        # indistinguishable from a format change and fails closed.)
210
        candidates = db.execute('SELECT hash,content,codec FROM blobs WHERE content IS NOT NULL AND length(content)<=? '
211
                                'ORDER BY size LIMIT 8', (MAX_OBJECT,)).fetchall()
212
        if candidates:
213
            pinned = False
214
            for object_hash, content, codec in candidates:
215
                try:
216
                    hashed(decode(content, codec), object_hash)
217
                    pinned = True
218
                    break
219
                except (Invalid, Unavailable):
220
                    continue
221
            if not pinned:
222
                raise Unavailable('blob hash format unrecognized: no sampled blob verifies')
223
        def blob(object_hash):
224
            row = db.execute('SELECT content,size,codec FROM blobs WHERE hash=? AND length(content)<=?', (object_hash, MAX_OBJECT)).fetchone()
225
            if row is None:
226
                if db.execute('SELECT 1 FROM blobs WHERE hash=?', (object_hash,)).fetchone():
227
                    raise Unavailable('stored blob exceeds reader bound')
228
                # Chunk-only hydration is not yet qualified by this reader.
229
                raise Unavailable('complete blob unavailable; chunk-only storage unsupported')
230
            content, size, codec = row
231
            if type(size) is not int or size < 0:
232
                raise Invalid('invalid stored blob size')
233
            if size > MAX_OBJECT:
234
                raise Unavailable('blob size exceeds reader bound')
235
            raw = decode(content, codec)
236
            if len(raw) != size:
237
                raise Invalid('blob size mismatch')
238
            return hashed(raw, object_hash)
239
240
        def visit(tree_hash, relative, ancestors):
241
            nonlocal total, entries_seen
242
            if tree_hash in ancestors:
243
                raise Invalid('tree cycle')
244
            if len(ancestors) >= 32 or time.monotonic() > deadline:
245
                raise Unavailable('tree depth or time bound exceeded')
246
            if tree_hash == blake3.blake3(b'').hexdigest():
247
                return
248
            row = db.execute('SELECT content FROM trees WHERE hash=? AND (content IS NULL OR length(content)<=?)', (tree_hash, MAX_OBJECT)).fetchone()
249
            if row is None:
250
                if db.execute('SELECT 1 FROM trees WHERE hash=?', (tree_hash,)).fetchone():
251
                    raise Unavailable('committed tree exceeds reader bound')
252
                raise Invalid('committed tree missing')
253
            if row[0] is None:
254
                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():
255
                    raise Unavailable('stored tree entry exceeds reader bound')
256
                stored = db.execute('SELECT kind,name,hash,mode FROM tree_entries WHERE tree_hash=? ORDER BY name LIMIT ?', (tree_hash, MAX_ENTRIES + 1)).fetchall()
257
                raw = '\n'.join('\t'.join((kind, name, obj, 'tree' if kind == 'tree' else mode)) for kind, name, obj, mode in stored).encode()
258
            else:
259
                raw = decode(row[0], 1)
260
            if not ancestors:
261
                try:
262
                    hashed(raw, tree_hash)
263
                except Invalid as exc:
264
                    raise Unavailable('root tree serialization unrecognized: ' + str(exc)) from exc
265
            else:
266
                hashed(raw, tree_hash)
267
            try:
268
                lines = raw.decode('utf-8').split('\n') if raw else []
269
            except UnicodeError as exc:
270
                raise Invalid('invalid tree encoding') from exc
271
            previous = None
272
            for line in lines:
273
                parts = line.split('\t')
274
                if len(parts) != 4:
275
                    raise Invalid('invalid tree entry')
276
                kind, name, object_hash, mode = parts
277
                if (not name or name in ('.', '..', '.oak', '.git') or any(c in name for c in '/\\\x00\n\r')
278
                        or (previous is not None and name <= previous)):
279
                    raise Invalid('unsafe or unordered tree path')
280
                previous = name
281
                path = relative / name
282
                visit_entry, _ = in_scope(path.as_posix(), scope)
283
                if not visit_entry:
284
                    continue
285
                entries_seen += 1
286
                if entries_seen > MAX_ENTRIES:
287
                    raise Unavailable('tree entry bound exceeded')
288
                if (destination / path).exists() or (destination / path).is_symlink():
289
                    # Git fails the path set for colliding committed paths;
290
                    # report it so the oracle fails the same way.
291
                    collisions.append(path.as_posix())
292
                    continue
293
                if kind == 'tree' and mode == 'tree':
294
                    (destination / path).mkdir()
295
                    visit(object_hash, path, ancestors | {tree_hash})
296
                elif kind == 'blob' and mode in ('regular', 'executable'):
297
                    if max_blob_bytes is not None:
298
                        stored = db.execute('SELECT size FROM blobs WHERE hash=?', (object_hash,)).fetchone()
299
                        if stored and type(stored[0]) is int and stored[0] > max_blob_bytes:
300
                            # The task's own size limit fails first, as in Git,
301
                            # before any reader resource bound applies.
302
                            oversized.append(path.as_posix())
303
                            continue
304
                    content = blob(object_hash)
305
                    total += len(content)
306
                    if total > MAX_TOTAL:
307
                        raise Unavailable('total payload bound exceeded')
308
                    target = destination / path
309
                    target.write_bytes(content)
310
                    target.chmod(0o755 if mode == 'executable' else 0o644)
311
                elif scope is not None:
312
                    # Git's scoped check fails any non-100644 entry; report
313
                    # it so the oracle fails the payload the same way.
314
                    nonregular.append(path.as_posix())
315
                else:
316
                    raise Unavailable('unsupported tree entry kind or mode')
317
        visit(root_hash, Path(), set())
318
        db.commit()
319
        if hashlib.sha256(database_snapshot()).hexdigest() != database_sha256:
320
            raise Unavailable('database changed during independent read')
321
    return {'head': head, 'database_sha256': database_sha256, 'reader_version': READER_VERSION,
322
            'scope': list(scope) if scope is not None else None, 'nonregular_paths': sorted(nonregular),
323
            'oversized_paths': sorted(oversized), 'collision_paths': sorted(collisions),
324
            'schema_migrations_max': migrations,
325
            'reader_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
326
            'blake3_version': importlib.metadata.version('blake3'),
327
            'zstandard_version': importlib.metadata.version('zstandard')}
328
329
330
def main():
331
    try:
332
        args = sys.argv[1:]
333
        scope = None
334
        max_blob_bytes = None
335
        usage = 'usage: oak_sqlite_reader.py REPOSITORY NEW_DESTINATION [--max-blob-bytes N] [--scope PATH...]'
336
        if len(args) < 2:
337
            raise Unavailable(usage)
338
        rest = args[2:]
339
        if rest[:1] == ['--max-blob-bytes']:
340
            if len(rest) < 2 or not rest[1].isdigit():
341
                raise Unavailable(usage)
342
            max_blob_bytes = int(rest[1])
343
            rest = rest[2:]
344
        if rest[:1] == ['--scope']:
345
            scope = rest[1:]
346
            if not scope:
347
                raise Unavailable('empty reader scope')
348
        elif rest:
349
            raise Unavailable(usage)
350
        result = read_committed_tree(Path(args[0]), Path(args[1]), scope, max_blob_bytes)
351
        code = 0
352
    except Unavailable as exc:
353
        result, code = {'unavailable': str(exc)}, 77
354
    except Invalid as exc:
355
        result, code = {'corrupt': True, 'reason': str(exc)}, 1
356
    except (OSError, ValueError, TypeError, sqlite3.Error) as exc:
357
        result, code = {'unavailable': 'reader instrument error: ' + str(exc)}, 77
358
    print(json.dumps(result, sort_keys=True))
359
    return code
360
361
if __name__ == '__main__':
362
    raise SystemExit(main())