Viewing files at commit 878fc46b2f31Commit detailsView latest

Browse files
tests/test_oak_sqlite_reader.py 582 lines · 29.2 KB · python
1
"""Independent Oak reader: format re-derivation, tamper detection, bounds.
2
3
Two layers:
4
- ``SyntheticReaderTests`` author Oak SQLite v1 state directly (pure BLAKE3,
5
  raw-block zstd frames) so every structural and tamper case runs without the
6
  Oak binary: a reader that accepts any corrupted row fails here.
7
- ``RealOakReaderTests`` read repositories produced by the real Oak CLI (skipped
8
  when ``oak`` is not installed), including the workflow-evidence path.
9
"""
10
from contextlib import contextmanager
11
from unittest import mock
12
import hashlib
13
import importlib.util
14
import json
15
import os
16
from pathlib import Path
17
import shutil
18
import sqlite3
19
import subprocess
20
import sys
21
import tempfile
22
import unittest
23
24
ROOT = Path(__file__).resolve().parents[1]
25
READER = ROOT / 'scripts/oakbench/oak_sqlite_reader.py'
26
sys.path.insert(0, str(ROOT / 'scripts'))
27
from oakbench import oak_sqlite_reader as reader  # noqa: E402
28
from oakbench import pureblake3, purezstd  # noqa: E402
29
30
B3 = pureblake3.hexdigest
31
SCHEMA = '''
32
CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL);
33
CREATE TABLE schema_migrations (version TEXT PRIMARY KEY, applied_at TEXT, success INTEGER);
34
CREATE TABLE blobs (hash TEXT PRIMARY KEY, content BLOB NOT NULL, size INTEGER NOT NULL, codec INTEGER NOT NULL DEFAULT 0);
35
CREATE TABLE trees (hash TEXT PRIMARY KEY, created_at TEXT, content BLOB);
36
CREATE TABLE tree_entries (tree_hash TEXT NOT NULL, name TEXT NOT NULL, kind TEXT NOT NULL, hash TEXT NOT NULL,
37
                           mode TEXT NOT NULL DEFAULT 'regular', PRIMARY KEY (tree_hash, name));
38
CREATE TABLE commits (hash TEXT PRIMARY KEY, branch_name TEXT NOT NULL, parent_hash TEXT, manifest_hash TEXT NOT NULL,
39
                      author TEXT NOT NULL, message TEXT, timestamp TEXT NOT NULL, merge_parent_hash TEXT);
40
CREATE TABLE chunks (hash TEXT PRIMARY KEY, content BLOB NOT NULL, size INTEGER NOT NULL);
41
CREATE TABLE blob_chunks (blob_hash TEXT NOT NULL, chunk_hash TEXT NOT NULL, chunk_index INTEGER NOT NULL,
42
                          offset INTEGER NOT NULL, size INTEGER NOT NULL, PRIMARY KEY (blob_hash, chunk_index));
43
'''
44
45
46
class SyntheticRepo:
47
    """Minimal Oak SQLite v1 writer re-derived from the format, for tests only."""
48
49
    def __init__(self, root: Path):
50
        self.root = root
51
        (root / '.oak').mkdir(parents=True)
52
        self.path = root / '.oak' / 'oak.db'
53
        with self.db() as db:
54
            db.executescript(SCHEMA)
55
            db.execute("INSERT INTO schema_migrations VALUES ('0014_change_captures','x',1)")
56
            db.execute("INSERT INTO metadata VALUES ('current_branch','main')")
57
        self.pin = self.blob(b'pin')  # a small verifying blob for the format pin
58
59
    @contextmanager
60
    def db(self):
61
        db = sqlite3.connect(str(self.path))
62
        try:
63
            db.execute('PRAGMA journal_mode=DELETE')
64
            yield db
65
            db.commit()
66
        finally:
67
            db.close()
68
69
    def blob(self, data: bytes, compressed=True, store=True) -> str:
70
        digest = B3(data)
71
        if store:
72
            content, codec = (purezstd.encode_raw(data), 1) if compressed else (data, 0)
73
            with self.db() as db:
74
                db.execute('INSERT OR REPLACE INTO blobs VALUES (?,?,?,?)', (digest, content, len(data), codec))
75
        return digest
76
77
    def chunked(self, data: bytes, size=1000, store_blob=False) -> str:
