linux observer
1# SPDX-License-Identifier: AGPL-3.0-only
2# Copyright (c) 2026 sol pbc
3"""Install ownership guard for pipx-managed service installs."""
4
5from __future__ import annotations
6
7import sys
8from enum import Enum
9from pathlib import Path
10
11MARKER_REL = Path(".config/solstone-linux/.install-source")
12PIPX_BIN_REL = Path(".local/bin/solstone-linux")
13
14
15def marker_path() -> Path:
16 return Path.home() / MARKER_REL
17
18
19def pipx_bin_path() -> Path:
20 return Path.home() / PIPX_BIN_REL
21
22
23class State(str, Enum):
24 ABSENT = "ABSENT"
25 OWNED = "OWNED"
26 CROSS_REPO = "CROSS_REPO"
27 PARTIAL_OWNED = "PARTIAL_OWNED"
28 UNKNOWN = "UNKNOWN"
29
30
31def _parse_marker() -> Path | None:
32 try:
33 raw = marker_path().read_text(encoding="utf-8")
34 except OSError:
35 return None
36
37 stripped = raw.strip()
38 if not stripped:
39 return None
40
41 lines = stripped.splitlines()
42 if len(lines) != 1:
43 return None
44
45 candidate = Path(lines[0].strip())
46 if not candidate.is_absolute():
47 return None
48
49 return candidate.resolve()
50
51
52def check(curdir: Path) -> tuple[State, Path | None]:
53 resolved_curdir = curdir.resolve()
54 marker = marker_path()
55 pipx_bin_present = pipx_bin_path().exists()
56
57 if not marker.exists():
58 if not pipx_bin_present:
59 return (State.ABSENT, None)
60 return (State.UNKNOWN, None)
61
62 owner = _parse_marker()
63 if owner is None:
64 return (State.UNKNOWN, None)
65 if owner != resolved_curdir:
66 return (State.CROSS_REPO, owner)
67 if pipx_bin_present:
68 return (State.OWNED, owner)
69 return (State.PARTIAL_OWNED, owner)
70
71
72def write_marker(curdir: Path) -> None:
73 path = marker_path()
74 path.parent.mkdir(parents=True, exist_ok=True)
75 path.write_text(f"{curdir.resolve()}\n", encoding="utf-8")
76
77
78def remove_marker() -> None:
79 marker_path().unlink(missing_ok=True)
80
81
82def _unknown_reason() -> str:
83 if marker_path().exists():
84 return ".install-source marker is malformed"
85 return "no .install-source marker — likely pre-hygiene install"
86
87
88def _print_cross_repo_error(curdir: Path, owner: Path | None, uninstall: bool) -> None:
89 lines = [
90 "error: cross-repo contamination detected",
91 f"current repo: {curdir.resolve()}",
92 f"installed from: {owner}",
93 "",
94 "To recover, run from the installed repo:",
95 " make uninstall-service",
96 "Or manually:",
97 ]
98 if uninstall:
99 lines.extend(
100 [
101 " systemctl --user stop solstone-linux.service",
102 " systemctl --user disable solstone-linux.service",
103 " rm -f ~/.config/systemd/user/solstone-linux.service",
104 ]
105 )
106 lines.extend(
107 [
108 " pipx uninstall solstone-linux",
109 " rm ~/.config/solstone-linux/.install-source",
110 ]
111 )
112 print("\n".join(lines), file=sys.stderr)
113
114
115def _print_unknown_error(uninstall: bool) -> None:
116 lines = [
117 f"error: installed: unknown ({_unknown_reason()})",
118 "",
119 "To recover:",
120 ]
121 if uninstall:
122 lines.extend(
123 [
124 " systemctl --user stop solstone-linux.service",
125 " systemctl --user disable solstone-linux.service",
126 " rm -f ~/.config/systemd/user/solstone-linux.service",
127 ]
128 )
129 lines.extend(
130 [
131 " pipx uninstall solstone-linux",
132 " rm -f ~/.config/solstone-linux/.install-source",
133 "Then re-run make install-service.",
134 ]
135 )
136 print("\n".join(lines), file=sys.stderr)
137
138
139def _preinstall(curdir: Path) -> int:
140 state, owner = check(curdir)
141 if state is State.ABSENT:
142 print("mode: fresh install")
143 return 0
144 if state is State.OWNED:
145 print("mode: upgrade")
146 return 10
147 if state is State.PARTIAL_OWNED:
148 print(
149 "warning: .install-source marker present but pipx binary missing — reinstalling"
150 )
151 print("mode: upgrade")
152 return 10
153 if state is State.CROSS_REPO:
154 print("mode: aborted — cross-repo contamination")
155 _print_cross_repo_error(curdir, owner, uninstall=False)
156 return 2
157
158 print("mode: aborted — unknown install state")
159 _print_unknown_error(uninstall=False)
160 return 2
161
162
163def _preuninstall(curdir: Path) -> int:
164 state, owner = check(curdir)
165 if state is State.ABSENT:
166 print("no artifacts to remove")
167 return 0
168 if state in {State.OWNED, State.PARTIAL_OWNED}:
169 return 10
170 if state is State.CROSS_REPO:
171 print("mode: aborted — cross-repo contamination")
172 _print_cross_repo_error(curdir, owner, uninstall=True)
173 return 2
174
175 print("mode: aborted — unknown install state")
176 _print_unknown_error(uninstall=True)
177 return 2
178
179
180def main() -> int:
181 if len(sys.argv) < 2:
182 print(
183 "usage: install_guard <preinstall|preuninstall|write|remove> [curdir]",
184 file=sys.stderr,
185 )
186 return 2
187
188 command = sys.argv[1]
189 if command == "remove":
190 remove_marker()
191 return 0
192
193 if command in {"preinstall", "preuninstall", "write"}:
194 if len(sys.argv) != 3:
195 print(f"usage: install_guard {command} <curdir>", file=sys.stderr)
196 return 2
197 curdir = Path(sys.argv[2])
198 if command == "preinstall":
199 return _preinstall(curdir)
200 if command == "preuninstall":
201 return _preuninstall(curdir)
202 write_marker(curdir)
203 return 0
204
205 print(f"unknown command: {command}", file=sys.stderr)
206 return 2
207
208
209if __name__ == "__main__":
210 sys.exit(main())