89 lines
3.1 KiB
Python
89 lines
3.1 KiB
Python
"""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
|