personal memory agent
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(backup): vendor SHA-pinned restic v0.19.0 + secret-scrubbed runner

Foundation lode for sol private backup (spb): a new solstone/think/backup/
package with two concerns and no business logic (no keys, repo-init,
backup/restore/prune, scheduling, UI, or CLI — those are later lodes).

- readiness.py (stdlib-only, read-only): pinned restic 0.19.0 constants, the
four .bz2 SHA256 pins, the official GitHub download-URL template, per-platform
tool dirs, os/arch mapping (x86_64->amd64, aarch64->arm64), sentinel load +
reuse gate (check_restic_ready), and on-disk binary verification.
- install.py (the writer): ensure_restic acquires the per-platform .bz2 from the
official restic GitHub release (never a sol-pbc URL), SHA-verifies against the
embedded pin before decompress/exec (fail-closed, no re-download loop),
decompresses, chmods +x, and atomically installs the binary, a drift-detecting
sentinel, and restic's BSD-2 LICENSE via temp-file + Path.rename. A bundled
.bz2 override (SOLSTONE_RESTIC_BUNDLE) routes through the same pin-verify path.
- runner.py (the runner, capture-mode only): run_restic passes every secret via
env (RESTIC_PASSWORD / backend AWS_*/B2_*), keeps RESTIC_REPOSITORY
credential-free, builds a minimal allowlisted child env, never uses
--insecure-tls, supports --json and --max-repack-size, and scrubs all secrets
from stdout/stderr/json/argv on success, non-zero-exit, and timeout paths.
Long-running/streaming mode is deferred to avoid leaking restic output through
ManagedProcess health logs + the callosum logs tract.

Network-mocked unit tests cover fail-closed verification, sentinel reuse,
per-platform asset selection, the bundled override, and the load-bearing runner
secret-scrubbing invariants, plus a skip-if-restic-absent local: round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

