Remove encryption; user-data filters; SQLite safety; recovery, retention, scheduler

- No client-side encryption: plaintext content-addressed remote (SHA-256
  names), no backup key, no cryptography dependency (server disk encryption
  is the trust model).
- Filters back user data: images, archives, databases, PDFs accepted; 10 MiB
  cap; binary sniffing removed; temp names hardened (~$, #..#, .temp).
- SQLite zero-error policy: backup-API snapshots + integrity_check, journal
  folding, locked/corrupt loud skips, verified restores.
- Recovery: reindex from manifests, remote adopt, on-demand blob fetch,
  5-day retention + thinning, GC, date-guarded remote purge, metrics.
- Scheduler with WebDAV quota signal and 70% pressure backstop (floor kept).
- 37 tests incl. live-monitor capture safety and DB safety.
This commit is contained in:
retoor
2026-10-10 03:41:36 +02:00
parent 7e05e3d26c
commit 3499f6cda0
35 changed files with 3148 additions and 203 deletions
+35 -5
View File
@@ -12,6 +12,7 @@ from pathlib import Path
from typing import Any, Literal
from .db import Database
from . import dbsafe
from .ingest import Repository, is_within
from .store import BlobStore, sha256
@@ -48,12 +49,24 @@ class Restorer:
self.db = db
self.repo = repo
self.blobs = blobs
# Async (sha256) -> bytes; set by the service layer for on-demand
# remote fetch after reindex. Defaults to local spool only.
self.fetch_blob = None
# path safety
def allowed_bases(self) -> list[str]:
return [str(Path.home())] + [r["path"] for r in self.db.roots()]
@staticmethod
def _protected_dirs() -> list[str]:
try:
from .config import Paths
p = Paths.resolve()
return [str(p.config_dir), str(p.data_dir), str(p.cache_dir)]
except Exception:
return []
def safe_target(self, target: str) -> str:
"""Resolve a write target; refuse traversal, symlink escapes and symlink targets."""
if not os.path.isabs(target):
@@ -61,6 +74,9 @@ class Restorer:
normalized = os.path.normpath(target)
if normalized != target.rstrip("/") or ".." in Path(target).parts:
raise RestoreError("unsafe-path", f"{target} is not a normalized path")
for protected in self._protected_dirs():
if is_within(normalized, protected):
raise RestoreError("unsafe-path", f"{target} is inside versiond's own data directory {protected}")
parent = Path(normalized).parent
existing = parent
while not existing.exists() and existing != existing.parent:
@@ -172,7 +188,7 @@ class Restorer:
# execution
def execute(self, plan_id: str) -> dict[str, Any]:
async def execute(self, plan_id: str) -> dict[str, Any]:
row = self.db.one("SELECT * FROM restore_plans WHERE id = ?", (plan_id,))
if row is None:
raise RestoreError("not-found", f"restore plan {plan_id} does not exist")
@@ -187,14 +203,19 @@ class Restorer:
results = []
for action in plan["actions"]:
try:
results.append(self._apply(action))
results.append(await self._apply(action))
except (OSError, RestoreError) as exc:
results.append({**action, "result": "error", "error": str(exc)})
self.db.execute("UPDATE restore_plans SET status = 'executed' WHERE id = ?", (plan_id,))
self.db.audit("restore.execute", plan_id=plan_id, count=len(results))
return {"plan_id": plan_id, "results": results}
def _apply(self, action: dict[str, Any]) -> dict[str, Any]:
async def _blob(self, sha256: str) -> bytes:
if self.fetch_blob is not None:
return await self.fetch_blob(sha256)
return self.blobs.get(sha256)
async def _apply(self, action: dict[str, Any]) -> dict[str, Any]:
kind = action["action"]
if kind in ("unchanged", "skip-conflict", "skip-not-existing-at-as-of"):
return {**action, "result": "skipped"}
@@ -205,7 +226,16 @@ class Restorer:
self.repo.mark_deleted(destination)
return {**action, "result": "removed"}
version = self.db.version(action["version_id"])
content = self.blobs.get(version["blob_sha256"])
try:
content = await self._blob(version["blob_sha256"])
except (FileNotFoundError, ValueError) as exc:
raise RestoreError("unrestorable", f"blob {version['blob_sha256']} is gone or corrupt") from exc
if dbsafe.is_sqlite_image(content):
# Never write an unverified database back to disk.
try:
dbsafe.verify_sqlite_bytes(content)
except dbsafe.DatabaseUnsafe as exc:
raise RestoreError("unrestorable", f"stored version fails integrity check: {exc.reason}")
if kind == "write-renamed":
stem, ext = os.path.splitext(destination)
destination = f"{stem}.restored-{time.strftime('%Y%m%d-%H%M%S')}{ext}"
@@ -217,7 +247,7 @@ class Restorer:
content, st = self.repo.read_file(path)
except (OSError, Exception):
return None
if self.repo.filters.size_reason(len(content)) or self.repo.filters.content_reason(content):
if self.repo.filters.size_reason(len(content)):
return None
return self.repo.commit(path, content, "restore", "pre-restore", st)["version_id"]