Coverage for plugin/scripts/_rhiza_common.py: 100%
12 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
1#!/usr/bin/env python3
2"""The two things every part of the sync pipeline needs: its error type and its logger.
4Both would naturally live in `sync.py`, but the modules it orchestrates raise the error
5and write the log lines — so keeping them there would force each extracted module to
6import its own orchestrator, which is a cycle. One small shared module is the cheaper
7answer than five copies or a circular import.
8"""
10from __future__ import annotations
12import sys
13from pathlib import PurePosixPath
16class SyncError(Exception):
17 """A fatal, non-conflict sync failure (bad config, dirty tree, git error)."""
20def has_drive_letter(value: str) -> bool:
21 """Does *value* begin with a Windows drive letter (``C:``)?
23 Shared because two callers ask it for opposite reasons and must agree on the answer:
24 `_rhiza_bundles` **rejects** a drive-lettered bundle path as an escape from the
25 project directory, while `_rhiza_template` **accepts** one as a local template to
26 clone verbatim. A copy that drifted would either sync from `https://github.com/C:/…`
27 or let a bundle write outside the repo.
29 >>> has_drive_letter("C:/Users/dev/template")
30 True
31 >>> has_drive_letter(r"d:\\work\\rhiza")
32 True
34 A forward-slash path, a URL and an ``owner/repo`` are all unaffected — none has a
35 colon in second position:
37 >>> [has_drive_letter(v) for v in ("/tmp/t", "jebel-quant/rhiza", "https://x/y")]
38 [False, False, False]
39 """
40 return len(value) >= 2 and value[0].isalpha() and value[1] == ":"
43def escapes_root(value: str) -> bool:
44 r"""Would joining *value* onto a project root land outside it?
46 Every path this plugin joins onto a target directory arrives from the template repo —
47 a bundle's ``dest``, a lock file's ``files`` entry — so none of them is the repo's own
48 text. Three shapes leave the project: an absolute path, a Windows drive letter, and a
49 ``..`` component. A backslash is normalised to a forward slash **first**, so a Windows
50 separator cannot smuggle a traversal past the check:
52 >>> [escapes_root(v) for v in ("Makefile", ".github/workflows/ci.yml", "a/./b")]
53 [False, False, False]
54 >>> [escapes_root(v) for v in ("/etc/passwd", "..\\secrets.env", "C:/Windows", "a/../../b")]
55 [True, True, True, True]
57 A predicate rather than a raiser because its two callers must fail differently: the
58 sync raises :class:`SyncError` on a bad bundle path, while `stage_synced` returns a
59 summary dict and an exit code. Sharing the *rule* is the point; a second copy of it
60 is how one of them ends up enforcing something slightly different.
61 """
62 normalized = value.replace("\\", "/")
63 pure = PurePosixPath(normalized)
64 return pure.is_absolute() or has_drive_letter(normalized) or ".." in pure.parts
67def log(message: str) -> None:
68 """Emit a progress/diagnostic line to stderr.
70 stderr, not stdout: `/update` reads the sync's exit code and shows this text to the
71 user, while stdout stays free for machine-readable output.
72 """
73 print(message, file=sys.stderr)