+1029
+4
solstone/think/backup/__init__.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Internal restic acquisition and runner support for sol private backup."""
+236
solstone/think/backup/install.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Acquire and install the pinned restic binary for sol private backup.""" 5 + 6 + from __future__ import annotations 7 + 8 + import bz2 9 + import hashlib 10 + import json 11 + import os 12 + import socket 13 + import tempfile 14 + import time 15 + import urllib.error 16 + import urllib.request 17 + from collections.abc import Mapping 18 + from pathlib import Path 19 + from typing import Any 20 + 21 + from solstone.think.backup.readiness import ( 22 + RESTIC_BUNDLE_ENV, 23 + RESTIC_SCHEMA_VERSION, 24 + RESTIC_TOOL, 25 + RESTIC_VERSION, 26 + _binary_path, 27 + _license_path, 28 + _platform_info, 29 + _sentinel_path, 30 + _tool_dir, 31 + check_restic_ready, 32 + select_restic_asset, 33 + ) 34 + 35 + RESTIC_LICENSE_TEXT = """BSD 2-Clause License 36 + 37 + Copyright (c) 2014, Alexander Neumann <alexander@bumpern.de> 38 + All rights reserved. 39 + 40 + Redistribution and use in source and binary forms, with or without 41 + modification, are permitted provided that the following conditions are met: 42 + 43 + * Redistributions of source code must retain the above copyright notice, this 44 + list of conditions and the following disclaimer. 45 + 46 + * Redistributions in binary form must reproduce the above copyright notice, 47 + this list of conditions and the following disclaimer in the documentation 48 + and/or other materials provided with the distribution. 49 + 50 + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 51 + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 52 + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 53 + DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 54 + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 55 + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 56 + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 57 + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 58 + OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 59 + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 60 + """ 61 + 62 + 63 + def _bundle_path(asset_filename: str) -> Path | None: 64 + env_path = os.getenv(RESTIC_BUNDLE_ENV) 65 + if env_path: 66 + return Path(env_path).expanduser().resolve() 67 + bundled = Path(__file__).resolve().parent / "_bin" / asset_filename 68 + return bundled if bundled.exists() else None 69 + 70 + 71 + def _fetch_url(url: str, *, timeout: float) -> bytes: 72 + if not url.startswith("https://"): 73 + raise RuntimeError(f"restic download URL must use HTTPS: {url}") 74 + with urllib.request.urlopen(url, timeout=timeout) as response: 75 + return response.read() 76 + 77 + 78 + def _download_with_retries(url: str, *, attempts: int, timeout: float) -> bytes: 79 + if attempts < 1: 80 + raise ValueError("restic download attempts must be at least 1") 81 + last_error: BaseException | None = None 82 + for attempt in range(attempts): 83 + try: 84 + return _fetch_url(url, timeout=timeout) 85 + except urllib.error.HTTPError: 86 + raise 87 + except (urllib.error.URLError, TimeoutError, socket.timeout) as exc: 88 + last_error = exc 89 + if attempt == attempts - 1: 90 + break 91 + time.sleep(0.25 * (attempt + 1)) 92 + if last_error is not None: 93 + raise last_error 94 + raise RuntimeError("restic download failed without an error") 95 + 96 + 97 + def _verify_bz2(data: bytes, expected_sha256: str, source: str) -> None: 98 + actual_sha256 = hashlib.sha256(data).hexdigest() 99 + if actual_sha256 != expected_sha256: 100 + raise RuntimeError( 101 + f"restic asset SHA mismatch: {source}\n" 102 + f" expected: {expected_sha256}\n" 103 + f" actual: {actual_sha256}" 104 + ) 105 + 106 + 107 + def _decompress_bz2(data: bytes, source: str) -> bytes: 108 + try: 109 + return bz2.decompress(data) 110 + except OSError as exc: 111 + raise RuntimeError(f"restic asset decompression failed: {source}") from exc 112 + 113 + 114 + def _write_binary_atomic(binary_path: Path, data: bytes) -> None: 115 + binary_path.parent.mkdir(parents=True, exist_ok=True) 116 + tmp_path: Path | None = None 117 + try: 118 + with tempfile.NamedTemporaryFile( 119 + "wb", 120 + dir=binary_path.parent, 121 + delete=False, 122 + ) as handle: 123 + handle.write(data) 124 + handle.flush() 125 + os.fsync(handle.fileno()) 126 + tmp_path = Path(handle.name) 127 + os.chmod(tmp_path, 0o755) 128 + tmp_path.rename(binary_path) 129 + except Exception: 130 + if tmp_path is not None: 131 + try: 132 + tmp_path.unlink() 133 + except FileNotFoundError: 134 + pass 135 + raise 136 + 137 + 138 + def _write_sentinel_atomic(path: Path, payload: Mapping[str, Any]) -> None: 139 + path.parent.mkdir(parents=True, exist_ok=True) 140 + tmp_path: Path | None = None 141 + try: 142 + with tempfile.NamedTemporaryFile( 143 + "w", 144 + dir=path.parent, 145 + delete=False, 146 + encoding="utf-8", 147 + ) as handle: 148 + json.dump(payload, handle, indent=2) 149 + handle.write("\n") 150 + handle.flush() 151 + os.fsync(handle.fileno()) 152 + tmp_path = Path(handle.name) 153 + tmp_path.rename(path) 154 + except Exception: 155 + if tmp_path is not None: 156 + try: 157 + tmp_path.unlink() 158 + except FileNotFoundError: 159 + pass 160 + raise 161 + 162 + 163 + def _write_license(path: Path) -> None: 164 + path.write_text(RESTIC_LICENSE_TEXT, encoding="utf-8") 165 + 166 + 167 + def _sentinel_payload( 168 + os_name: str, 169 + arch: str, 170 + binary_path: Path, 171 + binary_sha256: str, 172 + ) -> dict[str, Any]: 173 + return { 174 + "schema_version": RESTIC_SCHEMA_VERSION, 175 + "tool": RESTIC_TOOL, 176 + "version": RESTIC_VERSION, 177 + "sha256": binary_sha256, 178 + "platform": {"os": os_name, "arch": arch}, 179 + "binary_path": str(binary_path), 180 + } 181 + 182 + 183 + def _install_from_bz2( 184 + data: bytes, 185 + *, 186 + expected_sha256: str, 187 + source: str, 188 + tool_dir: Path, 189 + os_name: str, 190 + arch: str, 191 + ) -> Path: 192 + _verify_bz2(data, expected_sha256, source) 193 + binary_data = _decompress_bz2(data, source) 194 + binary_sha256 = hashlib.sha256(binary_data).hexdigest() 195 + tool_dir.mkdir(parents=True, exist_ok=True) 196 + binary_path = _binary_path(tool_dir) 197 + _write_binary_atomic(binary_path, binary_data) 198 + _write_sentinel_atomic( 199 + _sentinel_path(tool_dir), 200 + _sentinel_payload(os_name, arch, binary_path, binary_sha256), 201 + ) 202 + _write_license(_license_path(tool_dir)) 203 + return binary_path 204 + 205 + 206 + def ensure_restic( 207 + *, 208 + force: bool = False, 209 + tool_dir: Path | None = None, 210 + attempts: int = 3, 211 + timeout: float = 30.0, 212 + ) -> Path: 213 + os_name, arch = _platform_info() 214 + resolved_tool_dir = tool_dir if tool_dir is not None else _tool_dir(os_name) 215 + if not force: 216 + ready_path = check_restic_ready(resolved_tool_dir) 217 + if ready_path is not None: 218 + return ready_path 219 + 220 + asset_filename, url, expected_sha256 = select_restic_asset(os_name, arch) 221 + bundle_path = _bundle_path(asset_filename) 222 + if bundle_path is not None: 223 + source = str(bundle_path) 224 + data = bundle_path.read_bytes() 225 + else: 226 + source = url 227 + data = _download_with_retries(url, attempts=attempts, timeout=timeout) 228 + 229 + return _install_from_bz2( 230 + data, 231 + expected_sha256=expected_sha256, 232 + source=source, 233 + tool_dir=resolved_tool_dir, 234 + os_name=os_name, 235 + arch=arch, 236 + )
+188
solstone/think/backup/readiness.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Examine-only restic readiness checks for sol private backup. 5 + 6 + Stdlib-only by contract. Must NOT import third-party packages, 7 + solstone.observe.*, solstone.think.journal_io, or any module that writes journal 8 + state. 9 + """ 10 + 11 + from __future__ import annotations 12 + 13 + import hashlib 14 + import json 15 + import os 16 + import platform 17 + import subprocess 18 + import sys 19 + from pathlib import Path 20 + from typing import Any 21 + 22 + RESTIC_VERSION = "0.19.0" 23 + RESTIC_SCHEMA_VERSION = 1 24 + RESTIC_TOOL = "restic" 25 + RESTIC_BUNDLE_ENV = "SOLSTONE_RESTIC_BUNDLE" 26 + RESTIC_GITHUB_URL_TEMPLATE = ( 27 + "https://github.com/restic/restic/releases/download/v0.19.0/" 28 + "restic_0.19.0_{os}_{arch}.bz2" 29 + ) 30 + RESTIC_BZ2_SHA256: dict[str, str] = { 31 + "restic_0.19.0_darwin_amd64.bz2": ( 32 + "c9d9a71234bc0955fdba6da93cc9375f8793ec1e1cbce77a91014d536a969148" 33 + ), 34 + "restic_0.19.0_darwin_arm64.bz2": ( 35 + "1475397bf759ef4be16a77b19dec650bdbfec00d2cacd82005553411cdd37997" 36 + ), 37 + "restic_0.19.0_linux_amd64.bz2": ( 38 + "13176fe6d89d4357947a2cd107218ab2873a5f9d8e1ac2d4cd1c8e07e6839c21" 39 + ), 40 + "restic_0.19.0_linux_arm64.bz2": ( 41 + "e522ce6bf748d753fee8093e8ec59359972cf5b6bc65fc7c7cf38ae952351d91" 42 + ), 43 + } 44 + ARCH_ALIASES = { 45 + "x86_64": "amd64", 46 + "amd64": "amd64", 47 + "arm64": "arm64", 48 + "aarch64": "arm64", 49 + } 50 + MAC_TOOL_DIR = Path.home() / "Library/Application Support/solstone/restic" 51 + LINUX_TOOL_DIR = Path.home() / ".cache/solstone/restic" 52 + 53 + 54 + def _file_sha256(path: Path) -> str: 55 + digest = hashlib.sha256() 56 + with path.open("rb") as handle: 57 + for chunk in iter(lambda: handle.read(65536), b""): 58 + digest.update(chunk) 59 + return digest.hexdigest() 60 + 61 + 62 + def _platform_info() -> tuple[str, str]: 63 + os_name = "linux" if sys.platform.startswith("linux") else sys.platform 64 + machine = platform.machine().lower() 65 + arch = ARCH_ALIASES.get(machine) 66 + if os_name not in {"darwin", "linux"} or arch is None: 67 + raise RuntimeError( 68 + "restic unsupported platform: " 69 + f"{os_name}/{machine}; supported: darwin|linux on amd64|arm64" 70 + ) 71 + return os_name, arch 72 + 73 + 74 + def select_restic_asset( 75 + os_name: str | None = None, 76 + arch: str | None = None, 77 + ) -> tuple[str, str, str]: 78 + if os_name is None or arch is None: 79 + os_name, arch = _platform_info() 80 + normalized_arch = ARCH_ALIASES.get(arch.lower()) 81 + if os_name not in {"darwin", "linux"} or normalized_arch is None: 82 + raise RuntimeError( 83 + "restic unsupported platform: " 84 + f"{os_name}/{arch}; supported: darwin|linux on amd64|arm64" 85 + ) 86 + filename = f"restic_{RESTIC_VERSION}_{os_name}_{normalized_arch}.bz2" 87 + sha256 = RESTIC_BZ2_SHA256[filename] 88 + url = RESTIC_GITHUB_URL_TEMPLATE.format(os=os_name, arch=normalized_arch) 89 + return filename, url, sha256 90 + 91 + 92 + def _tool_dir(os_name: str | None = None) -> Path: 93 + if os_name is None: 94 + os_name, _arch = _platform_info() 95 + if os_name == "darwin": 96 + return MAC_TOOL_DIR 97 + if os_name == "linux": 98 + return LINUX_TOOL_DIR 99 + raise RuntimeError(f"restic unsupported platform: {os_name}") 100 + 101 + 102 + def _binary_path(tool_dir: Path) -> Path: 103 + return tool_dir / RESTIC_TOOL 104 + 105 + 106 + def _sentinel_path(tool_dir: Path) -> Path: 107 + return tool_dir / ".install-complete" 108 + 109 + 110 + def _license_path(tool_dir: Path) -> Path: 111 + return tool_dir / "restic.LICENSE" 112 + 113 + 114 + def _load_sentinel(path: Path) -> dict[str, Any] | None: 115 + if not path.exists(): 116 + return None 117 + try: 118 + payload = json.loads(path.read_text(encoding="utf-8")) 119 + except (OSError, json.JSONDecodeError): 120 + return None 121 + return payload if isinstance(payload, dict) else None 122 + 123 + 124 + def _sentinel_ready( 125 + payload: dict[str, Any] | None, 126 + os_name: str, 127 + arch: str, 128 + binary_path: Path, 129 + ) -> str | None: 130 + if payload is None: 131 + return None 132 + platform_info = payload.get("platform") 133 + if not isinstance(platform_info, dict): 134 + return None 135 + if ( 136 + payload.get("schema_version") != RESTIC_SCHEMA_VERSION 137 + or payload.get("tool") != RESTIC_TOOL 138 + or payload.get("version") != RESTIC_VERSION 139 + ): 140 + return None 141 + if platform_info.get("os") != os_name or platform_info.get("arch") != arch: 142 + return None 143 + if payload.get("binary_path") != str(binary_path): 144 + return None 145 + sha256 = payload.get("sha256") 146 + return sha256 if isinstance(sha256, str) and sha256 else None 147 + 148 + 149 + def _restic_version_ok(binary_path: Path) -> bool: 150 + try: 151 + result = subprocess.run( 152 + [str(binary_path), "version"], 153 + check=False, 154 + capture_output=True, 155 + text=True, 156 + ) 157 + except OSError: 158 + return False 159 + return result.returncode == 0 and f"restic {RESTIC_VERSION}" in result.stdout 160 + 161 + 162 + def _verify_binary(binary_path: Path, expected_sha256: str) -> bool: 163 + if not binary_path.is_file() or not os.access(binary_path, os.X_OK): 164 + return False 165 + try: 166 + actual_sha256 = _file_sha256(binary_path) 167 + except OSError: 168 + return False 169 + return actual_sha256 == expected_sha256 170 + 171 + 172 + def check_restic_ready(tool_dir: Path | None = None) -> Path | None: 173 + os_name, arch = _platform_info() 174 + resolved_tool_dir = tool_dir if tool_dir is not None else _tool_dir(os_name) 175 + binary_path = _binary_path(resolved_tool_dir) 176 + expected_sha256 = _sentinel_ready( 177 + _load_sentinel(_sentinel_path(resolved_tool_dir)), 178 + os_name, 179 + arch, 180 + binary_path, 181 + ) 182 + if expected_sha256 is None: 183 + return None 184 + if not _restic_version_ok(binary_path): 185 + return None 186 + if not _verify_binary(binary_path, expected_sha256): 187 + return None 188 + return binary_path
+157
solstone/think/backup/runner.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Capture-mode restic subprocess runner for sol private backup.""" 5 + 6 + from __future__ import annotations 7 + 8 + import json as json_module 9 + import os 10 + import subprocess 11 + from collections.abc import Iterable, Mapping, Sequence 12 + from dataclasses import dataclass 13 + from pathlib import Path 14 + from typing import Any 15 + 16 + 17 + @dataclass(frozen=True) 18 + class ResticResult: 19 + returncode: int 20 + stdout: str 21 + stderr: str 22 + json: Any | None 23 + argv: tuple[str, ...] 24 + 25 + 26 + def _scrub(text: str, secrets: Iterable[str | None]) -> str: 27 + scrubbed = text 28 + for secret in secrets: 29 + if secret: 30 + scrubbed = "[redacted]".join(scrubbed.split(secret)) 31 + return scrubbed 32 + 33 + 34 + def _child_env( 35 + repository: str, 36 + password: str, 37 + backend_env: Mapping[str, str | None] | None, 38 + ) -> tuple[dict[str, str], tuple[str, ...]]: 39 + env = { 40 + key: value 41 + for key in ("PATH", "HOME", "TMPDIR") 42 + if (value := os.environ.get(key)) is not None 43 + } 44 + env["RESTIC_REPOSITORY"] = repository 45 + env["RESTIC_PASSWORD"] = password 46 + if backend_env: 47 + env.update( 48 + {key: value for key, value in backend_env.items() if value is not None} 49 + ) 50 + 51 + secret_values = [password] 52 + if backend_env: 53 + secret_values.extend(value for value in backend_env.values() if value) 54 + return env, tuple(secret_values) 55 + 56 + 57 + def _build_argv( 58 + restic_path: Path, 59 + args: Sequence[str], 60 + json: bool, 61 + max_repack_size: str | None, 62 + ) -> list[str]: 63 + argv = [str(restic_path), *args] 64 + if json: 65 + argv.append("--json") 66 + if max_repack_size: 67 + argv.extend(["--max-repack-size", max_repack_size]) 68 + return argv 69 + 70 + 71 + def _guard_argv(argv: Sequence[str], secrets: Iterable[str]) -> None: 72 + if "--insecure-tls" in argv: 73 + raise RuntimeError("restic --insecure-tls is forbidden") 74 + secret_values = tuple(secret for secret in secrets if secret) 75 + for token in argv: 76 + for secret in secret_values: 77 + if secret in token: 78 + raise RuntimeError("restic argv contains a secret") 79 + 80 + 81 + def _parse_json(text: str) -> Any | None: 82 + if not text.strip(): 83 + return None 84 + try: 85 + return json_module.loads(text) 86 + except json_module.JSONDecodeError: 87 + pass 88 + 89 + lines = [line for line in text.splitlines() if line.strip()] 90 + if not lines: 91 + return None 92 + parsed: list[Any] = [] 93 + for line in lines: 94 + try: 95 + parsed.append(json_module.loads(line)) 96 + except json_module.JSONDecodeError: 97 + return None 98 + return parsed 99 + 100 + 101 + def _timeout_text(value: str | bytes | None) -> str: 102 + if value is None: 103 + return "" 104 + if isinstance(value, bytes): 105 + return value.decode(errors="replace") 106 + return value 107 + 108 + 109 + def run_restic( 110 + args: Sequence[str], 111 + *, 112 + repository: str, 113 + password: str, 114 + restic_path: Path, 115 + backend_env: Mapping[str, str | None] | None = None, 116 + json: bool = False, 117 + max_repack_size: str | None = None, 118 + timeout: float | None = None, 119 + ) -> ResticResult: 120 + env, secrets = _child_env(repository, password, backend_env) 121 + argv = _build_argv(restic_path, args, json, max_repack_size) 122 + _guard_argv(argv, secrets) 123 + safe_argv = tuple(argv) 124 + 125 + # Long-running/streaming backup mode is deferred: ManagedProcess.spawn 126 + # writes raw child stdout/stderr to health logs and the callosum logs tract, 127 + # and restic output may include presigned backend URLs or repo strings. 128 + try: 129 + result = subprocess.run( 130 + argv, 131 + check=False, 132 + capture_output=True, 133 + text=True, 134 + env=env, 135 + timeout=timeout, 136 + ) 137 + except subprocess.TimeoutExpired as exc: 138 + stdout = _scrub(_timeout_text(exc.stdout), secrets) 139 + stderr = _scrub(_timeout_text(exc.stderr), secrets) 140 + return ResticResult( 141 + returncode=124, 142 + stdout=stdout, 143 + stderr=stderr, 144 + json=None, 145 + argv=safe_argv, 146 + ) 147 + 148 + stdout = _scrub(result.stdout or "", secrets) 149 + stderr = _scrub(result.stderr or "", secrets) 150 + parsed_json = _parse_json(stdout) if json else None 151 + return ResticResult( 152 + returncode=result.returncode, 153 + stdout=stdout, 154 + stderr=stderr, 155 + json=parsed_json, 156 + argv=safe_argv, 157 + )
+168
tests/test_backup_acquire.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import bz2 7 + import hashlib 8 + import json 9 + import os 10 + from pathlib import Path 11 + 12 + import pytest 13 + 14 + from solstone.think.backup import install, readiness 15 + 16 + 17 + def _make_bz2(payload: bytes) -> bytes: 18 + return bz2.compress(payload) 19 + 20 + 21 + def _sha256(data: bytes) -> str: 22 + return hashlib.sha256(data).hexdigest() 23 + 24 + 25 + def _patch_linux_amd64(monkeypatch: pytest.MonkeyPatch) -> None: 26 + monkeypatch.setattr(install, "_platform_info", lambda: ("linux", "amd64")) 27 + monkeypatch.setattr(readiness, "_platform_info", lambda: ("linux", "amd64")) 28 + 29 + 30 + def test_ensure_restic_installs_verified_asset( 31 + monkeypatch: pytest.MonkeyPatch, 32 + tmp_path: Path, 33 + ): 34 + payload = b"fake restic binary" 35 + asset = _make_bz2(payload) 36 + filename = "restic_0.19.0_linux_amd64.bz2" 37 + _patch_linux_amd64(monkeypatch) 38 + monkeypatch.delenv(readiness.RESTIC_BUNDLE_ENV, raising=False) 39 + monkeypatch.setitem(readiness.RESTIC_BZ2_SHA256, filename, _sha256(asset)) 40 + monkeypatch.setattr(install, "_fetch_url", lambda url, *, timeout: asset) 41 + 42 + binary_path = install.ensure_restic(tool_dir=tmp_path) 43 + 44 + assert binary_path == tmp_path / "restic" 45 + assert binary_path.read_bytes() == payload 46 + assert os.access(binary_path, os.X_OK) 47 + 48 + sentinel = json.loads((tmp_path / ".install-complete").read_text()) 49 + assert sentinel["sha256"] == _sha256(payload) 50 + assert sentinel["platform"] == {"os": "linux", "arch": "amd64"} 51 + assert sentinel["binary_path"] == str(binary_path) 52 + assert (tmp_path / "restic.LICENSE").read_text() == install.RESTIC_LICENSE_TEXT 53 + 54 + 55 + def test_ensure_restic_fails_closed_on_sha_mismatch( 56 + monkeypatch: pytest.MonkeyPatch, 57 + tmp_path: Path, 58 + ): 59 + original = _make_bz2(b"original") 60 + tampered = _make_bz2(b"tampered") 61 + filename = "restic_0.19.0_linux_amd64.bz2" 62 + calls = 0 63 + 64 + def fake_fetch_url(url: str, *, timeout: float) -> bytes: 65 + nonlocal calls 66 + calls += 1 67 + return tampered 68 + 69 + _patch_linux_amd64(monkeypatch) 70 + monkeypatch.delenv(readiness.RESTIC_BUNDLE_ENV, raising=False) 71 + monkeypatch.setitem(readiness.RESTIC_BZ2_SHA256, filename, _sha256(original)) 72 + monkeypatch.setattr(install, "_fetch_url", fake_fetch_url) 73 + 74 + with pytest.raises(RuntimeError) as exc_info: 75 + install.ensure_restic(tool_dir=tmp_path) 76 + 77 + message = str(exc_info.value) 78 + assert "restic asset SHA mismatch" in message 79 + assert f"expected: {_sha256(original)}" in message 80 + assert f"actual: {_sha256(tampered)}" in message 81 + assert calls == 1 82 + 83 + 84 + def test_ensure_restic_reuses_ready_sentinel_without_downloading( 85 + monkeypatch: pytest.MonkeyPatch, 86 + tmp_path: Path, 87 + ): 88 + payload = b"fake restic binary" 89 + asset = _make_bz2(payload) 90 + filename = "restic_0.19.0_linux_amd64.bz2" 91 + _patch_linux_amd64(monkeypatch) 92 + monkeypatch.delenv(readiness.RESTIC_BUNDLE_ENV, raising=False) 93 + monkeypatch.setitem(readiness.RESTIC_BZ2_SHA256, filename, _sha256(asset)) 94 + monkeypatch.setattr(install, "_fetch_url", lambda url, *, timeout: asset) 95 + 96 + installed = install.ensure_restic(tool_dir=tmp_path) 97 + assert installed == tmp_path / "restic" 98 + 99 + calls = 0 100 + 101 + def fail_fetch_url(url: str, *, timeout: float) -> bytes: 102 + nonlocal calls 103 + calls += 1 104 + raise AssertionError("fetch should not be called") 105 + 106 + monkeypatch.setattr(readiness, "_restic_version_ok", lambda path: True) 107 + monkeypatch.setattr(install, "_fetch_url", fail_fetch_url) 108 + 109 + assert install.ensure_restic(tool_dir=tmp_path) == installed 110 + assert calls == 0 111 + 112 + 113 + @pytest.mark.parametrize( 114 + ("os_name", "arch", "expected_filename"), 115 + [ 116 + ("darwin", "amd64", "restic_0.19.0_darwin_amd64.bz2"), 117 + ("darwin", "arm64", "restic_0.19.0_darwin_arm64.bz2"), 118 + ("linux", "amd64", "restic_0.19.0_linux_amd64.bz2"), 119 + ("linux", "arm64", "restic_0.19.0_linux_arm64.bz2"), 120 + ("linux", "x86_64", "restic_0.19.0_linux_amd64.bz2"), 121 + ("linux", "aarch64", "restic_0.19.0_linux_arm64.bz2"), 122 + ], 123 + ) 124 + def test_select_restic_asset_platforms( 125 + os_name: str, 126 + arch: str, 127 + expected_filename: str, 128 + ): 129 + filename, url, sha256 = readiness.select_restic_asset(os_name, arch) 130 + 131 + assert filename == expected_filename 132 + assert url == ( 133 + "https://github.com/restic/restic/releases/download/v0.19.0/" 134 + f"{expected_filename}" 135 + ) 136 + assert sha256 == readiness.RESTIC_BZ2_SHA256[expected_filename] 137 + 138 + 139 + def test_select_restic_asset_rejects_unsupported_platform(): 140 + with pytest.raises(RuntimeError, match="unsupported platform"): 141 + readiness.select_restic_asset("windows", "amd64") 142 + with pytest.raises(RuntimeError, match="unsupported platform"): 143 + readiness.select_restic_asset("linux", "riscv64") 144 + 145 + 146 + def test_ensure_restic_uses_bundled_override_without_downloading( 147 + monkeypatch: pytest.MonkeyPatch, 148 + tmp_path: Path, 149 + ): 150 + payload = b"bundled fake restic binary" 151 + asset = _make_bz2(payload) 152 + bundle_path = tmp_path / "restic_0.19.0_linux_amd64.bz2" 153 + bundle_path.write_bytes(asset) 154 + filename = "restic_0.19.0_linux_amd64.bz2" 155 + 156 + _patch_linux_amd64(monkeypatch) 157 + monkeypatch.setenv(readiness.RESTIC_BUNDLE_ENV, str(bundle_path)) 158 + monkeypatch.setitem(readiness.RESTIC_BZ2_SHA256, filename, _sha256(asset)) 159 + 160 + def fail_fetch_url(url: str, *, timeout: float) -> bytes: 161 + raise AssertionError("fetch should not be called") 162 + 163 + monkeypatch.setattr(install, "_fetch_url", fail_fetch_url) 164 + 165 + binary_path = install.ensure_restic(tool_dir=tmp_path / "tool") 166 + 167 + assert binary_path.read_bytes() == payload 168 + assert os.access(binary_path, os.X_OK)
+276
tests/test_backup_runner.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import json 7 + import shutil 8 + import subprocess 9 + from pathlib import Path 10 + from typing import Any 11 + 12 + import pytest 13 + 14 + from solstone.think.backup import runner 15 + 16 + 17 + def test_run_restic_builds_safe_argv_and_minimal_env( 18 + monkeypatch: pytest.MonkeyPatch, 19 + ): 20 + captured: dict[str, Any] = {} 21 + 22 + def fake_run(argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: 23 + captured["argv"] = argv 24 + captured["env"] = kwargs["env"] 25 + return subprocess.CompletedProcess(argv, 0, stdout="{}", stderr="") 26 + 27 + monkeypatch.setenv("PATH", "/bin") 28 + monkeypatch.setenv("HOME", "/home/test") 29 + monkeypatch.setenv("TMPDIR", "/tmp/test") 30 + monkeypatch.setenv("UNRELATED_SECRET", "do-not-copy") 31 + monkeypatch.setattr(runner.subprocess, "run", fake_run) 32 + 33 + result = runner.run_restic( 34 + ["snapshots"], 35 + repository="s3:safe-bucket/path", 36 + password="repo-password", 37 + restic_path=Path("/usr/bin/restic"), 38 + backend_env={ 39 + "AWS_ACCESS_KEY_ID": "access-key", 40 + "AWS_SECRET_ACCESS_KEY": "backend-secret", 41 + }, 42 + json=True, 43 + max_repack_size="1G", 44 + ) 45 + 46 + assert result.argv == ( 47 + "/usr/bin/restic", 48 + "snapshots", 49 + "--json", 50 + "--max-repack-size", 51 + "1G", 52 + ) 53 + assert "--insecure-tls" not in result.argv 54 + assert all("repo-password" not in token for token in result.argv) 55 + assert all("backend-secret" not in token for token in result.argv) 56 + assert captured["env"] == { 57 + "PATH": "/bin", 58 + "HOME": "/home/test", 59 + "TMPDIR": "/tmp/test", 60 + "RESTIC_REPOSITORY": "s3:safe-bucket/path", 61 + "RESTIC_PASSWORD": "repo-password", 62 + "AWS_ACCESS_KEY_ID": "access-key", 63 + "AWS_SECRET_ACCESS_KEY": "backend-secret", 64 + } 65 + 66 + 67 + def test_run_restic_scrubs_success_output_and_json( 68 + monkeypatch: pytest.MonkeyPatch, 69 + ): 70 + secrets = ("repo-password", "backend-secret", "access-key") 71 + 72 + def fake_run(argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: 73 + stdout = json.dumps( 74 + {"message": ("repo-password backend-secret access-key should be hidden")} 75 + ) 76 + stderr = "stderr has repo-password and backend-secret" 77 + return subprocess.CompletedProcess(argv, 0, stdout=stdout, stderr=stderr) 78 + 79 + monkeypatch.setattr(runner.subprocess, "run", fake_run) 80 + 81 + result = runner.run_restic( 82 + ["snapshots"], 83 + repository="s3:safe-bucket/path", 84 + password="repo-password", 85 + restic_path=Path("/usr/bin/restic"), 86 + backend_env={ 87 + "AWS_ACCESS_KEY_ID": "access-key", 88 + "AWS_SECRET_ACCESS_KEY": "backend-secret", 89 + }, 90 + json=True, 91 + ) 92 + 93 + json_text = json.dumps(result.json) 94 + for secret in secrets: 95 + assert secret not in result.stdout 96 + assert secret not in result.stderr 97 + assert secret not in json_text 98 + assert all(secret not in token for token in result.argv) 99 + assert result.json == { 100 + "message": "[redacted] [redacted] [redacted] should be hidden" 101 + } 102 + 103 + 104 + def test_run_restic_returns_scrubbed_nonzero_result( 105 + monkeypatch: pytest.MonkeyPatch, 106 + ): 107 + secrets = ("repo-password", "backend-secret") 108 + 109 + def fake_run(argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: 110 + return subprocess.CompletedProcess( 111 + argv, 112 + 42, 113 + stdout="", 114 + stderr="failed with repo-password and backend-secret", 115 + ) 116 + 117 + monkeypatch.setattr(runner.subprocess, "run", fake_run) 118 + 119 + result = runner.run_restic( 120 + ["backup", "/tmp/data"], 121 + repository="s3:safe-bucket/path", 122 + password="repo-password", 123 + restic_path=Path("/usr/bin/restic"), 124 + backend_env={"AWS_SECRET_ACCESS_KEY": "backend-secret"}, 125 + ) 126 + 127 + assert result.returncode == 42 128 + json_text = json.dumps(result.json) 129 + for secret in secrets: 130 + assert secret not in result.stdout 131 + assert secret not in result.stderr 132 + assert secret not in json_text 133 + assert all(secret not in token for token in result.argv) 134 + assert "[redacted]" in result.stderr 135 + 136 + 137 + def test_empty_backend_values_are_not_scrubbed( 138 + monkeypatch: pytest.MonkeyPatch, 139 + ): 140 + def fake_run(argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: 141 + assert kwargs["env"]["EMPTY"] == "" 142 + assert "NONE" not in kwargs["env"] 143 + return subprocess.CompletedProcess(argv, 0, stdout="abc", stderr="") 144 + 145 + monkeypatch.setattr(runner.subprocess, "run", fake_run) 146 + 147 + result = runner.run_restic( 148 + ["snapshots"], 149 + repository="s3:safe-bucket/path", 150 + password="repo-password", 151 + restic_path=Path("/usr/bin/restic"), 152 + backend_env={"EMPTY": "", "NONE": None}, 153 + ) 154 + 155 + assert result.stdout == "abc" 156 + 157 + 158 + def test_run_restic_rejects_insecure_tls(monkeypatch: pytest.MonkeyPatch): 159 + def fail_run(argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: 160 + raise AssertionError("subprocess should not be called") 161 + 162 + monkeypatch.setattr(runner.subprocess, "run", fail_run) 163 + 164 + with pytest.raises(RuntimeError, match="--insecure-tls"): 165 + runner.run_restic( 166 + ["backup", "--insecure-tls", "/tmp/data"], 167 + repository="s3:safe-bucket/path", 168 + password="repo-password", 169 + restic_path=Path("/usr/bin/restic"), 170 + ) 171 + 172 + 173 + def test_run_restic_rejects_secret_in_argv(monkeypatch: pytest.MonkeyPatch): 174 + def fail_run(argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: 175 + raise AssertionError("subprocess should not be called") 176 + 177 + monkeypatch.setattr(runner.subprocess, "run", fail_run) 178 + 179 + with pytest.raises(RuntimeError, match="argv contains a secret"): 180 + runner.run_restic( 181 + ["backup", "/tmp/repo-password/data"], 182 + repository="s3:safe-bucket/path", 183 + password="repo-password", 184 + restic_path=Path("/usr/bin/restic"), 185 + ) 186 + 187 + 188 + def test_run_restic_timeout_returns_scrubbed_result( 189 + monkeypatch: pytest.MonkeyPatch, 190 + ): 191 + def fake_run(argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: 192 + raise subprocess.TimeoutExpired( 193 + argv, 194 + timeout=1, 195 + output=b"stdout repo-password backend-secret", 196 + stderr=b"stderr repo-password backend-secret", 197 + ) 198 + 199 + monkeypatch.setattr(runner.subprocess, "run", fake_run) 200 + 201 + result = runner.run_restic( 202 + ["backup", "/tmp/data"], 203 + repository="s3:safe-bucket/path", 204 + password="repo-password", 205 + restic_path=Path("/usr/bin/restic"), 206 + backend_env={"AWS_SECRET_ACCESS_KEY": "backend-secret"}, 207 + timeout=1, 208 + ) 209 + 210 + assert result.returncode == 124 211 + assert "repo-password" not in result.stdout 212 + assert "backend-secret" not in result.stdout 213 + assert "repo-password" not in result.stderr 214 + assert "backend-secret" not in result.stderr 215 + assert result.json is None 216 + 217 + 218 + def test_parse_json_lines_from_scrubbed_stdout( 219 + monkeypatch: pytest.MonkeyPatch, 220 + ): 221 + def fake_run(argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: 222 + stdout = '{"message":"backend-secret"}\n{"message":"ok"}\n' 223 + return subprocess.CompletedProcess(argv, 0, stdout=stdout, stderr="") 224 + 225 + monkeypatch.setattr(runner.subprocess, "run", fake_run) 226 + 227 + result = runner.run_restic( 228 + ["snapshots"], 229 + repository="s3:safe-bucket/path", 230 + password="repo-password", 231 + restic_path=Path("/usr/bin/restic"), 232 + backend_env={"AWS_SECRET_ACCESS_KEY": "backend-secret"}, 233 + json=True, 234 + ) 235 + 236 + assert result.json == [{"message": "[redacted]"}, {"message": "ok"}] 237 + 238 + 239 + RESTIC_BIN = shutil.which("restic") 240 + 241 + 242 + @pytest.mark.skipif(RESTIC_BIN is None, reason="restic is not installed") 243 + def test_run_restic_local_repository_integration(tmp_path: Path): 244 + restic_path = Path(RESTIC_BIN or "") 245 + repo = tmp_path / "repo" 246 + data = tmp_path / "data.txt" 247 + data.write_text("backup me", encoding="utf-8") 248 + 249 + init_result = runner.run_restic( 250 + ["init"], 251 + repository=f"local:{repo}", 252 + password="test-password", 253 + restic_path=restic_path, 254 + timeout=15, 255 + ) 256 + assert init_result.returncode == 0, init_result.stderr 257 + 258 + backup_result = runner.run_restic( 259 + ["backup", str(data)], 260 + repository=f"local:{repo}", 261 + password="test-password", 262 + restic_path=restic_path, 263 + timeout=30, 264 + ) 265 + assert backup_result.returncode == 0, backup_result.stderr 266 + 267 + snapshots_result = runner.run_restic( 268 + ["snapshots"], 269 + repository=f"local:{repo}", 270 + password="test-password", 271 + restic_path=restic_path, 272 + json=True, 273 + timeout=15, 274 + ) 275 + assert snapshots_result.returncode == 0, snapshots_result.stderr 276 + assert snapshots_result.json is not None