Skip to main content

pitboard_core/
assumptions.rs

1//! How pitboard writes down what it believes about somebody else's software.
2//!
3//! Every load-bearing fact about a tool pitboard parks logins for was read out of one build
4//! of that tool, and those tools ship several times a week. When such a fact moves, pitboard
5//! does not fail loudly: it parks a login under the wrong account, or writes to an item
6//! nobody reads, or leaves the outgoing account's device token in place for the incoming
7//! one. The 0.1.4 changelog records this class of bug happening once already, found by hand.
8//!
9//! So the facts are a list rather than a comment. Each one says what it is, where in the
10//! tool it was read, which build it was last verified against, and what in this crate falls
11//! over if it moves.
12//!
13//! Each provider keeps its own list, dated on its own schedule, because these are facts
14//! about different binaries with nothing to do with each other:
15//! [`crate::provider::claude::assumptions`] is Claude Code's. This module is the shape they
16//! share and the machinery that probes them.
17//!
18//! This is a register, not a check. Naming a fact does not verify it, and the list says so
19//! by dating every entry.
20
21use crate::provider::ProviderId;
22
23/// The system a build of a tool is for.
24///
25/// A tool's builds for different systems do not carry the same code. Claude Code's Linux
26/// build has no keychain code at all, so a fact about the macOS keychain, read from it,
27/// reports the keychain gone. Measured on 2.1.278, 2.1.281 and 2.1.284, where 1,462 string
28/// literals are in the macOS build only and 85 in the Linux build only.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum Platform {
32    MacOs,
33    Linux,
34}
35
36impl Platform {
37    /// Both, for a fact that holds the same on either.
38    pub const ALL: &'static [Platform] = &[Platform::MacOs, Platform::Linux];
39
40    /// Stable, lower case, as a report names it.
41    pub fn code(self) -> &'static str {
42        match self {
43            Platform::MacOs => "macos",
44            Platform::Linux => "linux",
45        }
46    }
47}
48
49/// One thing pitboard believes about a tool it parks logins for.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub struct Assumption {
52    /// Stable, snake_case, safe for a program to branch on.
53    pub name: &'static str,
54    /// What pitboard believes.
55    pub fact: &'static str,
56    /// Where in Claude Code it was read, so it can be read again.
57    pub read_from: &'static str,
58    /// The build it was last verified against.
59    pub verified_against: &'static str,
60    /// What in this crate stops being true if it moves.
61    pub depends: &'static str,
62    /// Literals that must be present in a Claude Code build for this fact to still be
63    /// readable there. Empty where the fact cannot be read out of a build at all, which is
64    /// every fact that is about behaviour rather than about a name.
65    ///
66    /// These are a cheap and shallow check. A literal being present does not prove the
67    /// behaviour around it is unchanged; a literal disappearing does prove something moved.
68    /// Read from the builds [`read_on`] names, and only from those.
69    pub probe: &'static [&'static str],
70    /// Literals whose *arrival* would disprove the fact.
71    ///
72    /// Some of what pitboard stands on is an absence: Claude Code has no Linux keyring
73    /// backend, so on Linux its login is a file, so pitboard's own store there is a file
74    /// too. A fact like that cannot be probed for by looking for something. Nothing being
75    /// there is not evidence a check is running, which is exactly how an absence stops
76    /// being true without anybody noticing, so the absence is written down and looked for.
77    ///
78    /// Needles here must be specific to the thing being ruled out. `secret-tool` and
79    /// `kwallet-query` both appear in the build already, in the list of credential helpers
80    /// its sandbox excludes from a shell, and either would report a keyring backend that is
81    /// not there.
82    pub absent: &'static [&'static str],
83}
84
85/// One provider's register.
86pub fn of(provider: ProviderId) -> &'static [Assumption] {
87    match provider {
88        ProviderId::Claude => crate::provider::claude::assumptions::ASSUMPTIONS,
89        ProviderId::Codex => crate::provider::codex::assumptions::ASSUMPTIONS,
90    }
91}
92
93/// The systems whose builds one of a provider's facts is read from.
94///
95/// Each register says this beside its facts rather than in them: `Assumption` can be
96/// written as a literal outside this crate, and a field added to it would break every such
97/// literal.
98pub fn read_on(provider: ProviderId, name: &str) -> &'static [Platform] {
99    match provider {
100        ProviderId::Claude => crate::provider::claude::assumptions::read_on(name),
101        ProviderId::Codex => Platform::ALL,
102    }
103}
104
105/// The build one provider's register was read from.
106pub fn verified_against(provider: ProviderId) -> &'static str {
107    match provider {
108        ProviderId::Claude => crate::provider::claude::assumptions::VERIFIED_AGAINST,
109        ProviderId::Codex => crate::provider::codex::assumptions::VERIFIED_AGAINST,
110    }
111}
112
113/// Every provider's register, in one list.
114///
115/// A provider whose register is missing from here is one nothing checks, and nothing would
116/// say so, which is the same failure the registers exist to prevent.
117pub fn all() -> Vec<&'static Assumption> {
118    ProviderId::ALL.iter().flat_map(|&p| of(p)).collect()
119}
120
121/// The assumption of that name, for a check or a probe that wants to speak about one.
122pub fn named(name: &str) -> Option<&'static Assumption> {
123    all().into_iter().find(|a| a.name == name)
124}
125
126/// What a probe found in one Claude Code build.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum Reading {
129    /// Every literal this fact is readable by is there, and nothing that would disprove it
130    /// has turned up.
131    Holds,
132    /// This fact cannot be read out of a build at all; it is about behaviour, not a name.
133    NotReadable,
134    /// Something moved. These literals are gone.
135    Moved(Vec<&'static str>),
136    /// Something arrived that this fact said would not be there. An absence that stopped
137    /// being an absence: a keyring backend where pitboard is relying on there being none.
138    Appeared(Vec<&'static str>),
139}
140
141/// Check one assumption against the printable strings of a Claude Code build.
142///
143/// Shallow on purpose. A literal being present does not prove the behaviour around it is
144/// unchanged, and this never claims it does; a literal disappearing does prove something
145/// moved, which is the only thing worth waking somebody for.
146pub fn read_from_build(assumption: &Assumption, strings: &str) -> Reading {
147    // An arrival is reported before a disappearance: a fact that rests on nothing being
148    // there is wrong the moment something is, whatever else still reads the same.
149    let arrived: Vec<&'static str> = assumption
150        .absent
151        .iter()
152        .filter(|needle| strings.contains(**needle))
153        .copied()
154        .collect();
155    if !arrived.is_empty() {
156        return Reading::Appeared(arrived);
157    }
158    if assumption.probe.is_empty() {
159        return if assumption.absent.is_empty() {
160            Reading::NotReadable
161        } else {
162            // Nothing to look for, and nothing that should not be there was found.
163            Reading::Holds
164        };
165    }
166    let gone: Vec<&'static str> = assumption
167        .probe
168        .iter()
169        .filter(|needle| !strings.contains(**needle))
170        .copied()
171        .collect();
172    if gone.is_empty() {
173        Reading::Holds
174    } else {
175        Reading::Moved(gone)
176    }
177}
178
179/// Every printable run of `least` bytes or more, which is all a probe needs of a binary and
180/// is the one thing a compiled bundle reliably gives up.
181pub fn printable_runs(bytes: &[u8], least: usize) -> String {
182    let mut out = String::new();
183    let mut run = Vec::new();
184    for &b in bytes {
185        if (0x20..0x7f).contains(&b) || b == b'\t' {
186            run.push(b);
187            continue;
188        }
189        if run.len() >= least {
190            out.push_str(&String::from_utf8_lossy(&run));
191            out.push('\n');
192        }
193        run.clear();
194    }
195    if run.len() >= least {
196        out.push_str(&String::from_utf8_lossy(&run));
197        out.push('\n');
198    }
199    out
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    /// The one fact here that rests on an absence. A keyring backend arriving in Claude
207    /// Code would make pitboard's Linux store the wrong shape without anything pitboard
208    /// reads going missing, so it is looked for rather than waited for.
209    #[test]
210    fn a_keyring_arriving_where_there_was_none_is_reported() {
211        let no_keyring = named("no_keyring_off_macos").unwrap();
212        let backends = r#"tengu_windows_credman CLAUDE_CODE_FORCE_WINDOWS_CREDMAN ["keychain","plaintext","windows-credman"]"#;
213        assert_eq!(read_from_build(no_keyring, backends), Reading::Holds);
214        assert_eq!(
215            read_from_build(no_keyring, &format!("{backends} Bun.secrets.get")),
216            Reading::Appeared(vec!["Bun.secrets"])
217        );
218    }
219
220    /// `libsecret` is in every Linux build of Claude Code since at least 2.1.278, in the
221    /// Bun runtime it ships inside, and Claude Code's own code never reaches it. As a needle
222    /// it reported a keyring backend that was not there, from the first run that read a
223    /// Linux build.
224    #[test]
225    fn the_bundled_runtime_is_not_taken_for_a_keyring() {
226        let no_keyring = named("no_keyring_off_macos").unwrap();
227        assert!(!no_keyring.absent.contains(&"libsecret"));
228        let backends = r#"tengu_windows_credman CLAUDE_CODE_FORCE_WINDOWS_CREDMAN ["keychain","plaintext","windows-credman"]"#;
229        assert_eq!(
230            read_from_build(
231                no_keyring,
232                &format!("{backends} libsecret not available. libsecret-1.so.0")
233            ),
234            Reading::Holds
235        );
236    }
237
238    /// Every fact is read from at least one build. One read from none would never be
239    /// checked, and nothing would say so.
240    #[test]
241    fn every_fact_is_read_from_some_build() {
242        for &provider in ProviderId::ALL {
243            for a in of(provider) {
244                assert!(
245                    !read_on(provider, a.name).is_empty(),
246                    "{} is read from no build",
247                    a.name
248                );
249            }
250        }
251    }
252
253    /// The keychain facts are read from a macOS build, and the fact about Linux having no
254    /// keyring from a Linux one. Read from the wrong build, each reported drift that was
255    /// not there.
256    #[test]
257    fn each_keychain_fact_is_read_where_the_keychain_code_is() {
258        for name in ["keychain_write_route", "keychain_absence_codes"] {
259            assert_eq!(
260                read_on(ProviderId::Claude, name),
261                &[Platform::MacOs],
262                "{name}"
263            );
264        }
265        assert_eq!(
266            read_on(ProviderId::Claude, "no_keyring_off_macos"),
267            &[Platform::Linux]
268        );
269    }
270
271    /// `secret-tool` and `kwallet-query` are both in a shipping build already, in the list
272    /// of credential helpers its sandbox keeps out of a shell. Either as a needle would
273    /// report a keyring backend on every build there has ever been.
274    #[test]
275    fn nothing_already_in_a_build_is_used_to_rule_a_backend_out() {
276        for a in all() {
277            for needle in a.absent {
278                assert!(
279                    !["secret-tool", "kwallet-query", "keytar", "keyring"].contains(needle),
280                    "{}: `{needle}` is in the build for other reasons",
281                    a.name
282                );
283                assert!(
284                    needle.len() >= 8,
285                    "{}: `{needle}` is too short to mean one thing",
286                    a.name
287                );
288            }
289        }
290    }
291
292    /// An absence with nothing to read holds until something turns up. Without this it
293    /// would report as unreadable, which is what a fact nobody is checking looks like.
294    #[test]
295    fn a_fact_that_is_only_an_absence_still_reads() {
296        let only_absent = Assumption {
297            name: "x",
298            fact: "x",
299            read_from: "x",
300            verified_against: "9.9.9",
301            depends: "x",
302            probe: &[],
303            absent: &["a_thing_that_should_not_be_here"],
304        };
305        assert_eq!(
306            read_from_build(&only_absent, "nothing to see"),
307            Reading::Holds
308        );
309        assert_eq!(
310            read_from_build(&only_absent, "a_thing_that_should_not_be_here"),
311            Reading::Appeared(vec!["a_thing_that_should_not_be_here"])
312        );
313    }
314
315    #[test]
316    fn every_assumption_is_named_once_and_says_all_four_things() {
317        let mut names: Vec<&str> = all().iter().map(|a| a.name).collect();
318        let before = names.len();
319        names.sort_unstable();
320        names.dedup();
321        assert_eq!(names.len(), before, "two assumptions share a name");
322
323        for a in all() {
324            assert!(!a.fact.is_empty(), "{} says nothing", a.name);
325            assert!(!a.read_from.is_empty(), "{} says nowhere", a.name);
326            assert!(!a.depends.is_empty(), "{} costs nothing", a.name);
327            assert!(
328                a.verified_against.split('.').count() == 3,
329                "{} is dated against `{}`, which is not a version",
330                a.name,
331                a.verified_against
332            );
333            assert!(
334                a.name
335                    .bytes()
336                    .all(|b| b.is_ascii_lowercase() || b == b'_' || b.is_ascii_digit()),
337                "{} is not a stable code",
338                a.name
339            );
340        }
341    }
342
343    #[test]
344    fn a_probe_reads_what_is_there_and_names_what_is_not() {
345        let write_lock = named("write_lock").expect("listed");
346        let whole = write_lock.probe.join(" and also ");
347        assert_eq!(read_from_build(write_lock, &whole), Reading::Holds);
348
349        let moved = read_from_build(write_lock, "nothing of the sort");
350        assert_eq!(moved, Reading::Moved(write_lock.probe.to_vec()));
351
352        // A fact about behaviour cannot be read out of a build, and says so rather than
353        // pretending either way.
354        let cache = named("credential_cache").expect("listed");
355        assert_eq!(read_from_build(cache, ""), Reading::NotReadable);
356    }
357
358    #[test]
359    fn printable_runs_finds_the_strings_and_nothing_else() {
360        let bytes = b"\x00\x01hello there\x00\x02tiny\x00wide load\xff";
361        let found = printable_runs(bytes, 6);
362        assert!(found.contains("hello there"));
363        assert!(found.contains("wide load"));
364        assert!(
365            !found.contains("tiny"),
366            "a run shorter than asked for is not a string"
367        );
368    }
369
370    #[test]
371    fn an_assumption_can_be_looked_up_by_name() {
372        assert_eq!(
373            named("write_lock").expect("it is listed").name,
374            "write_lock"
375        );
376        assert_eq!(named("nothing_like_this"), None);
377    }
378}