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

1#!/usr/bin/env python3 

2"""The two things every part of the sync pipeline needs: its error type and its logger. 

3 

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""" 

9 

10from __future__ import annotations 

11 

12import sys 

13from pathlib import PurePosixPath 

14 

15 

16class SyncError(Exception): 

17 """A fatal, non-conflict sync failure (bad config, dirty tree, git error).""" 

18 

19 

20def has_drive_letter(value: str) -> bool: 

21 """Does *value* begin with a Windows drive letter (``C:``)? 

22 

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. 

28 

29 >>> has_drive_letter("C:/Users/dev/template") 

30 True 

31 >>> has_drive_letter(r"d:\\work\\rhiza") 

32 True 

33 

34 A forward-slash path, a URL and an ``owner/repo`` are all unaffected — none has a 

35 colon in second position: 

36 

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] == ":" 

41 

42 

43def escapes_root(value: str) -> bool: 

44 r"""Would joining *value* onto a project root land outside it? 

45 

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: 

51 

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] 

56 

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 

65 

66 

67def log(message: str) -> None: 

68 """Emit a progress/diagnostic line to stderr. 

69 

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)