78
        digest = self.blob(data, store=store_blob)
79
        with self.db() as db:
80
            for index, offset in enumerate(range(0, len(data), size)):
81
                piece = data[offset:offset + size]
82
                chunk = B3(piece)
83
                db.execute('INSERT OR REPLACE INTO chunks VALUES (?,?,?)', (chunk, piece, len(piece)))
84
                db.execute('INSERT INTO blob_chunks VALUES (?,?,?,?,?)', (digest, chunk, index, offset, len(piece)))
85
        return digest
86
87
    def tree(self, entries) -> str:
88
        """entries: iterable of (kind, name, hash, mode); sorted like Oak unless raw."""
89
        raw = '\n'.join('\t'.join(e) for e in entries).encode()
90
        digest = B3(raw)
91
        with self.db() as db:
92
            db.execute('INSERT OR REPLACE INTO trees(hash,content) VALUES (?,?)', (digest, purezstd.encode_raw(raw)))
93
        return digest
94
95
    def commit(self, tree, parent=None, merge=None, timestamp='2026-09-28T10:00:00.123456789+00:00',
96
               message='m', preimage=None, head=True) -> str:
97
        fields = ['main', parent or '', merge or '', tree, 'tester', message, timestamp]
98
        digest = B3(preimage if preimage is not None else '\n'.join(fields).encode())
99
        with self.db() as db:
100
            db.execute('INSERT OR REPLACE INTO commits VALUES (?,?,?,?,?,?,?,?)',
101
                       (digest, 'main', parent, tree, 'tester', message, timestamp, merge))
102
            if head:
103
                db.execute("INSERT OR REPLACE INTO metadata VALUES ('head',?)", (digest,))
104
        return digest
105
106
    def file_tree(self, files: dict) -> str:
107
        return self.tree(sorted((('blob', name, self.blob(data), 'regular') for name, data in files.items()),
108
                                key=lambda e: e[1]))
109
110
111
class SyntheticReaderTests(unittest.TestCase):
112
    def setUp(self):
113
        self.tmp = tempfile.TemporaryDirectory()
114
        self.addCleanup(self.tmp.cleanup)
115
        self.base = Path(self.tmp.name)
116
        self.repo = SyntheticRepo(self.base / 'repo')
117
        self.out = self.base / 'out'
118
119
    def read(self, **kwargs):
120
        destination = kwargs.pop('destination', self.out)
121
        return reader.read_committed_tree(self.repo.root, destination, **kwargs)
122
123
    def standard(self):
124
        a = self.repo.blob(b'alpha\n')
125
        x = self.repo.blob(b'#!/bin/sh\n', compressed=False)
126
        link = self.repo.blob(b'a.txt')
127
        sub = self.repo.tree([('blob', 'inner', self.repo.blob(b'inner\n'), 'regular')])
128
        root = self.repo.tree([('blob', 'a.txt', a, 'regular'), ('blob', 'back\\slash', a, 'regular'),
129
                               ('blob', 'link', link, 'symlink'), ('tree', 'sub', sub, 'tree'),
130
                               ('blob', 'tool', x, 'executable')])
131
        return self.repo.commit(root)
132
133
    # --- positive -----------------------------------------------------------
134
    def test_materializes_files_modes_symlinks_and_manifest(self):
135
        head = self.standard()
136
        result = self.read(manifest=True, deep=True, complete=True)
137
        self.assertEqual(result['head'], head)
138
        self.assertEqual(result['reader_backend'], 'pure')
139
        self.assertEqual((self.out / 'a.txt').read_bytes(), b'alpha\n')
140
        self.assertEqual((self.out / 'back\\slash').read_bytes(), b'alpha\n')
141
        self.assertTrue(os.access(self.out / 'tool', os.X_OK))
142
        self.assertEqual(os.readlink(self.out / 'link'), 'a.txt')
143
        manifest = {e['path']: e for e in result['manifest']}
144
        self.assertEqual(manifest['link']['target_hex'], b'a.txt'.hex())
145
        self.assertEqual(manifest['sub']['kind'], 'dir')
146
        self.assertEqual(manifest['sub/inner']['sha256'], hashlib.sha256(b'inner\n').hexdigest())
147
        self.assertTrue(manifest['tool']['executable'])
148
149
    def test_manifest_only_read_writes_nothing(self):
