S06-S08: Python spec, Go in-process leaf, tests and Forgejo CI
This commit is contained in:
commit
d67509e470
16 changed files with 726 additions and 0 deletions
89
python/secure_messaging/admin_history.py
Normal file
89
python/secure_messaging/admin_history.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue