| 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.1'
|
| 36 | MAX_OBJECT = 32 * 1024 * 1024
|
| 37 | MAX_TOTAL = 128 * 1024 * 1024
|
| 38 | MAX_ENTRIES = 8192
|
| 39 | MAX_DATABASE = 512 * 1024 * 1024
|
| 40 |
|
| 41 | class Unavailable(Exception):
|
| 42 | pass
|
| 43 |
|
| 44 | class Invalid(Exception):
|
| 45 | pass
|
| 46 |
|
| 47 |
|
| 48 | REQUIRED_COLUMNS = {
|
| 49 | 'metadata': {'key', 'value'},
|
| 50 | 'commits': {'hash', 'branch_name', 'parent_hash', 'merge_parent_hash', 'manifest_hash', 'author', 'message', 'timestamp'},
|
| 51 | 'blobs': {'hash', 'content', 'size', 'codec'},
|
| 52 | 'trees': {'hash', 'content'},
|
| 53 | 'tree_entries': {'tree_hash', 'name', 'kind', 'hash', 'mode'},
|
| 54 | }
|
| 55 |
|
| 56 |
|
| 57 | def normalize_scope(scope):
|
| 58 | """Validated repo-relative POSIX scope paths, or None for the whole tree."""
|
| 59 | if scope is None:
|
| 60 | return None
|
| 61 | result = []
|
| 62 | for item in scope:
|
| 63 | parts = item.split('/')
|
| 64 | if (not item or item.startswith('/') or any(part in ('', '.', '..', '.oak', '.git') for part in parts)
|
| 65 | or any(c in item for c in '\\\x00\n\r')):
|
| 66 | raise Unavailable('invalid reader scope path')
|
| 67 | result.append(item)
|
| 68 | return tuple(sorted(set(result)))
|
| 69 |
|
| 70 |
|
| 71 | def in_scope(relative: str, scope) -> tuple[bool, bool]:
|
| 72 | """(visit, materialize) for a repo-relative path under the scope."""
|
| 73 | if scope is None:
|
| 74 | return True, True
|
| 75 | for item in scope:
|
| 76 | if relative == item or relative.startswith(item + '/'):
|
| 77 | return True, True
|
| 78 | if item.startswith(relative + '/'):
|
| 79 | return True, False
|
| 80 | return False, False
|
| 81 |
|
| 82 |
|
| 83 | def read_committed_tree(repo: Path, destination: Path, scope=None) -> dict:
|
| 84 | scope = normalize_scope(scope)
|
| 85 | try:
|
| 86 | import blake3
|
| 87 | import zstandard
|
| 88 | except ImportError as exc:
|
| 89 | raise Unavailable('reader dependencies unavailable: blake3 and zstandard required') from exc
|
| 90 | db_path = repo / '.oak' / 'oak.db'
|
| 91 | if (repo.is_symlink() or (repo / '.oak').is_symlink() or db_path.is_symlink()
|
| 92 | or not db_path.is_file()):
|
| 93 | raise Unavailable('local Oak SQLite database missing or linked')
|
| 94 | if destination.exists() or destination.is_symlink():
|
| 95 | raise Unavailable('reader destination must not exist')
|
| 96 | deadline = time.monotonic() + 60
|
| 97 | destination.mkdir(parents=True)
|
| 98 |
|
| 99 | def hashed(raw, expected):
|
| 100 | if not isinstance(expected, str) or not re.fullmatch('[0-9a-f]{64}', expected):
|
| 101 | raise Invalid('invalid object hash')
|
| 102 | if blake3.blake3(raw).hexdigest() != expected:
|
| 103 | raise Invalid('object hash mismatch')
|
| 104 | return raw
|
| 105 |
|
| 106 | def decode(content, codec):
|
| 107 | if not isinstance(content, bytes):
|
| 108 | raise Invalid('stored object has invalid type')
|
| 109 | if len(content) > MAX_OBJECT:
|
| 110 | raise Unavailable('stored object exceeds reader bound')
|
| 111 | if codec == 0:
|
| 112 | return content
|
| 113 | if codec != 1:
|
| 114 | raise Unavailable('unsupported object codec')
|
| 115 | try:
|
| 116 | size = zstandard.frame_content_size(content)
|
| 117 | if zstandard.get_frame_parameters(content).window_size > MAX_OBJECT:
|
| 118 | raise Unavailable('decompression window exceeds reader bound')
|
| 119 | if size not in (-1, zstandard.CONTENTSIZE_UNKNOWN) and not 0 <= size <= MAX_OBJECT:
|
| 120 | raise Unavailable('decompressed object exceeds reader bound')
|
| 121 | raw = zstandard.ZstdDecompressor(max_window_size=MAX_OBJECT).decompress(
|
| 122 | content, max_output_size=MAX_OBJECT, allow_extra_data=False)
|
| 123 | except zstandard.ZstdError as exc:
|
| 124 | raise Invalid('invalid compressed object') from exc
|
| 125 | if len(raw) > MAX_OBJECT:
|
| 126 | raise Unavailable('decompressed object exceeds reader bound')
|
| 127 | return raw
|
| 128 |
|
| 129 | def database_snapshot():
|
| 130 | if any(Path(str(db_path) + suffix).exists() for suffix in ('-wal', '-journal')):
|
| 131 | raise Unavailable('active SQLite journal; quiescent database required')
|
| 132 | with db_path.open('rb') as stream:
|
| 133 | content = stream.read(MAX_DATABASE + 1)
|
| 134 | if len(content) > MAX_DATABASE:
|
| 135 | raise Unavailable('database exceeds independent reader bound')
|
| 136 | return content
|
| 137 |
|
| 138 | # Snapshot only quiescent databases. immutable=1 is safe on this private copy,
|
| 139 | # never on a live WAL database; the original bytes are checked again below.
|
| 140 | snapshot = database_snapshot()
|
| 141 | database_sha256 = hashlib.sha256(snapshot).hexdigest()
|
| 142 | with tempfile.TemporaryDirectory(prefix='oakbench-reader-db-') as temp:
|
| 143 | snapshot_path = Path(temp) / 'snapshot.db'
|
| 144 | snapshot_path.write_bytes(snapshot)
|
| 145 | del snapshot
|
| 146 | return _read_snapshot(snapshot_path, database_sha256, database_snapshot,
|
| 147 | destination, deadline, hashed, decode, blake3, scope)
|
| 148 |
|
| 149 |
|
| 150 | def _read_snapshot(snapshot_path, database_sha256, database_snapshot, destination,
|
| 151 | deadline, hashed, decode, blake3, scope=None):
|
| 152 | total = 0
|
| 153 | entries_seen = 0
|
| 154 | nonregular = []
|
| 155 | with sqlite3.connect(snapshot_path.as_uri() + '?mode=ro&immutable=1', uri=True, timeout=5) as db:
|
| 156 | db.execute('PRAGMA query_only=ON')
|
| 157 | db.execute('PRAGMA trusted_schema=OFF')
|
| 158 | db.set_progress_handler(lambda: int(time.monotonic() > deadline), 1000)
|
| 159 | db.execute('BEGIN')
|
| 160 | for table, columns in REQUIRED_COLUMNS.items():
|
| 161 | present = {row[1] for row in db.execute(f'PRAGMA table_info({table})')}
|
| 162 | if not columns <= present:
|
| 163 | raise Unavailable('unrecognized Oak storage schema: ' + table)
|
| 164 | migrations = None
|
| 165 | if db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='schema_migrations'").fetchone():
|
| 166 | row = db.execute("SELECT max(version) FROM schema_migrations WHERE length(version)<=128").fetchone()
|
| 167 | migrations = row[0] if row else None
|
| 168 |
|
| 169 | def metadata(key):
|
| 170 | row = db.execute('SELECT value FROM metadata WHERE key=? AND length(value)<=4096', (key,)).fetchone()
|
| 171 | return row[0] if row else None
|
| 172 |
|
| 173 | if metadata('hash_format') not in (None, 'v1'):
|
| 174 | raise Unavailable('unsupported Oak hash format')
|
| 175 | head = metadata('head')
|
| 176 | branch = metadata('current_branch')
|
| 177 | if not head or not branch:
|
| 178 | raise Unavailable('committed HEAD unavailable')
|
| 179 | 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()
|
| 180 | if not commit or any(v is not None and (not isinstance(v, str) or len(v) > MAX_OBJECT) for v in commit):
|
| 181 | raise Invalid('committed HEAD object missing or invalid')
|
| 182 | # Oak v1 stores the same RFC3339 timestamp text used in its hash preimage.
|
| 183 | # A preimage mismatch is an unrecognized format (fail closed), never a
|
| 184 | # published Oak correctness failure.
|
| 185 | try:
|
| 186 | hashed('\n'.join(v or '' for v in commit).encode(), head)
|
| 187 | except Invalid as exc:
|
| 188 | raise Unavailable('commit preimage format unrecognized: ' + str(exc)) from exc
|
| 189 | root_hash = commit[3]
|
| 190 |
|
| 191 | def blob(object_hash):
|
| 192 | row = db.execute('SELECT content,size,codec FROM blobs WHERE hash=? AND length(content)<=?', (object_hash, MAX_OBJECT)).fetchone()
|
| 193 | if row is None:
|
| 194 | if db.execute('SELECT 1 FROM blobs WHERE hash=?', (object_hash,)).fetchone():
|
| 195 | raise Unavailable('stored blob exceeds reader bound')
|
| 196 | # Chunk-only hydration is not yet qualified by this reader.
|
| 197 | raise Unavailable('complete blob unavailable; chunk-only storage unsupported')
|
| 198 | content, size, codec = row
|
| 199 | if type(size) is not int or size < 0:
|
| 200 | raise Invalid('invalid stored blob size')
|
| 201 | if size > MAX_OBJECT:
|
| 202 | raise Unavailable('blob size exceeds reader bound')
|
| 203 | raw = decode(content, codec)
|
| 204 | if len(raw) != size:
|
| 205 | raise Invalid('blob size mismatch')
|
| 206 | return hashed(raw, object_hash)
|
| 207 |
|
| 208 | def visit(tree_hash, relative, ancestors):
|
| 209 | nonlocal total, entries_seen
|
| 210 | if tree_hash in ancestors:
|
| 211 | raise Invalid('tree cycle')
|
| 212 | if len(ancestors) >= 32 or time.monotonic() > deadline:
|
| 213 | raise Unavailable('tree depth or time bound exceeded')
|
| 214 | if tree_hash == blake3.blake3(b'').hexdigest():
|
| 215 | return
|
| 216 | row = db.execute('SELECT content FROM trees WHERE hash=? AND (content IS NULL OR length(content)<=?)', (tree_hash, MAX_OBJECT)).fetchone()
|
| 217 | if row is None:
|
| 218 | if db.execute('SELECT 1 FROM trees WHERE hash=?', (tree_hash,)).fetchone():
|
| 219 | raise Unavailable('committed tree exceeds reader bound')
|
| 220 | raise Invalid('committed tree missing')
|
| 221 | if row[0] is None:
|
| 222 | 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():
|
| 223 | raise Unavailable('stored tree entry exceeds reader bound')
|
| 224 | stored = db.execute('SELECT kind,name,hash,mode FROM tree_entries WHERE tree_hash=? ORDER BY name LIMIT ?', (tree_hash, MAX_ENTRIES + 1)).fetchall()
|
| 225 | raw = '\n'.join('\t'.join((kind, name, obj, 'tree' if kind == 'tree' else mode)) for kind, name, obj, mode in stored).encode()
|
| 226 | else:
|
| 227 | raw = decode(row[0], 1)
|
| 228 | if not ancestors:
|
| 229 | try:
|
| 230 | hashed(raw, tree_hash)
|
| 231 | except Invalid as exc:
|
| 232 | raise Unavailable('root tree serialization unrecognized: ' + str(exc)) from exc
|
| 233 | else:
|
| 234 | hashed(raw, tree_hash)
|
| 235 | try:
|
| 236 | lines = raw.decode('utf-8').split('\n') if raw else []
|
| 237 | except UnicodeError as exc:
|
| 238 | raise Invalid('invalid tree encoding') from exc
|
| 239 | previous = None
|
| 240 | for line in lines:
|
| 241 | parts = line.split('\t')
|
| 242 | if len(parts) != 4:
|
| 243 | raise Invalid('invalid tree entry')
|
| 244 | kind, name, object_hash, mode = parts
|
| 245 | if (not name or name in ('.', '..', '.oak', '.git') or any(c in name for c in '/\\\x00\n\r')
|
| 246 | or (previous is not None and name <= previous)):
|
| 247 | raise Invalid('unsafe or unordered tree path')
|
| 248 | previous = name
|
| 249 | path = relative / name
|
| 250 | visit_entry, _ = in_scope(path.as_posix(), scope)
|
| 251 | if not visit_entry:
|
| 252 | continue
|
| 253 | entries_seen += 1
|
| 254 | if entries_seen > MAX_ENTRIES:
|
| 255 | raise Unavailable('tree entry bound exceeded')
|
| 256 | if (destination / path).exists() or (destination / path).is_symlink():
|
| 257 | raise Unavailable('filesystem path collision on reader host')
|
| 258 | if kind == 'tree' and mode == 'tree':
|
| 259 | (destination / path).mkdir()
|
| 260 | visit(object_hash, path, ancestors | {tree_hash})
|
| 261 | elif kind == 'blob' and mode in ('regular', 'executable'):
|
| 262 | content = blob(object_hash)
|
| 263 | total += len(content)
|
| 264 | if total > MAX_TOTAL:
|
| 265 | raise Unavailable('total payload bound exceeded')
|
| 266 | target = destination / path
|
| 267 | target.write_bytes(content)
|
| 268 | target.chmod(0o755 if mode == 'executable' else 0o644)
|
| 269 | elif scope is not None:
|
| 270 | # Git's scoped check fails any non-100644 entry; report
|
| 271 | # it so the oracle fails the payload the same way.
|
| 272 | nonregular.append(path.as_posix())
|
| 273 | else:
|
| 274 | raise Unavailable('unsupported tree entry kind or mode')
|
| 275 | visit(root_hash, Path(), set())
|
| 276 | db.commit()
|
| 277 | if hashlib.sha256(database_snapshot()).hexdigest() != database_sha256:
|
| 278 | raise Unavailable('database changed during independent read')
|
| 279 | return {'head': head, 'database_sha256': database_sha256, 'reader_version': READER_VERSION,
|
| 280 | 'scope': list(scope) if scope is not None else None, 'nonregular_paths': sorted(nonregular),
|
| 281 | 'schema_migrations_max': migrations,
|
| 282 | 'reader_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
| 283 | 'blake3_version': importlib.metadata.version('blake3'),
|
| 284 | 'zstandard_version': importlib.metadata.version('zstandard')}
|
| 285 |
|
| 286 |
|
| 287 | def main():
|
| 288 | try:
|
| 289 | args = sys.argv[1:]
|
| 290 | scope = None
|
| 291 | if len(args) >= 3 and args[2] == '--scope':
|
| 292 | scope = args[3:]
|
| 293 | if not scope:
|
| 294 | raise Unavailable('empty reader scope')
|
| 295 | elif len(args) != 2:
|
| 296 | raise Unavailable('usage: oak_sqlite_reader.py REPOSITORY NEW_DESTINATION [--scope PATH...]')
|
| 297 | result = read_committed_tree(Path(args[0]), Path(args[1]), scope)
|
| 298 | code = 0
|
| 299 | except Unavailable as exc:
|
| 300 | result, code = {'unavailable': str(exc)}, 77
|
| 301 | except Invalid as exc:
|
| 302 | result, code = {'corrupt': True, 'reason': str(exc)}, 1
|
| 303 | except (OSError, ValueError, TypeError, sqlite3.Error) as exc:
|
| 304 | result, code = {'unavailable': 'reader instrument error: ' + str(exc)}, 77
|
| 305 | print(json.dumps(result, sort_keys=True))
|
| 306 | return code
|
| 307 |
|
| 308 | if __name__ == '__main__':
|
| 309 | raise SystemExit(main())
|