150
        self.standard()
151
        result = self.read(destination=None, manifest=True)
152
        self.assertFalse(self.out.exists())
153
        self.assertEqual(len(result['manifest']), 6)
154
155
    def test_scoped_symlink_is_reported_not_materialized(self):
156
        self.standard()
157
        result = self.read(scope=['link', 'a.txt'])
158
        self.assertEqual(result['nonregular_paths'], ['link'])
159
        self.assertFalse(os.path.lexists(self.out / 'link'))
160
161
    def test_chunk_only_blob_is_reassembled_and_verified(self):
162
        data = os.urandom(4500)
163
        blob = self.repo.chunked(data)
164
        self.repo.commit(self.repo.tree([('blob', 'big', blob, 'regular')]))
165
        result = self.read(complete=True)
166
        self.assertEqual((self.out / 'big').read_bytes(), data)
167
        self.assertEqual(result['chunked_blobs_reassembled'], 1)
168
169
    def test_deep_read_crosschecks_chunk_mapping_of_complete_blobs(self):
170
        blob = self.repo.chunked(os.urandom(2500), store_blob=True)
171
        self.repo.commit(self.repo.tree([('blob', 'big', blob, 'regular')]))
172
        self.assertEqual(self.read(deep=True)['chunk_mappings_crosschecked'], 1)
173
174
    def test_empty_blob_is_implied_by_its_hash(self):
175
        empty = self.repo.blob(b'', store=False)
176
        self.repo.commit(self.repo.tree([('blob', 'empty', empty, 'regular')]))
177
        self.read(complete=True)
178
        self.assertEqual((self.out / 'empty').read_bytes(), b'')
179
180
    def test_history_walk_verifies_ancestors_and_reports_shallow_boundary(self):
181
        first = self.repo.commit(self.repo.file_tree({'f': b'1'}), parent='c' * 64, head=False)
182
        self.repo.commit(self.repo.file_tree({'f': b'2'}), parent=first)
183
        history = self.read(deep=True)['history']
184
        self.assertEqual(history['commits_verified'], 2)
185
        self.assertEqual(history['missing_parents'], ['c' * 64])
186
187
    def test_server_microsecond_truncated_timestamp_verifies_like_oak(self):
188
        tree = self.repo.file_tree({'f': b'x'})
189
        original = '\n'.join(['main', '', '', tree, 'tester', 'm', '2026-09-28T10:00:00.123456789+00:00']).encode()
190
        self.repo.commit(tree, timestamp='2026-09-28T10:00:00.123456+00:00', preimage=original)
191
        self.assertEqual(self.read()['commit_preimage'], 'v1-microsecond-truncated')
192
193
    def test_legacy_six_field_commit_verifies(self):
194
        tree = self.repo.file_tree({'f': b'x'})
195
        legacy = '\n'.join(['main', '', tree, 'tester', 'm', '2026-04-01T00:00:00+00:00']).encode()
196
        self.repo.commit(tree, timestamp='2026-04-01T00:00:00+00:00', preimage=legacy)
197
        self.assertEqual(self.read()['commit_preimage'], 'v1-legacy-6-field')
198
199
    def test_cli_runs_isolated_with_stdlib_only(self):
200
        self.standard()
201
        result = subprocess.run([sys.executable, '-I', str(READER), str(self.repo.root), '-', '--manifest', '--deep'],
202
                                capture_output=True, text=True)
203
        self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
204
        payload = json.loads(result.stdout)
205
        self.assertEqual(payload['reader_version'], reader.READER_VERSION)
206
        self.assertEqual(payload['codec_sha256'], reader.codec_sha256())
207
208
    # --- tamper detection: every corrupted row is flagged Invalid ----------
209
    def assert_invalid(self, reason, **kwargs):
210
        with self.assertRaises(reader.Invalid) as caught:
211
            self.read(**kwargs)
212
        self.assertIn(reason, str(caught.exception))
213
214
    def test_tampered_blob_bytes(self):
215
        self.standard()
216
        target = self.repo.blob(b'alpha\n')
217
        with self.repo.db() as db:
218
            db.execute('UPDATE blobs SET content=? WHERE hash=?', (purezstd.encode_raw(b'alphb\n'), target))
219
        self.assert_invalid('hash mismatch')
