Viewing files at commit 21316fe59317Commit detailsView latest

Browse files
tests/test_oak_sqlite_reader.py 520 lines · 25.7 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
    def test_tampered_root_tree_is_unrecognized_format(self):
252
        self.standard()
253
        with self.repo.db() as db:
254
            head = db.execute("SELECT manifest_hash FROM commits").fetchone()[0]
255
            db.execute('UPDATE trees SET content=? WHERE hash=?', (purezstd.encode_raw(b'x'), head))
256
        with self.assertRaisesRegex(reader.Unavailable, 'root tree serialization unrecognized'):
257
            self.read()
258
259
    def test_tampered_head_commit_is_unrecognized_format(self):
260
        self.standard()
261
        with self.repo.db() as db:
262
            db.execute("UPDATE commits SET message='tampered'")
263
        with self.assertRaisesRegex(reader.Unavailable, 'commit preimage'):
264
            self.read()
265
266
    def test_tampered_ancestor_commit(self):
267
        first = self.repo.commit(self.repo.file_tree({'f': b'1'}), head=False)
268
        self.repo.commit(self.repo.file_tree({'f': b'2'}), parent=first)
269
        with self.repo.db() as db:
270
            db.execute("UPDATE commits SET author='mallory' WHERE hash=?", (first,))
271
        self.assert_invalid('ancestor commit hash mismatch', deep=True)
272
273
    def test_tampered_chunk_content(self):
274
        blob = self.repo.chunked(os.urandom(2500))
275
        self.repo.commit(self.repo.tree([('blob', 'big', blob, 'regular')]))
276
        with self.repo.db() as db:
277
            db.execute('UPDATE chunks SET content=? WHERE size=500', (os.urandom(500),))
278
        self.assert_invalid('hash mismatch')
279
280
    def test_chunk_mapping_gap(self):
281
        blob = self.repo.chunked(os.urandom(2500))
282
        self.repo.commit(self.repo.tree([('blob', 'big', blob, 'regular')]))
283
        with self.repo.db() as db:
284
            db.execute('UPDATE blob_chunks SET offset=offset+1 WHERE chunk_index=1')
285
        self.assert_invalid('non-contiguous')
286
287
    def test_chunk_map_disagreeing_with_complete_blob_in_deep_mode(self):
288
        blob = self.repo.chunked(os.urandom(2500), store_blob=True)
289
        self.repo.commit(self.repo.tree([('blob', 'big', blob, 'regular')]))
290
        with self.repo.db() as db:
291
            db.execute('DELETE FROM blob_chunks WHERE chunk_index=2')
292
        self.read()  # a shallow read trusts the verified complete blob
293
        shutil.rmtree(self.out)
294
        self.assert_invalid('size mismatch', deep=True)
295
296
    def test_absent_content_is_invalid_when_complete_required(self):
297
        blob = self.repo.chunked(os.urandom(2500))
298
        self.repo.commit(self.repo.tree([('blob', 'big', blob, 'regular')]))
299
        with self.repo.db() as db:
300
            db.execute('DELETE FROM chunks WHERE size=500')
301
        with self.assertRaisesRegex(reader.Unavailable, 'chunk content absent'):
302
            self.read()
303
        self.assert_invalid('chunk content absent', destination=None, complete=True)
304
305
    def test_unhydrated_blob_is_unavailable_unless_complete(self):
306
        ghost = self.repo.blob(b'never stored', store=False)
307
        self.repo.commit(self.repo.tree([('blob', 'ghost', ghost, 'regular')]))
308
        with self.assertRaisesRegex(reader.Unavailable, 'no chunk mapping'):
309
            self.read()
310
        self.assert_invalid('no chunk mapping', destination=None, complete=True)
311
312
    def test_structural_violations(self):
313
        h = self.repo.pin
