"""Commit journal (spec 13.1): append-only, hash-chained, fail-closed. Each line is ``{"seq", "chunk", "content_id", "file_sha256", "prev", "ts_utc"}``; ``prev`` is the hash of the previous line's exact UTF-8 text (SHA-256 of the canonical JSON text of the previous line, no trailing newline). seq 0 uses SHA-256 of the empty string as prev. ``journal_tail`` recorded in the checkpoint is (seq, hash of last line). """ import os from .util import canonical_json, sha256_bytes, atomic_write_bytes EMPTY_HASH = sha256_bytes(b"") class JournalError(Exception): pass def _line_for(record): return canonical_json(record) def _line_hash(line_text): return sha256_bytes(line_text) def append_commit(journal_path, seq, chunk_index, content_id, file_sha256, ts_utc, prev_hash): """Append one commit line atomically (fail-closed: fsync).""" record = { "seq": seq, "chunk": chunk_index, "content_id": content_id, "file_sha256": file_sha256, "prev": prev_hash, "ts_utc": ts_utc, } line = _line_for(record) os.makedirs(os.path.dirname(os.path.abspath(journal_path)), exist_ok=True) with open(journal_path, "ab") as fh: fh.write(line.encode("utf-8")) fh.flush() os.fsync(fh.fileno()) # Line hash is defined over the line text WITHOUT the trailing newline # (read_journal recomputes it the same way). return _line_hash(line.rstrip("\n")) def read_journal(journal_path): """Read and validate the chain. Returns (records, tail_hash). Raises JournalError on chain break or malformed line.""" if not os.path.exists(journal_path): return [], EMPTY_HASH records = [] prev = EMPTY_HASH tail = EMPTY_HASH with open(journal_path, "rb") as fh: for raw in fh: text = raw.rstrip(b"\r\n") if not text: continue if raw.endswith(b"\r\n"): line = raw[:-2] elif raw.endswith(b"\n"): line = raw[:-1] else: line = raw try: import json record = json.loads(line.decode("utf-8")) except Exception as e: raise JournalError("journal line unparseable: %s" % e) if record.get("prev") != prev: raise JournalError("journal chain broken at seq %r" % record.get("seq")) lh = _line_hash(line.decode("utf-8")) records.append(record) prev = lh tail = lh return records, tail def seq_after(records): """Next seq = last seq + 1 (0 when journal is empty).""" if not records: return 0 return records[-1].get("seq", -1) + 1