220
221
    def test_tampered_blob_compressed_frame(self):
222
        self.standard()
223
        target = self.repo.blob(b'alpha\n')
224
        with self.repo.db() as db:
225
            frame = bytearray(db.execute('SELECT content FROM blobs WHERE hash=?', (target,)).fetchone()[0])
226
            frame[-6] ^= 1  # payload byte of a raw block; the XXH64 checksum no longer matches
227
            db.execute('UPDATE blobs SET content=? WHERE hash=?', (bytes(frame), target))
228
        self.assert_invalid('invalid compressed object')
229
230
    def test_tampered_blob_size(self):
231
        self.standard()
232
        with self.repo.db() as db:
233
            db.execute('UPDATE blobs SET size=size+1 WHERE hash=?', (self.repo.blob(b'alpha\n'),))
234
        self.assert_invalid('size mismatch')
235
236
    def test_tampered_nested_tree_row(self):
237
        self.standard()
238
        sub = self.repo.tree([('blob', 'inner', self.repo.blob(b'inner\n'), 'regular')])
239
        with self.repo.db() as db:
240
            db.execute('UPDATE trees SET content=? WHERE hash=?',
241
                       (purezstd.encode_raw(b'blob\tinner\t' + b'0' * 64 + b'\tregular'), sub))
242
        self.assert_invalid('hash mismatch')
243
244
    def test_missing_nested_tree_row(self):
245
        self.standard()
246
        sub = self.repo.tree([('blob', 'inner', self.repo.blob(b'inner\n'), 'regular')])
247
        with self.repo.db() as db:
248
            db.execute('DELETE FROM trees WHERE hash=?', (sub,))
249
        self.assert_invalid('tree missing')
250
251
    # QA P2-1: a mismatch on a recognized format is corruption, not drift.
252
    def test_tampered_root_tree_is_invalid_when_format_pinned(self):
253
        self.standard()
254
        with self.repo.db() as db:
255
            root = db.execute("SELECT manifest_hash FROM commits").fetchone()[0]
256
            db.execute('UPDATE trees SET content=? WHERE hash=?', (purezstd.encode_raw(b'x'), root))
257
        self.assert_invalid('root tree hash mismatch', destination=None)
258
259
    def test_tampered_head_commit_is_invalid_when_format_pinned(self):
260
        self.standard()
261
        with self.repo.db() as db:
262
            db.execute("UPDATE commits SET message='tampered'")
263
        self.assert_invalid('committed HEAD commit hash mismatch', destination=None)
264
265
    def test_every_blob_corrupt_is_invalid_when_trees_and_commit_verify(self):
266
        self.standard()
267
        with self.repo.db() as db:
268
            for (object_hash, size) in db.execute('SELECT hash,size FROM blobs').fetchall():
269
                db.execute('UPDATE blobs SET content=?,codec=0 WHERE hash=?', (b'x' * size, object_hash))
270
        self.assert_invalid('hash mismatch', destination=None)
271
272
    def test_nothing_verifies_is_unrecognized_format(self):
273
        # Every commit, tree and blob fails v1: indistinguishable from a new format.
274
        self.standard()
275
        with self.repo.db() as db:
276
            db.execute("UPDATE commits SET message='other-format'")
277
            for (tree_hash,) in db.execute('SELECT hash FROM trees').fetchall():
278
                db.execute('UPDATE trees SET content=? WHERE hash=?', (purezstd.encode_raw(b'v2:' + tree_hash.encode()), tree_hash))
279
            for (object_hash, size) in db.execute('SELECT hash,size FROM blobs').fetchall():
280
                db.execute('UPDATE blobs SET content=?,codec=0 WHERE hash=?', (b'y' * size, object_hash))
281
        with self.assertRaisesRegex(reader.Unavailable, 'format unrecognized'):
282
            self.read(destination=None)
283
284
    def test_zero_byte_wal_is_absent_but_nonempty_wal_blocks(self):
285
        self.standard()
286
        wal = Path(str(self.repo.path) + '-wal')
287
        wal.write_bytes(b'')
288
        self.read(destination=None)
289
        wal.write_bytes(b'x')
290
        with self.assertRaisesRegex(reader.Unavailable, 'active SQLite journal'):
291
            self.read(destination=None)
292
293
    def test_tampered_ancestor_commit(self):
