mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 08:52:45 +00:00
309 lines
11 KiB
Python
309 lines
11 KiB
Python
"""Audit persistence sinks and chain verification helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Dict, List, Optional, Protocol
|
|
|
|
|
|
class AuditSink(Protocol):
|
|
def append_entry(
|
|
self, entry: Dict[str, Any], *, last_hash: Optional[str]
|
|
) -> str: ...
|
|
|
|
|
|
@dataclass
|
|
class AuditVerificationIssue:
|
|
code: str
|
|
file_path: str
|
|
line_number: int
|
|
message: str
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"code": self.code,
|
|
"file_path": self.file_path,
|
|
"line_number": self.line_number,
|
|
"message": self.message,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class AuditVerificationResult:
|
|
ok: bool
|
|
files_checked: List[str]
|
|
entries_checked: int
|
|
window_start_prev_hash: str
|
|
terminal_hash: str
|
|
window_truncated: bool
|
|
issues: List[AuditVerificationIssue] = field(default_factory=list)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"ok": self.ok,
|
|
"files_checked": list(self.files_checked),
|
|
"entries_checked": self.entries_checked,
|
|
"window_start_prev_hash": self.window_start_prev_hash,
|
|
"terminal_hash": self.terminal_hash,
|
|
"window_truncated": self.window_truncated,
|
|
"issues": [issue.to_dict() for issue in self.issues],
|
|
}
|
|
|
|
|
|
def _rotated_chain_paths(base_path: str) -> List[str]:
|
|
base = Path(base_path)
|
|
parent = base.parent if str(base.parent) else Path(".")
|
|
if not parent.exists():
|
|
return []
|
|
pattern = re.compile(rf"^{re.escape(base.name)}\.(\d+)$")
|
|
matches = []
|
|
for child in parent.iterdir():
|
|
if not child.is_file():
|
|
continue
|
|
match = pattern.match(child.name)
|
|
if not match:
|
|
continue
|
|
matches.append((int(match.group(1)), str(child)))
|
|
matches.sort(reverse=True)
|
|
return [path for _, path in matches]
|
|
|
|
|
|
def iter_audit_chain_paths(base_path: str) -> List[str]:
|
|
paths = _rotated_chain_paths(base_path)
|
|
if os.path.exists(base_path):
|
|
paths.append(base_path)
|
|
return paths
|
|
|
|
|
|
def _tail_hash_from_file(path: str) -> Optional[str]:
|
|
if not os.path.exists(path):
|
|
return None
|
|
try:
|
|
with open(path, "rb") as handle:
|
|
handle.seek(0, os.SEEK_END)
|
|
size = handle.tell()
|
|
if size <= 0:
|
|
return None
|
|
step = min(size, 8192)
|
|
handle.seek(size - step)
|
|
data = handle.read().decode("utf-8", errors="ignore")
|
|
except Exception:
|
|
return None
|
|
|
|
lines = [line.strip() for line in data.splitlines() if line.strip()]
|
|
if not lines:
|
|
return None
|
|
try:
|
|
payload = json.loads(lines[-1])
|
|
except Exception:
|
|
return None
|
|
value = payload.get("entry_hash")
|
|
return value if isinstance(value, str) and value else None
|
|
|
|
|
|
def read_last_entry_hash_from_chain(base_path: str) -> str:
|
|
newest_first = [base_path] if os.path.exists(base_path) else []
|
|
newest_first.extend(reversed(_rotated_chain_paths(base_path)))
|
|
for path in newest_first:
|
|
value = _tail_hash_from_file(path)
|
|
if value:
|
|
return value
|
|
return "GENESIS"
|
|
|
|
|
|
class LocalFileAuditSink:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
path: str,
|
|
max_bytes: int,
|
|
backups: int,
|
|
chain_hash: Callable[[str, Dict[str, Any]], str],
|
|
) -> None:
|
|
self.path = path
|
|
self.max_bytes = max_bytes
|
|
self.backups = backups
|
|
self._chain_hash = chain_hash
|
|
|
|
def _ensure_parent_dir(self) -> None:
|
|
try:
|
|
os.makedirs(os.path.dirname(os.path.abspath(self.path)), exist_ok=True)
|
|
except Exception:
|
|
pass
|
|
|
|
def rotate_if_needed(self) -> None:
|
|
if self.max_bytes <= 0 or self.backups < 0:
|
|
return
|
|
if not os.path.exists(self.path):
|
|
return
|
|
if os.path.getsize(self.path) < self.max_bytes:
|
|
return
|
|
if self.backups == 0:
|
|
os.remove(self.path)
|
|
return
|
|
for idx in range(self.backups, 0, -1):
|
|
src = f"{self.path}.{idx}"
|
|
dst = f"{self.path}.{idx + 1}"
|
|
if os.path.exists(src):
|
|
if idx == self.backups:
|
|
os.remove(src)
|
|
else:
|
|
os.replace(src, dst)
|
|
os.replace(self.path, f"{self.path}.1")
|
|
|
|
def append_entry(self, entry: Dict[str, Any], *, last_hash: Optional[str]) -> str:
|
|
self._ensure_parent_dir()
|
|
self.rotate_if_needed()
|
|
prev_hash = last_hash or read_last_entry_hash_from_chain(self.path)
|
|
event_hash = self._chain_hash(prev_hash, entry)
|
|
wrapped = dict(entry)
|
|
wrapped["prev_hash"] = prev_hash
|
|
wrapped["entry_hash"] = event_hash
|
|
line = json.dumps(wrapped, sort_keys=True, ensure_ascii=True) + "\n"
|
|
with open(self.path, "a", encoding="utf-8") as handle:
|
|
handle.write(line)
|
|
return event_hash
|
|
|
|
|
|
def verify_audit_chain(
|
|
base_path: str,
|
|
*,
|
|
chain_hash: Callable[[str, Dict[str, Any]], str],
|
|
) -> AuditVerificationResult:
|
|
files = iter_audit_chain_paths(base_path)
|
|
issues: List[AuditVerificationIssue] = []
|
|
if not files:
|
|
issues.append(
|
|
AuditVerificationIssue(
|
|
code="missing_chain",
|
|
file_path=base_path,
|
|
line_number=0,
|
|
message="No audit log files found for verification.",
|
|
)
|
|
)
|
|
return AuditVerificationResult(
|
|
ok=False,
|
|
files_checked=[],
|
|
entries_checked=0,
|
|
window_start_prev_hash="GENESIS",
|
|
terminal_hash="GENESIS",
|
|
window_truncated=False,
|
|
issues=issues,
|
|
)
|
|
|
|
previous_entry_hash: Optional[str] = None
|
|
window_start_prev_hash = "GENESIS"
|
|
entries_checked = 0
|
|
|
|
for file_path in files:
|
|
with open(file_path, "r", encoding="utf-8") as handle:
|
|
for line_number, raw_line in enumerate(handle, start=1):
|
|
line = raw_line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
wrapped = json.loads(line)
|
|
except Exception as exc:
|
|
issues.append(
|
|
AuditVerificationIssue(
|
|
code="invalid_json",
|
|
file_path=file_path,
|
|
line_number=line_number,
|
|
message=f"Invalid JSON entry: {exc}",
|
|
)
|
|
)
|
|
return AuditVerificationResult(
|
|
ok=False,
|
|
files_checked=files,
|
|
entries_checked=entries_checked,
|
|
window_start_prev_hash=window_start_prev_hash,
|
|
terminal_hash=previous_entry_hash or "GENESIS",
|
|
window_truncated=window_start_prev_hash != "GENESIS",
|
|
issues=issues,
|
|
)
|
|
|
|
prev_hash = wrapped.get("prev_hash")
|
|
entry_hash = wrapped.get("entry_hash")
|
|
if not isinstance(prev_hash, str) or not isinstance(entry_hash, str):
|
|
issues.append(
|
|
AuditVerificationIssue(
|
|
code="missing_hash_fields",
|
|
file_path=file_path,
|
|
line_number=line_number,
|
|
message="Audit entry is missing string prev_hash/entry_hash fields.",
|
|
)
|
|
)
|
|
return AuditVerificationResult(
|
|
ok=False,
|
|
files_checked=files,
|
|
entries_checked=entries_checked,
|
|
window_start_prev_hash=window_start_prev_hash,
|
|
terminal_hash=previous_entry_hash or "GENESIS",
|
|
window_truncated=window_start_prev_hash != "GENESIS",
|
|
issues=issues,
|
|
)
|
|
|
|
if previous_entry_hash is None:
|
|
window_start_prev_hash = prev_hash
|
|
elif prev_hash != previous_entry_hash:
|
|
issues.append(
|
|
AuditVerificationIssue(
|
|
code="prev_hash_mismatch",
|
|
file_path=file_path,
|
|
line_number=line_number,
|
|
message=(
|
|
"Audit chain continuity failed: prev_hash does not match "
|
|
"the preceding entry_hash."
|
|
),
|
|
)
|
|
)
|
|
return AuditVerificationResult(
|
|
ok=False,
|
|
files_checked=files,
|
|
entries_checked=entries_checked,
|
|
window_start_prev_hash=window_start_prev_hash,
|
|
terminal_hash=previous_entry_hash or "GENESIS",
|
|
window_truncated=window_start_prev_hash != "GENESIS",
|
|
issues=issues,
|
|
)
|
|
|
|
payload = dict(wrapped)
|
|
payload.pop("prev_hash", None)
|
|
payload.pop("entry_hash", None)
|
|
expected_hash = chain_hash(prev_hash, payload)
|
|
if entry_hash != expected_hash:
|
|
issues.append(
|
|
AuditVerificationIssue(
|
|
code="entry_hash_mismatch",
|
|
file_path=file_path,
|
|
line_number=line_number,
|
|
message="Audit entry_hash does not match the persisted payload.",
|
|
)
|
|
)
|
|
return AuditVerificationResult(
|
|
ok=False,
|
|
files_checked=files,
|
|
entries_checked=entries_checked,
|
|
window_start_prev_hash=window_start_prev_hash,
|
|
terminal_hash=previous_entry_hash or "GENESIS",
|
|
window_truncated=window_start_prev_hash != "GENESIS",
|
|
issues=issues,
|
|
)
|
|
|
|
previous_entry_hash = entry_hash
|
|
entries_checked += 1
|
|
|
|
return AuditVerificationResult(
|
|
ok=True,
|
|
files_checked=files,
|
|
entries_checked=entries_checked,
|
|
window_start_prev_hash=window_start_prev_hash,
|
|
terminal_hash=previous_entry_hash or "GENESIS",
|
|
window_truncated=window_start_prev_hash != "GENESIS",
|
|
issues=[],
|
|
)
|