"""Append-only admin configuration history (Verae DataCube-shaped JSONL). Each row stores previous file, new file, unified diff, actor, and dual hashes. Compatible with pfc.chain field names so a real cube can ingest the log. """ from __future__ import annotations # annotations import difflib # unified diff import hashlib # sha256 import json # rows import time # created_ts from pathlib import Path # file from typing import Any, Dict, List, Optional # types def sha256_hex(data: bytes) -> str: """Document hash for the history row.""" return hashlib.sha256(data).hexdigest() # hex def unified_diff(prev: str, new: str, name: str = "config") -> str: """Unified diff of previous and new config text.""" return "".join( # join generator difflib.unified_diff( prev.splitlines(True), # old lines keepends new.splitlines(True), # new lines fromfile=name + ".prev", # label tofile=name + ".new", # label ) ) class AdminHistory: """JSONL chain under a cube directory: blockchain/chain.jsonl.""" def __init__(self, cube_dir: Path): self.path = Path(cube_dir) / "blockchain" / "chain.jsonl" # pfc layout self.path.parent.mkdir(parents=True, exist_ok=True) # ensure dir if not self.path.exists(): # empty chain self.path.touch() # create def _rows(self) -> List[Dict[str, Any]]: """Read all rows.""" rows = [] # accumulator for line in self.path.read_text().splitlines(): # each line if line.strip(): # skip blanks rows.append(json.loads(line)) # parse return rows # list def tip(self) -> Optional[Dict[str, Any]]: """Last row or None.""" rows = self._rows() # load return rows[-1] if rows else None # tip def append_change( self, *, actor: str, prev_text: str, new_text: str, name: str = "secure-messaging.json", ) -> Dict[str, Any]: """Append prev, new, diff. Empty actor is rejected.""" if not (actor or "").strip(): # required raise ValueError("actor required") prev_h = sha256_hex(prev_text.encode()) # hash old new_h = sha256_hex(new_text.encode()) # hash new payload = { # chain payload "type": "admin-config", "actor": actor, "prev": prev_text, "new": new_text, "diff": unified_diff(prev_text, new_text, name), "prev_sha256": prev_h, "new_sha256": new_h, "created_ts": time.time(), } body = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() # canonical prev = self.tip() # previous chain row row = { # pfc-like envelope "seq": (prev["seq"] + 1) if prev else 0, "prev_sha256": prev["sha256"] if prev else "0" * 64, "payload": payload, "sha256": sha256_hex(body), } with self.path.open("a") as f: # append-only f.write(json.dumps(row, sort_keys=True) + "\n") # one line return row # for tests