diff --git a/hooks/hooks-cursor.json b/hooks/hooks-cursor.json index 710df55..7eb5fb2 100644 --- a/hooks/hooks-cursor.json +++ b/hooks/hooks-cursor.json @@ -3,7 +3,7 @@ "hooks": { "sessionStart": [ { - "command": "export PATH=\"$HOME/.local/bin:$HOME/bin:$PATH\"; sr=session-recall; pgrep -f \"$sr sync\" >/dev/null 2>&1 || { command -v \"$sr\" >/dev/null 2>&1 && (\"$sr\" sync >/tmp/session-recall-index.log 2>&1 &) || echo 'session-recall: CLI not on PATH - history not synced (see README: Keeping the index fresh)' >>/tmp/session-recall-index.log; }" + "command": "export PATH=\"$HOME/.local/bin:$HOME/bin:$PATH\"; log=\"${TMPDIR:-/tmp}/session-recall-sync.log\"; command -v session-recall >/dev/null 2>&1 && (session-recall sync >\"$log\" 2>&1 &) || echo 'session-recall: CLI not on PATH - history not synced (see README: Keeping the index fresh)' >>\"$log\"" } ] } diff --git a/hooks/hooks.json b/hooks/hooks.json index 0385243..74fd480 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "Keep the session-recall index fresh — incremental re-index in the background at session start.", + "description": "Keep session-recall up to date — incremental sync in the background at session start (local index in solo mode, push in hub mode).", "hooks": { "SessionStart": [ { @@ -7,7 +7,7 @@ { "type": "command", "timeout": 10, - "command": "export PATH=\"$HOME/.local/bin:$HOME/bin:$PATH\"; sr=session-recall; pgrep -f \"$sr sync\" >/dev/null 2>&1 || { command -v \"$sr\" >/dev/null 2>&1 && (\"$sr\" sync >/tmp/session-recall-index.log 2>&1 &) || echo 'session-recall: CLI not on PATH - history not synced (see README: Keeping the index fresh)' >>/tmp/session-recall-index.log; }" + "command": "export PATH=\"$HOME/.local/bin:$HOME/bin:$PATH\"; log=\"${TMPDIR:-/tmp}/session-recall-sync.log\"; command -v session-recall >/dev/null 2>&1 && (session-recall sync >\"$log\" 2>&1 &) || echo 'session-recall: CLI not on PATH - history not synced (see README: Keeping the index fresh)' >>\"$log\"" } ] } diff --git a/src/session_recall/cli.py b/src/session_recall/cli.py index c73281f..abe5945 100644 --- a/src/session_recall/cli.py +++ b/src/session_recall/cli.py @@ -54,8 +54,11 @@ def _run_health(store: Store) -> int: # is reported precisely by `index`; health must not crash and must # not use the global DB mtime as a fake conversation timestamp. pass + from .hub.client import CONFIG_PATH as HUB_CONFIG_PATH + secret_files = (HUB_CONFIG_PATH, config.DATA_DIR / "share" / "identity.json") report = check_all( - store, make_embedder(), roots, transcripts, source_timestamps) + store, make_embedder(), roots, transcripts, source_timestamps, + secret_files) width = max(len(d.name) for d in report.dimensions) for d in report.dimensions: @@ -133,19 +136,34 @@ def main(argv=None): return hub_cli.run(args) if args.cmd == "sync": from .hub.client import HubConfig, HubError, push - cfg = HubConfig.load() - if cfg is None: - # Solo install: sync IS the old index run, unchanged. Re-entering - # main() keeps that one behaviour defined in exactly one place. - return main(["index"]) + from .metadocs.lock import acquire_lock, release_lock + # Session start fires this, and sessions overlap: a second run racing + # the first used to mean two indexers fighting over one SQLite file + # ("database is locked") or two pushes re-uploading the same bytes. The + # guard lives here, not in the hook, because the hook is a shell string + # and no shell one-liner is portable — `pgrep` alone does not exist on + # Windows, where it silently degraded to no guard at all. + lock_fd = acquire_lock(config.DATA_DIR, "sync.lock") + if lock_fd is None: + # exit 0: the other run is doing this run's work, which is success + print("session-recall: a sync is already running — stepping aside") + return 0 try: - stats = push(cfg) - except HubError as failure: - print(f"session-recall: hub push failed: {failure}", file=sys.stderr) - return 1 - print(f"pushed {stats['files']} transcript(s), " - f"{stats['uploaded_bytes']} B, {stats['redacted']} secret(s) redacted") - return 1 if stats["failed"] else 0 + cfg = HubConfig.load() + if cfg is None: + # Solo install: sync IS the old index run, unchanged. Re-entering + # main() keeps that one behaviour defined in exactly one place. + return main(["index"]) + try: + stats = push(cfg) + except HubError as failure: + print(f"session-recall: hub push failed: {failure}", file=sys.stderr) + return 1 + print(f"pushed {stats['files']} transcript(s), " + f"{stats['uploaded_bytes']} B, {stats['redacted']} secret(s) redacted") + return 1 if stats["failed"] else 0 + finally: + release_lock(lock_fd) if args.cmd == "metadocs": # reads the index read-only via plain sqlite3 return metadocs_cli.run(args) if args.cmd == "setup": # indexes via a child process (fresh env resolve) diff --git a/src/session_recall/config.py b/src/session_recall/config.py index b79aece..167758e 100644 --- a/src/session_recall/config.py +++ b/src/session_recall/config.py @@ -20,9 +20,19 @@ CODEX_ARCHIVED_SESSIONS = CODEX_HOME / "archived_sessions" -def _default_cursor_db() -> Path: - base = (Path.home() / "Library" / "Application Support" - if sys.platform == "darwin" else Path.home() / ".config") +def _default_cursor_db(platform: str | None = None, env: dict | None = None) -> Path: + """Cursor keeps its per-user state where its VS Code base does, which is a + different directory on each OS — `%APPDATA%` on Windows, not `~/.config`, + which is why a Windows install reported `sources: missing cursor` while the + file sat there all along.""" + platform = sys.platform if platform is None else platform + env = os.environ if env is None else env + if platform == "darwin": + base = Path.home() / "Library" / "Application Support" + elif platform.startswith("win"): + base = Path(env.get("APPDATA") or (Path.home() / "AppData" / "Roaming")) + else: + base = Path(env.get("XDG_CONFIG_HOME") or (Path.home() / ".config")) return base / "Cursor" / "User" / "globalStorage" / "state.vscdb" @@ -104,7 +114,7 @@ def user_lang(env: dict | None = None) -> str | None: return lang if live: try: - stored = (json.loads(SETTINGS_PATH.read_text()).get("lang") or "") + stored = (json.loads(SETTINGS_PATH.read_text(encoding="utf-8")).get("lang") or "") return stored.strip().lower() or None except (OSError, ValueError): return None diff --git a/src/session_recall/cursor.py b/src/session_recall/cursor.py index 5f56db7..ddc3c9c 100644 --- a/src/session_recall/cursor.py +++ b/src/session_recall/cursor.py @@ -451,7 +451,7 @@ def workspace_folder(db_path: Path, workspace_id: str) -> tuple[str, str]: return "", "" ws = db_path.parent.parent / "workspaceStorage" / workspace_id / "workspace.json" try: - folder = json.loads(ws.read_text()).get("folder") or "" + folder = json.loads(ws.read_text(encoding="utf-8")).get("folder") or "" except (OSError, ValueError): return "", "" if folder.startswith("file://"): diff --git a/src/session_recall/health.py b/src/session_recall/health.py index 64758f2..cbcb41a 100644 --- a/src/session_recall/health.py +++ b/src/session_recall/health.py @@ -136,6 +136,29 @@ def check_embed_space(store: Store) -> Dimension: "run `session-recall index` once to attest every source") +def check_secrets(secret_files: tuple[Path, ...]) -> Dimension | None: + """Are the files holding keys actually private? + + Worth a dimension rather than a comment because the answer depends on + where the data directory ended up, and nothing else in the tool would ever + say so. None when this machine holds no such file yet — an empty row would + only be noise before the first `hub join`.""" + from .perms import exposure + + present = [p for p in secret_files if Path(p).exists()] + if not present: + return None + leaks = [(p, why) for p in present if (why := exposure(p))] + if not leaks: + return Dimension("Key files", "GREEN", + f"{len(present)} private to this account") + path, why = leaks[0] + return Dimension( + "Key files", "RED", f"{path.name}: {why}", + "move the data directory back under your home directory, or treat the " + "key as shared and reissue it") + + @dataclass(frozen=True) class Report: dimensions: list[Dimension] @@ -144,7 +167,8 @@ class Report: def check_all(store: Store, embedder, roots: dict[str, Path], transcripts: list[Path], - source_timestamps: tuple[int | float, ...] = ()) -> Report: + source_timestamps: tuple[int | float, ...] = (), + secret_files: tuple[Path, ...] = ()) -> Report: """Every dimension plus one verdict. The verdict is the worst zone present: a single dead dimension makes recall untrustworthy, and averaging would hide it behind everything that still works.""" @@ -155,6 +179,9 @@ def check_all(store: Store, embedder, roots: dict[str, Path], check_corpus(store), check_paths(roots), ] + secrets = check_secrets(secret_files) + if secrets is not None: + dims.append(secrets) order = {"GREEN": 0, "AMBER": 1, "RED": 2} verdict = max((d.zone for d in dims), key=lambda z: order[z]) return Report(dimensions=dims, verdict=verdict) diff --git a/src/session_recall/hub/auth.py b/src/session_recall/hub/auth.py index e68682c..d8845ec 100644 --- a/src/session_recall/hub/auth.py +++ b/src/session_recall/hub/auth.py @@ -22,12 +22,13 @@ import hashlib import json -import os import re import secrets import time from pathlib import Path +from .. import perms + _KEY_RE = re.compile(r"^sr_([a-z0-9][a-z0-9-]{0,31})_([0-9a-f]{32})$") _BEARER_RE = re.compile(r"^Bearer\s+(\S+)$", re.IGNORECASE) @@ -62,15 +63,15 @@ def __init__(self, path: Path, clock=time.time): def _load(self) -> dict: try: - return json.loads(self.path.read_text()) + return json.loads(self.path.read_text(encoding="utf-8")) except (OSError, ValueError): return {} def _save(self, data: dict) -> None: self.path.parent.mkdir(parents=True, exist_ok=True) tmp = self.path.with_suffix(".tmp") - tmp.write_text(json.dumps(data, indent=2, sort_keys=True)) - os.chmod(tmp, 0o600) + tmp.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8") + perms.protect(tmp) tmp.replace(self.path) # atomic: a crash mid-write never truncates the store def issue(self, owner: str, note: str = "") -> str: diff --git a/src/session_recall/hub/client.py b/src/session_recall/hub/client.py index cea8a09..54aeaa3 100644 --- a/src/session_recall/hub/client.py +++ b/src/session_recall/hub/client.py @@ -75,7 +75,7 @@ class HubConfig: @classmethod def load(cls, path: Path | None = None) -> "HubConfig | None": try: - data = json.loads(Path(path or CONFIG_PATH).read_text()) + data = json.loads(Path(path or CONFIG_PATH).read_text(encoding="utf-8")) except (OSError, ValueError): return None if not data.get("url") or not data.get("key"): @@ -89,7 +89,7 @@ def save(self, path: Path | None = None) -> None: # 0600 before the key is written, not after: a world-readable moment # is all it takes on a shared machine. fd = os.open(path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600) - with os.fdopen(fd, "w") as fh: + with os.fdopen(fd, "w", encoding="utf-8") as fh: json.dump({"url": self.url, "key": self.key, "consented": self.consented}, fh, indent=2) diff --git a/src/session_recall/hub/masking.py b/src/session_recall/hub/masking.py index 7d848d1..17127f8 100644 --- a/src/session_recall/hub/masking.py +++ b/src/session_recall/hub/masking.py @@ -33,13 +33,14 @@ import hashlib import json -import os import re import secrets as pysecrets import subprocess import time from pathlib import Path +from .. import perms + MIN_LENGTH = 12 _DOPPLER_TIMEOUT_S = 30 @@ -150,7 +151,7 @@ def build(cls, entries: dict[str, str], salt: str | None = None) -> "SecretMap": @classmethod def load(cls, path: Path) -> "SecretMap": try: - data = json.loads(Path(path).read_text()) + data = json.loads(Path(path).read_text(encoding="utf-8")) except (OSError, ValueError): return cls(salt="", labels={}) return cls(data.get("salt", ""), data.get("labels", {}), @@ -162,8 +163,8 @@ def save(self, path: Path) -> None: tmp = path.with_suffix(".tmp") tmp.write_text(json.dumps( {"salt": self.salt, "labels": self.labels, "updated": self.updated}, - indent=2, sort_keys=True)) - os.chmod(tmp, 0o600) + indent=2, sort_keys=True), encoding="utf-8") + perms.protect(tmp) tmp.replace(path) def __bool__(self) -> bool: diff --git a/src/session_recall/hub/storage.py b/src/session_recall/hub/storage.py index 3a04ccc..ee33e92 100644 --- a/src/session_recall/hub/storage.py +++ b/src/session_recall/hub/storage.py @@ -140,7 +140,7 @@ def _path(self, owner: str) -> Path: def read(self, owner: str) -> dict[str, int]: try: - data = json.loads(self._path(owner).read_text()) + data = json.loads(self._path(owner).read_text(encoding="utf-8")) except (OSError, ValueError): return {} return {k: int(v) for k, v in data.items() if isinstance(v, int)} @@ -151,7 +151,7 @@ def write(self, owner: str, rel: str, received: int) -> None: path = self._path(owner) path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(".tmp") - tmp.write_text(json.dumps(data, sort_keys=True)) + tmp.write_text(json.dumps(data, sort_keys=True), encoding="utf-8") tmp.replace(path) diff --git a/src/session_recall/metadocs/cli.py b/src/session_recall/metadocs/cli.py index dc67258..a8369e6 100644 --- a/src/session_recall/metadocs/cli.py +++ b/src/session_recall/metadocs/cli.py @@ -125,7 +125,7 @@ def run(args: argparse.Namespace) -> int: f"sessions distilled so far: {len(marks.marks)}") log = app_config.DATA_DIR / "metadocs.log" if log.exists(): - tail = log.read_text()[-800:] + tail = log.read_text(encoding="utf-8")[-800:] print(f"--- log tail ---\n{tail.strip()}") return 0 diff --git a/src/session_recall/metadocs/config.py b/src/session_recall/metadocs/config.py index 39bedab..d67bc97 100644 --- a/src/session_recall/metadocs/config.py +++ b/src/session_recall/metadocs/config.py @@ -47,14 +47,15 @@ def load(data_dir: Path | None = None) -> MetaConfig | None: p = config_path(data_dir) if not p.exists(): return None - raw = json.loads(p.read_text()) + raw = json.loads(p.read_text(encoding="utf-8")) return MetaConfig(**raw) def save(cfg: MetaConfig, data_dir: Path | None = None) -> None: p = config_path(data_dir) p.parent.mkdir(parents=True, exist_ok=True) - p.write_text(json.dumps(asdict(cfg), indent=2, ensure_ascii=False)) + p.write_text(json.dumps(asdict(cfg), indent=2, ensure_ascii=False), + encoding="utf-8") class Watermarks: @@ -68,7 +69,7 @@ class Watermarks: def __init__(self, path: Path): self.path = path self.marks: dict[str, int] = ( - json.loads(path.read_text()) if path.exists() else {}) + json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}) def key(self, source: str, session_id: str) -> str: return f"{source}:{session_id}" @@ -83,4 +84,4 @@ def advance(self, source: str, session_id: str, ts: int) -> None: def save(self) -> None: self.path.parent.mkdir(parents=True, exist_ok=True) - self.path.write_text(json.dumps(self.marks)) + self.path.write_text(json.dumps(self.marks), encoding="utf-8") diff --git a/src/session_recall/metadocs/distill.py b/src/session_recall/metadocs/distill.py index 1eb2b02..9b047f6 100644 --- a/src/session_recall/metadocs/distill.py +++ b/src/session_recall/metadocs/distill.py @@ -121,7 +121,7 @@ def distill(project: str, session_key: str, turns: list) -> bool | None: with tempfile.TemporaryDirectory() as empty: cfg_path = Path(empty) / "mcp.json" cfg_path.write_text(json.dumps( - _mcp_config(repo, project, session_key))) + _mcp_config(repo, project, session_key)), encoding="utf-8") argv = [shutil.which("claude") or "claude", "-p", "--no-session-persistence", "--mcp-config", str(cfg_path), diff --git a/src/session_recall/metadocs/entries.py b/src/session_recall/metadocs/entries.py index 91a85aa..5b5426c 100644 --- a/src/session_recall/metadocs/entries.py +++ b/src/session_recall/metadocs/entries.py @@ -95,17 +95,17 @@ def save(repo: Path, entry: Entry) -> Path: entry.updated = _today() path = entry_path(repo, entry) path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(render(entry)) + path.write_text(render(entry), encoding="utf-8") return path def load(repo: Path, entry_id: str) -> Entry | None: for path in repo.glob(f"*/*/{entry_id}.md"): - got = parse(path.read_text()) + got = parse(path.read_text(encoding="utf-8")) if got and got.id == entry_id: return got for path in repo.glob(f"{USER_DIR}/{entry_id}.md"): - got = parse(path.read_text()) + got = parse(path.read_text(encoding="utf-8")) if got and got.id == entry_id: return got return None @@ -136,7 +136,7 @@ def iter_entries(repo: Path, project: str | None = None, if path in seen: continue seen.add(path) - entry = parse(path.read_text()) + entry = parse(path.read_text(encoding="utf-8")) if entry: yield entry @@ -191,7 +191,7 @@ def migrate(repo: Path) -> int: if old.name not in _OLD_FILES: continue project, category = old.parent.name, old.stem - for title, body in _sections(old.read_text()): + for title, body in _sections(old.read_text(encoding="utf-8")): m = _SOURCES_RE.search(body) sources = [s.strip() for s in m.group(1).split(",")] if m else [] body_clean = _SOURCES_RE.sub("", body).strip() @@ -202,8 +202,8 @@ def migrate(repo: Path) -> int: old.unlink() user_map = repo / "USER.md" if user_map.exists(): - for title, body in _sections(user_map.read_text()) or [("Карта данных", - user_map.read_text())]: + raw_user_map = user_map.read_text(encoding="utf-8") + for title, body in _sections(raw_user_map) or [("Карта данных", raw_user_map)]: m = _SOURCES_RE.search(body) sources = [s.strip() for s in m.group(1).split(",")] if m else [] save(repo, Entry(id=new_id("user"), project="", category="user", diff --git a/src/session_recall/metadocs/indexing.py b/src/session_recall/metadocs/indexing.py index 41e117d..47f292b 100644 --- a/src/session_recall/metadocs/indexing.py +++ b/src/session_recall/metadocs/indexing.py @@ -61,7 +61,7 @@ def index_metadocs(store: Store, embedder, repo: Path) -> int: sig = f"{_SIG_TAG}:{_embed_fp()}:{int(st.st_mtime)}:{st.st_size}" if store.is_indexed(str(path), sig): continue - entry = entries.parse(path.read_text()) + entry = entries.parse(path.read_text(encoding="utf-8")) if entry is None: continue # half-written or foreign file: skip, no marker text = f"{entry.title}\n\n{entry.body}" diff --git a/src/session_recall/metadocs/lock.py b/src/session_recall/metadocs/lock.py index 8d22c11..0887b50 100644 --- a/src/session_recall/metadocs/lock.py +++ b/src/session_recall/metadocs/lock.py @@ -1,12 +1,28 @@ """One run at a time. Runs are hours-long against a backlog, so the nightly launchd job WILL overlap a manual run sooner or later — and two runs would -race each other over watermarks and the git repo. flock, not a pid file: the -lock dies with the process, so a crash never wedges the job.""" +race each other over watermarks and the git repo. A kernel lock, not a pid +file: it dies with the process, so a crash never wedges the job. + +Two backends, one contract. POSIX gets `flock`; Windows has no `fcntl` at all, +so it gets `msvcrt.locking`, whose byte-range locks carry the property this +module actually depends on — a second handle onto the same file is refused +even inside the same process, and the lock is released when the process dies. +The import is guarded rather than branched on `sys.platform` so that a +platform without either module fails loudly here, at the one place that knows +what the lock is for, instead of at the first overlapping run.""" -import fcntl import os from pathlib import Path +try: + import fcntl + msvcrt = None +except ModuleNotFoundError: # Windows + fcntl = None + import msvcrt + +_LOCK_BYTES = 1 # msvcrt locks a range; one byte at offset 0 is the whole point + def acquire_lock(data_dir: Path, name: str = "metadocs.lock") -> int | None: """Returns the fd holding the lock, or None when another run owns it. @@ -16,8 +32,28 @@ def acquire_lock(data_dir: Path, name: str = "metadocs.lock") -> int | None: data_dir.mkdir(parents=True, exist_ok=True) fd = os.open(data_dir / name, os.O_CREAT | os.O_WRONLY, 0o600) try: - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + if fcntl is not None: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + else: + # LK_NBLCK fails immediately instead of retrying, matching LOCK_NB. + # Locking past EOF is legal, so the empty lock file needs no bytes. + msvcrt.locking(fd, msvcrt.LK_NBLCK, _LOCK_BYTES) except OSError: os.close(fd) return None return fd + + +def release_lock(fd: int | None) -> None: + """Give the lock back. Closing the fd is enough on both backends — this + exists so callers holding a lock across a long run have one obvious way to + drop it early, and so Windows unlocks the range before the handle goes.""" + if fd is None: + return + if msvcrt is not None: + try: + os.lseek(fd, 0, os.SEEK_SET) + msvcrt.locking(fd, msvcrt.LK_UNLCK, _LOCK_BYTES) + except OSError: + pass + os.close(fd) diff --git a/src/session_recall/metadocs/schedule.py b/src/session_recall/metadocs/schedule.py index 20c20a0..8bc0dd1 100644 --- a/src/session_recall/metadocs/schedule.py +++ b/src/session_recall/metadocs/schedule.py @@ -113,7 +113,7 @@ def _systemd_enable(daily_at: str, log_path: Path, runner) -> Path: d = systemd_dir() d.mkdir(parents=True, exist_ok=True) for name, text in build_units(daily_at, log_path).items(): - (d / name).write_text(text) + (d / name).write_text(text, encoding="utf-8") runner(["systemctl", "--user", "daemon-reload"]) done = runner(["systemctl", "--user", "enable", "--now", f"{UNIT}.timer"]) if done.returncode != 0: diff --git a/src/session_recall/onboarding.py b/src/session_recall/onboarding.py index ccb4ff1..e453b48 100644 --- a/src/session_recall/onboarding.py +++ b/src/session_recall/onboarding.py @@ -35,12 +35,13 @@ def _store_lang(lang: str) -> None: settings file stays.""" settings = {} try: - settings = json.loads(config.SETTINGS_PATH.read_text()) + settings = json.loads(config.SETTINGS_PATH.read_text(encoding="utf-8")) except (OSError, ValueError): pass settings["lang"] = lang config.SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) - config.SETTINGS_PATH.write_text(json.dumps(settings, indent=2) + "\n") + config.SETTINGS_PATH.write_text(json.dumps(settings, indent=2) + "\n", + encoding="utf-8") def _transcript_footprint() -> tuple[int, int]: diff --git a/src/session_recall/perms.py b/src/session_recall/perms.py new file mode 100644 index 0000000..aadfa28 --- /dev/null +++ b/src/session_recall/perms.py @@ -0,0 +1,71 @@ +"""Who else can read a file that holds a secret. + +Two platforms, two mechanisms, and they are not interchangeable. + +POSIX has per-file mode bits and NEEDS them: the data directory is 0755, so a +key written with the default mode is readable by every account on the box. +0600 is what makes it private, which is why the writers here create the file +with that mode rather than fixing it afterwards. + +Windows has no mode bits — `os.chmod` there moves the read-only flag and +nothing else — and does not need them for the same reason: a user profile +directory already carries a DACL that no other unprivileged account can +traverse. Hand-building a DACL on top (`icacls /inheritance:r /grant:r`) would +mostly restate the inherited one, and the single account it could additionally +exclude is an administrator, who can take ownership of the file anyway. It +would also have to name accounts on a localised system and would fail on a +share — cost and failure modes for no property gained. + +So `protect` sets the mode where the mode is the mechanism, and `exposure` +checks the property that actually holds on each platform instead of the one +POSIX happens to name. That check is the part worth having: it catches the +case that really does leak on Windows — a data directory pointed outside the +profile (`XDG_DATA_HOME` on a share, a synced folder), where the file inherits +whatever that location grants to whoever. +""" + +import os +import stat +import sys +from pathlib import Path + +SECRET_MODE = 0o600 + + +def _mode_bits_are_the_mechanism(platform: str) -> bool: + return not platform.startswith("win") + + +def protect(path: Path, platform: str | None = None) -> None: + """Make `path` private, where that is something a program can do. + + A no-op on Windows on purpose: `os.chmod(path, 0o600)` there sets the + read-only flag, which stops the OWNER from writing and stops nobody from + reading — the opposite of the intent, dressed as the intent.""" + if _mode_bits_are_the_mechanism(platform or sys.platform): + os.chmod(path, SECRET_MODE) + + +def exposure(path: Path, private_root: Path | None = None, + platform: str | None = None) -> str | None: + """Why `path` is readable beyond its owner, or None when it is not. + + `private_root` is the directory whose ACL is trusted to be per-user — the + home directory in production, injectable so tests do not have to write a + fake key into the real profile to exercise the Windows branch.""" + path = Path(path) + platform = platform or sys.platform + if _mode_bits_are_the_mechanism(platform): + mode = stat.S_IMODE(path.stat().st_mode) + if mode & 0o077: + return f"mode {mode:04o} — group or other can read it" + return None + root = Path(private_root if private_root is not None else Path.home()) + try: + path.resolve().relative_to(root.resolve()) + except (ValueError, OSError): + # Also the UNC case: a \\server\share path is never under the profile, + # and its ACL is the file server's business, not ours. + return (f"outside {root} — Windows has no per-file mode bits, so it " + f"inherits whatever that location grants") + return None diff --git a/src/session_recall/share/approval.py b/src/session_recall/share/approval.py index 32d8701..49cb687 100644 --- a/src/session_recall/share/approval.py +++ b/src/session_recall/share/approval.py @@ -181,7 +181,7 @@ def dispatch(identity: Identity, trust: TrustStore, share_dir: Path, if not d.is_dir(): return sent for p in sorted(d.glob("*.json")): - cand = Candidate(**json.loads(p.read_text())) + cand = Candidate(**json.loads(p.read_text(encoding="utf-8"))) if cand.status not in statuses: continue peer = trust.get_by_address(cand.peer_address) diff --git a/src/session_recall/share/envelope.py b/src/session_recall/share/envelope.py index 28e2f96..bdfb272 100644 --- a/src/session_recall/share/envelope.py +++ b/src/session_recall/share/envelope.py @@ -35,7 +35,7 @@ class ShareState: def __init__(self, path: Path): self.path = path if path.exists(): - raw = json.loads(path.read_text()) + raw = json.loads(path.read_text(encoding="utf-8")) self.seen: dict[str, float] = raw.get("seen", {}) self.rate: dict[str, list[float]] = raw.get("rate", {}) else: @@ -46,7 +46,7 @@ def _save(self) -> None: tmp = self.path.with_suffix(".tmp") fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, stat.S_IRUSR | stat.S_IWUSR) - with os.fdopen(fd, "w") as f: + with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump({"seen": self.seen, "rate": self.rate}, f) os.replace(tmp, self.path) diff --git a/src/session_recall/share/identity.py b/src/session_recall/share/identity.py index 55c65f7..3f91b44 100644 --- a/src/session_recall/share/identity.py +++ b/src/session_recall/share/identity.py @@ -25,7 +25,7 @@ def _write_private(path: Path, payload: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.parent.chmod(0o700) fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, stat.S_IRUSR | stat.S_IWUSR) - with os.fdopen(fd, "w") as f: + with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(payload, f, indent=2) @@ -79,7 +79,7 @@ def load(share_dir: Path) -> Identity | None: path = share_dir / IDENTITY_FILE if not path.exists(): return None - data = json.loads(path.read_text()) + data = json.loads(path.read_text(encoding="utf-8")) return Identity(name=data["name"], address=data["address"], signing_key=SigningKey(unb64(data["sign_sk"])), box_key=PrivateKey(unb64(data["box_sk"])), diff --git a/src/session_recall/share/pairing.py b/src/session_recall/share/pairing.py index 0adb38c..c7e706f 100644 --- a/src/session_recall/share/pairing.py +++ b/src/session_recall/share/pairing.py @@ -70,7 +70,7 @@ def start_invite(identity: Identity, transport, share_dir: Path) -> str: _seal(key, identity.public_bundle(local))) (share_dir / PENDING_INVITE_FILE).write_text(json.dumps( {"invite_id": b32(invite_id), "key": b64(key), "local_address": local, - "created_at": time.time()})) + "created_at": time.time()}), encoding="utf-8") return group(b32(invite_id + key)) @@ -93,7 +93,7 @@ def complete_invite(identity: Identity, transport, share_dir: Path) -> PairingRe pending_path = share_dir / PENDING_INVITE_FILE if not pending_path.exists(): raise PairingError("no pending invite — run `share invite` first") - pending = json.loads(pending_path.read_text()) + pending = json.loads(pending_path.read_text(encoding="utf-8")) if time.time() - pending["created_at"] > INVITE_TTL_S: pending_path.unlink() raise PairingError("invite expired — start a fresh one") @@ -112,13 +112,13 @@ def _finish(identity: Identity, share_dir: Path, their: dict, # never writes the trust store itself. (share_dir / PENDING_PEER_FILE).write_text(json.dumps( {"bundle": their, "sas": sas, "local_address": local_address, - "ts": time.time()})) + "ts": time.time()}), encoding="utf-8") return PairingResult(bundle=their, sas=sas) def pending_peer(share_dir: Path) -> dict | None: path = share_dir / PENDING_PEER_FILE - return json.loads(path.read_text()) if path.exists() else None + return json.loads(path.read_text(encoding="utf-8")) if path.exists() else None def clear_pending_peer(share_dir: Path) -> None: diff --git a/src/session_recall/share/relay.py b/src/session_recall/share/relay.py index fe7dc30..bfe8f15 100644 --- a/src/session_recall/share/relay.py +++ b/src/session_recall/share/relay.py @@ -50,7 +50,7 @@ def __init__(self, root: Path, clock=time.time): self.clock = clock self._addrs_path = self.root / "addrs.json" self.addrs: dict[str, str] = ( - json.loads(self._addrs_path.read_text()) if self._addrs_path.exists() else {}) + json.loads(self._addrs_path.read_text(encoding="utf-8")) if self._addrs_path.exists() else {}) def _prune(self, directory: Path, ttl: float) -> None: if not directory.is_dir(): @@ -107,7 +107,7 @@ def check_fetch(self, address: str, pk_b64: str, ts: float, sig_b64: str) -> boo if known is None: self.addrs[address] = pk_b64 self.root.mkdir(parents=True, exist_ok=True) - self._addrs_path.write_text(json.dumps(self.addrs)) + self._addrs_path.write_text(json.dumps(self.addrs), encoding="utf-8") return True diff --git a/src/session_recall/share/telegram.py b/src/session_recall/share/telegram.py index 0548e9f..057c48f 100644 --- a/src/session_recall/share/telegram.py +++ b/src/session_recall/share/telegram.py @@ -64,7 +64,7 @@ def load_config(share_dir: Path) -> TgConfig | None: p = share_dir / TG_FILE if not p.exists(): return None - raw = json.loads(p.read_text()) + raw = json.loads(p.read_text(encoding="utf-8")) return TgConfig(token=raw["token"], chat_id=raw.get("chat_id"), offset=raw.get("offset", 0)) @@ -74,7 +74,7 @@ def save_config(share_dir: Path, cfg: TgConfig) -> None: p = share_dir / TG_FILE fd = os.open(p, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, stat.S_IRUSR | stat.S_IWUSR) - with os.fdopen(fd, "w") as f: + with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump({"token": cfg.token, "chat_id": cfg.chat_id, "offset": cfg.offset}, f) diff --git a/src/session_recall/share/thread.py b/src/session_recall/share/thread.py index 0912c25..4e2e313 100644 --- a/src/session_recall/share/thread.py +++ b/src/session_recall/share/thread.py @@ -77,7 +77,7 @@ def save(share_dir: Path, thread: Thread) -> None: path = _path(share_dir, thread.id) fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, stat.S_IRUSR | stat.S_IWUSR) - with os.fdopen(fd, "w") as f: + with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(asdict(thread), f, indent=2, ensure_ascii=False) @@ -85,7 +85,7 @@ def load(share_dir: Path, thread_id: str) -> Thread | None: path = _path(share_dir, thread_id) if not path.exists(): return None - return Thread(**json.loads(path.read_text())) + return Thread(**json.loads(path.read_text(encoding="utf-8"))) def open_or_create(share_dir: Path, thread_id: str, peer_address: str, @@ -103,6 +103,6 @@ def listing(share_dir: Path) -> list[Thread]: d = share_dir / THREADS_DIR if not d.is_dir(): return [] - threads = [Thread(**json.loads(p.read_text())) for p in d.glob("*.json")] + threads = [Thread(**json.loads(p.read_text(encoding="utf-8"))) for p in d.glob("*.json")] return sorted(threads, key=lambda t: t.turns[-1]["ts"] if t.turns else t.created_at, reverse=True) diff --git a/src/session_recall/share/trust.py b/src/session_recall/share/trust.py index 4b26144..b291e15 100644 --- a/src/session_recall/share/trust.py +++ b/src/session_recall/share/trust.py @@ -67,7 +67,7 @@ class TrustStore: def __init__(self, path: Path): self.path = path if path.exists(): - raw = json.loads(path.read_text()) + raw = json.loads(path.read_text(encoding="utf-8")) self._state = _State( peers=[Peer(**p) for p in raw.get("peers", [])], allowed_projects=list(raw.get("allowed_projects", [])), @@ -82,7 +82,7 @@ def _save(self) -> None: tmp = self.path.with_suffix(".tmp") fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, stat.S_IRUSR | stat.S_IWUSR) - with os.fdopen(fd, "w") as f: + with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump({"peers": [asdict(p) for p in self._state.peers], "allowed_projects": self._state.allowed_projects, "paused": self._state.paused}, f, indent=2) diff --git a/src/session_recall/share/worker.py b/src/session_recall/share/worker.py index e6f3850..8692b22 100644 --- a/src/session_recall/share/worker.py +++ b/src/session_recall/share/worker.py @@ -84,7 +84,7 @@ def _write_candidate(share_dir: Path, cand: Candidate) -> Path: path = _outbox(share_dir) / f"{cand.id}.json" fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, stat.S_IRUSR | stat.S_IWUSR) - with os.fdopen(fd, "w") as f: + with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(asdict(cand), f, indent=2, ensure_ascii=False) return path @@ -184,7 +184,7 @@ def list_pending(share_dir: Path) -> list[Candidate]: return [] out = [] for p in sorted(d.glob("*.json")): - cand = Candidate(**json.loads(p.read_text())) + cand = Candidate(**json.loads(p.read_text(encoding="utf-8"))) if cand.status == "pending": out.append(cand) return out @@ -192,7 +192,7 @@ def list_pending(share_dir: Path) -> list[Candidate]: def load_candidate(share_dir: Path, cand_id: str) -> Candidate | None: p = share_dir / OUTBOX_DIR / f"{cand_id}.json" - return Candidate(**json.loads(p.read_text())) if p.exists() else None + return Candidate(**json.loads(p.read_text(encoding="utf-8"))) if p.exists() else None def set_status(share_dir: Path, cand_id: str, status: str) -> Candidate | None: diff --git a/src/session_recall/timefmt.py b/src/session_recall/timefmt.py index e01e62c..746a9ce 100644 --- a/src/session_recall/timefmt.py +++ b/src/session_recall/timefmt.py @@ -11,7 +11,7 @@ def local_timezone(): if env_tz: candidates.append(env_tz) try: - configured = Path("/etc/timezone").read_text().strip() + configured = Path("/etc/timezone").read_text(encoding="utf-8").strip() if configured: candidates.append(configured) except OSError: diff --git a/tests/test_cli.py b/tests/test_cli.py index f0dc8c9..419e468 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -93,6 +93,35 @@ def test_cli_cursor_schema_failure_keeps_other_sources(tmp_path, monkeypatch, ca store.close() +def test_sync_steps_aside_while_another_sync_holds_the_lock(tmp_path, monkeypatch, + capsys): + """SessionStart fires `sync`, and sessions overlap. The guard has to live in + the CLI: the hook is a shell string, and its old `pgrep` test silently did + nothing on Windows, where two indexers then fought over one SQLite file.""" + from session_recall.metadocs.lock import acquire_lock, release_lock + + monkeypatch.setattr(config, "DATA_DIR", tmp_path / "data") + held = acquire_lock(config.DATA_DIR, "sync.lock") + assert held is not None + try: + assert cli.main(["sync"]) == 0 # stepping aside is success + assert "already running" in capsys.readouterr().out + finally: + release_lock(held) + + # lock free again → sync runs for real (solo install: falls through to index) + monkeypatch.setattr(config, "CLAUDE_PROJECTS", tmp_path / "no-projects") + monkeypatch.setattr(config, "CODEX_SESSIONS", tmp_path / "no-codex-sessions") + monkeypatch.setattr(config, "CODEX_ARCHIVED_SESSIONS", tmp_path / "no-archive") + monkeypatch.setattr(config, "CURSOR_DB", tmp_path / "no-cursor.db") + monkeypatch.setattr(config, "DB_PATH", tmp_path / "sync.db") + monkeypatch.setattr(cli, "make_embedder", lambda: FakeEmbedder()) + monkeypatch.setattr("session_recall.hub.client.HubConfig.load", + classmethod(lambda cls, path=None: None)) + assert cli.main(["sync"]) == 0 + assert "indexed" in capsys.readouterr().out + + def test_cli_module_entrypoint_runs_main(): # Regression: `python -m session_recall.cli` must invoke main(), not no-op. # A missing __main__ guard once made `index` silently do nothing (no DB). diff --git a/tests/test_config.py b/tests/test_config.py index e36efa7..c8b36c2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -23,6 +23,22 @@ def test_codex_roots_follow_codex_home(monkeypatch, tmp_path): assert config.CODEX_ARCHIVED_SESSIONS == tmp_path / "custom-codex" / "archived_sessions" importlib.reload(config) +def test_cursor_db_follows_each_platform_own_app_data_dir(): + """Cursor stores its state where its VS Code base does. Reading `~/.config` + on Windows found nothing and reported `sources: missing cursor` while the + file sat in %APPDATA% the whole time.""" + mac = config._default_cursor_db("darwin", {}) + win = config._default_cursor_db("win32", {"APPDATA": r"C:\Users\egor\AppData\Roaming"}) + linux = config._default_cursor_db("linux", {"XDG_CONFIG_HOME": "/home/egor/.config"}) + + assert mac.parts[-5:] == ("Application Support", "Cursor", "User", + "globalStorage", "state.vscdb") + assert win == Path(r"C:\Users\egor\AppData\Roaming") / "Cursor" / "User" \ + / "globalStorage" / "state.vscdb" + assert linux == Path("/home/egor/.config") / "Cursor" / "User" \ + / "globalStorage" / "state.vscdb" + + def test_chunk_dataclass(): c = Chunk(session_id="s", uuid="u", role="user", text="hi", project="p", cwd="/c", git_branch="b", ts=1, file_path="/f.jsonl", diff --git a/tests/test_cursor.py b/tests/test_cursor.py index 71542ca..9c9eb38 100644 --- a/tests/test_cursor.py +++ b/tests/test_cursor.py @@ -107,7 +107,9 @@ def test_index_cursor_end_to_end_with_workspace_mapping(tmp_path): "SELECT role, project, cwd, source FROM chunks ORDER BY turn_index").fetchall() assert rows == [("user", "deploy-service", "/Users/me/deploy-service", "cursor"), ("assistant", "deploy-service", "/Users/me/deploy-service", "cursor")] - raw = next(snapshots.glob("*.jsonl")).read_text() + # snapshots are written as utf-8 bytes; reading them back in the locale + # codepage (the Windows default) turns Cyrillic into mojibake + raw = next(snapshots.glob("*.jsonl")).read_text(encoding="utf-8") assert "Проверяю расписание крона" in raw assert "cursor-thinking-signature-must-not-escape" not in raw diff --git a/tests/test_health.py b/tests/test_health.py index 5aefe5a..fd9a040 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -1,7 +1,8 @@ # tests/test_health.py import time -from session_recall.health import score, check_freshness, check_corpus, check_paths +from session_recall.health import (score, check_freshness, check_corpus, + check_paths, check_secrets) from session_recall.models import Chunk from session_recall.store import Store @@ -22,6 +23,23 @@ def test_score_bands_and_direction(): assert score(100, green=24, amber=72, higher_is_better=False).zone == "RED" +def test_secrets_dimension_is_absent_before_there_is_a_key(tmp_path): + """A machine that never joined a hub has nothing to protect; an empty row + would be noise in the one place that must stay scannable.""" + assert check_secrets((tmp_path / "hub.json",)) is None + + +def test_secrets_dimension_names_the_file_that_leaks(tmp_path, monkeypatch): + """Nothing else in the tool would ever tell the user their key is readable, + which is why the answer belongs in `health` rather than in a comment.""" + key = tmp_path / "hub.json" + key.write_text('{"key": "sr_egor_deadbeef"}', encoding="utf-8") + monkeypatch.setattr("session_recall.perms.exposure", + lambda p, *a, **kw: "mode 0644 — group or other can read it") + dim = check_secrets((key,)) + assert dim.zone == "RED" and "hub.json" in dim.detail and dim.hint + + def test_freshness_measures_the_gap_to_disk_not_the_index_alone(tmp_path): """The failure that went unnoticed for a day and a half: the index kept answering happily while transcripts on disk moved on without it. An index-only timestamp diff --git a/tests/test_hub_client.py b/tests/test_hub_client.py index 823ca1b..59a46ee 100644 --- a/tests/test_hub_client.py +++ b/tests/test_hub_client.py @@ -3,8 +3,10 @@ import json import stat +import sys import threading from http.server import ThreadingHTTPServer +from pathlib import Path import pytest @@ -13,6 +15,7 @@ from session_recall.hub.client import (CONSENT, HubConfig, HubError, join, local_files, push) from session_recall.hub.masking import SecretMap +from session_recall.perms import exposure ANTHROPIC = "sk-ant-api03-" + "B" * 40 NETCUP = "Xk39dmPQ7wLz2vRt" @@ -34,16 +37,22 @@ def url(hub): @pytest.fixture def roots(tmp_path): - """A miniature version of the three local transcript roots.""" + """A miniature version of the three local transcript roots. + + Written as real transcripts are — utf-8, LF, no locale in the loop — so the + byte counts these tests assert on mean the same thing on every platform.""" + def transcript(path: Path, line: str) -> None: + path.write_text(line, encoding="utf-8", newline="") + claude = tmp_path / "claude-projects" / "-Users-egor-proj" claude.mkdir(parents=True) - (claude / "sess-a.jsonl").write_text('{"type":"user","text":"привет"}\n') + transcript(claude / "sess-a.jsonl", '{"type":"user","text":"привет"}\n') codex = tmp_path / "codex-sessions" / "2026" / "08" / "05" codex.mkdir(parents=True) - (codex / "roll-1.jsonl").write_text('{"type":"message","text":"codex"}\n') + transcript(codex / "roll-1.jsonl", '{"type":"message","text":"codex"}\n') archive = tmp_path / "codex-archive" archive.mkdir() - (archive / "old.jsonl").write_text('{"type":"message","text":"old"}\n') + transcript(archive / "old.jsonl", '{"type":"message","text":"old"}\n') return {"claude_root": tmp_path / "claude-projects", "codex_sessions": tmp_path / "codex-sessions", "codex_archive": archive} @@ -72,9 +81,15 @@ def test_join_verifies_the_key_before_saving(url, hub, tmp_path): def test_join_stores_the_key_readable_only_by_its_owner(url, hub, tmp_path): + """Asserted through `perms.exposure` rather than the mode directly, because + the mechanism differs: POSIX makes the file 0600, Windows has no per-file + mode and relies on the profile directory's ACL. The property — nobody else + can read the key — is the same on both, so the test is too.""" path = tmp_path / "hub.json" join(url, hub.keys.issue("egor"), path=path) - assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert exposure(path, private_root=tmp_path) is None + if not sys.platform.startswith("win"): + assert stat.S_IMODE(path.stat().st_mode) == 0o600 assert HubConfig.load(path).url == url @@ -95,7 +110,11 @@ def test_push_sends_only_the_tail_of_a_grown_transcript(cfg, hub, roots): push(cfg, roots=roots) grown = roots["claude_root"] / "-Users-egor-proj" / "sess-a.jsonl" addition = '{"type":"assistant","text":"ответ"}\n' - with open(grown, "a") as fh: + # explicit utf-8 and no newline translation: a transcript is counted in + # BYTES here, and Windows text mode would silently spend a locale codepage + # and an extra \r on every line, so the assertions below would measure the + # test's own encoding rather than what push sent + with open(grown, "a", encoding="utf-8", newline="") as fh: fh.write(addition) stats = push(cfg, roots=roots) @@ -103,29 +122,31 @@ def test_push_sends_only_the_tail_of_a_grown_transcript(cfg, hub, roots): assert stats["uploaded_bytes"] == len(addition.encode()) stored = storage.resolve(hub.transcripts, "egor", "claude/-Users-egor-proj/sess-a.jsonl") - assert stored.read_text().endswith(addition) + assert stored.read_text(encoding="utf-8").endswith(addition) def test_a_rewritten_transcript_is_resent_whole(cfg, hub, roots): push(cfg, roots=roots) rewritten = roots["claude_root"] / "-Users-egor-proj" / "sess-a.jsonl" - rewritten.write_text('{"short":1}\n') # now SHORTER than the hub's copy + rewritten.write_text('{"short":1}\n', encoding="utf-8", newline="") push(cfg, roots=roots) stored = storage.resolve(hub.transcripts, "egor", "claude/-Users-egor-proj/sess-a.jsonl") - assert stored.read_text() == '{"short":1}\n' + assert stored.read_text(encoding="utf-8") == '{"short":1}\n' def test_format_shaped_secrets_never_leave_the_machine(cfg, hub, roots): """Client-side redaction is the first layer: an API key is cut before the request is built, so it is not merely masked on arrival — it never travels.""" leaky = roots["claude_root"] / "-Users-egor-proj" / "sess-a.jsonl" - leaky.write_text(json.dumps({"text": f"key is {ANTHROPIC}"}) + "\n") + leaky.write_text(json.dumps({"text": f"key is {ANTHROPIC}"}) + "\n", + encoding="utf-8", newline="") stats = push(cfg, roots=roots) - stored = storage.resolve(hub.transcripts, "egor", - "claude/-Users-egor-proj/sess-a.jsonl").read_text() + stored = storage.resolve( + hub.transcripts, "egor", + "claude/-Users-egor-proj/sess-a.jsonl").read_text(encoding="utf-8") assert stats["redacted"] == 1 assert ANTHROPIC not in stored and "[REDACTED:anthropic-key]" in stored @@ -135,7 +156,7 @@ def test_redaction_keeps_the_resume_point_aligned(cfg, hub, roots): otherwise the next push re-sends from a wrong offset forever.""" leaky = roots["claude_root"] / "-Users-egor-proj" / "sess-a.jsonl" line = json.dumps({"text": f"key is {ANTHROPIC}"}) + "\n" - leaky.write_text(line) + leaky.write_text(line, encoding="utf-8", newline="") push(cfg, roots=roots) rel = "claude/-Users-egor-proj/sess-a.jsonl" diff --git a/tests/test_metadocs.py b/tests/test_metadocs.py index d179fce..f24a3ed 100644 --- a/tests/test_metadocs.py +++ b/tests/test_metadocs.py @@ -6,6 +6,7 @@ advance after safe work, and the whole run is git-reviewable. """ +import json import os import sqlite3 import subprocess @@ -13,6 +14,14 @@ import pytest + +def embedded(path) -> str: + """A path that has been serialised into JSON, ready to be looked for in the + serialised text. On Windows every separator comes back escaped (`\\\\`), so + a raw `str(path)` substring check fails against a config that is in fact + correct; on POSIX this returns the path unchanged.""" + return json.dumps(str(path))[1:-1] + from session_recall.metadocs import agent_server, collect, distill, entries from session_recall.metadocs import run as run_mod from session_recall.metadocs import schedule @@ -112,8 +121,10 @@ def test_migration_splits_old_format(repo): (repo / "proj").mkdir() (repo / "proj" / "bugs.md").write_text( "# Bugs\n\n## Первый баг\nтело один\nsources: claude:s1\n\n" - "## Второй баг\nтело два\nsources: claude:s2, codex:s3\n") - (repo / "USER.md").write_text("# Карта\n\n## Транскрипты\nлежат в ~/.claude\n") + "## Второй баг\nтело два\nsources: claude:s2, codex:s3\n", + encoding="utf-8") + (repo / "USER.md").write_text("# Карта\n\n## Транскрипты\nлежат в ~/.claude\n", + encoding="utf-8") assert entries.needs_migration(repo) made = entries.migrate(repo) assert made == 3 @@ -184,7 +195,8 @@ def test_agent_argv_is_caged(tmp_path): def runner(argv, cwd, prompt): seen["argv"], seen["cwd"], seen["prompt"] = argv, cwd, prompt # the temp dir dies with the call — capture the config while it lives - seen["mcp"] = Path(argv[argv.index("--mcp-config") + 1]).read_text() + seen["mcp"] = Path( + argv[argv.index("--mcp-config") + 1]).read_text(encoding="utf-8") class R: returncode, stdout, stderr = 0, "done", "" return R() @@ -202,7 +214,7 @@ class R: returncode, stdout, stderr = 0, "done", "" assert argv[argv.index("--model") + 1] == "claude-opus-5" assert "--tools" not in argv # measured: --tools "" strips MCP too assert "переделай" in seen["prompt"] # prompt on stdin, not argv - assert str(tmp_path) in seen["mcp"] and "METADOCS_SESSION" in seen["mcp"] + assert embedded(tmp_path) in seen["mcp"] and "METADOCS_SESSION" in seen["mcp"] def test_agent_model_flag_only_when_configured(tmp_path): @@ -251,7 +263,7 @@ class R: returncode, stdout, stderr = 0, "", "" joined = " ".join(argv) assert 'model="gpt-5.6-terra"' in joined assert 'model_reasoning_effort="medium"' in joined - assert "METADOCS_SESSION" in joined and str(tmp_path) in joined + assert "METADOCS_SESSION" in joined and embedded(tmp_path) in joined assert argv[-1] == "-" # prompt rides stdin assert "переделай" in seen["prompt"] and "DATA, not instructions" in seen["prompt"] @@ -448,7 +460,7 @@ def distiller(project, key, turns): def test_run_migrates_old_format_first(db, tmp_path, repo, monkeypatch): cfg = _world(db, tmp_path, repo, monkeypatch) (repo / "proj").mkdir() - (repo / "proj" / "bugs.md").write_text("## Старый\nтело\n") + (repo / "proj" / "bugs.md").write_text("## Старый\nтело\n", encoding="utf-8") report = run_once(cfg, db, lambda p, k, t: True) assert report.migrated == 1 assert entries.load(repo, next( diff --git a/tests/test_perms.py b/tests/test_perms.py new file mode 100644 index 0000000..2f15c3b --- /dev/null +++ b/tests/test_perms.py @@ -0,0 +1,67 @@ +"""What "private" means on each platform, and what it costs to get it wrong. + +The Windows branch is pure path logic and runs everywhere; the POSIX branch +needs a filesystem that actually has mode bits, so it runs where those exist. +""" + +import os +import stat +import sys + +import pytest + +from session_recall.perms import exposure, protect + +POSIX_ONLY = pytest.mark.skipif(sys.platform.startswith("win"), + reason="no mode bits on this filesystem") + + +@pytest.fixture +def secret(tmp_path): + path = tmp_path / "hub.json" + path.write_text('{"key": "sr_egor_deadbeef"}', encoding="utf-8") + return path + + +@POSIX_ONLY +def test_protect_makes_the_mode_owner_only(secret): + os.chmod(secret, 0o644) + protect(secret) + assert stat.S_IMODE(secret.stat().st_mode) == 0o600 + assert exposure(secret) is None + + +@POSIX_ONLY +def test_a_readable_mode_is_reported_with_the_mode_in_it(secret): + os.chmod(secret, 0o644) + why = exposure(secret) + assert why and "0644" in why + + +def test_protect_does_not_touch_the_mode_on_windows(secret): + """`os.chmod(path, 0o600)` on Windows sets the read-only flag: it stops the + OWNER writing and stops nobody reading. Doing nothing is the honest move, + and a regression here would be silent — the call would look like it worked.""" + before = secret.stat().st_mode + protect(secret, platform="win32") + assert secret.stat().st_mode == before + with open(secret, "a", encoding="utf-8") as fh: # still writable + fh.write("") + + +def test_windows_file_under_the_profile_is_private(secret, tmp_path): + assert exposure(secret, private_root=tmp_path, platform="win32") is None + + +def test_windows_file_outside_the_profile_is_reported(secret, tmp_path): + """The case this whole check exists for: XDG_DATA_HOME pointed at a share + or a synced folder, where the key inherits that location's ACL.""" + why = exposure(secret, private_root=tmp_path / "elsewhere", platform="win32") + assert why and "outside" in why + + +def test_missing_private_root_does_not_crash_the_check(secret, tmp_path): + """`private_root` may not exist yet; resolving it must not raise, or + `health` would die on the machine that most needs to hear the answer.""" + assert exposure(secret, private_root=tmp_path / "nope" / "deeper", + platform="win32") diff --git a/tests/test_share_pairing.py b/tests/test_share_pairing.py index 9623399..3613b13 100644 --- a/tests/test_share_pairing.py +++ b/tests/test_share_pairing.py @@ -1,7 +1,9 @@ +import sys import time import pytest +from session_recall.perms import exposure from session_recall.share import identity as identity_mod from session_recall.share import pairing from session_recall.share.pairing import PairingError @@ -75,9 +77,14 @@ def test_complete_without_invite(two_sides): def test_identity_files_are_private(two_sides): + """Same property on both platforms, different mechanism underneath — see + `perms.exposure`: mode bits where they exist, the profile directory's ACL + where they do not.""" _, a_dir, _, _, _ = two_sides - mode = (a_dir / "identity.json").stat().st_mode - assert mode & 0o077 == 0, "identity must be 0600" + identity = a_dir / "identity.json" + assert exposure(identity, private_root=a_dir) is None + if not sys.platform.startswith("win"): + assert identity.stat().st_mode & 0o077 == 0, "identity must be 0600" def test_identity_create_refuses_overwrite(tmp_path): diff --git a/tests/test_share_relay.py b/tests/test_share_relay.py index eb1d30d..8f3fc90 100644 --- a/tests/test_share_relay.py +++ b/tests/test_share_relay.py @@ -84,9 +84,14 @@ def test_stale_fetch_signature_rejected(server, users, monkeypatch): def test_oversized_blob_rejected(server): + """The relay answers 413 and hangs up without draining the oversized body — + which is the point of the cap. Whether the client then reads that response + or just sees the reset is the OS's call: Linux delivers the 413, Windows + aborts the connection (WinError 10053). Rejection is the invariant; which + error carries it is not.""" url, _ = server t = HttpRelayTransport(url) - with pytest.raises(urllib.error.HTTPError): + with pytest.raises((urllib.error.HTTPError, ConnectionError)): t.put_slot("pair-a-big", b"x" * (relay.MAX_BLOB + 1))