294
        first = self.repo.commit(self.repo.file_tree({'f': b'1'}), head=False)
295
        self.repo.commit(self.repo.file_tree({'f': b'2'}), parent=first)
296
        with self.repo.db() as db:
297
            db.execute("UPDATE commits SET author='mallory' WHERE hash=?", (first,))
298
        self.assert_invalid('ancestor commit hash mismatch', deep=True)
299
300
    def test_tampered_chunk_content(self):
301
        blob = self.repo.chunked(os.urandom(2500))
302
        self.repo.commit(self.repo.tree([('blob', 'big', blob, 'regular')]))
303
        with self.repo.db() as db:
304
            db.execute('UPDATE chunks SET content=? WHERE size=500', (os.urandom(500),))
305
        self.assert_invalid('hash mismatch')
306
307
    def test_chunk_mapping_gap(self):
308
        blob = self.repo.chunked(os.urandom(2500))
309
        self.repo.commit(self.repo.tree([('blob', 'big', blob, 'regular')]))
310
        with self.repo.db() as db:
311
            db.execute('UPDATE blob_chunks SET offset=offset+1 WHERE chunk_index=1')
312
        self.assert_invalid('non-contiguous')
313
314
    def test_chunk_map_disagreeing_with_complete_blob_in_deep_mode(self):
315
        blob = self.repo.chunked(os.urandom(2500), store_blob=True)
316
        self.repo.commit(self.repo.tree([('blob', 'big', blob, 'regular')]))
317
        with self.repo.db() as db:
318
            db.execute('DELETE FROM blob_chunks WHERE chunk_index=2')
319
        self.read()  # a shallow read trusts the verified complete blob
320
        shutil.rmtree(self.out)
321
        self.assert_invalid('size mismatch', deep=True)
322
323
    def test_absent_content_is_invalid_when_complete_required(self):
324
        blob = self.repo.chunked(os.urandom(2500))
325
        self.repo.commit(self.repo.tree([('blob', 'big', blob, 'regular')]))
326
        with self.repo.db() as db:
327
            db.execute('DELETE FROM chunks WHERE size=500')
328
        with self.assertRaisesRegex(reader.Unavailable, 'chunk content absent'):
329
            self.read()
330
        self.assert_invalid('chunk content absent', destination=None, complete=True)
331
332
    def test_unhydrated_blob_is_unavailable_unless_complete(self):
333
        ghost = self.repo.blob(b'never stored', store=False)
334
        self.repo.commit(self.repo.tree([('blob', 'ghost', ghost, 'regular')]))
335
        with self.assertRaisesRegex(reader.Unavailable, 'no chunk mapping'):
336
            self.read()
337
        self.assert_invalid('no chunk mapping', destination=None, complete=True)
338
339
    def test_structural_violations(self):
340
        h = self.repo.pin
341
        cases = {
342
            'control character': [('blob', 'tab\tx', h, 'regular')],
343
            'reserved name': [('blob', '..', h, 'regular')],
344
            'VCS metadata': [('blob', '.oak', h, 'regular')],
345
            'order': [('blob', 'b', h, 'regular'), ('blob', 'a', h, 'regular')],
346
            'invalid object hash in tree entry': [('blob', 'a', 'A' * 64, 'regular')],
347
        }
348
        for reason, entries in cases.items():
349
            with self.subTest(reason=reason):
350
                # The tab case cannot even be serialized as 4 fields; either way Invalid.
351
                self.repo.commit(self.repo.tree(entries))
352
                with self.assertRaises(reader.Invalid):
353
                    self.read(destination=None)
354
355
    def test_unknown_kind_or_mode_is_unavailable_unscoped_and_reported_scoped(self):
356
        self.repo.commit(self.repo.tree([('blob', 'a', self.repo.pin, 'socket'),
357
                                         ('blob', 'b', self.repo.pin, 'regular')]))
358
        with self.assertRaisesRegex(reader.Unavailable, 'unsupported tree entry'):
359
            self.read(destination=None)
360
        self.assertEqual(self.read(scope=['a', 'b'])['nonregular_paths'], ['a'])
361
362
    def test_empty_symlink_target_is_invalid(self):
363
        empty = self.repo.blob(b'', store=False)
364
        self.repo.commit(self.repo.tree([('blob', 'l', empty, 'symlink')]))
