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//!
21//! Beside its facts, each register keeps a table, [`PerSystem`], that says of every fact on
22//! every system whether it was read there and from which build, why it is not read there,
23//! or which pull request of the Windows work reads it there. A fact that behaves
24//! differently on one system is a fact of its own for that system, never a second reading
25//! under the same name.
26
27use crate::provider::ProviderId;
28
29/// The system a build of a tool is for.
30///
31/// A tool's builds for different systems do not carry the same code. Claude Code's Linux
32/// build has no keychain code at all, so a fact about the macOS keychain, read from it,
33/// reports the keychain gone. Measured on 2.1.278, 2.1.281 and 2.1.284, where 1,462 string
34/// literals are in the macOS build only and 85 in the Linux build only. Its Windows build of
35/// 2.1.289 is built from the same commit as the other two and has no keychain backend
36/// either, though it keeps the keychain code the builds share, and a Credential Manager
37/// store of its own.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39#[non_exhaustive]
40pub enum Platform {
41    MacOs,
42    Linux,
43    Windows,
44}
45
46impl Platform {
47    /// Every system, in the order a report lists them. Kept private: a fact's systems are
48    /// the ones its line in [`PerSystem`] reads it on, and no list of systems stands in for
49    /// that.
50    const EACH: [Platform; 3] = [Platform::MacOs, Platform::Linux, Platform::Windows];
51
52    /// Stable, lower case, as a report names it.
53    pub fn code(self) -> &'static str {
54        match self {
55            Platform::MacOs => "macos",
56            Platform::Linux => "linux",
57            Platform::Windows => "windows",
58        }
59    }
60}
61
62/// What a register says of one fact on one system.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum OnSystem {
65    /// Read from this build of the tool for that system. A conformance run reads the fact
66    /// from that system's builds, and only from those.
67    Read(&'static str),
68    /// Not read there, and why: the system has nothing the fact is about, or its build
69    /// carries what the fact rules out for another reason.
70    NotRead(&'static str),
71    /// Not read there yet. `by` names the pull requests of the Windows work that read it,
72    /// `W2` to `W27`, and `reads` what each reads, which is a fact of its own wherever the
73    /// tool behaves differently there.
74    Pending {
75        by: &'static [&'static str],
76        reads: &'static str,
77    },
78}
79
80/// One fact's line in the table beside its register: what the register says of it on each
81/// system. A field for each system, so no fact can be left out on one or said twice.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub struct PerSystem {
84    /// The fact's name in the register.
85    pub name: &'static str,
86    pub macos: OnSystem,
87    pub linux: OnSystem,
88    pub windows: OnSystem,
89}
90
91impl PerSystem {
92    /// What this line says on one system.
93    pub fn on(&self, platform: Platform) -> OnSystem {
94        match platform {
95            Platform::MacOs => self.macos,
96            Platform::Linux => self.linux,
97            Platform::Windows => self.windows,
98        }
99    }
100}
101
102/// One thing Pitboard believes about a tool it parks logins for.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub struct Assumption {
105    /// Stable, snake_case, safe for a program to branch on.
106    pub name: &'static str,
107    /// What Pitboard believes.
108    pub fact: &'static str,
109    /// Where in Claude Code it was read, so it can be read again.
110    pub read_from: &'static str,
111    /// The build its macOS and Linux readings name. A fact read on Windows may be read there
112    /// from a later build, which the register's table names, so [`verified_on`] is the build
113    /// for any one system.
114    pub verified_against: &'static str,
115    /// What in this crate stops being true if it moves.
116    pub depends: &'static str,
117    /// Literals that must be present in a Claude Code build for this fact to still be
118    /// readable there. Empty where the fact cannot be read out of a build at all, which is
119    /// every fact that is about behaviour rather than about a name.
120    ///
121    /// These are a cheap and shallow check. A literal being present does not prove the
122    /// behaviour around it is unchanged; a literal disappearing does prove something moved.
123    /// Read from the builds [`read_on`] names, and only from those.
124    pub probe: &'static [&'static str],
125    /// Literals whose *arrival* would disprove the fact.
126    ///
127    /// Some of what Pitboard stands on is an absence: Claude Code has no Linux keyring
128    /// backend, so on Linux its login is a file, so Pitboard's own store there is a file
129    /// too. A fact like that cannot be probed for by looking for something. Nothing being
130    /// there is not evidence a check is running, which is exactly how an absence stops
131    /// being true without anybody noticing, so the absence is written down and looked for.
132    ///
133    /// Needles here must be specific to the thing being ruled out. `secret-tool` and
134    /// `kwallet-query` both appear in the build already, in the list of credential helpers
135    /// its sandbox excludes from a shell, and either would report a keyring backend that is
136    /// not there.
137    pub absent: &'static [&'static str],
138}
139
140/// One provider's register.
141pub fn of(provider: ProviderId) -> &'static [Assumption] {
142    match provider {
143        ProviderId::Claude => crate::provider::claude::assumptions::ASSUMPTIONS,
144        ProviderId::Codex => crate::provider::codex::assumptions::ASSUMPTIONS,
145    }
146}
147
148/// The table beside one provider's register, a line for each of its facts.
149///
150/// Each register says this beside its facts rather than in them: `Assumption` can be
151/// written as a literal outside this crate, and a field added to it would break every such
152/// literal.
153pub fn per_system(provider: ProviderId) -> &'static [PerSystem] {
154    match provider {
155        ProviderId::Claude => crate::provider::claude::assumptions::PER_SYSTEM,
156        ProviderId::Codex => crate::provider::codex::assumptions::PER_SYSTEM,
157    }
158}
159
160/// What a provider's register says of one of its facts on one system, or nothing for a
161/// name it does not hold.
162pub fn on(provider: ProviderId, name: &str, platform: Platform) -> Option<OnSystem> {
163    per_system(provider)
164        .iter()
165        .find(|line| line.name == name)
166        .map(|line| line.on(platform))
167}
168
169/// The systems whose builds one of a provider's facts is read from.
170pub fn read_on(provider: ProviderId, name: &str) -> Vec<Platform> {
171    Platform::EACH
172        .into_iter()
173        .filter(|&platform| verified_on(provider, name, platform).is_some())
174        .collect()
175}
176
177/// The build one of a provider's facts was read from on one system, or nothing where it is
178/// not read there.
179///
180/// `Assumption::verified_against` is the build its macOS and Linux readings name, and one
181/// field could not also say that the same fact was read on Windows from a later build.
182pub fn verified_on(provider: ProviderId, name: &str, platform: Platform) -> Option<&'static str> {
183    match on(provider, name, platform)? {
184        OnSystem::Read(build) => Some(build),
185        OnSystem::NotRead(_) | OnSystem::Pending { .. } => None,
186    }
187}
188
189/// Every fact whose reading on a system waits on a pull request still to come, with that
190/// system. The Windows work is done when this is empty.
191pub fn pending() -> Vec<(ProviderId, &'static str, Platform)> {
192    ProviderId::ALL
193        .iter()
194        .flat_map(|&provider| {
195            per_system(provider).iter().flat_map(move |line| {
196                Platform::EACH
197                    .into_iter()
198                    .filter(|&platform| matches!(line.on(platform), OnSystem::Pending { .. }))
199                    .map(move |platform| (provider, line.name, platform))
200            })
201        })
202        .collect()
203}
204
205/// The build one provider's register was read from on one system, unless an entry names
206/// its own.
207pub fn verified_against(provider: ProviderId, platform: Platform) -> &'static str {
208    use crate::provider::{claude, codex};
209    match (provider, platform) {
210        (ProviderId::Claude, Platform::MacOs | Platform::Linux) => {
211            claude::assumptions::VERIFIED_AGAINST
212        }
213        (ProviderId::Claude, Platform::Windows) => claude::assumptions::WINDOWS_VERIFIED_AGAINST,
214        (ProviderId::Codex, Platform::MacOs | Platform::Linux) => {
215            codex::assumptions::VERIFIED_AGAINST
216        }
217        (ProviderId::Codex, Platform::Windows) => codex::assumptions::WINDOWS_VERIFIED_AGAINST,
218    }
219}
220
221/// Every provider's register, in one list.
222///
223/// A provider whose register is missing from here is one nothing checks, and nothing would
224/// say so, which is the same failure the registers exist to prevent.
225pub fn all() -> Vec<&'static Assumption> {
226    ProviderId::ALL.iter().flat_map(|&p| of(p)).collect()
227}
228
229/// The assumption of that name, for a check or a probe that wants to speak about one.
230pub fn named(name: &str) -> Option<&'static Assumption> {
231    all().into_iter().find(|a| a.name == name)
232}
233
234/// What a probe found in one Claude Code build.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub enum Reading {
237    /// Every literal this fact is readable by is there, and nothing that would disprove it
238    /// has turned up.
239    Holds,
240    /// This fact cannot be read out of a build at all; it is about behaviour, not a name.
241    NotReadable,
242    /// Something moved. These literals are gone.
243    Moved(Vec<&'static str>),
244    /// Something arrived that this fact said would not be there. An absence that stopped
245    /// being an absence: a keyring backend where Pitboard is relying on there being none.
246    Appeared(Vec<&'static str>),
247}
248
249/// Check one assumption against the printable strings of a Claude Code build.
250///
251/// Shallow on purpose. A literal being present does not prove the behaviour around it is
252/// unchanged, and this never claims it does; a literal disappearing does prove something
253/// moved, which is the only thing worth waking somebody for.
254pub fn read_from_build(assumption: &Assumption, strings: &str) -> Reading {
255    // An arrival is reported before a disappearance: a fact that rests on nothing being
256    // there is wrong the moment something is, whatever else still reads the same.
257    let arrived: Vec<&'static str> = assumption
258        .absent
259        .iter()
260        .filter(|needle| strings.contains(**needle))
261        .copied()
262        .collect();
263    if !arrived.is_empty() {
264        return Reading::Appeared(arrived);
265    }
266    if assumption.probe.is_empty() {
267        return if assumption.absent.is_empty() {
268            Reading::NotReadable
269        } else {
270            // Nothing to look for, and nothing that should not be there was found.
271            Reading::Holds
272        };
273    }
274    let gone: Vec<&'static str> = assumption
275        .probe
276        .iter()
277        .filter(|needle| !strings.contains(**needle))
278        .copied()
279        .collect();
280    if gone.is_empty() {
281        Reading::Holds
282    } else {
283        Reading::Moved(gone)
284    }
285}
286
287/// Every printable run of `least` bytes or more, which is all a probe needs of a binary and
288/// is the one thing a compiled bundle reliably gives up.
289pub fn printable_runs(bytes: &[u8], least: usize) -> String {
290    let mut out = String::new();
291    let mut run = Vec::new();
292    for &b in bytes {
293        if (0x20..0x7f).contains(&b) || b == b'\t' {
294            run.push(b);
295            continue;
296        }
297        if run.len() >= least {
298            out.push_str(&String::from_utf8_lossy(&run));
299            out.push('\n');
300        }
301        run.clear();
302    }
303    if run.len() >= least {
304        out.push_str(&String::from_utf8_lossy(&run));
305        out.push('\n');
306    }
307    out
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    /// The one fact here that rests on an absence. A keyring backend arriving in Claude
315    /// Code would make Pitboard's Linux store the wrong shape without anything Pitboard
316    /// reads going missing, so it is looked for rather than waited for.
317    #[test]
318    fn a_keyring_arriving_where_there_was_none_is_reported() {
319        let no_keyring = named("no_keyring_off_macos").unwrap();
320        let backends = r#"tengu_windows_credman CLAUDE_CODE_FORCE_WINDOWS_CREDMAN ["keychain","plaintext","windows-credman"]"#;
321        assert_eq!(read_from_build(no_keyring, backends), Reading::Holds);
322        assert_eq!(
323            read_from_build(no_keyring, &format!("{backends} Bun.secrets.get")),
324            Reading::Appeared(vec!["Bun.secrets"])
325        );
326    }
327
328    /// `libsecret` is in every Linux build of Claude Code since at least 2.1.278, in the
329    /// Bun runtime it ships inside, and Claude Code's own code never reaches it. As a needle
330    /// it reported a keyring backend that was not there, from the first run that read a
331    /// Linux build.
332    #[test]
333    fn the_bundled_runtime_is_not_taken_for_a_keyring() {
334        let no_keyring = named("no_keyring_off_macos").unwrap();
335        assert!(!no_keyring.absent.contains(&"libsecret"));
336        let backends = r#"tengu_windows_credman CLAUDE_CODE_FORCE_WINDOWS_CREDMAN ["keychain","plaintext","windows-credman"]"#;
337        assert_eq!(
338            read_from_build(
339                no_keyring,
340                &format!("{backends} libsecret not available. libsecret-1.so.0")
341            ),
342            Reading::Holds
343        );
344    }
345
346    /// Every fact is read from at least one build. One read from none would never be
347    /// checked, and nothing would say so.
348    #[test]
349    fn every_fact_is_read_from_some_build() {
350        for &provider in ProviderId::ALL {
351            for a in of(provider) {
352                assert!(
353                    !read_on(provider, a.name).is_empty(),
354                    "{} is read from no build",
355                    a.name
356                );
357            }
358        }
359    }
360
361    /// The keychain facts are read from a macOS build, and the fact about Linux having no
362    /// keyring from a Linux one. Read from the wrong build, each reported drift that was
363    /// not there.
364    #[test]
365    fn each_keychain_fact_is_read_where_the_keychain_code_is() {
366        for name in ["keychain_write_route", "keychain_absence_codes"] {
367            assert_eq!(
368                read_on(ProviderId::Claude, name),
369                [Platform::MacOs],
370                "{name}"
371            );
372        }
373        assert_eq!(
374            read_on(ProviderId::Claude, "no_keyring_off_macos"),
375            [Platform::Linux]
376        );
377    }
378
379    /// Each register's table has a line for every fact it holds and for nothing else, and
380    /// a line says one thing on each system. A fact with no line would be read nowhere and
381    /// nothing would say so; a line naming no fact would be a reading of nothing.
382    #[test]
383    fn every_fact_has_one_line_and_every_line_is_a_fact() {
384        for &provider in ProviderId::ALL {
385            let mut facts: Vec<&str> = of(provider).iter().map(|a| a.name).collect();
386            let mut lines: Vec<&str> = per_system(provider).iter().map(|l| l.name).collect();
387            facts.sort_unstable();
388            lines.sort_unstable();
389            let before = lines.len();
390            lines.dedup();
391            assert_eq!(lines.len(), before, "{provider:?}: a fact has two lines");
392            assert_eq!(lines, facts, "{provider:?}");
393            for a in of(provider) {
394                for platform in Platform::EACH {
395                    assert!(on(provider, a.name, platform).is_some(), "{}", a.name);
396                }
397            }
398        }
399    }
400
401    /// Claude Code's Credential Manager store calls `Bun.secrets`, so the Windows build
402    /// carries it 25 times in 2.1.289, x64 and arm64 both. A fact that rules it out, read
403    /// there, would report a keyring arrived on every build.
404    #[test]
405    fn no_fact_ruling_out_bun_secrets_is_read_on_windows() {
406        for &provider in ProviderId::ALL {
407            for a in of(provider) {
408                if a.absent.contains(&"Bun.secrets") {
409                    assert_eq!(
410                        verified_on(provider, a.name, Platform::Windows),
411                        None,
412                        "{}",
413                        a.name
414                    );
415                    assert!(
416                        matches!(
417                            on(provider, a.name, Platform::Windows),
418                            Some(OnSystem::NotRead(_))
419                        ),
420                        "{} must say why it is not read on Windows",
421                        a.name
422                    );
423                }
424            }
425        }
426    }
427
428    /// A reading still to come names the pull request of the Windows work that takes it,
429    /// `W2` to `W27`, and says what that reads. Nothing on macOS or Linux waits on one.
430    #[test]
431    fn every_pending_reading_names_a_pull_request_of_the_windows_work() {
432        for &provider in ProviderId::ALL {
433            for line in per_system(provider) {
434                for platform in Platform::EACH {
435                    let OnSystem::Pending { by, reads } = line.on(platform) else {
436                        continue;
437                    };
438                    assert_eq!(platform, Platform::Windows, "{}", line.name);
439                    assert!(!by.is_empty(), "{} waits on nothing", line.name);
440                    assert!(
441                        !reads.is_empty(),
442                        "{} says nothing of what is read",
443                        line.name
444                    );
445                    for pr in by {
446                        let number = pr.strip_prefix('W').and_then(|n| n.parse::<u32>().ok());
447                        assert!(
448                            matches!(number, Some(2..=27)),
449                            "{}: `{pr}` is not a pull request of the Windows work",
450                            line.name
451                        );
452                    }
453                }
454            }
455        }
456    }
457
458    /// The readings still to come are listed in one place, so the last pull request of the
459    /// Windows work can require the list to be empty. Today it is the facts of each tool that
460    /// Windows reads differently or later: seven of Claude Code's, and eight of Codex's, with
461    /// the layers its store is read from.
462    #[test]
463    fn the_readings_still_to_come_are_listed() {
464        let waiting = pending();
465        assert!(waiting.iter().all(|&(_, _, p)| p == Platform::Windows));
466        for (provider, count) in [(ProviderId::Claude, 7), (ProviderId::Codex, 8)] {
467            let names: Vec<&str> = waiting
468                .iter()
469                .filter(|&&(p, _, _)| p == provider)
470                .map(|&(_, name, _)| name)
471                .collect();
472            assert_eq!(names.len(), count, "{provider:?}: {names:?}");
473            for name in names {
474                assert!(matches!(
475                    on(provider, name, Platform::Windows),
476                    Some(OnSystem::Pending { .. })
477                ));
478            }
479        }
480    }
481
482    /// `verified_against` is the build a fact's macOS and Linux readings name, so a report
483    /// of either system and the fact itself cannot disagree.
484    #[test]
485    fn the_field_names_the_build_of_the_macos_and_linux_readings() {
486        for &provider in ProviderId::ALL {
487            for a in of(provider) {
488                for platform in [Platform::MacOs, Platform::Linux] {
489                    if let Some(build) = verified_on(provider, a.name, platform) {
490                        assert_eq!(
491                            build,
492                            a.verified_against,
493                            "{} on {}",
494                            a.name,
495                            platform.code()
496                        );
497                    }
498                }
499            }
500        }
501    }
502
503    /// A reading names a version, and a fact not read somewhere says why.
504    #[test]
505    fn every_line_says_a_build_or_a_reason() {
506        for &provider in ProviderId::ALL {
507            for line in per_system(provider) {
508                for platform in Platform::EACH {
509                    match line.on(platform) {
510                        OnSystem::Read(build) => assert_eq!(
511                            build.split('.').count(),
512                            3,
513                            "{} on {}: `{build}` is not a version",
514                            line.name,
515                            platform.code()
516                        ),
517                        OnSystem::NotRead(why) => {
518                            assert!(!why.is_empty(), "{} on {}", line.name, platform.code());
519                        }
520                        OnSystem::Pending { .. } => {}
521                    }
522                }
523            }
524        }
525    }
526
527    /// `secret-tool` and `kwallet-query` are both in a shipping build already, in the list
528    /// of credential helpers its sandbox keeps out of a shell. Either as a needle would
529    /// report a keyring backend on every build there has ever been.
530    #[test]
531    fn nothing_already_in_a_build_is_used_to_rule_a_backend_out() {
532        for a in all() {
533            for needle in a.absent {
534                assert!(
535                    !["secret-tool", "kwallet-query", "keytar", "keyring"].contains(needle),
536                    "{}: `{needle}` is in the build for other reasons",
537                    a.name
538                );
539                assert!(
540                    needle.len() >= 8,
541                    "{}: `{needle}` is too short to mean one thing",
542                    a.name
543                );
544            }
545        }
546    }
547
548    /// An absence with nothing to read holds until something turns up. Without this it
549    /// would report as unreadable, which is what a fact nobody is checking looks like.
550    #[test]
551    fn a_fact_that_is_only_an_absence_still_reads() {
552        let only_absent = Assumption {
553            name: "x",
554            fact: "x",
555            read_from: "x",
556            verified_against: "9.9.9",
557            depends: "x",
558            probe: &[],
559            absent: &["a_thing_that_should_not_be_here"],
560        };
561        assert_eq!(
562            read_from_build(&only_absent, "nothing to see"),
563            Reading::Holds
564        );
565        assert_eq!(
566            read_from_build(&only_absent, "a_thing_that_should_not_be_here"),
567            Reading::Appeared(vec!["a_thing_that_should_not_be_here"])
568        );
569    }
570
571    #[test]
572    fn every_assumption_is_named_once_and_says_all_four_things() {
573        let mut names: Vec<&str> = all().iter().map(|a| a.name).collect();
574        let before = names.len();
575        names.sort_unstable();
576        names.dedup();
577        assert_eq!(names.len(), before, "two assumptions share a name");
578
579        for a in all() {
580            assert!(!a.fact.is_empty(), "{} says nothing", a.name);
581            assert!(!a.read_from.is_empty(), "{} says nowhere", a.name);
582            assert!(!a.depends.is_empty(), "{} costs nothing", a.name);
583            assert!(
584                a.verified_against.split('.').count() == 3,
585                "{} is dated against `{}`, which is not a version",
586                a.name,
587                a.verified_against
588            );
589            assert!(
590                a.name
591                    .bytes()
592                    .all(|b| b.is_ascii_lowercase() || b == b'_' || b.is_ascii_digit()),
593                "{} is not a stable code",
594                a.name
595            );
596        }
597    }
598
599    #[test]
600    fn a_probe_reads_what_is_there_and_names_what_is_not() {
601        let write_lock = named("write_lock").expect("listed");
602        let whole = write_lock.probe.join(" and also ");
603        assert_eq!(read_from_build(write_lock, &whole), Reading::Holds);
604
605        let moved = read_from_build(write_lock, "nothing of the sort");
606        assert_eq!(moved, Reading::Moved(write_lock.probe.to_vec()));
607
608        // A fact about behaviour cannot be read out of a build, and says so rather than
609        // pretending either way.
610        let cache = named("credential_cache").expect("listed");
611        assert_eq!(read_from_build(cache, ""), Reading::NotReadable);
612    }
613
614    #[test]
615    fn printable_runs_finds_the_strings_and_nothing_else() {
616        let bytes = b"\x00\x01hello there\x00\x02tiny\x00wide load\xff";
617        let found = printable_runs(bytes, 6);
618        assert!(found.contains("hello there"));
619        assert!(found.contains("wide load"));
620        assert!(
621            !found.contains("tiny"),
622            "a run shorter than asked for is not a string"
623        );
624    }
625
626    #[test]
627    fn an_assumption_can_be_looked_up_by_name() {
628        assert_eq!(
629            named("write_lock").expect("it is listed").name,
630            "write_lock"
631        );
632        assert_eq!(named("nothing_like_this"), None);
633    }
634}