diff --git a/launchd/com.brainlayer.jsonl-backup.plist b/launchd/com.brainlayer.jsonl-backup.plist index 8ca8d008..b15baff1 100644 --- a/launchd/com.brainlayer.jsonl-backup.plist +++ b/launchd/com.brainlayer.jsonl-backup.plist @@ -10,8 +10,9 @@ ProgramArguments - /usr/bin/env - python3 + + __BRAINLAYER_PYTHON__ -m brainlayer.jsonl_backup @@ -30,8 +31,6 @@ PATH /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:__HOME__/.local/bin - PYTHONPATH - __BRAINLAYER_DIR__/src BRAINLAYER_BACKUP_TIMEOUT_SECONDS 1800 BRAINLAYER_JSONL_BACKUP_DRIVE_FOLDER diff --git a/scripts/launchd/install.sh b/scripts/launchd/install.sh index 8d25cbaa..4bf1ac9d 100755 --- a/scripts/launchd/install.sh +++ b/scripts/launchd/install.sh @@ -138,11 +138,45 @@ fi # PATH happens to front. On the M4 that is the framework Python's `brainlayer`, and installing from a # source checkout fronts `~/Gits/brainlayer/.venv/bin/brainlayer` -- neither of which any release moves. BRAINLAYER_BIN="$(stable_brainlayer_path "${BRAINLAYER_BIN:-${BRAINLAYER_KEG_CLI:-$(which brainlayer 2>/dev/null || echo "$HOME/.local/bin/brainlayer")}}")" +# Capture only caller intent before PYTHON_BIN receives its legacy PATH fallback. +BRAINLAYER_PYTHON_REQUESTED="${BRAINLAYER_PYTHON:-}" # In a keg, an unset PYTHON_BIN must NOT fall through to `command -v python3`: on a Mac whose PATH # puts /Library/Frameworks/Python.framework first, that renders a framework interpreter that never # sees the keg's site-packages, and no release can move it. An explicit override still wins. PYTHON_BIN="$(stable_brainlayer_path "${PYTHON_BIN:-${BRAINLAYER_KEG_PYTHON:-$(command -v python3)}}")" +# The backup wrappers import BrainLayer itself, so a source install must not inherit +# `command -v python3`: on the M4 that is the framework interpreter whose global +# .pth injects the mutable root checkout. Explicit overrides remain explicit; a keg +# uses its stable opt/ path; otherwise reuse hook_python's ARM/Intel-aware resolver +# and fail closed when neither Homebrew prefix exists. BRAINLAYER_PYTHON="$(stable_brainlayer_path "${BRAINLAYER_PYTHON:-$PYTHON_BIN}")" + +resolve_jsonl_backup_python() { + if [ -n "$BRAINLAYER_KEG_PYTHON" ] && [ -z "$BRAINLAYER_PYTHON_REQUESTED" ] && [ -z "${BRAINLAYER_HOOK_PYTHON:-}" ]; then + BRAINLAYER_PYTHON="$(stable_brainlayer_path "$BRAINLAYER_KEG_PYTHON")" + return 0 + fi + + HOOK_PYTHON_RESOLVER="$BRAINLAYER_DIR/src/brainlayer/hook_python.py" + if [ ! -f "$HOOK_PYTHON_RESOLVER" ]; then + HOOK_PYTHON_RESOLVER="$BRAINLAYER_DIR/brainlayer/hook_python.py" + fi + if [ ! -f "$HOOK_PYTHON_RESOLVER" ]; then + echo "ERROR: hook_python.py not found; refusing a PATH-derived BrainLayer interpreter" >&2 + return 1 + fi + + # `python hook_python.py` would put brainlayer/ itself on sys.path, where + # brainlayer/types.py shadows the stdlib `types` module under Apple's Python. + # run_path keeps the resolver executable as a standalone stdlib-only script + # without adding its package directory to import resolution. + if [ -n "$BRAINLAYER_PYTHON_REQUESTED" ]; then + BRAINLAYER_PYTHON="$(BRAINLAYER_HOOK_PYTHON="$BRAINLAYER_PYTHON_REQUESTED" /usr/bin/python3 -c 'import runpy, sys; path = sys.argv.pop(1); runpy.run_path(path, run_name="__main__")' "$HOOK_PYTHON_RESOLVER" --print-interpreter)" || return 1 + else + BRAINLAYER_PYTHON="$(/usr/bin/python3 -c 'import runpy, sys; path = sys.argv.pop(1); runpy.run_path(path, run_name="__main__")' "$HOOK_PYTHON_RESOLVER" --print-interpreter)" || return 1 + fi + BRAINLAYER_PYTHON="$(stable_brainlayer_path "$BRAINLAYER_PYTHON")" +} BRAINLAYER_ENV_FILE="${BRAINLAYER_ENV_FILE:-$HOME/.config/brainlayer/brainlayer.env}" BRAINLAYER_ENV_RUN="$BRAINLAYER_LIB_DIR/brainlayer-env-run.sh" TIER0_WATCHDOG_DST="$BRAINLAYER_LIB_DIR/tier0-watchdog.sh" @@ -622,6 +656,13 @@ install_plist() { verify_gemini_env_file || return 1 fi + # XML-escape the interpreter path, then escape sed replacement metacharacters. + # `&` is legal in a filename but means "the matched placeholder" to sed. + local brainlayer_python_xml + local brainlayer_python_sed + brainlayer_python_xml="$(printf '%s' "$BRAINLAYER_PYTHON" | sed -e 's/&/\&/g' -e 's//\>/g')" || return 1 + brainlayer_python_sed="$(printf '%s' "$brainlayer_python_xml" | sed -e 's/[\\&|]/\\&/g')" || return 1 + # Replace placeholders sed \ -e "s|__HOME__|$HOME|g" \ @@ -629,7 +670,7 @@ install_plist() { -e "s|__BRAINLAYER_DIR__|$BRAINLAYER_DIR|g" \ -e "s|__BRAINLAYER_LAUNCHD_DIR__|$BRAINLAYER_LAUNCHD_DIR|g" \ -e "s|__PYTHON_BIN__|$PYTHON_BIN|g" \ - -e "s|__BRAINLAYER_PYTHON__|$BRAINLAYER_PYTHON|g" \ + -e "s|__BRAINLAYER_PYTHON__|$brainlayer_python_sed|g" \ -e "s|__REPO_ROOT__|$BRAINLAYER_DIR|g" \ -e "s|__BRAINLAYER_ENV_FILE__|$BRAINLAYER_ENV_FILE|g" \ -e "s|__BRAINLAYER_ENV_RUN__|$BRAINLAYER_ENV_RUN|g" \ @@ -692,6 +733,8 @@ install_jsonl_backup_script() { return 1 fi + resolve_jsonl_backup_python || return 1 + escaped_brainlayer_dir="$(printf '%s' "$BRAINLAYER_DIR" | sed 's/[\\&|]/\\&/g')" || return 1 sed \ -e "s|__BRAINLAYER_DIR_VALUE__|$escaped_brainlayer_dir|g" \ diff --git a/scripts/launchd/jsonl-backup.sh b/scripts/launchd/jsonl-backup.sh index 5a824561..baf665fb 100755 --- a/scripts/launchd/jsonl-backup.sh +++ b/scripts/launchd/jsonl-backup.sh @@ -5,10 +5,7 @@ export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$HOME/.local/bin" export PYTHONUNBUFFERED=1 : "${BRAINLAYER_BACKUP_TIMEOUT_SECONDS:=1800}" export BRAINLAYER_BACKUP_TIMEOUT_SECONDS -BRAINLAYER_DIR="${BRAINLAYER_DIR:-__BRAINLAYER_DIR_VALUE__}" -case "$BRAINLAYER_DIR" in - __BRAINLAYER_DIR_*) BRAINLAYER_DIR="$HOME/Gits/brainlayer" ;; -esac -export PYTHONPATH="$BRAINLAYER_DIR/src${PYTHONPATH:+:$PYTHONPATH}" +: "${BRAINLAYER_PYTHON:?installer must render the prefix-aware keg interpreter}" +unset PYTHONPATH -exec "${BRAINLAYER_PYTHON:-python3}" -m brainlayer.jsonl_backup +exec "$BRAINLAYER_PYTHON" -m brainlayer.jsonl_backup diff --git a/src/brainlayer/hook_python.py b/src/brainlayer/hook_python.py index cfbe3a4f..101bd8fd 100644 --- a/src/brainlayer/hook_python.py +++ b/src/brainlayer/hook_python.py @@ -23,6 +23,7 @@ import shlex from dataclasses import dataclass from typing import Iterable, Iterator, Mapping, Sequence +from xml.sax.saxutils import escape __all__ = [ "BRAINLAYER_HOOK_SCRIPTS", @@ -36,6 +37,7 @@ "is_system_python", "main", "render_hook_command", + "render_launchd_plist", "resolve_hook_python", "shebang_of", ] @@ -54,6 +56,8 @@ "/usr/local/opt/brainlayer/libexec/venv/bin/python", ) +_XML_10_FORBIDDEN = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\ud800-\udfff\ufffe\uffff]") + #: Hook scripts this repo owns. The settings.json lint matches on these basenames so #: it never touches a hook belonging to another repo. `tests/test_hook_python.py` #: asserts every shebang-bearing file under `hooks/` appears here. @@ -115,7 +119,10 @@ def _tokens(value: str | None) -> list[str]: token = value.strip() if token.startswith("#!"): token = token[2:].strip() - return token.split() + try: + return shlex.split(token) + except ValueError: + return [] def is_bare_python3(value: str | None) -> bool: @@ -245,10 +252,16 @@ def resolve_hook_python( "substitute another interpreter for an override that was set on purpose — " f"fix the path or unset {HOOK_PYTHON_ENV} to use the keg." ) + if ( + not is_pinned_interpreter(shlex.quote(override)) + or not os.path.isfile(override) + or not os.access(override, os.X_OK) + ): + raise HookPythonUnresolved(f"{HOOK_PYTHON_ENV}={override!r} is not an executable Python interpreter") return override for candidate in candidates: - if os.path.exists(candidate): + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): return candidate looked_at.append(candidate) @@ -271,6 +284,30 @@ def render_hook_command( return f"{shlex.quote(interpreter) if ' ' in interpreter else interpreter} {script_path}" +def render_launchd_plist( + template: str, + *, + python: str | None = None, + env: Mapping[str, str] | None = None, +) -> str: + """Render the prefix-aware keg interpreter into a launchd template. + + Templates stay portable across ARM and Intel Homebrew prefixes. Resolution + uses the same fail-closed candidate order and override rules as hook command + rendering; a caller-supplied interpreter is accepted only when the existing + affirmative pin gate can vouch for it. + """ + interpreter = python or resolve_hook_python(env=env) + if ( + not is_pinned_interpreter(shlex.quote(interpreter)) + or not os.path.isfile(interpreter) + or not os.access(interpreter, os.X_OK) + or _XML_10_FORBIDDEN.search(interpreter) + ): + raise HookPythonUnresolved(f"launchd interpreter is not explicitly pinned: {interpreter!r}") + return template.replace("__BRAINLAYER_PYTHON__", escape(interpreter)) + + def _iter_hook_entries(settings: Mapping) -> Iterator[tuple[str, str]]: """Yield `(event, command)` for every command hook configured in `settings`.""" hooks = settings.get("hooks") if isinstance(settings, Mapping) else None @@ -381,7 +418,7 @@ def _why_unpinned(interpreter: str) -> str: def main(argv: Sequence[str] | None = None) -> int: - """`python -m brainlayer.hook_python [settings.json]` — lint a settings file. + """Lint settings, or print the prefix-aware interpreter for installers. Exits 0 when every BrainLayer hook names its interpreter, 1 when any is PATH-resolved, 2 when the file cannot be read. Hooks owned by other repos are @@ -389,8 +426,14 @@ def main(argv: Sequence[str] | None = None) -> int: """ import argparse import json + import sys parser = argparse.ArgumentParser(prog="brainlayer.hook_python") + parser.add_argument( + "--print-interpreter", + action="store_true", + help="print the affirmative prefix-aware interpreter and exit", + ) parser.add_argument( "settings", nargs="?", @@ -399,6 +442,14 @@ def main(argv: Sequence[str] | None = None) -> int: ) args = parser.parse_args(argv) + if args.print_interpreter: + try: + print(resolve_hook_python()) + except HookPythonUnresolved as exc: + print(f"cannot resolve BrainLayer interpreter: {exc}", file=sys.stderr, flush=True) + return 2 + return 0 + try: with open(args.settings, encoding="utf-8") as handle: settings = json.load(handle) diff --git a/src/brainlayer/jsonl_backup.py b/src/brainlayer/jsonl_backup.py index 077d7abb..789c5f5f 100644 --- a/src/brainlayer/jsonl_backup.py +++ b/src/brainlayer/jsonl_backup.py @@ -1,8 +1,9 @@ -"""Nightly JSONL transcript backups to Google Drive. +"""Nightly JSONL transcript backups to Google Drive and iCloud Drive. -Install note: commit `launchd/com.brainlayer.jsonl-backup.plist`, then install it -after merge with the repo's launchd flow or a manual `launchctl bootstrap`; this -module intentionally does not install the agent itself. +Install note: commit `launchd/com.brainlayer.jsonl-backup.plist`, render its +`__BRAINLAYER_PYTHON__` placeholder through `hook_python.render_launchd_plist`, +then install it after merge with the repo's launchd flow; this module +intentionally does not install the agent itself. Source format policy: Claude/Codex/Cursor/Gemini JSONL files are backed up as plain transcript files. Antigravity has no stable text export contract, so this @@ -16,6 +17,7 @@ import datetime as dt import fcntl import functools +import gzip import hashlib import json import os @@ -26,6 +28,7 @@ import tempfile import time import traceback +import uuid from dataclasses import dataclass from pathlib import Path from typing import Any @@ -38,8 +41,14 @@ DEFAULT_STATE_PATH = Path.home() / ".local" / "share" / "brainlayer" / "jsonl-backup-state.json" DEFAULT_STAGING_DIR = Path.home() / ".local" / "share" / "brainlayer" / "jsonl-backups" DEFAULT_LOG_PATH = Path.home() / ".local" / "share" / "brainlayer" / "logs" / "jsonl-backup.log" +DEFAULT_ICLOUD_DIR = ( + Path.home() / "Library" / "Mobile Documents" / "com~apple~CloudDocs" / "Archives" / "brainlayer-jsonl-backups" +) +# A distinct sibling of golems' reserved path avoids shared naming/pruning ownership. DEFAULT_ACTIVE_SKIP_SECONDS = 10 * 60 DEFAULT_TIMEOUT_SECONDS = 1800 +DEFAULT_ICLOUD_TIMEOUT_SECONDS = 300 +ICLOUD_DIR_ENV = "BRAINLAYER_JSONL_BACKUP_ICLOUD_DIR" JSONL_RETENTION = backup_daily.DriveRetentionPolicy( keep_latest=30, filename_prefix="claude-jsonl-", @@ -125,6 +134,12 @@ def _configured_backup_timeout_seconds() -> int | None: return seconds if seconds > 0 else None +def _configured_icloud_dir() -> Path | None: + """Return the opt-in iCloud destination; Drive-only is the default.""" + raw = os.environ.get(ICLOUD_DIR_ENV, "").strip() + return Path(raw).expanduser() if raw else None + + def _load_state(path: Path) -> dict[str, Any]: path = Path(path).expanduser() if not path.exists(): @@ -317,14 +332,480 @@ def _forever_enabled() -> bool: return os.environ.get("BRAINLAYER_JSONL_FOREVER", "").strip().lower() in {"1", "true", "yes", "on"} -def _sha256_file(path: Path) -> str: +class ICloudDeadlineExceeded(RuntimeError): + """The local iCloud operation budget expired without disproving an object.""" + + +class ICloudProbeError(RuntimeError): + """Foundation state could not be read, so the object was not disproved.""" + + +def _check_icloud_deadline(deadline: float | None, phase: str) -> None: + if deadline is not None and time.monotonic() >= deadline: + raise ICloudDeadlineExceeded(f"iCloud operation deadline exceeded during {phase}") + + +def _sha256_file(path: Path, *, deadline: float | None = None) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): + _check_icloud_deadline(deadline, f"hashing {path}") digest.update(chunk) + _check_icloud_deadline(deadline, f"hashing {path}") + return digest.hexdigest() + + +def _sha256_gzip_payload(path: Path, *, deadline: float | None = None) -> str: + """Address valid gzip archives by their logical payload, not header metadata. + + Retrying a bundle can change the gzip header timestamp while preserving the + tar payload. A payload-derived destination therefore reuses the same iCloud + object after a later Drive failure instead of accumulating untracked copies. + Non-gzip inputs retain byte-addressed behavior for compatibility. + """ + digest = hashlib.sha256() + try: + with gzip.open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + _check_icloud_deadline(deadline, f"hashing gzip payload {path}") + digest.update(chunk) + except backup_daily.BackupTimeoutError: + raise + except (gzip.BadGzipFile, EOFError, OSError): + return _sha256_file(path, deadline=deadline) + _check_icloud_deadline(deadline, f"hashing gzip payload {path}") return digest.hexdigest() +_ICLOUD_STATUS_SCRIPT = r""" +ObjC.import("Foundation"); +const args = $.NSProcessInfo.processInfo.arguments; +const action = ObjC.unwrap(args.objectAtIndex(args.count - 2)); +const path = ObjC.unwrap(args.lastObject); +const url = $.NSURL.fileURLWithPath(path); +function resourceValue(key) { + const value = Ref(); + const error = Ref(); + if (!url.getResourceValueForKeyError(value, key, error)) { + const detail = error[0] ? ObjC.unwrap(error[0].localizedDescription) : "unknown error"; + throw new Error(detail); + } + if (value[0] === undefined || value[0] === null) return null; + const unwrapped = ObjC.unwrap(value[0]); + return unwrapped === undefined ? null : unwrapped; +} +if (action === "download") { + const error = Ref(); + if (!$.NSFileManager.defaultManager.startDownloadingUbiquitousItemAtURLError(url, error)) { + const detail = error[0] ? ObjC.unwrap(error[0].localizedDescription) : "unknown error"; + throw new Error(detail); + } +} +const uploadError = resourceValue($.NSURLUbiquitousItemUploadingErrorKey); +JSON.stringify({ + is_ubiquitous: resourceValue($.NSURLIsUbiquitousItemKey), + is_uploaded: resourceValue($.NSURLUbiquitousItemIsUploadedKey), + is_uploading: resourceValue($.NSURLUbiquitousItemIsUploadingKey), + downloading_status: resourceValue($.NSURLUbiquitousItemDownloadingStatusKey), + uploading_error: uploadError === null ? null : String(uploadError) +}); +""" + + +def _normalized_icloud_download_status(value: Any) -> str | None: + if value is None: + return None + text = str(value) + prefix = "NSURLUbiquitousItemDownloadingStatus" + if text.startswith(prefix): + text = text[len(prefix) :] + return text[:1].lower() + text[1:] if text else text + + +def _quarantine_unverified_icloud_item(path: Path) -> None: + """Hide an unverified iCloud item without deleting personal backup data.""" + if not path.exists(): + return + hidden_name = path.name if path.name.startswith(".") else f".{path.name}" + os.replace(path, path.with_name(f"{hidden_name}.{uuid.uuid4().hex}.unverified")) + + +def _icloud_item_state( + path: Path, + *, + request_download: bool = False, + timeout_seconds: float | None = None, +) -> dict[str, Any]: + """Read authoritative iCloud state, optionally forcing cloud materialization.""" + try: + completed = subprocess.run( + [ + "/usr/bin/osascript", + "-l", + "JavaScript", + "-e", + _ICLOUD_STATUS_SCRIPT, + "--", + "download" if request_download else "status", + str(path), + ], + check=True, + capture_output=True, + text=True, + timeout=timeout_seconds, + ) + except backup_daily.BackupTimeoutError: + raise + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() or str(exc) + raise ICloudProbeError(f"iCloud status probe failed for {path}: {detail}") from exc + except OSError as exc: + raise ICloudProbeError(f"iCloud status probe could not run for {path}: {exc}") from exc + try: + state = json.loads(completed.stdout) + except json.JSONDecodeError as exc: + raise ICloudProbeError(f"iCloud status returned invalid JSON: {completed.stdout!r}") from exc + if not isinstance(state, dict): + raise ICloudProbeError(f"iCloud status returned a non-object: {state!r}") + state["downloading_status"] = _normalized_icloud_download_status(state.get("downloading_status")) + return state + + +def copy_archive_to_icloud( + archive_path: Path, + icloud_dir: Path, + *, + timeout_seconds: float = DEFAULT_ICLOUD_TIMEOUT_SECONDS, + poll_interval_seconds: float = 2.0, + deadline: float | None = None, +) -> dict[str, Any]: + """Copy to iCloud, force materialization, then verify authoritative state and bytes.""" + archive_path = Path(archive_path).expanduser() + icloud_dir = Path(icloud_dir).expanduser() + deadline = deadline if deadline is not None else time.monotonic() + timeout_seconds + + def remaining_seconds() -> float: + return max(deadline - time.monotonic(), 0.001) + + icloud_dir.mkdir(parents=True, exist_ok=True) + _check_icloud_deadline(deadline, "preparing the iCloud directory") + expected_size = archive_path.stat().st_size + expected_sha256 = _sha256_file(archive_path, deadline=deadline) + logical_sha256 = _sha256_gzip_payload(archive_path, deadline=deadline) + suffix = "".join(archive_path.suffixes) + destination = icloud_dir / f"claude-jsonl-{logical_sha256}{suffix}" + placeholder = destination.with_name(f".{destination.name}.icloud") + + def receipt(*, reused: bool) -> dict[str, Any]: + _check_icloud_deadline(deadline, "building the iCloud receipt") + actual_size = destination.stat().st_size + actual_sha256 = _sha256_file(destination, deadline=deadline) + return { + "path": str(destination), + "uploaded": True, + "materialization": "MATERIALIZED", + "bytes": actual_size, + "sha256": actual_sha256, + "source_sha256": expected_sha256, + "logical_sha256": logical_sha256, + "downloading_status": "current", + "reused": reused, + } + + # A logical address is immutable. If a prior verified copy already occupies + # it, materialize and validate that object rather than replacing it before a + # retry is proven. Gzip header metadata may differ while the tar payload is + # identical, which is exactly what the logical address represents. + if destination.exists() or placeholder.exists(): + try: + status_path = placeholder if not destination.exists() and placeholder.exists() else destination + state = _icloud_item_state( + status_path, + request_download=True, + timeout_seconds=remaining_seconds(), + ) + while True: + uploading_error = state.get("uploading_error") + if uploading_error: + raise RuntimeError(f"existing iCloud upload failed for {destination}: {uploading_error}") + uploaded = state.get("is_uploaded") is True and state.get("is_uploading") is False + materialized = state.get("downloading_status") == "current" and destination.is_file() + if state.get("is_ubiquitous") is True and uploaded and materialized: + actual_logical_sha256 = _sha256_gzip_payload(destination, deadline=deadline) + if actual_logical_sha256 != logical_sha256: + raise RuntimeError( + "iCloud logical-address collision: " + f"path={destination} expected={logical_sha256} actual={actual_logical_sha256}" + ) + return receipt(reused=True) + if time.monotonic() >= deadline: + raise ICloudDeadlineExceeded( + f"existing iCloud copy was not uploaded and materialized within {timeout_seconds}s: " + f"path={destination} state={state!r}" + ) + time.sleep(min(poll_interval_seconds, remaining_seconds())) + status_path = placeholder if not destination.exists() and placeholder.exists() else destination + state = _icloud_item_state( + status_path, + request_download=True, + timeout_seconds=remaining_seconds(), + ) + except Exception as exc: + # This path was not proven usable. Preserve it under a unique hidden + # name, then let the normal fresh-copy path repair the logical address. + if isinstance( + exc, + ( + backup_daily.BackupTimeoutError, + ICloudDeadlineExceeded, + ICloudProbeError, + subprocess.TimeoutExpired, + ), + ): + raise + _quarantine_unverified_icloud_item(destination) + _quarantine_unverified_icloud_item(placeholder) + if time.monotonic() >= deadline: + raise + + temp_path = icloud_dir / f".{destination.name}.{os.getpid()}.partial" + try: + with archive_path.open("rb") as source, temp_path.open("xb") as destination_handle: + while chunk := source.read(1024 * 1024): + _check_icloud_deadline(deadline, f"copying {archive_path} to iCloud") + destination_handle.write(chunk) + _check_icloud_deadline(deadline, f"copying {archive_path} to iCloud") + destination_handle.flush() + os.fsync(destination_handle.fileno()) + _check_icloud_deadline(deadline, "publishing the iCloud copy") + os.replace(temp_path, destination) + finally: + temp_path.unlink(missing_ok=True) + + verified = False + try: + _check_icloud_deadline(deadline, "starting iCloud status verification") + state = _icloud_item_state(destination, request_download=True, timeout_seconds=remaining_seconds()) + while True: + uploading_error = state.get("uploading_error") + if uploading_error: + raise RuntimeError(f"iCloud upload failed for {destination}: {uploading_error}") + uploaded = state.get("is_uploaded") is True and state.get("is_uploading") is False + materialized = state.get("downloading_status") == "current" and destination.is_file() + if state.get("is_ubiquitous") is True and uploaded and materialized: + actual_size = destination.stat().st_size + actual_sha256 = _sha256_file(destination, deadline=deadline) + if actual_size != expected_size or actual_sha256 != expected_sha256: + raise RuntimeError( + "iCloud copy content mismatch: " + f"expected size={expected_size} sha256={expected_sha256}, " + f"actual size={actual_size} sha256={actual_sha256}" + ) + verified = True + return receipt(reused=False) + if time.monotonic() >= deadline: + placeholder = destination.with_name(f".{destination.name}.icloud") + materialization = "PLACEHOLDER" if placeholder.exists() or not destination.exists() else "PENDING" + raise RuntimeError( + f"iCloud copy was not uploaded and materialized within {timeout_seconds}s: " + f"path={destination} materialization={materialization} state={state!r}" + ) + time.sleep(min(poll_interval_seconds, remaining_seconds())) + placeholder = destination.with_name(f".{destination.name}.icloud") + status_path = placeholder if not destination.exists() and placeholder.exists() else destination + state = _icloud_item_state(status_path, request_download=True, timeout_seconds=remaining_seconds()) + finally: + if not verified: + _quarantine_unverified_icloud_item(destination) + _quarantine_unverified_icloud_item(destination.with_name(f".{destination.name}.icloud")) + + +def _icloud_copy_receipt(copy_result: dict[str, Any], icloud_dir: Path) -> tuple[str, dict[str, Any]]: + """Reduce a verified copy result to the durable state needed for later revalidation.""" + path = Path(copy_result.get("path", "")).expanduser() + directory = Path(icloud_dir).expanduser() + if not path.name or path.parent != directory: + raise RuntimeError(f"iCloud copy receipt is outside the configured directory: {path}") + size = copy_result.get("bytes") + sha256 = copy_result.get("sha256") + if not isinstance(size, int) or size < 0 or not isinstance(sha256, str) or len(sha256) != 64: + raise RuntimeError(f"iCloud copy receipt is missing exact-byte proof: {copy_result!r}") + return path.name, {"bytes": size, "sha256": sha256} + + +def _icloud_inventory_is_verified( + state: dict[str, Any], + candidates: list[JsonlCandidate], + icloud_dir: Path, + *, + timeout_seconds: float = DEFAULT_ICLOUD_TIMEOUT_SECONDS, + poll_interval_seconds: float = 2.0, + validated_sources: set[str] | None = None, + deadline: float | None = None, +) -> bool: + """Revalidate every iCloud object that current source-state entries rely on. + + Finder placeholders are not proof. Each referenced object is requested for + download, required to report authoritative uploaded/current state, then + checked against the exact size and SHA-256 persisted after its original copy. + Legacy marker-only state therefore bootstraps once instead of being trusted. + """ + directory = Path(icloud_dir).expanduser() + files = state.get("files") + candidates_by_path = {candidate.path.as_posix(): candidate for candidate in candidates} + if state.get("icloud_directory") != str(directory): + unavailable_sources = [] + if isinstance(files, dict): + unavailable_sources = sorted( + source_path + for source_path, entry in files.items() + if source_path not in candidates_by_path + and isinstance(entry, dict) + and (entry.get("icloud_required") is True or bool(entry.get("icloud_archive"))) + ) + if unavailable_sources: + raise RuntimeError( + "iCloud directory changed and the new destination cannot be seeded because " + f"previously covered sources are unavailable: sources={unavailable_sources!r}" + ) + return False + + receipts = state.get("icloud_archives") + if not isinstance(files, dict): + return not candidates + if not isinstance(receipts, dict): + receipts = {} + + referenced_by_sources: dict[str, set[str]] = {} + complete = True + for source_path, entry in files.items(): + candidate = candidates_by_path.get(source_path) + if not isinstance(entry, dict): + continue + # Changed/new sources are selected for this run and receive a new receipt. + if candidate is not None and (entry.get("mtime") != candidate.mtime or entry.get("size") != candidate.size): + continue + archive_name = entry.get("icloud_archive") + if not isinstance(archive_name, str) or not archive_name or Path(archive_name).name != archive_name: + if candidate is None: + if entry.get("icloud_required") is True: + raise RuntimeError( + "iCloud coverage cannot be repaired because a required source is unavailable: " + f"source={source_path}" + ) + # State predating the optional iCloud leg can retain Drive-only + # entries after their local source disappears. Such an entry never + # claimed iCloud coverage, so it is outside this inventory. + continue + complete = False + continue + referenced_by_sources.setdefault(archive_name, set()).add(source_path) + + if candidates and not referenced_by_sources: + return False + + def invalid_archive(archive_name: str, reason: str, *, quarantine: bool = False) -> bool: + unavailable_sources = sorted(referenced_by_sources[archive_name] - candidates_by_path.keys()) + if unavailable_sources: + raise RuntimeError( + "iCloud coverage cannot be repaired because its archive is invalid and a source is unavailable: " + f"archive={archive_name} reason={reason} sources={unavailable_sources!r}" + ) + if quarantine: + destination = directory / archive_name + _quarantine_unverified_icloud_item(destination) + _quarantine_unverified_icloud_item(directory / f".{archive_name}.icloud") + return False + + deadline = deadline if deadline is not None else time.monotonic() + timeout_seconds + for archive_name in sorted(referenced_by_sources): + try: + _check_icloud_deadline(deadline, f"validating iCloud inventory archive {archive_name}") + receipt = receipts.get(archive_name) + if not isinstance(receipt, dict): + invalid_archive(archive_name, "missing exact-byte receipt") + complete = False + continue + expected_size = receipt.get("bytes") + expected_sha256 = receipt.get("sha256") + if ( + not isinstance(expected_size, int) + or expected_size < 0 + or not isinstance(expected_sha256, str) + or len(expected_sha256) != 64 + ): + invalid_archive(archive_name, "malformed exact-byte receipt") + complete = False + continue + + destination = directory / archive_name + placeholder = directory / f".{archive_name}.icloud" + if not destination.exists() and not placeholder.exists(): + invalid_archive(archive_name, "archive and placeholder are missing") + complete = False + continue + + archive_valid = True + while True: + _check_icloud_deadline(deadline, f"probing iCloud inventory archive {archive_name}") + remaining = max(deadline - time.monotonic(), 0.001) + status_path = placeholder if not destination.exists() and placeholder.exists() else destination + item_state = _icloud_item_state( + status_path, + request_download=True, + timeout_seconds=remaining, + ) + uploaded = item_state.get("is_uploaded") is True and item_state.get("is_uploading") is False + materialized = item_state.get("downloading_status") == "current" and destination.is_file() + if item_state.get("uploading_error"): + invalid_archive( + archive_name, + f"upload error: {item_state['uploading_error']}", + quarantine=True, + ) + archive_valid = False + break + if item_state.get("is_ubiquitous") is True and uploaded and materialized: + if ( + destination.stat().st_size != expected_size + or _sha256_file(destination, deadline=deadline) != expected_sha256 + ): + invalid_archive( + archive_name, + "materialized bytes do not match the receipt", + quarantine=True, + ) + archive_valid = False + elif validated_sources is not None: + validated_sources.update(referenced_by_sources[archive_name]) + break + if time.monotonic() >= deadline: + raise ICloudDeadlineExceeded( + f"iCloud operation deadline exceeded while materializing inventory archive {archive_name}" + ) + time.sleep(min(poll_interval_seconds, remaining)) + if not archive_valid: + complete = False + except (OSError, RuntimeError, subprocess.TimeoutExpired) as exc: + if isinstance( + exc, + ( + backup_daily.BackupTimeoutError, + ICloudDeadlineExceeded, + ICloudProbeError, + subprocess.TimeoutExpired, + ), + ): + raise + if isinstance(exc, RuntimeError) and str(exc).startswith("iCloud coverage cannot be repaired"): + raise + invalid_archive(archive_name, str(exc)) + complete = False + continue + referenced_sources = set().union(*referenced_by_sources.values()) if referenced_by_sources else set() + return complete and candidates_by_path.keys() <= referenced_sources + + def _upload_forever_files( candidates: list[JsonlCandidate], *, @@ -549,6 +1030,9 @@ def _update_state_for_uploaded( archive_id: str | None = None, archive_md5: str | None = None, digests: dict[str, str] | None = None, + icloud_dir: Path | None = None, + icloud_copy: dict[str, Any] | None = None, + clear_icloud_verification: bool = False, ) -> dict[str, Any]: """Record which archive object carries each file, and the bytes it carried. @@ -557,6 +1041,13 @@ def _update_state_for_uploaded( unrecoverable while state still reports it as backed up. """ files = dict(state.get("files") or {}) + existing_icloud_directory = state.get("icloud_directory") + icloud_archive_name: str | None = None + icloud_receipt: dict[str, Any] | None = None + if icloud_copy is not None: + if icloud_dir is None: + raise RuntimeError("an iCloud copy receipt requires its configured directory") + icloud_archive_name, icloud_receipt = _icloud_copy_receipt(icloud_copy, icloud_dir) for candidate in candidates: entry: dict[str, Any] = {"mtime": candidate.mtime, "size": candidate.size} if archive_name and archive_id: @@ -566,8 +1057,35 @@ def _update_state_for_uploaded( entry["archive_md5"] = archive_md5 digest = (digests or {}).get(candidate.path.as_posix()) entry["sha256"] = digest if digest else _sha256_file(candidate.path) + if icloud_archive_name is not None: + entry["icloud_archive"] = icloud_archive_name + elif isinstance(existing_icloud_directory, str) and existing_icloud_directory: + entry["icloud_required"] = True files[candidate.path.as_posix()] = entry - return {"files": files, "updated_at": dt.datetime.now(dt.UTC).isoformat()} + updated = {"files": files, "updated_at": dt.datetime.now(dt.UTC).isoformat()} + + archives = dict(state.get("icloud_archives") or {}) + if icloud_archive_name is not None and icloud_receipt is not None: + archives[icloud_archive_name] = icloud_receipt + referenced_archives = { + entry.get("icloud_archive") + for entry in files.values() + if isinstance(entry, dict) and isinstance(entry.get("icloud_archive"), str) + } + retained_archives = {name: receipt for name, receipt in archives.items() if name in referenced_archives} + if retained_archives: + updated["icloud_archives"] = retained_archives + + if icloud_dir is not None: + updated["icloud_directory"] = str(Path(icloud_dir).expanduser()) + if not clear_icloud_verification: + updated["icloud_verified"] = True + elif not clear_icloud_verification and state.get("icloud_verified") is True: + existing_directory = state.get("icloud_directory") + if isinstance(existing_directory, str) and existing_directory: + updated["icloud_directory"] = existing_directory + updated["icloud_verified"] = True + return updated def _enqueue_run_summary(result: dict[str, Any], *, queue_dir: Path | None) -> None: @@ -626,6 +1144,7 @@ def run_backup( upload: bool = True, active_skip_seconds: int = DEFAULT_ACTIVE_SKIP_SECONDS, forever_folder_parts: list[str] = DEFAULT_FOREVER_FOLDER_PARTS, + icloud_dir: Path | None = None, ) -> dict[str, Any]: date_stamp = date_stamp or _today() now = time.time() if now is None else now @@ -634,6 +1153,31 @@ def run_backup( state_path = Path(state_path).expanduser() state = _load_state(state_path) candidates = _discover_jsonl_candidates(roots) + selection_state = state + icloud_bootstrap_pending = False + icloud_deadline: float | None = None + if upload and icloud_dir is not None: + icloud_deadline = time.monotonic() + DEFAULT_ICLOUD_TIMEOUT_SECONDS + validated_icloud_sources: set[str] = set() + icloud_covered = _icloud_inventory_is_verified( + state, + candidates, + icloud_dir, + validated_sources=validated_icloud_sources, + deadline=icloud_deadline, + ) + if not icloud_covered: + # Legacy state proves only Drive coverage. The first iCloud-enabled run + # must seed iCloud with every source rather than falsely returning no-op. + state_files = state.get("files") or {} + selection_state = { + "files": { + source_path: entry + for source_path, entry in state_files.items() + if source_path in validated_icloud_sources + } + } + icloud_bootstrap_pending = True credentials = None service = None surviving_archives: dict[str, str | None] | None = None @@ -655,12 +1199,34 @@ def run_backup( surviving_archives = {} changed, active, covered, vanished = _select_backup_candidates( candidates, - state=state, + state=selection_state, now=now, active_skip_seconds=active_skip_seconds, surviving_archives=surviving_archives, ) + if not changed and icloud_bootstrap_pending and active: + error = ( + "iCloud coverage is not verified; repair deferred because " + f"{len(active)} discovered source(s) are still active" + ) + result = { + "attempted_at": attempted_at, + "status": "deferred", + "uploaded": False, + "verified": False, + "already_covered_files": covered, + "discovered_file_count": len(candidates), + "skipped_active_count": len(active), + "vanished_source_count": vanished, + "icloud_repair_deferred": True, + "error": error, + "message": error, + } + _append_json_log(log_path, result) + _enqueue_run_summary(result, queue_dir=queue_dir) + return result + if not changed: result: dict[str, Any] = { "attempted_at": attempted_at, @@ -707,6 +1273,14 @@ def run_backup( ) ) if result["verified"] and upload: + # iCloud goes first: a failed iCloud verification must not create an + # unrecorded duplicate Drive object that consumes the retention window. + if icloud_dir is not None: + result["icloud_copy"] = copy_archive_to_icloud( + archive_path, + icloud_dir, + deadline=icloud_deadline, + ) if service is None: credentials = backup_daily.get_drive_credentials() service = backup_daily.build_drive_service() @@ -722,6 +1296,10 @@ def run_backup( expected_size=archive_size, ) result.update({"status": "uploaded", "uploaded": True, "drive_file": uploaded}) + # The same incident was two individually reasonable deletions composed together: + # successful upload removed local staging, then Drive retention removed the remote + # bundle. Persist the exact Drive object and archived-source digests before either + # deletion path runs so the next selection cannot silently trust the dead copy. _atomic_write_json( state_path, _update_state_for_uploaded( @@ -731,6 +1309,11 @@ def run_backup( archive_id=file_id, archive_md5=uploaded.get("md5Checksum"), digests=bundle_digests, + # An active source was deliberately omitted from this bootstrap. + # Leave the global marker unset so the next run seeds that source. + icloud_dir=icloud_dir, + icloud_copy=result.get("icloud_copy"), + clear_icloud_verification=bool(icloud_bootstrap_pending and active), ), ) try: @@ -798,6 +1381,7 @@ def main() -> int: folder_parts=os.environ.get("BRAINLAYER_JSONL_BACKUP_DRIVE_FOLDER", "/".join(DEFAULT_FOLDER_PARTS)).split( "/" ), + icloud_dir=_configured_icloud_dir(), ) except backup_daily.BackupTimeoutError: result = { diff --git a/tests/test_hook_python.py b/tests/test_hook_python.py index 9463a2cc..4aee2ff0 100644 --- a/tests/test_hook_python.py +++ b/tests/test_hook_python.py @@ -167,6 +167,7 @@ class TestIsPinnedInterpreter: # this as an override, so the linter must accept it too, or the escape hatch and # the gate contradict each other (review round 1, medium). "/tmp/myvenv/bin/python", + "'/Users/Jane Doe/.venv/bin/python'", "/Users/x/Gits/brainlayer/.venv/bin/python3.13", ], ) @@ -251,10 +252,20 @@ def test_an_absolute_venv_override_outside_a_keg_is_accepted(tmp_path): target = tmp_path / "myvenv" / "bin" / "python" target.parent.mkdir(parents=True) target.write_text("#!/bin/sh\n") + target.chmod(0o755) resolved = resolve_hook_python(env={HOOK_PYTHON_ENV: str(target)}, candidates=()) assert resolved == str(target) assert is_pinned_interpreter(resolved), "the linter must accept what the hatch returns" + @staticmethod + def test_an_absolute_venv_override_with_spaces_is_accepted(tmp_path): + target = tmp_path / "Jane Doe" / ".venv" / "bin" / "python" + target.parent.mkdir(parents=True) + target.write_text("#!/bin/sh\n") + target.chmod(0o755) + + assert resolve_hook_python(env={HOOK_PYTHON_ENV: str(target)}, candidates=()) == str(target) + @staticmethod def test_first_existing_candidate_wins(tmp_path): missing = tmp_path / "missing" / "python" @@ -263,6 +274,20 @@ def test_first_existing_candidate_wins(tmp_path): present.chmod(0o755) assert resolve_hook_python(env={}, candidates=(str(missing), str(present))) == str(present) + @staticmethod + def test_candidate_must_be_a_regular_executable_file(tmp_path): + directory = tmp_path / "directory" / "python" + directory.mkdir(parents=True) + non_executable = tmp_path / "not-executable" / "python" + non_executable.parent.mkdir() + non_executable.write_text("#!/bin/sh\n") + usable = tmp_path / "usable" / "python" + usable.parent.mkdir() + usable.write_text("#!/bin/sh\n") + usable.chmod(0o755) + + assert resolve_hook_python(env={}, candidates=(str(directory), str(non_executable), str(usable))) == str(usable) + @staticmethod def test_never_falls_back_to_path(): """A silent `python3` fallback is the bug, not the remedy.""" @@ -298,6 +323,15 @@ def test_default_candidate_is_the_opt_symlink(): """`opt/` outlives the Cellar version a command was rendered against.""" assert DEFAULT_KEG_PYTHON == "/opt/homebrew/opt/brainlayer/libexec/venv/bin/python" + @staticmethod + def test_non_python_executable_override_is_refused(tmp_path): + target = tmp_path / "bin" / "bash" + target.parent.mkdir() + target.write_text("#!/bin/sh\n") + target.chmod(0o755) + with pytest.raises(HookPythonUnresolved, match="executable Python"): + resolve_hook_python(env={HOOK_PYTHON_ENV: str(target)}, candidates=()) + class TestRenderHookCommand: @staticmethod @@ -481,6 +515,17 @@ def test_exits_two_on_invalid_json(tmp_path, capsys): assert main([str(path)]) == 2 assert "cannot read" in capsys.readouterr().out + @staticmethod + def test_print_interpreter_uses_affirmative_resolver(tmp_path, monkeypatch, capsys): + python = tmp_path / "venv" / "bin" / "python" + python.parent.mkdir(parents=True) + python.write_text("#!/bin/sh\n") + python.chmod(0o755) + monkeypatch.setenv(HOOK_PYTHON_ENV, str(python)) + + assert main(["--print-interpreter"]) == 0 + assert capsys.readouterr().out.strip() == str(python) + #: conftest sandboxes HOME for every test, so `~/.claude/settings.json` is not #: reachable by default — deliberately: a unit suite must not read Etan's home. Point @@ -506,3 +551,87 @@ def test_installed_brainlayer_hooks_are_pinned(): assert findings == [], "BrainLayer hooks still resolve their interpreter through PATH: " + "; ".join( f"{f.event}: {f.command}" for f in findings ) + + +def test_launchd_plist_templates_pin_their_interpreter(tmp_path): + """Render machine-specific placeholders, then apply the existing pin gate.""" + import plistlib + + from brainlayer.hook_python import render_launchd_plist + + plists = sorted((REPO_ROOT / "launchd").glob("*.plist")) + assert plists, "expected launchd templates to exist" + python = tmp_path / "intel" / "opt" / "brainlayer" / "libexec" / "venv" / "bin" / "python" + python.parent.mkdir(parents=True) + python.write_text("#!/bin/sh\n") + python.chmod(0o755) + + unpinned: list[str] = [] + for path in plists: + template = path.read_text(encoding="utf-8") + rendered = render_launchd_plist(template, python=str(python)) + args = plistlib.loads(rendered.encode()).get("ProgramArguments") or [] + if not args: + continue + interpreter = args[0] + # A wrapper or installed CLI is not a direct interpreter claim. + if "python" not in interpreter and not interpreter.endswith("/env"): + continue + if interpreter.endswith("/env"): + interpreter = f"{interpreter} {args[1] if len(args) > 1 else ''}".strip() + if not is_pinned_interpreter(interpreter): + unpinned.append(f"{path.name}: {interpreter}") + + assert not unpinned, "launchd templates must name a pinned interpreter, not PATH: " + "; ".join(unpinned) + + +def test_launchd_plist_render_uses_available_intel_keg(monkeypatch): + from brainlayer import hook_python + + template = "__BRAINLAYER_PYTHON__" + intel = "/usr/local/opt/brainlayer/libexec/venv/bin/python" + monkeypatch.setattr(hook_python.os.path, "exists", lambda path: path == intel) + monkeypatch.setattr(hook_python.os.path, "isfile", lambda path: path == intel) + monkeypatch.setattr(hook_python.os, "access", lambda path, mode: path == intel and mode == hook_python.os.X_OK) + + rendered = hook_python.render_launchd_plist(template, env={}) + + assert rendered == f"{intel}" + + +def test_launchd_plist_render_rejects_non_executable_interpreter(tmp_path): + from brainlayer.hook_python import HookPythonUnresolved, render_launchd_plist + + python = tmp_path / "venv" / "bin" / "python" + python.parent.mkdir(parents=True) + python.write_text("#!/bin/sh\n") + + with pytest.raises(HookPythonUnresolved): + render_launchd_plist("__BRAINLAYER_PYTHON__", python=str(python)) + + +def test_launchd_plist_render_accepts_executable_interpreter_with_spaces(tmp_path): + import xml.etree.ElementTree as ET + + from brainlayer.hook_python import render_launchd_plist + + python = tmp_path / "Jane Doe" / ".venv" / "bin" / "python" + python.parent.mkdir(parents=True) + python.write_text("#!/bin/sh\n") + python.chmod(0o755) + + rendered = render_launchd_plist("__BRAINLAYER_PYTHON__", python=str(python)) + + assert ET.fromstring(rendered).text == str(python) + + +def test_launchd_plist_render_rejects_xml_forbidden_interpreter_path(tmp_path): + from brainlayer.hook_python import HookPythonUnresolved, render_launchd_plist + + python = tmp_path / "bad\x01path" / ".venv" / "bin" / "python" + python.parent.mkdir(parents=True) + python.write_text("#!/bin/sh\n") + python.chmod(0o755) + + with pytest.raises(HookPythonUnresolved): + render_launchd_plist("__BRAINLAYER_PYTHON__", python=str(python)) diff --git a/tests/test_installable_build.py b/tests/test_installable_build.py index c53ecdc2..a4ee94a9 100644 --- a/tests/test_installable_build.py +++ b/tests/test_installable_build.py @@ -187,7 +187,7 @@ def _copy_packaged_launchd(launchd_dir: Path) -> None: target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(origin, target) package_dir = launchd_dir.parent - for name in ("__init__.py", "config.py", "paths.py", "spotlight.py"): + for name in ("__init__.py", "config.py", "hook_python.py", "paths.py", "spotlight.py"): shutil.copy2(REPO_ROOT / "src" / "brainlayer" / name, package_dir / name) @@ -1623,7 +1623,10 @@ def test_launchd_installer_renders_brainlayer_python_override(tmp_path: Path) -> env_file = tmp_path / "brainlayer.env" env_file.write_text("BRAINLAYER_ENRICH_ENABLED=0\n", encoding="utf-8") env_file.chmod(0o600) - brainlayer_python = tmp_path / "tool" / "bin" / "python" + brainlayer_python = tmp_path / "tool&operator" / "bin" / "python" + brainlayer_python.parent.mkdir(parents=True) + brainlayer_python.write_text("#!/bin/sh\n", encoding="utf-8") + brainlayer_python.chmod(0o755) result = subprocess.run( [str(REPO_ROOT / "scripts" / "launchd" / "install.sh"), "backup"], @@ -1644,10 +1647,57 @@ def test_launchd_installer_renders_brainlayer_python_override(tmp_path: Path) -> assert result.returncode == 0, result.stdout + result.stderr rendered = home / "Library" / "LaunchAgents" / "com.brainlayer.backup-daily.plist" - assert f"{brainlayer_python}" in rendered.read_text(encoding="utf-8") + assert plistlib.loads(rendered.read_bytes())["EnvironmentVariables"]["BRAINLAYER_PYTHON"] == str(brainlayer_python) assert "__BRAINLAYER_PYTHON__" not in rendered.read_text(encoding="utf-8") +def test_source_jsonl_installer_uses_prefix_aware_resolver_not_path_python(tmp_path: Path) -> None: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + authoring_python = fake_bin / "python3" + authoring_python.write_text(f'#!/bin/sh\nexec "{sys.executable}" "$@"\n', encoding="utf-8") + authoring_python.chmod(0o755) + fake_launchctl = fake_bin / "launchctl" + fake_launchctl.write_text("\n".join(_fake_launchctl_lines()), encoding="utf-8") + fake_launchctl.chmod(0o755) + home = tmp_path / "home" + home.mkdir() + env_file = tmp_path / "brainlayer.env" + env_file.write_text("BRAINLAYER_ENRICH_ENABLED=0\n", encoding="utf-8") + env_file.chmod(0o600) + pinned_python = tmp_path / "intel-prefix" / "opt" / "brainlayer" / "libexec" / "venv" / "bin" / "python" + pinned_python.parent.mkdir(parents=True) + pinned_python.write_text("#!/bin/sh\n", encoding="utf-8") + pinned_python.chmod(0o755) + child_env = { + key: value + for key, value in os.environ.items() + if key not in {"BRAINLAYER_PYTHON", "PYTHON_BIN", "BRAINLAYER_HOOK_PYTHON"} + } + + result = subprocess.run( + [str(REPO_ROOT / "scripts" / "launchd" / "install.sh"), "jsonl-backup"], + env={ + **child_env, + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "HOME": str(home), + "BRAINLAYER_BIN": sys.executable, + "BRAINLAYER_HOOK_PYTHON": str(pinned_python), + "BRAINLAYER_ENV_FILE": str(env_file), + "FAKE_LAUNCHCTL_LOG": str(tmp_path / "launchctl.log"), + }, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stdout + result.stderr + rendered = home / "Library" / "LaunchAgents" / "com.brainlayer.jsonl-backup.plist" + content = rendered.read_text(encoding="utf-8") + assert f"{pinned_python}" in content + assert str(authoring_python) not in content + + def test_packaged_launchd_installer_renders_p0_counter_console_shim(tmp_path: Path) -> None: launchd_dir = tmp_path / "site-packages" / "brainlayer" / "launchd" _copy_packaged_launchd(launchd_dir) diff --git a/tests/test_jsonl_backup.py b/tests/test_jsonl_backup.py index 1f9d7bd5..a5cff66e 100644 --- a/tests/test_jsonl_backup.py +++ b/tests/test_jsonl_backup.py @@ -1,7 +1,9 @@ +import gzip import hashlib import io import json import os +import subprocess import tarfile import threading import time @@ -17,6 +19,49 @@ def _write_jsonl(path: Path, line: str = '{"type":"message"}\n', *, mtime: float return path +def _mock_drive_success(jsonl_backup, monkeypatch, uploads: list[Path] | None = None) -> None: + monkeypatch.setattr(jsonl_backup.backup_daily, "get_drive_credentials", lambda: object()) + monkeypatch.setattr(jsonl_backup.backup_daily, "build_drive_service", lambda: object()) + monkeypatch.setattr(jsonl_backup.backup_daily, "ensure_drive_folder_chain", lambda *args: "folder-id") + + def upload(path, *args): # noqa: ARG001 + if uploads is not None: + uploads.append(Path(path)) + return {"id": "drive-id", "name": Path(path).name, "size": str(Path(path).stat().st_size)} + + monkeypatch.setattr(jsonl_backup.backup_daily, "upload_file_to_drive_raw", upload) + monkeypatch.setattr(jsonl_backup.backup_daily, "verify_drive_upload", lambda *args, **kwargs: None) + monkeypatch.setattr(jsonl_backup.backup_daily, "prune_drive_backups", lambda *args, **kwargs: []) + + +def _icloud_state(*, uploaded: bool, status: str) -> dict: + return { + "is_ubiquitous": True, + "is_uploaded": uploaded, + "is_uploading": not uploaded, + "downloading_status": status, + } + + +def _copy_to_icloud_receipt(archive: Path, destination: Path, **kwargs) -> dict: # noqa: ARG001 + destination.mkdir(parents=True, exist_ok=True) + try: + logical_bytes = gzip.decompress(archive.read_bytes()) + except (gzip.BadGzipFile, EOFError): + logical_bytes = archive.read_bytes() + suffix = "".join(archive.suffixes) + logical_sha256 = hashlib.sha256(logical_bytes).hexdigest() + target = destination / f"claude-jsonl-{logical_sha256}{suffix}" + target.write_bytes(archive.read_bytes()) + return { + "path": str(target), + "uploaded": True, + "materialization": "MATERIALIZED", + "bytes": target.stat().st_size, + "sha256": hashlib.sha256(target.read_bytes()).hexdigest(), + } + + def test_jsonl_retention_invariant_is_a_ci_guard_not_only_a_behavior_fixture(): """Fail CI when #815's surviving-copy predicate or call-site ordering is loosened.""" from brainlayer.backup_retention_invariant import inspect_jsonl_retention_invariant @@ -163,6 +208,11 @@ def _state_matches(entry, candidate, surviving_archives=None): archive_id=file_id, archive_md5=uploaded.get("md5Checksum"), digests=bundle_digests, + # An active source was deliberately omitted from this bootstrap. + # Leave the global marker unset so the next run seeds that source. + icloud_dir=icloud_dir, + icloud_copy=result.get("icloud_copy"), + clear_icloud_verification=bool(icloud_bootstrap_pending and active), ), ) """ @@ -173,6 +223,11 @@ def _state_matches(entry, candidate, surviving_archives=None): archive_id=file_id, archive_md5=uploaded.get("md5Checksum"), digests=bundle_digests, + # An active source was deliberately omitted from this bootstrap. + # Leave the global marker unset so the next run seeds that source. + icloud_dir=icloud_dir, + icloud_copy=result.get("icloud_copy"), + clear_icloud_verification=bool(icloud_bootstrap_pending and active), ) """ assert persisted_state_block in source @@ -600,6 +655,1300 @@ def fake_prune(service, *, folder_parts, retention_policy): # noqa: ARG001 assert "JSONL backup uploaded 3 files" in queued[0].read_text() +def test_icloud_copy_requires_uploaded_materialized_exact_bytes(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + archive = tmp_path / "staging" / "claude-jsonl-2026-09-09.tar.gz" + archive.parent.mkdir() + archive.write_bytes(b"verified archive bytes") + icloud_dir = tmp_path / "CloudDocs" / "Archives" / "brainlayer-jsonl-backups" + states = iter( + [ + _icloud_state(uploaded=False, status="notDownloaded"), + _icloud_state(uploaded=True, status="current"), + ] + ) + actions: list[tuple[str, float | None]] = [] + expected_name = f"claude-jsonl-{hashlib.sha256(archive.read_bytes()).hexdigest()}.tar.gz" + + def fake_icloud_item_state(path, *, request_download=False, timeout_seconds=None): + assert Path(path) == icloud_dir / expected_name + actions.append(("download" if request_download else "status", timeout_seconds)) + return next(states) + + monkeypatch.setattr(jsonl_backup, "_icloud_item_state", fake_icloud_item_state) + + result = jsonl_backup.copy_archive_to_icloud( + archive, + icloud_dir, + timeout_seconds=1, + poll_interval_seconds=0, + ) + + assert [action for action, _ in actions] == ["download", "download"] + assert all(timeout is not None and 0 < timeout <= 1 for _, timeout in actions) + assert result["materialization"] == "MATERIALIZED" + assert result["sha256"] == hashlib.sha256(archive.read_bytes()).hexdigest() + assert Path(result["path"]).read_bytes() == archive.read_bytes() + + +def test_icloud_destination_is_strictly_opt_in(monkeypatch): + from brainlayer import jsonl_backup + + monkeypatch.delenv("BRAINLAYER_JSONL_BACKUP_ICLOUD_DIR", raising=False) + assert jsonl_backup._configured_icloud_dir() is None + monkeypatch.setenv("BRAINLAYER_JSONL_BACKUP_ICLOUD_DIR", "/CloudDocs/Archives/brainlayer-jsonl-backups") + assert jsonl_backup._configured_icloud_dir() == Path("/CloudDocs/Archives/brainlayer-jsonl-backups") + + +def test_logical_gzip_hash_does_not_swallow_wall_clock_timeout(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + archive = tmp_path / "archive.tar.gz" + archive.write_bytes(b"bytes") + monkeypatch.setattr( + jsonl_backup.gzip, + "open", + lambda *args, **kwargs: (_ for _ in ()).throw(jsonl_backup.backup_daily.BackupTimeoutError("deadline")), + ) + monkeypatch.setattr(jsonl_backup, "_sha256_file", lambda path: "fallback") + + with pytest.raises(jsonl_backup.backup_daily.BackupTimeoutError, match="deadline"): + jsonl_backup._sha256_gzip_payload(archive) + + +def test_icloud_copy_rehydrates_placeholder_before_hashing(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + archive = tmp_path / "claude-jsonl-2026-09-09.tar.gz" + archive.write_bytes(b"archive bytes") + icloud_dir = tmp_path / "CloudDocs" / "Archives" / "brainlayer-jsonl-backups" + calls: list[Path] = [] + placeholder: Path | None = None + + def fake_icloud_item_state(path, *, request_download=False, timeout_seconds=None): # noqa: ARG001 + nonlocal placeholder + calls.append(Path(path)) + assert request_download is True + if len(calls) == 1: + destination = Path(path) + placeholder = destination.with_name(f".{destination.name}.icloud") + destination.unlink() + placeholder.write_bytes(b"") + return _icloud_state(uploaded=True, status="notDownloaded") + assert placeholder is not None + assert Path(path) == placeholder + placeholder.unlink() + Path(calls[0]).write_bytes(archive.read_bytes()) + return _icloud_state(uploaded=True, status="current") + + monkeypatch.setattr(jsonl_backup, "_icloud_item_state", fake_icloud_item_state) + + result = jsonl_backup.copy_archive_to_icloud( + archive, + icloud_dir, + timeout_seconds=1, + poll_interval_seconds=0, + ) + + assert calls == [Path(result["path"]), placeholder] + assert result["materialization"] == "MATERIALIZED" + + +def test_icloud_copy_rejects_uploaded_item_with_wrong_materialized_bytes(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + archive = tmp_path / "claude-jsonl-2026-09-09.tar.gz" + archive.write_bytes(b"expected bytes") + icloud_dir = tmp_path / "CloudDocs" / "Archives" / "brainlayer-jsonl-backups" + + def fake_icloud_item_state(path, *, request_download=False, timeout_seconds=None): # noqa: ARG001 + Path(path).write_bytes(b"remote bytes changed") + return _icloud_state(uploaded=True, status="current") + + monkeypatch.setattr(jsonl_backup, "_icloud_item_state", fake_icloud_item_state) + + with pytest.raises(RuntimeError, match="iCloud copy content mismatch"): + jsonl_backup.copy_archive_to_icloud( + archive, + icloud_dir, + timeout_seconds=1, + poll_interval_seconds=0, + ) + quarantined = list(icloud_dir.iterdir()) + assert len(quarantined) == 1 + assert quarantined[0].name.endswith(".unverified") + + +def test_icloud_status_reports_stderr_when_osascript_fails(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + error = subprocess.CalledProcessError(1, ["osascript"], stderr="Foundation failed") + monkeypatch.setattr(jsonl_backup.subprocess, "run", lambda *args, **kwargs: (_ for _ in ()).throw(error)) + + with pytest.raises(jsonl_backup.ICloudProbeError, match="Foundation failed"): + jsonl_backup._icloud_item_state(tmp_path / "archive.tar.gz", timeout_seconds=0.5) + + +def test_icloud_status_marks_malformed_json_as_inconclusive_probe_failure(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + completed = subprocess.CompletedProcess(["osascript"], 0, stdout="{truncated", stderr="") + monkeypatch.setattr(jsonl_backup.subprocess, "run", lambda *args, **kwargs: completed) + + with pytest.raises(jsonl_backup.ICloudProbeError, match="invalid JSON"): + jsonl_backup._icloud_item_state(tmp_path / "archive.tar.gz", timeout_seconds=0.5) + + +def test_icloud_status_does_not_wrap_wall_clock_timeout(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + timeout = jsonl_backup.backup_daily.BackupTimeoutError("wall clock expired") + monkeypatch.setattr(jsonl_backup.subprocess, "run", lambda *args, **kwargs: (_ for _ in ()).throw(timeout)) + + with pytest.raises(jsonl_backup.backup_daily.BackupTimeoutError, match="wall clock expired"): + jsonl_backup._icloud_item_state(tmp_path / "archive.tar.gz", timeout_seconds=0.5) + + +def test_icloud_timeout_quarantines_unverified_placeholder(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + archive = tmp_path / "archive.tar.gz" + archive.write_bytes(b"bytes") + icloud_dir = tmp_path / "CloudDocs" + clock = [0.0] + monkeypatch.setattr(jsonl_backup.time, "monotonic", lambda: clock[0]) + + def leave_placeholder(path, **kwargs): # noqa: ARG001 + destination = Path(path) + destination.unlink() + destination.with_name(f".{destination.name}.icloud").write_bytes(b"") + clock[0] = 2.0 + return _icloud_state(uploaded=False, status="notDownloaded") + + monkeypatch.setattr( + jsonl_backup, + "_icloud_item_state", + leave_placeholder, + ) + + with pytest.raises(RuntimeError, match="not uploaded and materialized"): + jsonl_backup.copy_archive_to_icloud(archive, icloud_dir, timeout_seconds=1, poll_interval_seconds=0) + quarantined = list(icloud_dir.iterdir()) + assert len(quarantined) == 1 + assert ".icloud." in quarantined[0].name + assert quarantined[0].name.endswith(".unverified") + + +def test_icloud_upload_error_quarantines_unverified_destination(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + archive = tmp_path / "archive.tar.gz" + archive.write_bytes(b"bytes") + icloud_dir = tmp_path / "CloudDocs" + state = _icloud_state(uploaded=False, status="current") | {"uploading_error": "quota"} + monkeypatch.setattr(jsonl_backup, "_icloud_item_state", lambda *args, **kwargs: state) + + with pytest.raises(RuntimeError, match="quota"): + jsonl_backup.copy_archive_to_icloud(archive, icloud_dir, timeout_seconds=1) + quarantined = list(icloud_dir.iterdir()) + assert len(quarantined) == 1 + assert quarantined[0].name.endswith(".unverified") + + +def test_repeated_icloud_failures_preserve_every_quarantined_copy(tmp_path): + from brainlayer import jsonl_backup + + destination = tmp_path / "archive.tar.gz" + destination.write_bytes(b"first failed copy") + jsonl_backup._quarantine_unverified_icloud_item(destination) + destination.write_bytes(b"second failed copy") + jsonl_backup._quarantine_unverified_icloud_item(destination) + + quarantined = list(tmp_path.glob("*.unverified")) + assert len(quarantined) == 2 + assert {path.read_bytes() for path in quarantined} == {b"first failed copy", b"second failed copy"} + + +def test_same_day_incremental_icloud_bundles_do_not_overwrite(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + first = tmp_path / "first" / "claude-jsonl-2026-09-09.tar.gz" + second = tmp_path / "second" / "claude-jsonl-2026-09-10.tar.gz" + first.parent.mkdir() + second.parent.mkdir() + first.write_bytes(b"first incremental bundle") + second.write_bytes(b"second incremental bundle") + icloud_dir = tmp_path / "CloudDocs" + monkeypatch.setattr( + jsonl_backup, + "_icloud_item_state", + lambda *args, **kwargs: _icloud_state(uploaded=True, status="current"), + ) + + first_result = jsonl_backup.copy_archive_to_icloud(first, icloud_dir, timeout_seconds=1) + second_result = jsonl_backup.copy_archive_to_icloud(second, icloud_dir, timeout_seconds=1, poll_interval_seconds=0) + + assert first_result["path"] != second_result["path"] + assert sorted(path.read_bytes() for path in icloud_dir.iterdir()) == sorted( + [first.read_bytes(), second.read_bytes()] + ) + + +def test_icloud_retry_reuses_logical_gzip_payload_destination(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + first = tmp_path / "first" / "claude-jsonl-2026-09-09.tar.gz" + second = tmp_path / "second" / "claude-jsonl-2026-09-10.tar.gz" + first.parent.mkdir() + second.parent.mkdir() + first.write_bytes(gzip.compress(b"same tar payload", mtime=1)) + second.write_bytes(gzip.compress(b"same tar payload", mtime=2)) + assert first.read_bytes() != second.read_bytes() + icloud_dir = tmp_path / "CloudDocs" + monkeypatch.setattr( + jsonl_backup, + "_icloud_item_state", + lambda *args, **kwargs: _icloud_state(uploaded=True, status="current"), + ) + + first_result = jsonl_backup.copy_archive_to_icloud(first, icloud_dir, timeout_seconds=1) + second_result = jsonl_backup.copy_archive_to_icloud(second, icloud_dir, timeout_seconds=1) + + assert first_result["path"] == second_result["path"] + assert list(icloud_dir.iterdir()) == [Path(second_result["path"])] + assert second_result["reused"] is True + assert Path(second_result["path"]).read_bytes() == first.read_bytes() + + +def test_icloud_retry_waits_for_existing_logical_object_to_materialize(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + first = tmp_path / "first" / "claude-jsonl-2026-09-09.tar.gz" + second = tmp_path / "second" / first.name + first.parent.mkdir() + second.parent.mkdir() + first.write_bytes(gzip.compress(b"same tar payload", mtime=1)) + second.write_bytes(gzip.compress(b"same tar payload", mtime=2)) + icloud_dir = tmp_path / "CloudDocs" + monkeypatch.setattr( + jsonl_backup, + "_icloud_item_state", + lambda *args, **kwargs: _icloud_state(uploaded=True, status="current"), + ) + jsonl_backup.copy_archive_to_icloud(first, icloud_dir, timeout_seconds=1) + + states = iter( + [ + _icloud_state(uploaded=False, status="notDownloaded"), + _icloud_state(uploaded=True, status="current"), + ] + ) + observed: list[dict] = [] + + def pending_then_current(*args, **kwargs): # noqa: ARG001 + state = next(states) + observed.append(state) + return state + + monkeypatch.setattr(jsonl_backup, "_icloud_item_state", pending_then_current) + result = jsonl_backup.copy_archive_to_icloud(second, icloud_dir, timeout_seconds=1, poll_interval_seconds=0) + + assert len(observed) == 2 + assert result["reused"] is True + + +def test_existing_icloud_probe_cannot_restart_timeout_for_repair(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + archive = tmp_path / "archive.tar.gz" + archive.write_bytes(gzip.compress(b"same tar payload", mtime=1)) + icloud_dir = tmp_path / "CloudDocs" + icloud_dir.mkdir() + logical_sha256 = jsonl_backup._sha256_gzip_payload(archive) + destination = icloud_dir / f"claude-jsonl-{logical_sha256}.tar.gz" + destination.write_bytes(archive.read_bytes()) + clock = [0.0] + calls = 0 + + def exhaust_deadline(*args, **kwargs): # noqa: ARG001 + nonlocal calls + calls += 1 + clock[0] = 2.0 + raise RuntimeError("existing iCloud probe timed out") + + monkeypatch.setattr(jsonl_backup.time, "monotonic", lambda: clock[0]) + monkeypatch.setattr(jsonl_backup, "_icloud_item_state", exhaust_deadline) + + with pytest.raises(RuntimeError, match="existing iCloud probe timed out"): + jsonl_backup.copy_archive_to_icloud(archive, icloud_dir, timeout_seconds=1) + + assert calls == 1 + + +def test_existing_verified_object_is_not_quarantined_when_local_deadline_expires(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + archive = tmp_path / "archive.tar.gz" + archive.write_bytes(gzip.compress(b"same tar payload", mtime=1)) + icloud_dir = tmp_path / "CloudDocs" + icloud_dir.mkdir() + logical_sha256 = jsonl_backup._sha256_gzip_payload(archive) + destination = icloud_dir / f"claude-jsonl-{logical_sha256}.tar.gz" + destination.write_bytes(archive.read_bytes()) + original_hash = jsonl_backup._sha256_gzip_payload + + def expire_on_existing(path, **kwargs): + if Path(path) == destination: + raise jsonl_backup.ICloudDeadlineExceeded("deadline") + return original_hash(path, **kwargs) + + monkeypatch.setattr( + jsonl_backup, + "_icloud_item_state", + lambda *args, **kwargs: _icloud_state(uploaded=True, status="current"), + ) + monkeypatch.setattr(jsonl_backup, "_sha256_gzip_payload", expire_on_existing) + + with pytest.raises(jsonl_backup.ICloudDeadlineExceeded): + jsonl_backup.copy_archive_to_icloud(archive, icloud_dir, timeout_seconds=1) + + assert destination.is_file() + assert list(icloud_dir.glob("*.unverified")) == [] + + +def test_existing_verified_object_is_not_quarantined_when_polling_reaches_deadline(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + archive = tmp_path / "archive.tar.gz" + archive.write_bytes(gzip.compress(b"same tar payload", mtime=1)) + icloud_dir = tmp_path / "CloudDocs" + icloud_dir.mkdir() + logical_sha256 = jsonl_backup._sha256_gzip_payload(archive) + destination = icloud_dir / f"claude-jsonl-{logical_sha256}.tar.gz" + destination.write_bytes(archive.read_bytes()) + clock = [0.0] + + def pending_until_deadline(*args, **kwargs): # noqa: ARG001 + clock[0] = 2.0 + return _icloud_state(uploaded=True, status="notDownloaded") + + monkeypatch.setattr(jsonl_backup.time, "monotonic", lambda: clock[0]) + monkeypatch.setattr(jsonl_backup, "_icloud_item_state", pending_until_deadline) + + with pytest.raises(jsonl_backup.ICloudDeadlineExceeded): + jsonl_backup.copy_archive_to_icloud(archive, icloud_dir, timeout_seconds=1) + + assert destination.is_file() + assert list(icloud_dir.glob("*.unverified")) == [] + + +def test_existing_verified_object_is_not_quarantined_when_status_probe_times_out(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + archive = tmp_path / "archive.tar.gz" + archive.write_bytes(gzip.compress(b"same tar payload", mtime=1)) + icloud_dir = tmp_path / "CloudDocs" + icloud_dir.mkdir() + logical_sha256 = jsonl_backup._sha256_gzip_payload(archive) + destination = icloud_dir / f"claude-jsonl-{logical_sha256}.tar.gz" + destination.write_bytes(archive.read_bytes()) + timeout = subprocess.TimeoutExpired(["osascript"], 1) + monkeypatch.setattr( + jsonl_backup, + "_icloud_item_state", + lambda *args, **kwargs: (_ for _ in ()).throw(timeout), + ) + + with pytest.raises(subprocess.TimeoutExpired): + jsonl_backup.copy_archive_to_icloud(archive, icloud_dir, timeout_seconds=1) + + assert destination.is_file() + assert list(icloud_dir.glob("*.unverified")) == [] + + +@pytest.mark.parametrize("message", ["Foundation transport failed", "iCloud status returned invalid JSON"]) +def test_existing_verified_object_is_not_quarantined_when_status_probe_is_inconclusive(tmp_path, monkeypatch, message): + from brainlayer import jsonl_backup + + archive = tmp_path / "archive.tar.gz" + archive.write_bytes(gzip.compress(b"same tar payload", mtime=1)) + icloud_dir = tmp_path / "CloudDocs" + icloud_dir.mkdir() + logical_sha256 = jsonl_backup._sha256_gzip_payload(archive) + destination = icloud_dir / f"claude-jsonl-{logical_sha256}.tar.gz" + destination.write_bytes(archive.read_bytes()) + error = jsonl_backup.ICloudProbeError(message) + monkeypatch.setattr( + jsonl_backup, + "_icloud_item_state", + lambda *args, **kwargs: (_ for _ in ()).throw(error), + ) + + with pytest.raises(jsonl_backup.ICloudProbeError, match=message): + jsonl_backup.copy_archive_to_icloud(archive, icloud_dir, timeout_seconds=1) + + assert destination.is_file() + assert list(icloud_dir.glob("*.unverified")) == [] + + +def test_icloud_deadline_starts_before_first_archive_scan(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + archive = tmp_path / "archive.tar.gz" + archive.write_bytes(gzip.compress(b"payload", mtime=1)) + observed_deadlines: list[float | None] = [] + + def stop_first_scan(path, *, deadline=None): # noqa: ARG001 + observed_deadlines.append(deadline) + raise RuntimeError("scan stopped") + + monkeypatch.setattr(jsonl_backup.time, "monotonic", lambda: 10.0) + monkeypatch.setattr(jsonl_backup, "_sha256_file", stop_first_scan) + + with pytest.raises(RuntimeError, match="scan stopped"): + jsonl_backup.copy_archive_to_icloud(archive, tmp_path / "CloudDocs", timeout_seconds=5) + + assert observed_deadlines == [15.0] + + +def test_icloud_copy_deadline_blocks_status_probe(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + archive = tmp_path / "archive.tar.gz" + archive.write_bytes(b"payload") + clock = [0.0] + status_calls = 0 + real_check = jsonl_backup._check_icloud_deadline + + monkeypatch.setattr(jsonl_backup.time, "monotonic", lambda: clock[0]) + monkeypatch.setattr(jsonl_backup, "_sha256_file", lambda path, **kwargs: "a" * 64) + monkeypatch.setattr(jsonl_backup, "_sha256_gzip_payload", lambda path, **kwargs: "b" * 64) + + def expire_during_copy(deadline, phase): + if phase.startswith("copying "): + clock[0] = 2.0 + real_check(deadline, phase) + + def count_status(*args, **kwargs): # noqa: ARG001 + nonlocal status_calls + status_calls += 1 + return _icloud_state(uploaded=True, status="current") + + monkeypatch.setattr(jsonl_backup, "_check_icloud_deadline", expire_during_copy) + monkeypatch.setattr(jsonl_backup, "_icloud_item_state", count_status) + + with pytest.raises(RuntimeError, match="deadline exceeded during copying"): + jsonl_backup.copy_archive_to_icloud(archive, tmp_path / "CloudDocs", timeout_seconds=1) + + assert status_calls == 0 + + +def test_icloud_retry_preserves_prior_verified_logical_object(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + first = tmp_path / "first" / "claude-jsonl-2026-09-09.tar.gz" + second = tmp_path / "second" / first.name + first.parent.mkdir() + second.parent.mkdir() + first.write_bytes(gzip.compress(b"same tar payload", mtime=1)) + second.write_bytes(gzip.compress(b"same tar payload", mtime=2)) + icloud_dir = tmp_path / "CloudDocs" + monkeypatch.setattr( + jsonl_backup, + "_icloud_item_state", + lambda *args, **kwargs: _icloud_state(uploaded=True, status="current"), + ) + first_result = jsonl_backup.copy_archive_to_icloud(first, icloud_dir, timeout_seconds=1) + destination = Path(first_result["path"]) + + def only_prior_object_is_verified(path, **kwargs): # noqa: ARG001 + if Path(path).read_bytes() == first.read_bytes(): + return _icloud_state(uploaded=True, status="current") + raise RuntimeError("replacement upload failed") + + monkeypatch.setattr(jsonl_backup, "_icloud_item_state", only_prior_object_is_verified) + second_result = jsonl_backup.copy_archive_to_icloud(second, icloud_dir, timeout_seconds=1) + + assert second_result["reused"] is True + assert destination.read_bytes() == first.read_bytes() + assert list(icloud_dir.iterdir()) == [destination] + + +def test_stale_icloud_object_is_quarantined_before_fresh_upload(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + archive = tmp_path / "archive.tar.gz" + archive.write_bytes(b"fresh archive") + logical_sha256 = hashlib.sha256(archive.read_bytes()).hexdigest() + icloud_dir = tmp_path / "CloudDocs" + icloud_dir.mkdir() + destination = icloud_dir / f"claude-jsonl-{logical_sha256}.tar.gz" + destination.write_bytes(b"stale object") + states = iter( + [ + _icloud_state(uploaded=False, status="notDownloaded") | {"uploading_error": "stale"}, + _icloud_state(uploaded=True, status="current"), + ] + ) + monkeypatch.setattr(jsonl_backup, "_icloud_item_state", lambda *args, **kwargs: next(states)) + + result = jsonl_backup.copy_archive_to_icloud(archive, icloud_dir, timeout_seconds=1) + + assert result["reused"] is False + assert destination.read_bytes() == archive.read_bytes() + quarantined = list(icloud_dir.glob("*.unverified")) + assert len(quarantined) == 1 + assert quarantined[0].read_bytes() == b"stale object" + + +def test_icloud_poll_sleep_cannot_overshoot_deadline(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + archive = tmp_path / "archive.tar.gz" + archive.write_bytes(b"bytes") + clock = [0.0] + states = iter( + [_icloud_state(uploaded=False, status="notDownloaded"), _icloud_state(uploaded=True, status="current")] + ) + sleeps: list[float] = [] + monkeypatch.setattr(jsonl_backup.time, "monotonic", lambda: clock[0]) + monkeypatch.setattr(jsonl_backup.time, "sleep", sleeps.append) + + def pending_then_current(*args, **kwargs): # noqa: ARG001 + clock[0] = 0.75 + return next(states) + + monkeypatch.setattr(jsonl_backup, "_icloud_item_state", pending_then_current) + + jsonl_backup.copy_archive_to_icloud(archive, tmp_path / "CloudDocs", timeout_seconds=1, poll_interval_seconds=10) + + assert sleeps == [0.25] + + +def test_jsonl_backup_does_not_advance_state_until_icloud_copy_is_verified(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + now = time.time() + source_root = tmp_path / "sessions" + source_file = _write_jsonl(source_root / "changed.jsonl", mtime=now - 3600) + state_path = tmp_path / "state.json" + drive_uploads: list[Path] = [] + + _mock_drive_success(jsonl_backup, monkeypatch, drive_uploads) + monkeypatch.setattr( + jsonl_backup, + "copy_archive_to_icloud", + lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("iCloud verification failed")), + ) + + with pytest.raises(RuntimeError, match="iCloud verification failed"): + jsonl_backup.run_backup( + source_roots=[source_root], + state_path=state_path, + staging_dir=tmp_path / "staging", + log_path=tmp_path / "jsonl-backup.log", + queue_dir=tmp_path / "queue", + icloud_dir=tmp_path / "CloudDocs" / "Archives" / "brainlayer-jsonl-backups", + date_stamp="2026-09-09", + now=now, + upload=True, + ) + + assert not state_path.exists() + assert source_file.exists() + assert drive_uploads == [] + + +def test_run_backup_reuses_one_icloud_deadline_for_inventory_and_repair(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + now = time.time() + source_root = tmp_path / "sessions" + _write_jsonl(source_root / "changed.jsonl", mtime=now - 3600) + icloud_dir = tmp_path / "CloudDocs" + observed: list[tuple[str, float | None]] = [] + + _mock_drive_success(jsonl_backup, monkeypatch) + + def inventory(*args, deadline=None, **kwargs): # noqa: ARG001 + observed.append(("inventory", deadline)) + return False + + def copy(archive, destination, *, deadline=None, **kwargs): # noqa: ARG001 + observed.append(("copy", deadline)) + return _copy_to_icloud_receipt(Path(archive), Path(destination)) + + monkeypatch.setattr(jsonl_backup, "_icloud_inventory_is_verified", inventory) + monkeypatch.setattr(jsonl_backup, "copy_archive_to_icloud", copy) + + jsonl_backup.run_backup( + source_roots=[source_root], + state_path=tmp_path / "state.json", + staging_dir=tmp_path / "staging", + log_path=tmp_path / "backup.log", + queue_dir=tmp_path / "queue", + icloud_dir=icloud_dir, + now=now, + upload=True, + ) + + assert observed[0][0] == "inventory" + assert observed[1][0] == "copy" + assert observed[0][1] is not None + assert observed[0][1] == observed[1][1] + + +def test_enabling_icloud_bootstraps_files_covered_only_by_legacy_drive_state(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + now = time.time() + source_root = tmp_path / "sessions" + source_file = _write_jsonl(source_root / "legacy-covered.jsonl", mtime=now - 3600) + state_path = tmp_path / "state.json" + state_path.write_text( + json.dumps( + { + "files": { + source_file.as_posix(): {"mtime": source_file.stat().st_mtime, "size": source_file.stat().st_size} + } + } + ) + ) + icloud_dir = tmp_path / "CloudDocs" / "Archives" / "brainlayer-jsonl-backups" + + _mock_drive_success(jsonl_backup, monkeypatch) + monkeypatch.setattr( + jsonl_backup, + "copy_archive_to_icloud", + _copy_to_icloud_receipt, + ) + + result = jsonl_backup.run_backup( + source_roots=[source_root], + state_path=state_path, + staging_dir=tmp_path / "staging", + log_path=tmp_path / "jsonl-backup.log", + queue_dir=tmp_path / "queue", + icloud_dir=icloud_dir, + date_stamp="2026-09-09", + now=now, + upload=True, + ) + + assert result["status"] == "uploaded" + assert result["bundled_file_count"] == 1 + state = json.loads(state_path.read_text()) + assert state["icloud_directory"] == str(icloud_dir) + assert state["icloud_verified"] is True + assert state["files"][source_file.as_posix()]["icloud_archive"] + assert state["icloud_archives"] + + +def test_missing_recorded_icloud_archive_forces_opt_in_rebootstrap(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + now = time.time() + source_root = tmp_path / "sessions" + source_file = _write_jsonl(source_root / "covered.jsonl", mtime=now - 3600) + icloud_dir = tmp_path / "CloudDocs" / "Archives" / "brainlayer-jsonl-backups" + missing_name = "claude-jsonl-missing.tar.gz" + state_path = tmp_path / "state.json" + state_path.write_text( + json.dumps( + { + "files": { + source_file.as_posix(): { + "mtime": source_file.stat().st_mtime, + "size": source_file.stat().st_size, + "sha256": hashlib.sha256(source_file.read_bytes()).hexdigest(), + "archive": "drive.tar.gz", + "archive_id": "drive-id", + "icloud_archive": missing_name, + } + }, + "icloud_directory": str(icloud_dir), + "icloud_verified": True, + "icloud_archives": { + missing_name: {"bytes": 123, "sha256": "0" * 64}, + }, + } + ) + ) + copied: list[Path] = [] + + _mock_drive_success(jsonl_backup, monkeypatch) + monkeypatch.setattr(jsonl_backup, "_list_surviving_archives", lambda *args, **kwargs: {"drive-id": None}) + + def copy(archive, destination, **kwargs): # noqa: ARG001 + copied.append(Path(archive)) + return _copy_to_icloud_receipt(Path(archive), Path(destination)) + + monkeypatch.setattr(jsonl_backup, "copy_archive_to_icloud", copy) + + result = jsonl_backup.run_backup( + source_roots=[source_root], + state_path=state_path, + staging_dir=tmp_path / "staging", + log_path=tmp_path / "jsonl-backup.log", + queue_dir=tmp_path / "queue", + icloud_dir=icloud_dir, + date_stamp="2026-09-09", + now=now, + upload=True, + ) + + assert result["status"] == "uploaded" + assert result["bundled_file_count"] == 1 + assert len(copied) == 1 + + +def test_missing_icloud_archive_for_vanished_source_fails_loudly(tmp_path): + from brainlayer import jsonl_backup + + now = time.time() + source_root = tmp_path / "sessions" + vanished = _write_jsonl(source_root / "vanished.jsonl", mtime=now - 3600) + stat = vanished.stat() + vanished.unlink() + icloud_dir = tmp_path / "CloudDocs" + state_path = tmp_path / "state.json" + state_path.write_text( + json.dumps( + { + "files": { + vanished.as_posix(): { + "mtime": stat.st_mtime, + "size": stat.st_size, + "icloud_archive": "missing.tar.gz", + } + }, + "icloud_directory": str(icloud_dir), + "icloud_verified": True, + "icloud_archives": {"missing.tar.gz": {"bytes": 123, "sha256": "0" * 64}}, + } + ) + ) + + with pytest.raises(RuntimeError, match="source is unavailable"): + jsonl_backup.run_backup( + source_roots=[source_root], + state_path=state_path, + staging_dir=tmp_path / "staging", + log_path=tmp_path / "jsonl-backup.log", + queue_dir=tmp_path / "queue", + icloud_dir=icloud_dir, + date_stamp="2026-09-09", + now=now, + upload=True, + ) + + +def test_icloud_inventory_still_validates_archives_for_vanished_sources(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + source_path = (tmp_path / "sessions" / "vanished.jsonl").as_posix() + icloud_dir = tmp_path / "CloudDocs" + icloud_dir.mkdir() + archive_name = "vanished-source.tar.gz" + archive = icloud_dir / archive_name + archive.write_bytes(b"only remaining copy") + probes: list[Path] = [] + + def probe(path, **kwargs): + probes.append(Path(path)) + return _icloud_state(uploaded=True, status="current") + + monkeypatch.setattr(jsonl_backup, "_icloud_item_state", probe) + state = { + "files": { + source_path: { + "mtime": 1.0, + "size": 10, + "icloud_archive": archive_name, + } + }, + "icloud_directory": str(icloud_dir), + "icloud_verified": True, + "icloud_archives": { + archive_name: { + "bytes": archive.stat().st_size, + "sha256": hashlib.sha256(archive.read_bytes()).hexdigest(), + } + }, + } + hash_deadlines: list[float | None] = [] + original_hash = jsonl_backup._sha256_file + + def hash_with_deadline(path, *, deadline=None): + hash_deadlines.append(deadline) + return original_hash(path, deadline=deadline) + + monkeypatch.setattr(jsonl_backup, "_sha256_file", hash_with_deadline) + + assert jsonl_backup._icloud_inventory_is_verified(state, [], icloud_dir, timeout_seconds=1) + assert probes == [archive] + assert len(hash_deadlines) == 1 + assert hash_deadlines[0] is not None + + +def test_icloud_inventory_preserves_later_valid_receipts_after_one_archive_fails(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + icloud_dir = tmp_path / "CloudDocs" + icloud_dir.mkdir() + source_root = tmp_path / "sessions" + first = _write_jsonl(source_root / "first.jsonl", mtime=1.0) + second = _write_jsonl(source_root / "second.jsonl", mtime=1.0) + candidates = jsonl_backup._discover_jsonl_candidates([source_root]) + good_archive = icloud_dir / "z-good.tar.gz" + good_archive.write_bytes(b"verified archive") + state = { + "files": { + first.as_posix(): { + "mtime": first.stat().st_mtime, + "size": first.stat().st_size, + "icloud_archive": "a-missing.tar.gz", + }, + second.as_posix(): { + "mtime": second.stat().st_mtime, + "size": second.stat().st_size, + "icloud_archive": good_archive.name, + }, + }, + "icloud_directory": str(icloud_dir), + "icloud_verified": True, + "icloud_archives": { + "a-missing.tar.gz": {"bytes": 1, "sha256": "0" * 64}, + good_archive.name: { + "bytes": good_archive.stat().st_size, + "sha256": hashlib.sha256(good_archive.read_bytes()).hexdigest(), + }, + }, + } + validated_sources: set[str] = set() + monkeypatch.setattr( + jsonl_backup, + "_icloud_item_state", + lambda *args, **kwargs: _icloud_state(uploaded=True, status="current"), + ) + + assert not jsonl_backup._icloud_inventory_is_verified( + state, + candidates, + icloud_dir, + timeout_seconds=1, + validated_sources=validated_sources, + ) + assert validated_sources == {second.as_posix()} + + +def test_icloud_inventory_stops_when_receipt_hash_exhausts_shared_deadline(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + source_root = tmp_path / "sessions" + source = _write_jsonl(source_root / "covered.jsonl", mtime=1.0) + candidates = jsonl_backup._discover_jsonl_candidates([source_root]) + icloud_dir = tmp_path / "CloudDocs" + icloud_dir.mkdir() + archive = icloud_dir / "covered.tar.gz" + archive.write_bytes(b"verified archive") + state = { + "files": { + source.as_posix(): { + "mtime": source.stat().st_mtime, + "size": source.stat().st_size, + "icloud_archive": archive.name, + } + }, + "icloud_directory": str(icloud_dir), + "icloud_verified": True, + "icloud_archives": { + archive.name: { + "bytes": archive.stat().st_size, + "sha256": hashlib.sha256(archive.read_bytes()).hexdigest(), + } + }, + } + clock = [0.0] + + def exhaust_during_probe(*args, **kwargs): # noqa: ARG001 + clock[0] = 2.0 + return _icloud_state(uploaded=True, status="current") + + monkeypatch.setattr(jsonl_backup.time, "monotonic", lambda: clock[0]) + monkeypatch.setattr(jsonl_backup, "_icloud_item_state", exhaust_during_probe) + + with pytest.raises(jsonl_backup.ICloudDeadlineExceeded): + jsonl_backup._icloud_inventory_is_verified( + state, + candidates, + icloud_dir, + timeout_seconds=1, + ) + + +def test_icloud_inventory_ignores_vanished_legacy_entry_without_icloud_receipt(tmp_path): + from brainlayer import jsonl_backup + + icloud_dir = tmp_path / "CloudDocs" + state = { + "files": { + (tmp_path / "vanished.jsonl").as_posix(): { + "mtime": 1.0, + "size": 10, + "archive": "drive-only.tar.gz", + "archive_id": "drive-id", + } + }, + "icloud_directory": str(icloud_dir), + "icloud_verified": True, + "icloud_archives": {}, + } + + assert jsonl_backup._icloud_inventory_is_verified(state, [], icloud_dir) + + +def test_icloud_inventory_rehydrates_placeholder_and_checks_exact_receipt(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + now = time.time() + source_root = tmp_path / "sessions" + source_file = _write_jsonl(source_root / "covered.jsonl", mtime=now - 3600) + candidate = jsonl_backup._discover_jsonl_candidates([source_root])[0] + icloud_dir = tmp_path / "CloudDocs" + icloud_dir.mkdir() + archive_name = "claude-jsonl-covered.tar.gz" + archive_bytes = b"durable iCloud archive" + placeholder = icloud_dir / f".{archive_name}.icloud" + placeholder.write_bytes(b"") + calls: list[Path] = [] + + def materialize(path, *, request_download=False, timeout_seconds=None): + calls.append(Path(path)) + assert request_download is True + assert timeout_seconds is not None and timeout_seconds > 0 + placeholder.unlink() + (icloud_dir / archive_name).write_bytes(archive_bytes) + return _icloud_state(uploaded=True, status="current") + + monkeypatch.setattr(jsonl_backup, "_icloud_item_state", materialize) + state = { + "files": { + source_file.as_posix(): { + "mtime": source_file.stat().st_mtime, + "size": source_file.stat().st_size, + "icloud_archive": archive_name, + } + }, + "icloud_directory": str(icloud_dir), + "icloud_verified": True, + "icloud_archives": { + archive_name: { + "bytes": len(archive_bytes), + "sha256": hashlib.sha256(archive_bytes).hexdigest(), + } + }, + } + + assert jsonl_backup._icloud_inventory_is_verified(state, [candidate], icloud_dir, timeout_seconds=1) + assert calls == [placeholder] + + +def test_icloud_inventory_does_not_swallow_wall_clock_timeout(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + source = _write_jsonl(tmp_path / "source.jsonl", mtime=time.time() - 3600) + candidate = jsonl_backup.JsonlCandidate( + path=source, + root=tmp_path, + root_index=0, + mtime=source.stat().st_mtime, + size=source.stat().st_size, + ) + icloud_dir = tmp_path / "CloudDocs" + icloud_dir.mkdir() + archive_name = "archive.tar.gz" + (icloud_dir / archive_name).write_bytes(b"archive") + state = { + "files": { + source.as_posix(): { + "mtime": candidate.mtime, + "size": candidate.size, + "icloud_archive": archive_name, + } + }, + "icloud_directory": str(icloud_dir), + "icloud_verified": True, + "icloud_archives": {archive_name: {"bytes": 7, "sha256": "0" * 64}}, + } + monkeypatch.setattr( + jsonl_backup, + "_icloud_item_state", + lambda *args, **kwargs: (_ for _ in ()).throw(jsonl_backup.backup_daily.BackupTimeoutError("deadline")), + ) + + with pytest.raises(jsonl_backup.backup_daily.BackupTimeoutError, match="deadline"): + jsonl_backup._icloud_inventory_is_verified(state, [candidate], icloud_dir) + + +def test_icloud_inventory_quarantines_known_bad_materialized_archive(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + source = _write_jsonl(tmp_path / "source.jsonl", mtime=time.time() - 3600) + candidate = jsonl_backup.JsonlCandidate( + path=source, + root=tmp_path, + root_index=0, + mtime=source.stat().st_mtime, + size=source.stat().st_size, + ) + icloud_dir = tmp_path / "CloudDocs" + icloud_dir.mkdir() + archive_name = "archive.tar.gz" + archive = icloud_dir / archive_name + archive.write_bytes(b"corrupt archive") + state = { + "files": { + source.as_posix(): { + "mtime": candidate.mtime, + "size": candidate.size, + "icloud_archive": archive_name, + } + }, + "icloud_directory": str(icloud_dir), + "icloud_verified": True, + "icloud_archives": { + archive_name: { + "bytes": len(b"expected archive"), + "sha256": hashlib.sha256(b"expected archive").hexdigest(), + } + }, + } + monkeypatch.setattr( + jsonl_backup, + "_icloud_item_state", + lambda *args, **kwargs: _icloud_state(uploaded=True, status="current"), + ) + + assert not jsonl_backup._icloud_inventory_is_verified(state, [candidate], icloud_dir) + assert not archive.exists() + quarantined = list(icloud_dir.glob("*.unverified")) + assert len(quarantined) == 1 + assert quarantined[0].read_bytes() == b"corrupt archive" + + +def test_drive_only_change_invalidates_that_sources_icloud_receipt(tmp_path): + from brainlayer import jsonl_backup + + now = time.time() + source_file = _write_jsonl(tmp_path / "changed.jsonl", mtime=now - 3600) + candidate = jsonl_backup.JsonlCandidate( + path=source_file, + root=tmp_path, + root_index=0, + mtime=source_file.stat().st_mtime, + size=source_file.stat().st_size, + ) + state = { + "files": { + source_file.as_posix(): { + "mtime": candidate.mtime - 1, + "size": candidate.size, + "icloud_archive": "old.tar.gz", + } + }, + "icloud_directory": str(tmp_path / "CloudDocs"), + "icloud_verified": True, + "icloud_archives": {"old.tar.gz": {"bytes": 1, "sha256": "0" * 64}}, + } + + updated = jsonl_backup._update_state_for_uploaded( + state, + [candidate], + "drive.tar.gz", + archive_id="drive-id", + icloud_dir=None, + ) + + assert updated["icloud_verified"] is True + assert "icloud_archive" not in updated["files"][source_file.as_posix()] + assert updated["files"][source_file.as_posix()]["icloud_required"] is True + assert "icloud_archives" not in updated + assert not jsonl_backup._icloud_inventory_is_verified(updated, [candidate], tmp_path / "CloudDocs") + + +def test_vanished_drive_only_update_fails_icloud_revalidation_loudly(tmp_path): + from brainlayer import jsonl_backup + + source_path = (tmp_path / "vanished.jsonl").as_posix() + state = { + "files": {source_path: {"mtime": 1.0, "size": 10, "icloud_required": True}}, + "icloud_directory": str(tmp_path / "CloudDocs"), + "icloud_verified": True, + } + + with pytest.raises(RuntimeError, match="required source is unavailable"): + jsonl_backup._icloud_inventory_is_verified(state, [], tmp_path / "CloudDocs") + + +def test_changed_icloud_directory_fails_when_prior_covered_source_is_unavailable(tmp_path): + from brainlayer import jsonl_backup + + source_path = (tmp_path / "vanished.jsonl").as_posix() + state = { + "files": { + source_path: { + "mtime": 1.0, + "size": 10, + "icloud_archive": "prior.tar.gz", + } + }, + "icloud_directory": str(tmp_path / "OldCloudDocs"), + "icloud_verified": True, + "icloud_archives": {"prior.tar.gz": {"bytes": 10, "sha256": "0" * 64}}, + } + + with pytest.raises(RuntimeError, match="new destination cannot be seeded"): + jsonl_backup._icloud_inventory_is_verified(state, [], tmp_path / "NewCloudDocs") + + +def test_icloud_bootstrap_with_active_source_does_not_mark_complete(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + now = time.time() + source_root = tmp_path / "sessions" + inactive = _write_jsonl(source_root / "inactive.jsonl", mtime=now - 3600) + active = _write_jsonl(source_root / "active.jsonl", mtime=now - 60) + state_path = tmp_path / "state.json" + state_path.write_text( + json.dumps( + { + "files": { + path.as_posix(): {"mtime": path.stat().st_mtime, "size": path.stat().st_size} + for path in (inactive, active) + } + } + ) + ) + icloud_dir = tmp_path / "CloudDocs" / "Archives" / "brainlayer-jsonl-backups" + + _mock_drive_success(jsonl_backup, monkeypatch) + monkeypatch.setattr( + jsonl_backup, + "copy_archive_to_icloud", + _copy_to_icloud_receipt, + ) + + result = jsonl_backup.run_backup( + source_roots=[source_root], + state_path=state_path, + staging_dir=tmp_path / "staging", + log_path=tmp_path / "jsonl-backup.log", + queue_dir=tmp_path / "queue", + icloud_dir=icloud_dir, + date_stamp="2026-09-09", + now=now, + upload=True, + ) + + assert result["bundled_file_count"] == 1 + assert result["skipped_active_count"] == 1 + state = json.loads(state_path.read_text()) + assert state["icloud_directory"] == str(icloud_dir) + assert "icloud_verified" not in state + candidates = jsonl_backup._discover_jsonl_candidates([source_root]) + validated_sources: set[str] = set() + monkeypatch.setattr( + jsonl_backup, + "_icloud_item_state", + lambda *args, **kwargs: _icloud_state(uploaded=True, status="current"), + ) + + assert not jsonl_backup._icloud_inventory_is_verified( + state, + candidates, + icloud_dir, + validated_sources=validated_sources, + ) + assert validated_sources == {inactive.as_posix()} + + first_archive = state["files"][inactive.as_posix()]["icloud_archive"] + monkeypatch.setattr(jsonl_backup, "_list_surviving_archives", lambda *args, **kwargs: {"drive-id": None}) + second = jsonl_backup.run_backup( + source_roots=[source_root], + state_path=state_path, + staging_dir=tmp_path / "staging", + log_path=tmp_path / "jsonl-backup.log", + queue_dir=tmp_path / "queue", + icloud_dir=icloud_dir, + date_stamp="2026-09-10", + now=now + 3600, + upload=True, + ) + + assert second["bundled_file_count"] == 1 + completed_state = json.loads(state_path.read_text()) + assert completed_state["files"][inactive.as_posix()]["icloud_archive"] == first_archive + assert completed_state["files"][active.as_posix()]["icloud_archive"] != first_archive + assert completed_state["icloud_verified"] is True + + +def test_invalid_icloud_inventory_with_only_active_sources_defers_instead_of_verifying(tmp_path, monkeypatch): + from brainlayer import jsonl_backup + + now = time.time() + source_root = tmp_path / "sessions" + source = _write_jsonl(source_root / "active.jsonl", mtime=now - 60) + state_path = tmp_path / "state.json" + state_path.write_text( + json.dumps( + { + "files": { + source.as_posix(): { + "mtime": source.stat().st_mtime, + "size": source.stat().st_size, + } + } + } + ) + ) + + result = jsonl_backup.run_backup( + source_roots=[source_root], + state_path=state_path, + staging_dir=tmp_path / "staging", + log_path=tmp_path / "jsonl-backup.log", + queue_dir=tmp_path / "queue", + icloud_dir=tmp_path / "CloudDocs" / "Archives" / "brainlayer-jsonl-backups", + date_stamp="2026-09-09", + now=now, + upload=True, + ) + + assert result["status"] == "deferred" + assert result["verified"] is False + assert result["uploaded"] is False + assert result["skipped_active_count"] == 1 + assert "iCloud coverage" in result["error"] + assert json.loads(state_path.read_text())["files"][source.as_posix()]["size"] == source.stat().st_size + + +def test_drive_only_state_update_preserves_prior_icloud_coverage(): + from brainlayer import jsonl_backup + + state = { + "files": {}, + "icloud_directory": "/CloudDocs/Archives/brainlayer-jsonl-backups", + "icloud_verified": True, + } + + updated = jsonl_backup._update_state_for_uploaded(state, [], icloud_dir=None) + + assert updated["icloud_directory"] == state["icloud_directory"] + assert updated["icloud_verified"] is True + + def test_default_source_roots_append_all_agent_cli_transcript_roots(monkeypatch): from brainlayer import jsonl_backup @@ -1259,14 +2608,26 @@ def test_jsonl_backup_launchd_plist_and_docstring_install_note_are_committed(): assert "0" in plist assert "0" in script_plist assert "BRAINLAYER_BACKUP_TIMEOUT_SECONDS" in plist + assert "__BRAINLAYER_PYTHON__" in plist assert "BRAINLAYER_BACKUP_TIMEOUT_SECONDS" in wrapper + assert "BRAINLAYER_JSONL_BACKUP_ICLOUD_DIR" not in plist + assert "BRAINLAYER_JSONL_BACKUP_ICLOUD_DIR" not in script_plist + assert "BRAINLAYER_JSONL_BACKUP_ICLOUD_DIR" in module + assert "Archives/claude-sessions" not in plist + assert "Archives/claude-sessions" not in script_plist assert "1800" in plist assert "1800" in wrapper assert ".local/share/brainlayer/logs/jsonl-backup.log" in plist assert "jsonl-backup" in install assert "install_jsonl_backup_script" in install - assert "__BRAINLAYER_DIR_VALUE__" in wrapper - assert "PYTHONPATH" in wrapper + assert "HOOK_PYTHON_RESOLVER" in install + assert "resolve_jsonl_backup_python || return 1" in install + assert 'runpy.run_path(path, run_name="__main__")' in install + assert "--print-interpreter" in install + assert "__BRAINLAYER_DIR_VALUE__" not in wrapper + assert '"${BRAINLAYER_PYTHON:?' in wrapper + assert "unset PYTHONPATH" in wrapper + assert "PYTHONPATH" not in plist assert "__HOME__/.local/lib/brainlayer/jsonl-backup.sh" in script_plist assert "SoftResourceLimits" in plist assert "SoftResourceLimits" in script_plist diff --git a/tests/test_launchd_hygiene.py b/tests/test_launchd_hygiene.py index bbdc6742..17c25e54 100644 --- a/tests/test_launchd_hygiene.py +++ b/tests/test_launchd_hygiene.py @@ -517,6 +517,7 @@ def test_launchd_installer_rejects_key_only_enrichment_config(tmp_path): "HOME": str(tmp_path), "BRAINLAYER_BIN": "/usr/bin/true", "PYTHON_BIN": "/usr/bin/true", + "BRAINLAYER_PYTHON": "/usr/bin/true", "BRAINLAYER_ENV_FILE": str(env_file), }, capture_output=True,