314
        cases = {
315
            'control character': [('blob', 'tab\tx', h, 'regular')],
316
            'reserved name': [('blob', '..', h, 'regular')],
317
            'VCS metadata': [('blob', '.oak', h, 'regular')],
318
            'order': [('blob', 'b', h, 'regular'), ('blob', 'a', h, 'regular')],
319
            'invalid object hash in tree entry': [('blob', 'a', 'A' * 64, 'regular')],
320
        }
321
        for reason, entries in cases.items():
322
            with self.subTest(reason=reason):
323
                # The tab case cannot even be serialized as 4 fields; either way Invalid.
324
                self.repo.commit(self.repo.tree(entries))
325
                with self.assertRaises(reader.Invalid):
326
                    self.read(destination=None)
327
328
    def test_unknown_kind_or_mode_is_unavailable_unscoped_and_reported_scoped(self):
329
        self.repo.commit(self.repo.tree([('blob', 'a', self.repo.pin, 'socket'),
330
                                         ('blob', 'b', self.repo.pin, 'regular')]))
331
        with self.assertRaisesRegex(reader.Unavailable, 'unsupported tree entry'):
332
            self.read(destination=None)
333
        self.assertEqual(self.read(scope=['a', 'b'])['nonregular_paths'], ['a'])
334
335
    def test_empty_symlink_target_is_invalid(self):
336
        empty = self.repo.blob(b'', store=False)
337
        self.repo.commit(self.repo.tree([('blob', 'l', empty, 'symlink')]))
338
        self.assert_invalid('invalid symlink target', destination=None)
339
340
    def test_symlinks_are_created_after_all_regular_writes(self):
341
        # A link 'a' -> outside and a tree 'b' cannot route a write through the link.
342
        outside = self.base / 'outside'
343
        outside.mkdir()
344
        link = self.repo.blob(str(outside).encode())
345
        sub = self.repo.tree([('blob', 'f', self.repo.blob(b'inside\n'), 'regular')])
346
        self.repo.commit(self.repo.tree([('blob', 'a', link, 'symlink'), ('tree', 'b', sub, 'tree')]))
347
        self.read()
348
        self.assertEqual(list(outside.iterdir()), [])
349
        self.assertEqual((self.out / 'b' / 'f').read_bytes(), b'inside\n')
350
351
    # --- format pins and bounds (unmeasured, never Oak failures) -----------
352
    def test_newer_migration_and_hash_format_are_unavailable(self):
353
        self.standard()
354
        with self.repo.db() as db:
355
            db.execute("INSERT INTO metadata VALUES ('hash_format','v2')")
356
        with self.assertRaisesRegex(reader.Unavailable, 'hash format'):
357
            self.read(destination=None)
358
        with self.repo.db() as db:
359
            db.execute("DELETE FROM metadata WHERE key='hash_format'")
360
            db.execute("INSERT INTO schema_migrations VALUES ('9999_future','x',1)")
361
        with self.assertRaisesRegex(reader.Unavailable, 'schema migration'):
362
            self.read(destination=None)
363
364
    def test_declared_window_over_bound_is_unavailable_without_allocation(self):
365
        self.standard()
366
        frame = b'\x28\xb5\x2f\xfd\xa0' + (reader.MAX_OBJECT + 1).to_bytes(4, 'little') + b'\x01\x00\x00'
367
        with self.repo.db() as db:
368
            db.execute('UPDATE blobs SET content=?,codec=1 WHERE hash=?', (frame, self.repo.blob(b'alpha\n')))
369
        with self.assertRaisesRegex(reader.Unavailable, 'window exceeds'):
370
            self.read(destination=None)
371
372
    def test_bound_limited_sample_is_not_reported_as_format_drift(self):
373
        # A repository whose only blobs exceed reader bounds (e.g. a 1 GiB
374
        # sparse file that compresses tiny) is a bound, not a format change.
375
        with self.repo.db() as db:
376
            db.execute('DELETE FROM blobs')
377
        huge = self.repo.blob(b'z', store=False)
378
        frame = b'\x28\xb5\x2f\xfd\xa0' + (reader.MAX_OBJECT + 1).to_bytes(4, 'little') + b'\x01\x00\x00'