365
        self.assert_invalid('invalid symlink target', destination=None)
366
367
    def test_symlinks_are_created_after_all_regular_writes(self):
368
        # A link 'a' -> outside and a tree 'b' cannot route a write through the link.
369
        outside = self.base / 'outside'
370
        outside.mkdir()
371
        link = self.repo.blob(str(outside).encode())
372
        sub = self.repo.tree([('blob', 'f', self.repo.blob(b'inside\n'), 'regular')])
373
        self.repo.commit(self.repo.tree([('blob', 'a', link, 'symlink'), ('tree', 'b', sub, 'tree')]))
374
        self.read()
375
        self.assertEqual(list(outside.iterdir()), [])
376
        self.assertEqual((self.out / 'b' / 'f').read_bytes(), b'inside\n')
377
378
    # --- format pins and bounds (unmeasured, never Oak failures) -----------
379
    def test_newer_migration_and_hash_format_are_unavailable(self):
380
        self.standard()
381
        with self.repo.db() as db:
382
            db.execute("INSERT INTO metadata VALUES ('hash_format','v2')")
383
        with self.assertRaisesRegex(reader.Unavailable, 'hash format'):
384
            self.read(destination=None)
385
        with self.repo.db() as db:
386
            db.execute("DELETE FROM metadata WHERE key='hash_format'")
387
            db.execute("INSERT INTO schema_migrations VALUES ('9999_future','x',1)")
388
        with self.assertRaisesRegex(reader.Unavailable, 'schema migration'):
389
            self.read(destination=None)
390
391
    def test_declared_window_over_bound_is_unavailable_without_allocation(self):
392
        self.standard()
393
        frame = b'\x28\xb5\x2f\xfd\xa0' + (reader.MAX_OBJECT + 1).to_bytes(4, 'little') + b'\x01\x00\x00'
394
        with self.repo.db() as db:
395
            db.execute('UPDATE blobs SET content=?,codec=1 WHERE hash=?', (frame, self.repo.blob(b'alpha\n')))
396
        with self.assertRaisesRegex(reader.Unavailable, 'window exceeds'):
397
            self.read(destination=None)
398
399
    def test_bound_limited_sample_is_not_reported_as_format_drift(self):
400
        # A repository whose only blobs exceed reader bounds (e.g. a 1 GiB
401
        # sparse file that compresses tiny) is a bound, not a format change.
402
        with self.repo.db() as db:
403
            db.execute('DELETE FROM blobs')
404
        huge = self.repo.blob(b'z', store=False)
405
        frame = b'\x28\xb5\x2f\xfd\xa0' + (reader.MAX_OBJECT + 1).to_bytes(4, 'little') + b'\x01\x00\x00'
406
        with self.repo.db() as db:
407
            db.execute('INSERT INTO blobs VALUES (?,?,?,1)', (huge, frame, reader.MAX_OBJECT + 1))
408
        self.repo.commit(self.repo.tree([('blob', 'huge', huge, 'regular')]))
409
        with self.assertRaises(reader.Unavailable) as caught:
410
            self.read(destination=None)
411
        self.assertNotIn('format unrecognized', str(caught.exception))
412
413
    def test_unknown_codec_and_missing_database(self):
414
        self.standard()
415
        with self.repo.db() as db:
416
            db.execute('UPDATE blobs SET codec=9 WHERE hash=?', (self.repo.blob(b'alpha\n'),))
417
        with self.assertRaisesRegex(reader.Unavailable, 'unsupported object codec'):
418
            self.read(destination=None)
419
        shutil.rmtree(self.repo.root / '.oak')
420
        self.assert_invalid('database missing', destination=None)
421
422
    def test_active_wal_is_unavailable(self):
423
        self.standard()
424
        Path(str(self.repo.path) + '-wal').write_bytes(b'x')
425
        with self.assertRaisesRegex(reader.Unavailable, 'active SQLite journal'):
426
            self.read(destination=None)
427
428
    def test_native_backend_requires_optional_packages(self):
429
        self.standard()
430
        if importlib.util.find_spec('blake3') and importlib.util.find_spec('zstandard'):
431
            self.assertEqual(self.read(destination=None, backend='native')['reader_backend'], 'native')
432
        else:
433
            with self.assertRaisesRegex(reader.Unavailable, 'native reader backend'):
