ffdfb1f9b6
Reasons stacked up:
- AV: unsigned JARs that auto-download binaries + upload files trigger
Windows Defender false-positives more often than Python scripts
invoked by code-signed python.exe.
- Qt UI option: PySide6 opens a path to a real Qt UI (matching Prism's
look) if needed later. JVM Qt bindings are abandoned.
- frazclient already needs Python; inlining as 'import cloud_sync' is
zero overhead vs the launcher always shelling out to java.
Implementation:
- cloud_sync package: cli.py (argparse), creds.py, scope.py,
restic.py (binary discovery + auto-download + sha256 verify),
sync.py (pull/push subprocess restic).
- pyproject.toml with hatchling backend; pip-installable.
- Makefile builds cloud-sync.pyz via python -m zipapp (~53 KB).
- 33 pytest tests, stdlib only on runtime.
- CI workflow runs pytest matrix (3.10/3.11/3.12) + builds pyz.
- DESIGN.md + README.md updated to reflect Python.
E2E verified against local restic-rest-server:
pull empty → push initial → rm -rf local → pull restores → modify+push
creates second snapshot → client forget --prune blocked by --append-only.
Throws away ~565 LOC of Kotlin (and 18 jar tests) committed earlier in
this same session. Net result is ~250 LOC Python + 33 tests = smaller
and more aligned with the rest of the stack.
224 lines
6.9 KiB
Python
224 lines
6.9 KiB
Python
"""restic binary discovery + auto-download + invocation.
|
|
|
|
Discovery order:
|
|
1. ``--restic-binary <path>`` (explicit override)
|
|
2. ``<pack-folder>/.cloud-sync/restic-<RESTIC_VERSION>`` (pinned cached copy)
|
|
3. ``$PATH`` (only if version matches RESTIC_VERSION)
|
|
4. download from GitHub releases (unless ``--no-download``)
|
|
|
|
The version is pinned because repos written by one restic version can have
|
|
features another version can't read. Cache the pinned binary per-instance
|
|
so deleting the instance dir wipes everything cloud-sync owns.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import bz2
|
|
import hashlib
|
|
import os
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import urllib.request
|
|
import zipfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from .cli import Args
|
|
|
|
|
|
RESTIC_VERSION = "0.18.0"
|
|
RELEASE_TAG = f"v{RESTIC_VERSION}"
|
|
|
|
_HTTP_TIMEOUT = 30
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Platform:
|
|
os_tag: str # linux, darwin, windows
|
|
arch_tag: str # amd64, arm64
|
|
is_windows: bool
|
|
|
|
|
|
def _detect_platform() -> Platform:
|
|
name = platform.system().lower()
|
|
arch_raw = platform.machine().lower()
|
|
if name.startswith("linux"):
|
|
os_tag = "linux"
|
|
elif name.startswith("darwin"):
|
|
os_tag = "darwin"
|
|
elif name.startswith("windows"):
|
|
os_tag = "windows"
|
|
else:
|
|
raise RuntimeError(f"unsupported platform: {name}")
|
|
|
|
if arch_raw in ("amd64", "x86_64"):
|
|
arch_tag = "amd64"
|
|
elif arch_raw in ("aarch64", "arm64"):
|
|
arch_tag = "arm64"
|
|
else:
|
|
raise RuntimeError(f"unsupported architecture: {arch_raw}")
|
|
return Platform(os_tag, arch_tag, os_tag == "windows")
|
|
|
|
|
|
def _binary_filename(plat: Platform) -> str:
|
|
suffix = ".exe" if plat.is_windows else ""
|
|
return f"restic-{RESTIC_VERSION}{suffix}"
|
|
|
|
|
|
def resolve_binary(args: Args) -> Path:
|
|
"""Return a usable restic binary path, downloading if needed."""
|
|
if args.restic_binary is not None:
|
|
if not args.restic_binary.exists():
|
|
raise FileNotFoundError(
|
|
f"--restic-binary path does not exist: {args.restic_binary}"
|
|
)
|
|
return args.restic_binary
|
|
|
|
plat = _detect_platform()
|
|
cache_dir = args.pack_folder / ".cloud-sync"
|
|
cached = cache_dir / _binary_filename(plat)
|
|
if cached.exists() and os.access(cached, os.X_OK):
|
|
return cached
|
|
|
|
# Try $PATH only when version matches exactly
|
|
system = _find_system_binary_matching_version()
|
|
if system is not None:
|
|
return system
|
|
|
|
if not args.allow_download:
|
|
raise RuntimeError(
|
|
f"no usable restic binary at {cached} or on $PATH, "
|
|
f"and --no-download disabled auto-fetch"
|
|
)
|
|
|
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
_download_restic_to(cached, plat)
|
|
return cached
|
|
|
|
|
|
def run(
|
|
binary: Path,
|
|
args: list[str],
|
|
env: dict[str, str] | None = None,
|
|
cwd: Path | None = None,
|
|
timeout: int = 900,
|
|
) -> tuple[int, str]:
|
|
"""Run restic. Inherits stderr to caller's terminal for live progress.
|
|
Returns (returncode, captured_stdout)."""
|
|
merged_env = dict(os.environ)
|
|
if env:
|
|
merged_env.update(env)
|
|
p = subprocess.run( # noqa: S603 — controlled invocation
|
|
[str(binary), *args],
|
|
cwd=str(cwd) if cwd else None,
|
|
env=merged_env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=sys.stderr,
|
|
text=True,
|
|
timeout=timeout,
|
|
check=False,
|
|
)
|
|
return p.returncode, p.stdout
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# discovery + download helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _find_system_binary_matching_version() -> Path | None:
|
|
name = "restic.exe" if _detect_platform().is_windows else "restic"
|
|
found = shutil.which(name)
|
|
if not found:
|
|
return None
|
|
path = Path(found)
|
|
return path if _binary_version(path) == RESTIC_VERSION else None
|
|
|
|
|
|
def _binary_version(path: Path) -> str | None:
|
|
try:
|
|
out = subprocess.run( # noqa: S603
|
|
[str(path), "version"],
|
|
capture_output=True, text=True, timeout=5, check=False,
|
|
)
|
|
except (subprocess.SubprocessError, OSError):
|
|
return None
|
|
text = out.stdout + out.stderr
|
|
m = re.search(r"restic\s+(\d+\.\d+\.\d+)", text)
|
|
return m.group(1) if m else None
|
|
|
|
|
|
def _download_restic_to(target: Path, plat: Platform) -> None:
|
|
ext = "zip" if plat.is_windows else "bz2"
|
|
asset = f"restic_{RESTIC_VERSION}_{plat.os_tag}_{plat.arch_tag}.{ext}"
|
|
asset_url = (
|
|
f"https://github.com/restic/restic/releases/download/"
|
|
f"{RELEASE_TAG}/{asset}"
|
|
)
|
|
sums_url = (
|
|
f"https://github.com/restic/restic/releases/download/"
|
|
f"{RELEASE_TAG}/SHA256SUMS"
|
|
)
|
|
print(
|
|
f"cloud-sync: downloading restic {RESTIC_VERSION} from {asset_url}",
|
|
file=sys.stderr,
|
|
)
|
|
with tempfile.NamedTemporaryFile(suffix=f".{ext}", delete=False) as tmp:
|
|
tmp_path = Path(tmp.name)
|
|
try:
|
|
with urllib.request.urlopen(asset_url, timeout=_HTTP_TIMEOUT) as r:
|
|
tmp_path.write_bytes(r.read())
|
|
expected = _expected_sha(sums_url, asset)
|
|
actual = _sha256_file(tmp_path)
|
|
if expected.lower() != actual.lower():
|
|
raise RuntimeError(
|
|
f"restic download sha mismatch: expected {expected}, got {actual}"
|
|
)
|
|
_decompress_into(tmp_path, target, ext)
|
|
if not plat.is_windows:
|
|
target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IRUSR | stat.S_IWUSR)
|
|
finally:
|
|
tmp_path.unlink(missing_ok=True)
|
|
|
|
|
|
def _expected_sha(sums_url: str, asset: str) -> str:
|
|
with urllib.request.urlopen(sums_url, timeout=_HTTP_TIMEOUT) as r:
|
|
body = r.read().decode("utf-8")
|
|
for line in body.splitlines():
|
|
line = line.strip()
|
|
if line.endswith(asset):
|
|
return line.split()[0]
|
|
raise RuntimeError(f"restic SHA256SUMS missing entry for {asset}")
|
|
|
|
|
|
def _sha256_file(path: Path) -> str:
|
|
h = hashlib.sha256()
|
|
with path.open("rb") as f:
|
|
for chunk in iter(lambda: f.read(65536), b""):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
def _decompress_into(archive: Path, target: Path, ext: str) -> None:
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
if ext == "bz2":
|
|
with bz2.open(archive, "rb") as src, target.open("wb") as dst:
|
|
shutil.copyfileobj(src, dst)
|
|
elif ext == "zip":
|
|
with zipfile.ZipFile(archive) as zf:
|
|
inner = next(
|
|
(n for n in zf.namelist() if n.endswith("restic.exe")),
|
|
None,
|
|
)
|
|
if inner is None:
|
|
raise RuntimeError("restic.exe not found in downloaded zip")
|
|
with zf.open(inner) as src, target.open("wb") as dst:
|
|
shutil.copyfileobj(src, dst)
|
|
else:
|
|
raise RuntimeError(f"unsupported archive ext: {ext}")
|