"""Per-user vault file store — mirrors the client vault layout.""" from __future__ import annotations import hashlib import json import os import time from dataclasses import dataclass from pathlib import Path from .config import settings @dataclass class VaultFileEntry: path: str size: int mtime: float sha256: str def to_dict(self) -> dict: return { "path": self.path, "size": self.size, "mtime": self.mtime, "sha256": self.sha256, } def vault_root(user_id: str) -> Path: root = Path(settings.vault_path) / user_id / "files" root.mkdir(parents=True, exist_ok=True) return root def _safe_relpath(rel: str) -> str: """Normalize and reject path escape attempts.""" cleaned = rel.replace("\\", "/").lstrip("/") if not cleaned or cleaned.startswith("..") or "/../" in f"/{cleaned}/": raise ValueError(f"invalid vault path: {rel!r}") parts = Path(cleaned).parts if ".." in parts: raise ValueError(f"invalid vault path: {rel!r}") return "/".join(parts) def resolve_path(user_id: str, rel: str) -> Path: rel_n = _safe_relpath(rel) full = (vault_root(user_id) / rel_n).resolve() root = vault_root(user_id).resolve() if not str(full).startswith(str(root) + os.sep) and full != root: raise ValueError(f"path escapes vault: {rel!r}") return full def _sha256_file(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as f: for chunk in iter(lambda: f.read(1024 * 1024), b""): h.update(chunk) return h.hexdigest() def build_manifest(user_id: str) -> dict: root = vault_root(user_id) entries: list[VaultFileEntry] = [] for path in sorted(root.rglob("*")): if not path.is_file(): continue if path.name.startswith("."): continue rel = path.relative_to(root).as_posix() st = path.stat() entries.append( VaultFileEntry( path=rel, size=st.st_size, mtime=st.st_mtime, sha256=_sha256_file(path), ) ) return { "user_id": user_id, "generated_at": time.time(), "files": [e.to_dict() for e in entries], } def write_file(user_id: str, rel: str, data: bytes) -> VaultFileEntry: path = resolve_path(user_id, rel) path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(path.suffix + ".tmp") tmp.write_bytes(data) tmp.replace(path) st = path.stat() return VaultFileEntry( path=_safe_relpath(rel), size=st.st_size, mtime=st.st_mtime, sha256=_sha256_file(path), ) def read_file(user_id: str, rel: str) -> bytes: path = resolve_path(user_id, rel) if not path.is_file(): raise FileNotFoundError(rel) return path.read_bytes() def delete_file(user_id: str, rel: str, *, tombstone: bool = True) -> None: path = resolve_path(user_id, rel) if not path.exists(): raise FileNotFoundError(rel) if tombstone: tomb = path.parent / f".tombstone-{path.name}" meta = { "path": _safe_relpath(rel), "deleted_at": time.time(), "sha256": _sha256_file(path) if path.is_file() else None, } tomb.write_text(json.dumps(meta), encoding="utf-8") path.unlink()