pitboard_core/host/mod.rs
1//! The machine Pitboard runs on, behind one seam.
2//!
3//! Everything that differs between operating systems is answered here, so the rest of the
4//! crate asks a question and never which system it is on. The seam has two faces.
5//!
6//! [`Host`] is the part a test replaces: the stores secrets and logins live in, the process
7//! list, the scheduler and whether this process runs as the person, reached through the
8//! [`Context`] every call carries. A test puts [`memory::MemoryHost`] there and can make any
9//! of them fail, or say this process runs as root.
10//!
11//! [`fs`], [`proc`] and [`user`] are plain functions for what the machine does the same way
12//! whoever asks, which the tests run for real: creating a file only its owner can reach, and
13//! every other change to the disk, each taking the [`Permit`] the one gate every change
14//! passes makes; asking whether a process is still alive; naming the person signed in.
15//! [`login_path`] is one too: the `PATH` the person's login shell builds, which an app the
16//! system started does not have.
17//!
18//! The system is chosen once, in this file, and nowhere else. A fact that differs by system
19//! is a `match` on [`OS`], and [`Os`] lists every system Pitboard runs on, so a system added
20//! there does not compile until each such fact has been said for it. This replaced branches
21//! that read "macOS, or else Linux", which compiled anywhere and did the Linux thing.
22
23use crate::context::{Context, Environment};
24use crate::error::Result;
25use crate::service::Permit;
26use crate::store::RawStore;
27use std::path::{Path, PathBuf};
28use std::sync::Arc;
29
30pub(crate) mod administered;
31#[cfg(target_os = "linux")]
32mod linux;
33#[cfg(target_os = "macos")]
34mod macos;
35#[cfg(any(test, feature = "test-support"))]
36pub mod memory;
37pub(crate) mod program;
38#[cfg(unix)]
39mod unix;
40#[cfg(windows)]
41mod windows;
42
43#[cfg(target_os = "linux")]
44use linux as os;
45#[cfg(target_os = "macos")]
46use macos as os;
47#[cfg(windows)]
48use windows as os;
49
50pub(crate) use administered::Administered;
51pub(crate) use os::{fs, proc, user};
52
53/// The operating systems Pitboard runs on. Windows only once it is released
54/// ([`crate::release`]): until then a Windows build of a release refuses everything, and its
55/// face, `host/windows`, refuses whatever is not built yet.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Os {
58 MacOs,
59 Linux,
60 Windows,
61}
62
63/// The system this build runs on.
64pub const OS: Os = os::OS;
65
66/// The only program trusted to read Claude Code's keychain item. Named here so the backend
67/// that runs it and the doctor check that looks for it cannot drift apart.
68pub const SECURITY: &str = "/usr/bin/security";
69
70impl Os {
71 /// The program Pitboard reaches the system's own store of secrets through, where it goes
72 /// through one rather than calling the system directly.
73 pub fn secrets_tool(self) -> Option<&'static str> {
74 match self {
75 Os::MacOs => Some(SECURITY),
76 // Credential Manager is called directly, through Windows' own API.
77 Os::Linux | Os::Windows => None,
78 }
79 }
80
81 /// The command a person types on this system to make `paths` private to themselves.
82 /// `None` where Pitboard cannot say one yet: on Windows, where a file is private through
83 /// its access control list, W15 says the command along with who may read a file, and
84 /// until then nothing asks, since the Windows face cannot tell who may.
85 pub fn make_private_command(self, kind: Kind, paths: &[&str]) -> Option<String> {
86 match self {
87 Os::MacOs | Os::Linux => {
88 let mode = match kind {
89 Kind::Directory => "700",
90 Kind::File => "600",
91 Kind::Any => "go-rwx",
92 };
93 Some(format!("chmod {mode} {}", paths.join(" ")))
94 }
95 Os::Windows => None,
96 }
97 }
98
99 /// The folders of a home, relative to it, that the system asks the person about before
100 /// an app may look inside them. An app passes over a directory of `PATH` in one: looking
101 /// would put that question to the person for something Pitboard never needed, and a
102 /// program started from there would have it asked on its behalf.
103 ///
104 /// macOS asks for the Desktop, Documents and Downloads folders, iCloud Drive and the
105 /// folders of other cloud storage, under Privacy & Security's Files & Folders. Linux
106 /// asks nothing. Which folders Windows asks about, if any, is W17's to say, with where an
107 /// app finds a program there; until then none is passed over.
108 pub fn guarded_folders(self) -> &'static [&'static str] {
109 match self {
110 Os::MacOs => &[
111 "Desktop",
112 "Documents",
113 "Downloads",
114 "Library/Mobile Documents",
115 "Library/CloudStorage",
116 ],
117 Os::Linux | Os::Windows => &[],
118 }
119 }
120
121 /// Where the system's package managers put the programs they install, for an app that
122 /// has no shell's `PATH` to find one on, after each tool's own installer's place.
123 ///
124 /// On a Mac, Homebrew's `bin` on Apple silicon and on Intel. npm's global `bin` is one of
125 /// them where Homebrew installed Node, and `/usr/local/bin` where nodejs.org's installer
126 /// did, so a global npm install of a tool is found there too: Claude Code 2.1.289 lists
127 /// both among npm's places. Nothing on Linux looks, since no app runs there and the
128 /// command line has a shell's `PATH`, so none is said for it. Where winget, Scoop and npm
129 /// put programs on Windows is W17's to read; until then none is looked in.
130 pub fn package_bins(self) -> &'static [&'static str] {
131 match self {
132 Os::MacOs => &["/opt/homebrew/bin", "/usr/local/bin"],
133 Os::Linux | Os::Windows => &[],
134 }
135 }
136
137 /// Whether `a` and `b` are one path, as Pitboard compares the places a program runs
138 /// from: component by component, exactly on macOS and Linux, and in any case on Windows,
139 /// whose file systems take `Codex.exe` and `codex.exe` for one file. Their process lists
140 /// give a program's path as it was started or as the file system has it, and the places a
141 /// tool's holders name are named by their makers in one case. Every comparison of where
142 /// a program runs from goes through this, so a system whose paths ignore case says so
143 /// once.
144 pub(crate) fn same_path(self, a: &Path, b: &Path) -> bool {
145 match self {
146 Os::MacOs | Os::Linux => a == b,
147 Os::Windows => same_path_in_any_case(a, b),
148 }
149 }
150
151 /// The command line an app at `app` comes with, which is what its renewal schedule runs:
152 /// the app itself is not one. A Mac app carries it at `Contents/Helpers/pitboard`, where
153 /// `build-app.sh` puts it, and anything that is not an app bundle, such as a test or a
154 /// build directory, has none. No app runs on Linux. Where the Windows app carries its
155 /// own is the app's to say, which it does not yet.
156 pub fn app_command_line(self, app: &Path) -> Option<PathBuf> {
157 match self {
158 Os::MacOs => (app.extension() == Some("app".as_ref()))
159 .then(|| app.join("Contents/Helpers/pitboard")),
160 Os::Linux | Os::Windows => None,
161 }
162 }
163}
164
165/// Whether `a` and `b` are one path as Windows compares them: component by component, each
166/// name in any case ([`same_name_in_any_case`]). Said once, for every comparison of paths on
167/// Windows, the core's and an app's.
168pub fn same_path_in_any_case(a: &Path, b: &Path) -> bool {
169 let (mut a, mut b) = (a.components(), b.components());
170 loop {
171 match (a.next(), b.next()) {
172 (None, None) => return true,
173 (Some(one), Some(other))
174 if same_name_in_any_case(one.as_os_str(), other.as_os_str()) => {}
175 _ => return false,
176 }
177 }
178}
179
180/// Whether two names are one as Windows compares a file's name: each character in its upper
181/// case, one for one, and no other change, so `é` written as one character and as `e` with an
182/// accent are two names. A character whose upper case is more than one, such as `ß`, and one
183/// outside the Basic Multilingual Plane, which Windows compares a UTF-16 unit at a time, are
184/// compared as they are.
185fn same_name_in_any_case(a: &std::ffi::OsStr, b: &std::ffi::OsStr) -> bool {
186 fn upper(c: char) -> char {
187 if u32::from(c) > 0xFFFF {
188 return c;
189 }
190 let mut upper = c.to_uppercase();
191 match (upper.next(), upper.next()) {
192 (Some(one), None) => one,
193 _ => c,
194 }
195 }
196 let (a, b) = (a.to_string_lossy(), b.to_string_lossy());
197 a.chars().map(upper).eq(b.chars().map(upper))
198}
199
200/// What the person's login shell said its `PATH` is.
201#[derive(Debug, Clone, PartialEq, Eq)]
202#[cfg_attr(
203 windows,
204 allow(
205 dead_code,
206 reason = "only a login shell says or is late, and Windows has none; W17 says which \
207 `PATH` an app finds programs on there"
208 )
209)]
210pub(crate) enum LoginPath {
211 /// It said this.
212 Said(String),
213 /// It could not be asked, or did not say. Asking again would get the same.
214 Unknown,
215 /// It had not answered in time, and was stopped. Startup files are slowest while the
216 /// machine is busy, which is when an app that opens at login first asks, so this is not
217 /// the last word: a later ask may find what this one could not.
218 Late,
219}
220
221/// The `PATH` the person's own terminal has, which an app the system started does not: asked
222/// of their login shell where the system has one, which can take seconds, so never on an
223/// app's main thread. Windows has no login shell, and says nothing until W17 reads which
224/// `PATH` an app finds programs on there.
225pub(crate) fn login_path(env: &Environment) -> LoginPath {
226 os::login_path(env)
227}
228
229/// Who besides a file's owner can reach it, as the system says.
230#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct Access {
232 /// Whether anybody but the owner can read or change it.
233 pub shared: bool,
234 /// What the system says about it, for a person to read: `mode 600` where access is a
235 /// mode.
236 pub described: String,
237}
238
239/// What a person makes private with the command [`Os::make_private_command`] gives.
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241pub enum Kind {
242 Directory,
243 File,
244 /// Directories and files alike.
245 Any,
246}
247
248/// Whether this process runs as the person themselves, as the system says. Pitboard changes
249/// nothing unless it is [`Elevation::Normal`]: what a run with more rights than the person's
250/// own writes is not theirs, in their own home and their own keychain, and their next
251/// ordinary run may not be able to read it, replace it or take it away.
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253pub enum Elevation {
254 /// As the person themselves.
255 Normal,
256 /// With rights that are not the person's own. `why` says how, in words that follow
257 /// "Pitboard runs": `as root`, or `with sudo`.
258 Elevated { why: &'static str },
259 /// The system could not say, which Pitboard takes as a reason to change nothing.
260 Unknown,
261}
262
263/// One process this user is running, and where its program runs from.
264#[derive(Debug, Clone, PartialEq, Eq)]
265pub struct Process {
266 pub pid: u32,
267 /// The program's path where the system says one, or its bare name where it does not.
268 /// macOS gives what the program was started as, which is its full path when whatever
269 /// started it named one; Linux gives the file it runs, where this user may read that.
270 pub path: PathBuf,
271}
272
273/// The machine Pitboard is standing on, as one value rather than a set of `cfg` branches
274/// spread through the crate. A host answers where another program's secrets may be, where
275/// Pitboard's own parked logins go, what this user is running and how daily renewal is
276/// scheduled. It takes the context on every call because a context is built by a builder
277/// and can still change after it exists.
278///
279/// It was called `Platform` until a second provider was on the way. The name said "which
280/// operating system", the body reached into Claude Code's own slot hashing, and once
281/// "provider" became a word this codebase uses, a reader meeting `Platform` could not tell
282/// which of the two axes it meant. What stays behind this name is the machine, and only the
283/// machine: which item or file a tool keeps its login in is the tool's to say.
284pub(crate) trait Host: Send + Sync + std::fmt::Debug {
285 /// Secrets another program keeps in the system's own store of them, under `account`:
286 /// the login keychain on macOS. `None` where the system has no such store, and a tool
287 /// keeps its login in a file instead.
288 ///
289 /// Which items and which account is the other program's business, so both are handed
290 /// in. Deriving them here is how Claude Code's slot hashing came to live inside what
291 /// claimed to be an operating-system abstraction.
292 fn foreign_secrets(&self, ctx: &Context, account: &str) -> Option<Box<dyn RawStore>>;
293
294 /// The single file at `path`, as a store.
295 fn file(&self, path: PathBuf) -> Box<dyn RawStore>;
296
297 /// Where Pitboard's own parked logins go: the system's store of secrets where there is
298 /// one, a private directory of files where there is not. This one really is a fact
299 /// about the machine.
300 fn vault(&self, ctx: &Context) -> Box<dyn RawStore>;
301
302 /// Whether every `PITBOARD_HOME` on this machine parks its logins in the one vault. A
303 /// keychain belongs to the whole login session, so a park in it that one home cannot
304 /// account for may be another home's; a vault of files lives inside its home, and
305 /// nothing in it can be anybody else's.
306 fn vault_is_shared(&self) -> bool;
307
308 /// The processes this user is running `program` in, with where each runs from. `None`
309 /// where the process list could not be read, which is not the same as none running.
310 ///
311 /// What is compared is the program's own name, so a script or a shell that merely
312 /// mentions the program is not counted. Only this user's: another user's sessions use
313 /// another user's login, which a switch here never touches.
314 fn processes(&self, program: &str) -> Option<Vec<Process>>;
315
316 /// The system's own scheduler, which runs daily renewal. `None` where there is none
317 /// Pitboard knows how to ask.
318 fn scheduler(&self) -> Option<&dyn Scheduler>;
319
320 /// Whether this process runs as the person themselves, which is what the one gate every
321 /// change passes asks ([`crate::service::Permit`]). Takes the context because part of
322 /// the answer can be in the environment this process was started with, such as the
323 /// `SUDO_UID` sudo sets.
324 fn elevation(&self, ctx: &Context) -> Elevation;
325
326 /// The file at `path`, outside every home, that only an administrator writes, such as
327 /// Codex's `/etc/codex/requirements.toml`. Which file is the tool's to say.
328 ///
329 /// Unset in a build for tests, on the real hosts ([`administered::READ_BY_REAL_HOSTS`]).
330 fn administered_file(&self, path: &Path) -> Administered {
331 if administered::READ_BY_REAL_HOSTS {
332 administered::read_text(path)
333 } else {
334 Administered::Unset
335 }
336 }
337
338 /// The text an administrator's configuration profile forces `key` of the program whose
339 /// preferences are `domain` to: a managed preference, on macOS. Unset where nothing
340 /// forces it, whoever set it otherwise, and on a system with no managed preferences.
341 ///
342 /// Unset in a build for tests, on the real hosts ([`administered::READ_BY_REAL_HOSTS`]).
343 fn managed_preference(&self, domain: &str, key: &str) -> Administered;
344}
345
346/// The system's own scheduler, which starts `pitboard renew` once a day: launchd on macOS,
347/// a systemd user timer on Linux. Never a homemade daemon.
348///
349/// What Pitboard decides about the schedule, which program it runs, how often, and whether
350/// it is this home's to change, is [`crate::schedule`]'s. This is only how the system is
351/// asked, and what it says back.
352pub(crate) trait Scheduler: Send + Sync + std::fmt::Debug {
353 /// Where the schedule is kept, where a person would look for it.
354 fn location(&self, ctx: &Context) -> PathBuf;
355
356 /// Whether a schedule is there.
357 fn installed(&self, ctx: &Context) -> bool;
358
359 /// The Pitboard the schedule runs, read back from what [`Scheduler::put`] wrote. `None`
360 /// where nothing is installed, and where what is there does not name one the way `put`
361 /// writes it.
362 fn program(&self, ctx: &Context) -> Option<PathBuf>;
363
364 /// The variables the schedule's job is given, by name, as [`Scheduler::put`] wrote them,
365 /// read back. Empty where nothing is installed, and where what is there gives none the way
366 /// `put` writes them. The system can give a job more of its own, which this does not say.
367 fn environment(&self, ctx: &Context) -> Vec<(String, String)>;
368
369 /// Schedule `program renew`, given `environment`, each variable by name, besides what the
370 /// system gives every job, and ask the system to start it. What is written can hold a
371 /// proxy's password, so only the person can read it, whatever was there before.
372 ///
373 /// Where the system will not start it, what was there goes back as it was, and is
374 /// started again. The status, doctor and the app all read what is there, so a schedule
375 /// left written that nothing runs would say renewal is on while it is not, which is the
376 /// failure nobody would notice until the parked logins had run out.
377 fn put(
378 &self,
379 ctx: &Context,
380 permit: Permit,
381 program: &Path,
382 environment: &[(String, String)],
383 ) -> Result<()>;
384
385 /// Stop the schedule and take it away. `false` when nothing was there.
386 fn remove(&self, ctx: &Context, permit: Permit) -> Result<bool>;
387
388 /// Whether this process is a run the schedule itself started. `said` is the job a test
389 /// says this process runs as; `None` leaves it to what the system says.
390 fn started_this_process(&self, said: Option<&str>) -> bool;
391}
392
393/// Where Pitboard keeps its own files for a person whose home is `home`, when
394/// `PITBOARD_HOME` names nowhere else: `.pitboard` in that home on macOS and Linux.
395///
396/// Worked out from the home it is handed, never from this account's own, so a context made
397/// for a home of a test's own, or of an app's choosing, keeps Pitboard's files there. The
398/// daily renewal schedule belongs to this home and no other ([`crate::schedule`]).
399///
400/// On Windows it is the person's local application data, `%LOCALAPPDATA%\Pitboard`, which
401/// W14 finds from the home it is handed. Until then there is none: the empty path is refused
402/// as a home that is not a full path, so nothing is read or written anywhere Pitboard did not
403/// find, and `PITBOARD_HOME` has to name the folder.
404pub(crate) fn default_pitboard_home(home: &Path) -> PathBuf {
405 match OS {
406 Os::MacOs | Os::Linux => home.join(".pitboard"),
407 Os::Windows => PathBuf::new(),
408 }
409}
410
411/// The host this build is standing on, which is what every real context uses.
412pub(crate) fn current() -> Arc<dyn Host> {
413 os::host()
414}
415
416/// The path this program was started by, where that lasts longer than the file it runs.
417pub(crate) fn current_program() -> std::io::Result<PathBuf> {
418 os::current_program()
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 /// A machine without a store of secrets must say so rather than hand back something
426 /// that behaves like one. A tool's own module builds its chain out of this answer, so a
427 /// host that always offered one would build a chain that cannot work.
428 #[test]
429 fn a_store_of_secrets_is_offered_only_where_there_is_one() {
430 let ctx = Context::for_unit_test();
431 let offered = ctx.host().foreign_secrets(&ctx, "someone");
432 assert_eq!(
433 offered.map(|k| k.kind()),
434 match OS {
435 Os::MacOs => Some(crate::store::Backend::Keychain),
436 Os::Linux => None,
437 // Credential Manager, which W23 offers.
438 Os::Windows => None,
439 }
440 );
441 assert_eq!(
442 ctx.host().file(PathBuf::from("/nowhere/at/all")).kind(),
443 crate::store::Backend::File
444 );
445 }
446
447 /// A path is the same one only in the same case on macOS and Linux, as Pitboard has always
448 /// compared where a program runs from there. A doubled or trailing separator still
449 /// leaves it the same path.
450 #[test]
451 fn paths_are_compared_exactly_on_macos_and_linux() {
452 for os in [Os::MacOs, Os::Linux] {
453 let same = |a: &str, b: &str| os.same_path(Path::new(a), Path::new(b));
454 assert!(same("ChatGPT.app", "ChatGPT.app"), "{os:?}");
455 assert!(
456 same("/Applications/ChatGPT.app/", "/Applications//ChatGPT.app"),
457 "{os:?}"
458 );
459 assert!(!same("chatgpt.app", "ChatGPT.app"), "{os:?}");
460 assert!(!same("ChatGPT.app", "ChatGPT"), "{os:?}");
461 }
462 }
463
464 /// On Windows a path is the same one in any case, and only as Windows changes a name's
465 /// case: a name written decomposed is another name, and so is one whose upper case is
466 /// longer than itself.
467 #[test]
468 fn paths_are_compared_in_any_case_on_windows() {
469 let same = |a: &str, b: &str| Os::Windows.same_path(Path::new(a), Path::new(b));
470 assert!(same("Codex.exe", "codex.EXE"));
471 assert!(same("/Programs/OpenAI/Codex/", "/programs//openai/CODEX"));
472 assert!(same(r"C:\Users\Dana\Codex.exe", r"c:\users\dana\codex.exe"));
473 assert!(same("Éclair", "éCLAIR"));
474 assert!(!same("e\u{301}clair", "\u{e9}clair"), "not normalised");
475 assert!(!same("Straße", "STRASSE"), "ß is upper case as it is");
476 assert!(!same("Codex.exe", "Codex"));
477 assert!(!same("/a/b", "/a/b/c"));
478 }
479
480 /// Pitboard writes for a scheduler of each system's own: launchd and a systemd user timer.
481 /// Task Scheduler is written for by W25, and until then Windows offers none.
482 #[test]
483 fn a_scheduler_is_offered_where_pitboard_writes_for_one() {
484 assert_eq!(
485 current().scheduler().is_some(),
486 match OS {
487 Os::MacOs | Os::Linux => true,
488 Os::Windows => false,
489 }
490 );
491 }
492
493 /// This system's host reads sudo from the environment the context was read from, which
494 /// is where sudo says it, and root from the process itself. Set but empty, `SUDO_UID`
495 /// says nothing, as every variable Pitboard reads that way. Windows' face cannot tell
496 /// either until W12 reads the process's token.
497 #[test]
498 fn the_host_reads_sudo_from_the_context_and_root_from_the_process() {
499 let under = |value: &str| {
500 let env: Environment = [("HOME", "/Users/x"), ("SUDO_UID", value)]
501 .into_iter()
502 .collect();
503 let ctx = Context::for_command_line(&env);
504 ctx.host().elevation(&ctx)
505 };
506 assert_eq!(
507 under("501"),
508 match OS {
509 Os::MacOs | Os::Linux => Elevation::Elevated { why: "with sudo" },
510 Os::Windows => Elevation::Unknown,
511 }
512 );
513 let ctx = Context::for_unit_test();
514 assert_eq!(
515 ctx.host().elevation(&ctx),
516 user::elevation(false),
517 "withheld from a unit test, so only root is read"
518 );
519 assert_eq!(under(""), user::elevation(false));
520 }
521}