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:
+35
-5
@@ -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"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user