Add JSON integrity hash checks

This commit is contained in:
Marcel Peterkau
2026-06-27 10:35:35 +02:00
parent d1dab793a6
commit 87e972bb43
9 changed files with 302 additions and 11 deletions
+40 -1
View File
@@ -1,17 +1,56 @@
import hashlib
import json
import os
import tempfile
from copy import deepcopy
from pathlib import Path
from typing import Any
CONTENT_HASH_FIELD = "content_hash"
def _hashable_copy(data: Any, *, hash_field: str = CONTENT_HASH_FIELD) -> Any:
if isinstance(data, dict):
return {key: _hashable_copy(value, hash_field=hash_field) for key, value in data.items() if key != hash_field}
if isinstance(data, list):
return [_hashable_copy(item, hash_field=hash_field) for item in data]
return data
def compute_json_content_hash(data: Any, *, hash_field: str = CONTENT_HASH_FIELD) -> str:
payload = json.dumps(
_hashable_copy(data, hash_field=hash_field),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def attach_json_content_hash(data: Any, *, hash_field: str = CONTENT_HASH_FIELD) -> Any:
cloned = deepcopy(data)
if isinstance(cloned, dict):
cloned[hash_field] = compute_json_content_hash(cloned, hash_field=hash_field)
return cloned
def json_content_hash_matches(data: Any, *, hash_field: str = CONTENT_HASH_FIELD) -> bool:
if not isinstance(data, dict):
return True
stored = str(data.get(hash_field, "")).strip()
if not stored:
return False
return stored == compute_json_content_hash(data, hash_field=hash_field)
def write_json_atomic(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
temporary = Path(temporary_name)
payload = attach_json_content_hash(data)
try:
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
json.dump(data, handle, ensure_ascii=False, indent=2)
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
+43 -1
View File
@@ -21,7 +21,7 @@ from ccma.domain.contributions import (
)
from ccma.domain.dates import DateValidationError, normalize_date_input, validate_member_dates
from ccma.domain.models import ASSET_STATUS_LABELS, MEMBERSHIP_STATUS_LABELS, Asset, ContributionData, Event, Member
from ccma.storage.atomic import read_json, write_json_atomic
from ccma.storage.atomic import json_content_hash_matches, read_json, write_json_atomic
class RepositoryError(RuntimeError):
@@ -142,6 +142,8 @@ class MemberRepository:
errors: list[str] = []
try:
config = read_json(self.root / "repository.json")
if not json_content_hash_matches(config):
errors.append("repository.json: Hash fehlt oder stimmt nicht; Datei wurde vermutlich extern geändert.")
if int(config.get("schema_version", 0)) != 1:
errors.append("repository.json: nicht unterstützte schema_version")
policy = config.get("member_number_policy") or {}
@@ -155,6 +157,7 @@ class MemberRepository:
for member_dir in self._member_directories():
try:
member, _contributions = self.preflight_member_record(member_dir.name)
errors.extend(f"{member_dir.name}/{warning}" for warning in self.member_hash_warnings(member_dir.name))
validate_member_dates(
birth_date=member.birth_date,
accepted_at=member.accepted_at,
@@ -183,6 +186,7 @@ class MemberRepository:
for asset_dir in self._asset_directories():
try:
asset = self.get_asset(asset_dir.name)
errors.extend(f"{asset_dir.name}/{warning}" for warning in self.asset_hash_warnings(asset_dir.name))
if asset.asset_id != asset_dir.name:
errors.append(f"{asset_dir.name}/asset.json: asset_id stimmt nicht mit Ordner überein")
if asset.schema_version != 1:
@@ -1211,6 +1215,44 @@ class MemberRepository:
raise RepositoryError("repository.json enthält keine gültige Konfiguration.")
return configuration
def member_hash_warnings(self, member_id: str) -> list[str]:
warnings: list[str] = []
try:
member_raw = read_json(self._member_path(member_id) / "member.json")
if not json_content_hash_matches(member_raw):
warnings.append("member.json: Hash fehlt oder stimmt nicht; Datei wurde vermutlich extern geändert.")
except (OSError, ValueError, TypeError, json.JSONDecodeError):
pass
try:
contributions_raw = read_json(self._member_path(member_id) / "contributions.json")
if not json_content_hash_matches(contributions_raw):
warnings.append(
"contributions.json: Hash fehlt oder stimmt nicht; Datei wurde vermutlich extern geändert."
)
except (OSError, ValueError, TypeError, json.JSONDecodeError):
pass
return warnings
def asset_hash_warnings(self, asset_id: str) -> list[str]:
warnings: list[str] = []
try:
asset_raw = read_json(self._asset_path(asset_id) / "asset.json")
if not json_content_hash_matches(asset_raw):
warnings.append("asset.json: Hash fehlt oder stimmt nicht; Datei wurde vermutlich extern geändert.")
except (OSError, ValueError, TypeError, json.JSONDecodeError):
pass
return warnings
def refresh_member_record_hashes(self, member_id: str) -> None:
member = self.get_member(member_id)
contributions = self.get_contributions(member_id)
write_json_atomic(self._member_path(member_id) / "member.json", member.to_dict())
write_json_atomic(self._member_path(member_id) / "contributions.json", contributions.to_dict())
def refresh_asset_record_hashes(self, asset_id: str) -> None:
asset = self.get_asset(asset_id)
write_json_atomic(self._asset_path(asset_id) / "asset.json", asset.to_dict())
def get_member_number_policy(self) -> dict[str, str]:
try:
config = read_json(self.root / "repository.json")