mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 23:15:18 +02:00
Add JSON integrity hash checks
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user