379
        with self.repo.db() as db:
380
            db.execute('INSERT INTO blobs VALUES (?,?,?,1)', (huge, frame, reader.MAX_OBJECT + 1))
381
        self.repo.commit(self.repo.tree([('blob', 'huge', huge, 'regular')]))
382
        with self.assertRaises(reader.Unavailable) as caught:
383
            self.read(destination=None)
384
        self.assertNotIn('format unrecognized', str(caught.exception))
385
386
    def test_unknown_codec_and_missing_database(self):
387
        self.standard()
388
        with self.repo.db() as db:
389
            db.execute('UPDATE blobs SET codec=9 WHERE hash=?', (self.repo.blob(b'alpha\n'),))
390
        with self.assertRaisesRegex(reader.Unavailable, 'unsupported object codec'):
391
            self.read(destination=None)
392
        shutil.rmtree(self.repo.root / '.oak')
393
        self.assert_invalid('database missing', destination=None)
394
395
    def test_active_wal_is_unavailable(self):
396
        self.standard()
397
        Path(str(self.repo.path) + '-wal').write_bytes(b'x')
398
        with self.assertRaisesRegex(reader.Unavailable, 'active SQLite journal'):
399
            self.read(destination=None)
400
401
    def test_native_backend_requires_optional_packages(self):
402
        self.standard()
403
        if importlib.util.find_spec('blake3') and importlib.util.find_spec('zstandard'):
404
            self.assertEqual(self.read(destination=None, backend='native')['reader_backend'], 'native')
405
        else:
406
            with self.assertRaisesRegex(reader.Unavailable, 'native reader backend'):
407
                self.read(destination=None, backend='native')
408
409
    def test_database_change_during_read_is_unavailable(self):
410
        self.standard()
411
        original = Path.write_bytes
412
413
        def write_then_change(path, content):
414
            result = original(path, content)
415
            if path.name == 'snapshot.db':
416
                with self.repo.db() as db:
417
                    db.execute("INSERT OR REPLACE INTO metadata VALUES ('changed','1')")
418
            return result
419
        with mock.patch.object(Path, 'write_bytes', write_then_change):
420
            with self.assertRaisesRegex(reader.Unavailable, 'database changed'):
421
                self.read(destination=None)
422
423
424
@unittest.skipUnless(shutil.which('oak'), 'Oak binary required')
425
class RealOakReaderTests(unittest.TestCase):
426
    def setUp(self):
427
        self.tmp = tempfile.TemporaryDirectory()
428
        self.addCleanup(self.tmp.cleanup)
429
        self.root = Path(self.tmp.name)
430
        self.repo = self.root / 'repo'
431
        self.repo.mkdir()
432
        self.env = {**os.environ, 'OAK_NO_UPDATE_CHECK': '1', 'HOME': str(self.root)}
433
        self.oak('init')
434
        (self.repo / 'payload.txt').write_text('committed payload\n')
435
        (self.repo / 'pin.txt').write_text('pin\n')
436
        self.oak('commit', '--no-verify')
437
438
    def oak(self, *args):
439
        subprocess.run([shutil.which('oak'), *args], cwd=self.repo, env=self.env, check=True, capture_output=True)
440
441
    @contextmanager
442
    def database(self):
443
        db = sqlite3.connect(str(self.repo / '.oak/oak.db'))
444
        try:
445
            db.execute('PRAGMA journal_mode=DELETE')
446
            yield db
447
            db.commit()
448
        finally:
449
            db.close()
450
451
    def test_reads_committed_content_not_worktree(self):
452
        (self.repo / 'payload.txt').write_text('uncommitted replacement')
453
        result = reader.read_committed_tree(self.repo, self.root / 'out', deep=True, complete=True)
454
        self.assertEqual((self.root / 'out/payload.txt').read_text(), 'committed payload\n')
455
        self.assertEqual(result['commit_preimage'], 'v1')
456
457
    def test_manifest_matches_filesystem_for_modes_and_symlinks(self):