434
                self.read(destination=None, backend='native')
435
436
    def test_database_change_during_read_is_unavailable(self):
437
        self.standard()
438
        original = Path.write_bytes
439
440
        def write_then_change(path, content):
441
            result = original(path, content)
442
            if path.name == 'snapshot.db':
443
                with self.repo.db() as db:
444
                    db.execute("INSERT OR REPLACE INTO metadata VALUES ('changed','1')")
445
            return result
446
        with mock.patch.object(Path, 'write_bytes', write_then_change):
447
            with self.assertRaisesRegex(reader.Unavailable, 'database changed'):
448
                self.read(destination=None)
449
450
451
@unittest.skipUnless(shutil.which('oak'), 'Oak binary required')
452
class RealOakReaderTests(unittest.TestCase):
453
    def setUp(self):
454
        self.tmp = tempfile.TemporaryDirectory()
455
        self.addCleanup(self.tmp.cleanup)
456
        self.root = Path(self.tmp.name)
457
        self.repo = self.root / 'repo'
458
        self.repo.mkdir()
459
        self.env = {**os.environ, 'OAK_NO_UPDATE_CHECK': '1', 'HOME': str(self.root)}
460
        self.oak('init')
461
        (self.repo / 'payload.txt').write_text('committed payload\n')
462
        (self.repo / 'pin.txt').write_text('pin\n')
463
        self.oak('commit', '--no-verify')
464
465
    def oak(self, *args):
466
        subprocess.run([shutil.which('oak'), *args], cwd=self.repo, env=self.env, check=True, capture_output=True)
467
468
    @contextmanager
469
    def database(self):
470
        db = sqlite3.connect(str(self.repo / '.oak/oak.db'))
471
        try:
472
            db.execute('PRAGMA journal_mode=DELETE')
473
            yield db
474
            db.commit()
475
        finally:
476
            db.close()
477
478
    def test_reads_committed_content_not_worktree(self):
479
        (self.repo / 'payload.txt').write_text('uncommitted replacement')
480
        result = reader.read_committed_tree(self.repo, self.root / 'out', deep=True, complete=True)
481
        self.assertEqual((self.root / 'out/payload.txt').read_text(), 'committed payload\n')
482
        self.assertEqual(result['commit_preimage'], 'v1')
483
484
    def test_manifest_matches_filesystem_for_modes_and_symlinks(self):
485
        (self.repo / 'tool.sh').write_text('#!/bin/sh\n')
486
        (self.repo / 'tool.sh').chmod(0o755)
487
        (self.repo / 'link').symlink_to('payload.txt')
488
        (self.repo / 'nested dir').mkdir()
489
        (self.repo / 'nested dir' / 'caf\u00e9').write_text('x\n')
490
        self.oak('commit', '--no-verify')
491
        from oakbench.conformance_matrix import fs_manifest, reader_projection
492
        result = reader.read_committed_tree(self.repo, None, manifest=True, deep=True, complete=True)
493
        self.assertEqual(reader_projection(result['manifest']), fs_manifest(self.repo))
494
495
    def test_corrupt_real_blob_is_invalid(self):
496
        with self.database() as db:
497
            db.execute('UPDATE blobs SET content=?, size=?, codec=0 WHERE size=?', (b'corrupt', 7, len(b'committed payload\n')))
498
        with self.assertRaisesRegex(reader.Invalid, 'hash mismatch'):
499
            reader.read_committed_tree(self.repo, None)
500
501
    # QA P2-1 on a real Oak store: the three former "unavailable" tampers are Invalid.
502
    def root_tree(self, db):
503
        head = db.execute("SELECT value FROM metadata WHERE key='head'").fetchone()[0]
504
        return head, db.execute('SELECT manifest_hash FROM commits WHERE hash=?', (head,)).fetchone()[0]
505
506
    def test_real_store_root_tree_tamper_is_invalid(self):
507
        with self.database() as db:
508
            _, root = self.root_tree(db)
509
            db.execute('UPDATE trees SET content=? WHERE hash=?', (purezstd.encode_raw(b'blob\tx\t' + b'0' * 64 + b'\tregular'), root))
510
        with self.assertRaisesRegex(reader.Invalid, 'root tree hash mismatch'):
511
            reader.read_committed_tree(self.repo, None)
