| 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())
|