Skip to main content

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}