512
513
    def test_real_store_head_commit_tamper_is_invalid(self):
514
        with self.database() as db:
515
            head, _ = self.root_tree(db)
516
            db.execute("UPDATE commits SET author='mallory' WHERE hash=?", (head,))
517
        with self.assertRaisesRegex(reader.Invalid, 'HEAD commit hash mismatch'):
518
            reader.read_committed_tree(self.repo, None)
519
520
    def test_real_store_all_sampled_blobs_corrupt_is_invalid(self):
521
        with self.database() as db:
522
            for (object_hash, size) in db.execute('SELECT hash,size FROM blobs').fetchall():
523
                db.execute('UPDATE blobs SET content=?,codec=0 WHERE hash=?', (b'z' * size, object_hash))
524
        with self.assertRaisesRegex(reader.Invalid, 'hash mismatch'):
525
            reader.read_committed_tree(self.repo, None)
526
527
    def test_stat_cache_snapshot_leaves_no_journal_behind(self):
528
        sys.path.insert(0, str(ROOT / 'scripts'))
529
        import task_loop
530
        task_loop.stat_cache_snapshot(self.repo)
531
        for suffix in ('-wal', '-journal'):
532
            path = Path(str(self.repo / '.oak/oak.db') + suffix)
533
            self.assertFalse(path.exists() and path.stat().st_size > 0)
534
        reader.read_committed_tree(self.repo, None)
535
536
    def test_cli_exit_codes(self):
537
        ok = subprocess.run([sys.executable, '-I', str(READER), str(self.repo), '-'], capture_output=True, text=True)
538
        self.assertEqual(ok.returncode, 0, ok.stdout + ok.stderr)
539
        with self.database() as db:
540
            db.execute("DELETE FROM metadata WHERE key='head'")
541
        bad = subprocess.run([sys.executable, '-I', str(READER), str(self.repo), '-'], capture_output=True, text=True)
542
        self.assertEqual(bad.returncode, 1)
543
        self.assertTrue(json.loads(bad.stdout)['corrupt'])
544
545
    def commit_bugfix_payload(self):
546
        (self.repo / 'app').mkdir()
547
        (self.repo / 'app/pricing.py').write_text('def f(cents, percent):\n    return cents - ((cents * percent) // 100)\n')
548
        self.oak('commit', '--no-verify')
549
550
    def evidence(self):
551
        from oakbench.workflow_oracles import independent_oak_evidence, VERSION
552
        return independent_oak_evidence('bugfix_test_loop', self.repo, sys.executable,
553
            {'oracle_version': VERSION, 'workflow_integrity_source': 'trusted_task_worktree',
554
             'workflow_integrity_passed': None, 'workflow_integrity_unmeasured_reason': None})
555
556
    def test_workflow_evidence_is_independent_by_default(self):
557
        self.commit_bugfix_payload()
558
        result = self.evidence()
559
        self.assertIs(result['workflow_integrity_passed'], True, result)
560
        self.assertEqual(result['oak_reader_backend'], 'pure')
561
        self.assertEqual(result['workflow_integrity_committed_scope'], ['app/pricing.py'])
562
563
    def test_in_scope_blob_over_task_limit_fails_like_git(self):
564
        self.commit_bugfix_payload()
565
        with self.database() as db:
566
            db.execute('UPDATE blobs SET size=? WHERE size>?', (32 * 1024 * 1024 + 1, len(b'committed payload\n')))
567
        result = self.evidence()
568
        self.assertIs(result['workflow_integrity_passed'], False, result)
569
        self.assertEqual(result['workflow_integrity_failure_reason'], 'committed_payload_over_limit:app/pricing.py')
570
571
    def test_corrupt_reader_envelope_reaches_workflow_failure(self):
572
        self.commit_bugfix_payload()
573
        with self.database() as db:
574
            db.execute('UPDATE blobs SET content=?,size=?,codec=0 WHERE size>?', (b'corrupt', 7, len(b'committed payload\n')))
575
        result = self.evidence()
576
        self.assertIs(result['workflow_integrity_passed'], False, result)
577
        self.assertIn('oak_stored_content_invalid', result['workflow_integrity_failure_reason'])
578
        self.assertIsNone(result['workflow_integrity_unmeasured_reason'])
579
580
581
if __name__ == '__main__':
582
    unittest.main()