458
        (self.repo / 'tool.sh').write_text('#!/bin/sh\n')
459
        (self.repo / 'tool.sh').chmod(0o755)
460
        (self.repo / 'link').symlink_to('payload.txt')
461
        (self.repo / 'nested dir').mkdir()
462
        (self.repo / 'nested dir' / 'caf\u00e9').write_text('x\n')
463
        self.oak('commit', '--no-verify')
464
        from oakbench.conformance_matrix import fs_manifest, reader_projection
465
        result = reader.read_committed_tree(self.repo, None, manifest=True, deep=True, complete=True)
466
        self.assertEqual(reader_projection(result['manifest']), fs_manifest(self.repo))
467
468
    def test_corrupt_real_blob_is_invalid(self):
469
        with self.database() as db:
470
            db.execute('UPDATE blobs SET content=?, size=?, codec=0 WHERE size=?', (b'corrupt', 7, len(b'committed payload\n')))
471
        with self.assertRaisesRegex(reader.Invalid, 'hash mismatch'):
472
            reader.read_committed_tree(self.repo, None)
473
474
    def test_cli_exit_codes(self):
475
        ok = subprocess.run([sys.executable, '-I', str(READER), str(self.repo), '-'], capture_output=True, text=True)
476
        self.assertEqual(ok.returncode, 0, ok.stdout + ok.stderr)
477
        with self.database() as db:
478
            db.execute("DELETE FROM metadata WHERE key='head'")
479
        bad = subprocess.run([sys.executable, '-I', str(READER), str(self.repo), '-'], capture_output=True, text=True)
480
        self.assertEqual(bad.returncode, 1)
481
        self.assertTrue(json.loads(bad.stdout)['corrupt'])
482
483
    def commit_bugfix_payload(self):
484
        (self.repo / 'app').mkdir()
485
        (self.repo / 'app/pricing.py').write_text('def f(cents, percent):\n    return cents - ((cents * percent) // 100)\n')
486
        self.oak('commit', '--no-verify')
487
488
    def evidence(self):
489
        from oakbench.workflow_oracles import independent_oak_evidence, VERSION
490
        return independent_oak_evidence('bugfix_test_loop', self.repo, sys.executable,
491
            {'oracle_version': VERSION, 'workflow_integrity_source': 'trusted_task_worktree',
492
             'workflow_integrity_passed': None, 'workflow_integrity_unmeasured_reason': None})
493
494
    def test_workflow_evidence_is_independent_by_default(self):
495
        self.commit_bugfix_payload()
496
        result = self.evidence()
497
        self.assertIs(result['workflow_integrity_passed'], True, result)
498
        self.assertEqual(result['oak_reader_backend'], 'pure')
499
        self.assertEqual(result['workflow_integrity_committed_scope'], ['app/pricing.py'])
500
501
    def test_in_scope_blob_over_task_limit_fails_like_git(self):
502
        self.commit_bugfix_payload()
503
        with self.database() as db:
504
            db.execute('UPDATE blobs SET size=? WHERE size>?', (32 * 1024 * 1024 + 1, len(b'committed payload\n')))
505
        result = self.evidence()
506
        self.assertIs(result['workflow_integrity_passed'], False, result)
507
        self.assertEqual(result['workflow_integrity_failure_reason'], 'committed_payload_over_limit:app/pricing.py')
508
509
    def test_corrupt_reader_envelope_reaches_workflow_failure(self):
510
        self.commit_bugfix_payload()
511
        with self.database() as db:
512
            db.execute('UPDATE blobs SET content=?,size=?,codec=0 WHERE size>?', (b'corrupt', 7, len(b'committed payload\n')))
513
        result = self.evidence()
514
        self.assertIs(result['workflow_integrity_passed'], False, result)
515
        self.assertIn('oak_stored_content_invalid', result['workflow_integrity_failure_reason'])
516
        self.assertIsNone(result['workflow_integrity_unmeasured_reason'])
517
518
519
if __name__ == '__main__':
520
    unittest.main()