"""pull + push entry points. Both subprocess restic against ``rest:://:@//`` where the password is HTTP basic auth ONLY. Restic repos are initialised with ``--insecure-no-password`` so no encryption-at-rest password exists; protection relies on: 1. TLS in transit at the reverse proxy 2. ``--private-repos`` + htpasswd per user at restic-rest-server 3. ``--append-only`` to prevent client-side deletion 4. Disk-level encryption (LUKS) on the host Defense-in-depth via repo encryption was dropped because the threat model (homelab, operator-trusted) doesn't justify the password-coordination cost. """ from __future__ import annotations import sys import urllib.parse from pathlib import Path from . import restic, scope as scopemod from .cli import Args from .creds import read_credentials from .ui import HeadlessProgress, Progress def pull(args: Args, progress: Progress | None = None) -> int: """Restore latest snapshot's files into pack_folder. If the repo has no snapshots yet, this is a no-op (first run on this machine; nothing to restore). """ ui = progress or HeadlessProgress() ui.set_status("Reading credentials…") discord_id, password = read_credentials(args.token_file) ui.set_status("Resolving restic binary…") binary = restic.resolve_binary(args) repo = _restic_repo(args.url, discord_id, password) env = _restic_env() ui.set_status("Checking remote snapshots…") code, out = restic.run( binary, ["-r", repo, "--insecure-no-password", "snapshots", "--json", "--latest", "1"], env=env, cancel_check=ui.is_cancelled, ) if code == -1: return 1 if code != 0: print( f"cloud-sync: failed to list snapshots (restic exit {code})", file=sys.stderr, ) return 2 stripped = out.strip() if stripped in ("", "null", "[]"): ui.set_status("No snapshots yet — nothing to pull") print( "cloud-sync: no snapshots yet for this user " "(first run on this machine?); nothing to pull" ) return 0 scope = scopemod.load(args.pack_folder) _, exclude_from = scopemod.materialize_for_restic(args.pack_folder, scope) ui.set_status("Restoring files…") code, _ = restic.run( binary, [ "-r", repo, "--insecure-no-password", "restore", "latest", "--target", str(args.pack_folder), "--exclude-file", str(exclude_from), ], env=env, cancel_check=ui.is_cancelled, ) if code == -1: return 1 if code != 0: print(f"cloud-sync: restic restore failed (exit {code})", file=sys.stderr) return 2 ui.set_status("Pull complete") print("cloud-sync: pull ok") return 0 def push(args: Args, progress: Progress | None = None) -> int: """Snapshot the in-scope files into the user's repo.""" ui = progress or HeadlessProgress() ui.set_status("Reading credentials…") discord_id, password = read_credentials(args.token_file) ui.set_status("Resolving restic binary…") binary = restic.resolve_binary(args) repo = _restic_repo(args.url, discord_id, password) env = _restic_env() scope = scopemod.load(args.pack_folder) files_from, exclude_from = scopemod.materialize_for_restic(args.pack_folder, scope) ui.set_status("Uploading snapshot…") code, _ = restic.run( binary, [ "-r", repo, "--insecure-no-password", "backup", "--files-from", str(files_from), "--exclude-file", str(exclude_from), "--host", "cloud-sync", "--tag", "auto", ], env=env, cwd=args.pack_folder, cancel_check=ui.is_cancelled, ) if code == -1: return 1 if code != 0: print(f"cloud-sync: restic backup failed (exit {code})", file=sys.stderr) return 2 ui.set_status("Push complete") print("cloud-sync: push ok") return 0 # --------------------------------------------------------------------------- # helpers # --------------------------------------------------------------------------- def _restic_repo(base_url: str, discord_id: str, password: str) -> str: """Build rest:://:@// URL-embedded basic auth is universally supported by restic; alternative env vars (RESTIC_REST_USERNAME, RESTIC_REST_PASSWORD) require 0.16+. """ raw = base_url.strip() if raw.startswith("rest:"): raw = raw[len("rest:"):] raw = raw.rstrip("/") scheme_end = raw.find("://") if scheme_end <= 0: raise ValueError( f"--url must include scheme (http:// or https://): {base_url!r}" ) scheme = raw[: scheme_end + 3] host_and_path = raw[scheme_end + 3 :] u = urllib.parse.quote(discord_id, safe="") p = urllib.parse.quote(password, safe="") return f"rest:{scheme}{u}:{p}@{host_and_path}/{discord_id}/" def _restic_env() -> dict[str, str]: return { # No RESTIC_PASSWORD — repos use --insecure-no-password. "RESTIC_PROGRESS_FPS": "0", }