"""ContentManifest (PLAN.md §10.1, §6.4). Content Sync (§6.4): SHA-256-Manifest, resumierbare Chunks, Hashprüfung, Staging – Ziel sind byte-identische freigegebene Inhalte auf allen Nodes. Manifest-Einträge decken Medien, Plugins und Projektdateien ab; die Art wird je Eintrag vermerkt, damit Preflight Plugin-API- und Backend-Anforderungen prüfen kann (§10.1). """ from __future__ import annotations import hashlib import os import uuid from dataclasses import dataclass, field from pathlib import Path, PurePosixPath CHUNK_SIZE = 4 * 1024 * 1024 # 4 MiB Standard-Chunk für Resume (§6.4) KIND_MEDIA = "media" KIND_PLUGIN = "plugin" KIND_PROJECT = "project" def hash_file(path: Path | str, chunk_size: int = CHUNK_SIZE) -> tuple[str, list[str]]: """SHA-256 über die ganze Datei + je Chunk (resumierbar, §6.4). Rückgabe: (file_hash_hex, [chunk_hash_hex, ...] in Dateireihenfolge). """ if chunk_size <= 0: raise ValueError("chunk_size muss positiv sein") file_hash = hashlib.sha256() chunk_hashes: list[str] = [] with open(path, "rb") as fh: while True: data = fh.read(chunk_size) if not data: break chunk_hashes.append(hashlib.sha256(data).hexdigest()) file_hash.update(data) if not chunk_hashes: # leere Datei: ein leerer Chunk-Hash chunk_hashes.append(hashlib.sha256(b"").hexdigest()) return file_hash.hexdigest(), chunk_hashes @dataclass(frozen=True) class ManifestEntry: """Eine Datei im Manifest (§10.1): Pfad, Größe, Hash, Art.""" rel_path: str # portabel, POSIX-relativ kind: str # media | plugin | project size_bytes: int sha256: str chunk_hashes: tuple[str, ...] = () def to_dict(self) -> dict: return { "rel_path": self.rel_path, "kind": self.kind, "size_bytes": self.size_bytes, "sha256": self.sha256, "chunk_hashes": list(self.chunk_hashes), } @classmethod def from_dict(cls, data: dict) -> ManifestEntry: return cls( rel_path=data["rel_path"], kind=data["kind"], size_bytes=int(data["size_bytes"]), sha256=data["sha256"], chunk_hashes=tuple(data.get("chunk_hashes", ())), ) @dataclass(frozen=True) class ManifestDiff: """Unterschied zweier Manifeste (Basis für Sync-Transfer, §6.4).""" added: frozenset[str] = field(default_factory=frozenset) removed: frozenset[str] = field(default_factory=frozenset) changed: frozenset[str] = field(default_factory=frozenset) @property def empty(self) -> bool: return not (self.added or self.removed or self.changed) class ContentManifest: """Versioniertes Inhalts-Manifest (§10.1 ContentManifest). - manifest_id: Instanz-ID (UUID) - revision: monotone Manifest-Revision (neu bei jeder Änderung) - entries: rel_path → ManifestEntry Der Manifest-Inhalt selbst ist deterministisch (sortierte Einträge); die manifest_id identifiziert den Ausgabezeitpunkt. """ def __init__( self, manifest_id: str | None = None, revision: int = 0, entries: dict[str, ManifestEntry] | None = None, ) -> None: self.manifest_id = manifest_id or str(uuid.uuid4()) self.revision = revision self._entries: dict[str, ManifestEntry] = dict(entries or {}) @property def entries(self) -> dict[str, ManifestEntry]: return dict(self._entries) # ---------- Aufbau ---------- @classmethod def build( cls, root: Path | str, kind_of=None, chunk_size: int = CHUNK_SIZE, revision: int = 0, ) -> ContentManifest: """Baut das Manifest aus einem Verzeichnis (§6.4 SHA-256). kind_of: callable(rel_path) → "media"|"plugin"|"project"; Default: alles KIND_MEDIA. """ root_path = Path(root) if not root_path.is_dir(): raise FileNotFoundError(f"Manifest-Root fehlt: {root_path}") entries: dict[str, ManifestEntry] = {} for dirpath, _dirnames, filenames in os.walk(root_path): for name in sorted(filenames): abs_path = Path(dirpath) / name if not abs_path.is_file(): continue rel = abs_path.relative_to(root_path).as_posix() if ".." in PurePosixPath(rel).parts: continue # defensive: nie Traversal ins Manifest file_hash, chunks = hash_file(abs_path, chunk_size=chunk_size) kind = kind_of(rel) if kind_of else KIND_MEDIA entries[rel] = ManifestEntry( rel_path=rel, kind=kind, size_bytes=abs_path.stat().st_size, sha256=file_hash, chunk_hashes=tuple(chunks), ) return cls(revision=revision, entries=entries) # ---------- Verifikation (§6.4 Hashprüfung) ---------- def verify_file(self, root: Path | str, rel_path: str) -> bool: """Prüft Größe + Hash einer Datei gegen den Manifest-Eintrag.""" entry = self._entries.get(rel_path) if entry is None: return False abs_path = Path(root) / rel_path if not abs_path.is_file(): return False stat = abs_path.stat() if stat.st_size != entry.size_bytes: return False file_hash, _chunks = hash_file(abs_path) return file_hash == entry.sha256 # ---------- Diff (§6.4 Sync-Basis) ---------- def diff(self, other: ContentManifest) -> ManifestDiff: """Was muss von self nach other übertragen/gelöscht werden?""" mine = set(self._entries) theirs = set(other._entries) added = frozenset(theirs - mine) removed = frozenset(mine - theirs) changed = frozenset( rel for rel in mine & theirs if self._entries[rel].sha256 != other._entries[rel].sha256 ) return ManifestDiff(added=added, removed=removed, changed=changed) # ---------- Serialisierung ---------- def to_dict(self) -> dict: return { "manifest_id": self.manifest_id, "revision": self.revision, "entries": { rel: entry.to_dict() for rel, entry in sorted(self._entries.items()) }, } @classmethod def from_dict(cls, data: dict) -> ContentManifest: entries = { rel: ManifestEntry.from_dict(entry) for rel, entry in data.get("entries", {}).items() } return cls( manifest_id=data.get("manifest_id"), revision=int(data.get("revision", 0)), entries=entries, ) def __len__(self) -> int: return len(self._entries)