Skip to main content

caboodle/
package.rs

1// Compatibility: retained library imports, persisted paths, environment keys and
2// explicit old-command/version probes remain readable during the product rename.
3//! Package-manager receipts for stack tools (aegis-6f1).
4//!
5//! On a host whose stack is delivered by a package manager, that manager is
6//! the only writer. On apt hosts that is the root unattended-upgrades lane;
7//! automated Tambo runs never call apt or sudo. Tambo still has to judge
8//! such a tool, so it reads the package receipt (the package that owns the
9//! binary PATH runs, and its version) and compares the upstream version with
10//! the reviewed pin: current, ahead (never downgraded) or behind (the package
11//! lane will deliver it).
12
13use std::{env, process::Command};
14
15use crate::model::ToolName;
16
17/// What the package manager says about a tool's binary.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Receipt {
20    pub manager: &'static str,
21    pub package: String,
22    /// The upstream version, with any Debian epoch and revision removed.
23    pub version: String,
24}
25
26/// `[epoch:]upstream[-revision]` -> `upstream`. The stack's packages use no
27/// epoch and either no revision or a packaging-only one (quipu 0.13.0-2 adds
28/// its qp link), so the upstream part is what the reviewed pin names.
29#[must_use]
30pub fn upstream_version(debian: &str) -> &str {
31    let without_epoch = debian.split_once(':').map_or(debian, |(_, rest)| rest);
32    without_epoch
33        .rsplit_once('-')
34        .map_or(without_epoch, |(upstream, _)| upstream)
35}
36
37/// The program whose owner decides who manages `tool`, if it has a binary.
38#[must_use]
39pub fn primary_program(tool: ToolName) -> Option<String> {
40    match tool {
41        ToolName::Quipu => Some("quipu".into()),
42        ToolName::Bobbin => Some("bobbin".into()),
43        ToolName::Yupana => Some("yupana".into()),
44        ToolName::DesirePath => Some("dp".into()),
45        ToolName::Camayoc => None,
46        ToolName::Member(name) => {
47            crate::members::get(name).and_then(|m| m.programs.first().map(|p| p.name.clone()))
48        }
49    }
50}
51
52/// The apt receipt for the `program` PATH runs, when dpkg owns it.
53#[must_use]
54pub fn apt_receipt(program: &str) -> Option<Receipt> {
55    let path = crate::adapter::which_all(program, env::var_os("PATH").as_deref())
56        .into_iter()
57        .next()?;
58    let package = crate::apt_owned::owner(&path)?;
59    let query = env::var_os("CABOODLE_DPKG_QUERY").unwrap_or_else(|| "dpkg-query".into());
60    let out = Command::new(query)
61        .args(["-W", "-f", "${Version}", &package])
62        .output()
63        .ok()?;
64    if !out.status.success() {
65        return None;
66    }
67    let raw = String::from_utf8_lossy(&out.stdout).trim().to_owned();
68    if raw.is_empty() {
69        return None;
70    }
71    Some(Receipt {
72        manager: "apt",
73        version: upstream_version(&raw).to_owned(),
74        package,
75    })
76}
77
78/// The Homebrew tap that publishes the stack (aegis-em2). A formula installed
79/// from any other tap is package-owned but never driven by Tambo.
80pub const STACK_TAP: &str = "scbrown/tap";
81
82/// The stack formula for a member tool. caboodle-self is NOT here: on the Mac
83/// its installer lane owns it until the Tambo cutover (aegis-6f1 (a)).
84#[must_use]
85pub fn brew_formula(tool: ToolName) -> Option<&'static str> {
86    match tool {
87        ToolName::Bobbin => Some("bobbin"),
88        ToolName::DesirePath => Some("desire-path"),
89        ToolName::Quipu => Some("quipu"),
90        ToolName::Yupana => Some("yupana"),
91        ToolName::Member("seeds") => Some("seeds"),
92        // Chaski needs Python >= 3.11; its formula owns a python@3.12, which a
93        // release-fallback wheel on macOS's /usr/bin/python3 (3.9) cannot (aegis-o7n).
94        ToolName::Member("chaski") => Some("chaski"),
95        _ => None,
96    }
97}
98
99/// The brew executable, only where Tambo may drive it: macOS by default,
100/// or any host that names one explicitly in CABOODLE_BREW (tests use a fake).
101#[must_use]
102pub fn brew_program() -> Option<std::ffi::OsString> {
103    if let Some(explicit) = env::var_os("CABOODLE_BREW") {
104        return Some(explicit);
105    }
106    cfg!(target_os = "macos").then(|| "brew".into())
107}
108
109/// `brew info --json=v2 <tap>/<formula>`: the installed version, and only when
110/// the formula really comes from the stack tap.
111///
112/// NOT `--installed` (aegis-fv4, measured on the Mac 2026-10-16): with that
113/// flag brew ignores the formula argument and lists EVERY installed formula, so
114/// the first entry was an unrelated `homebrew/core` package and no stack member
115/// could ever produce a receipt ("brew receipt after install" on every run).
116fn brew_info(brew: &std::ffi::OsStr, formula: &str) -> Option<String> {
117    let out = Command::new(brew)
118        .args(["info", "--json=v2", &format!("{STACK_TAP}/{formula}")])
119        .output()
120        .ok()?;
121    if !out.status.success() {
122        return None;
123    }
124    installed_version(&out.stdout, formula)
125}
126
127/// The installed version of exactly `<STACK_TAP>/<formula>` in brew's v2 JSON.
128/// Selects the entry by full name rather than position, so extra or reordered
129/// entries can never stand in for the member.
130fn installed_version(json: &[u8], formula: &str) -> Option<String> {
131    let info: serde_json::Value = serde_json::from_slice(json).ok()?;
132    let wanted = format!("{STACK_TAP}/{formula}");
133    let entry =
134        info.get("formulae")?.as_array()?.iter().find(|entry| {
135            entry.get("full_name").and_then(|v| v.as_str()) == Some(wanted.as_str())
136        })?;
137    if entry.get("tap")?.as_str()? != STACK_TAP {
138        return None;
139    }
140    let version = entry
141        .get("installed")?
142        .as_array()?
143        .first()?
144        .get("version")?
145        .as_str()?;
146    // Homebrew marks rebuilds as `<version>_<n>`; the pin names the upstream.
147    Some(version.split('_').next()?.to_owned())
148}
149
150/// The brew receipt for a member tool installed from the stack tap.
151#[must_use]
152pub fn brew_receipt(tool: ToolName) -> Option<Receipt> {
153    let formula = brew_formula(tool)?;
154    let version = brew_info(&brew_program()?, formula)?;
155    Some(Receipt {
156        manager: "brew",
157        package: format!("{STACK_TAP}/{formula}"),
158        version,
159    })
160}
161
162/// The tap's current stable version for a member, when brew can read it.
163#[must_use]
164pub fn brew_available(tool: ToolName) -> Option<String> {
165    let formula = brew_formula(tool)?;
166    let out = Command::new(brew_program()?)
167        .args(["info", "--json=v2", &format!("{STACK_TAP}/{formula}")])
168        .output()
169        .ok()?;
170    if !out.status.success() {
171        return None;
172    }
173    let info: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
174    let entry = info.get("formulae")?.as_array()?.first()?;
175    if entry.get("tap")?.as_str()? != STACK_TAP {
176        return None;
177    }
178    Some(entry.get("versions")?.get("stable")?.as_str()?.to_owned())
179}
180
181/// Current stable channel and pin selected by Homebrew for one stack formula.
182#[derive(Debug, PartialEq, Eq)]
183pub struct BrewChannel {
184    pub installed: Option<String>,
185    pub stable: String,
186    pub pinned: bool,
187}
188
189fn channel_from_json(bytes: &[u8], formula: &str) -> anyhow::Result<Option<BrewChannel>> {
190    use anyhow::Context;
191    let info: serde_json::Value = serde_json::from_slice(bytes).context("brew channel JSON")?;
192    let wanted = format!("{STACK_TAP}/{formula}");
193    let entries = info
194        .get("formulae")
195        .and_then(serde_json::Value::as_array)
196        .context("brew channel formula list")?;
197    let Some(entry) = entries.iter().find(|entry| {
198        entry.get("full_name").and_then(serde_json::Value::as_str) == Some(wanted.as_str())
199            && entry.get("tap").and_then(serde_json::Value::as_str) == Some(STACK_TAP)
200    }) else {
201        return Ok(None);
202    };
203    let stable = entry
204        .get("versions")
205        .and_then(|v| v.get("stable"))
206        .and_then(serde_json::Value::as_str)
207        .filter(|v| !v.is_empty())
208        .context("brew stable channel version")?
209        .to_owned();
210    let pinned = entry
211        .get("pinned")
212        .and_then(serde_json::Value::as_bool)
213        .context("brew formula pin status")?;
214    let installed = entry
215        .get("installed")
216        .and_then(serde_json::Value::as_array)
217        .context("brew installed receipt list")?
218        .first()
219        .map(|receipt| {
220            receipt
221                .get("version")
222                .and_then(serde_json::Value::as_str)
223                .filter(|v| !v.is_empty())
224                .map(|v| v.split('_').next().unwrap_or(v).to_owned())
225                .context("brew installed version")
226        })
227        .transpose()?;
228    Ok(Some(BrewChannel {
229        installed,
230        stable,
231        pinned,
232    }))
233}
234
235/// Refresh once per selected update, before judging installed versions. An
236/// unreadable/stale channel must not be silently reported as converged.
237pub fn brew_refresh() -> anyhow::Result<()> {
238    let brew = brew_program().ok_or_else(|| anyhow::anyhow!("brew is not available"))?;
239    let status = Command::new(brew).args(["update", "--quiet"]).status()?;
240    if !status.success() {
241        anyhow::bail!("brew update failed ({status})");
242    }
243    Ok(())
244}
245
246pub fn brew_channel(tool: ToolName) -> anyhow::Result<Option<BrewChannel>> {
247    let Some(formula) = brew_formula(tool) else {
248        return Ok(None);
249    };
250    let brew = brew_program().ok_or_else(|| anyhow::anyhow!("brew is not available"))?;
251    let out = Command::new(brew)
252        .args(["info", "--json=v2", &format!("{STACK_TAP}/{formula}")])
253        .env("HOMEBREW_NO_AUTO_UPDATE", "1")
254        .output()?;
255    if !out.status.success() {
256        anyhow::bail!("brew channel read failed for {formula} ({})", out.status);
257    }
258    channel_from_json(&out.stdout, formula)
259}
260
261/// Install or upgrade a member from the stack tap. Brew runs as the user (no
262/// root), so Tambo drives it directly (aegis-6f1 ruling).
263pub fn brew_converge(tool: ToolName, upgrade: bool) -> anyhow::Result<()> {
264    let formula = brew_formula(tool).ok_or_else(|| anyhow::anyhow!("no stack formula"))?;
265    let brew = brew_program().ok_or_else(|| anyhow::anyhow!("brew is not available"))?;
266    let verb = if upgrade { "upgrade" } else { "install" };
267    let status = Command::new(&brew)
268        .args([verb, &format!("{STACK_TAP}/{formula}")])
269        .env("HOMEBREW_NO_AUTO_UPDATE", "1")
270        .status()?;
271    if !status.success() {
272        anyhow::bail!("brew {verb} {STACK_TAP}/{formula} failed ({status})");
273    }
274    Ok(())
275}
276
277/// How a package-owned tool stands against its reviewed pin.
278#[derive(Debug, PartialEq, Eq)]
279pub enum Standing {
280    Current,
281    Ahead,
282    Behind,
283    /// One of the versions has no comparable semantic version.
284    Unknown,
285}
286
287#[must_use]
288pub fn standing(receipt_version: &str, desired: &str) -> Standing {
289    let installed = format!("pkg {receipt_version}");
290    match (
291        crate::release_update::behind_reviewed(&installed, desired),
292        crate::release_update::behind_reviewed(desired, &installed),
293    ) {
294        (Some(true), _) => Standing::Behind,
295        (Some(false), Some(true)) => Standing::Ahead,
296        (Some(false), Some(false)) => Standing::Current,
297        _ => Standing::Unknown,
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::installed_version;
304
305    fn entry(full_name: &str, tap: &str, installed: &[&str]) -> serde_json::Value {
306        serde_json::json!({
307            "full_name": full_name,
308            "tap": tap,
309            "installed": installed.iter().map(|v| serde_json::json!({"version": v})).collect::<Vec<_>>(),
310        })
311    }
312
313    fn info(entries: Vec<serde_json::Value>) -> Vec<u8> {
314        serde_json::to_vec(&serde_json::json!({"formulae": entries, "casks": []})).unwrap()
315    }
316
317    #[test]
318    fn receipt_reads_the_named_member_not_the_first_installed_formula() {
319        // aegis-fv4: `--installed` made brew list every formula; abseil came first.
320        let listing = info(vec![
321            entry("abseil", "homebrew/core", &["20250814.1"]),
322            entry("scbrown/tap/quipu", "scbrown/tap", &["0.14.1"]),
323        ]);
324        assert_eq!(
325            installed_version(&listing, "quipu").as_deref(),
326            Some("0.14.1")
327        );
328        assert_eq!(installed_version(&listing, "bobbin"), None);
329    }
330
331    #[test]
332    fn receipt_refuses_uninstalled_foreign_tap_and_malformed_answers() {
333        assert_eq!(
334            installed_version(
335                &info(vec![entry("scbrown/tap/bobbin", "scbrown/tap", &[])]),
336                "bobbin"
337            ),
338            None
339        );
340        assert_eq!(
341            installed_version(
342                &info(vec![entry("scbrown/tap/bobbin", "other/tap", &["1.0.0"])]),
343                "bobbin"
344            ),
345            None
346        );
347        assert_eq!(installed_version(b"not json", "bobbin"), None);
348        assert_eq!(
349            installed_version(
350                &info(vec![entry(
351                    "scbrown/tap/yupana",
352                    "scbrown/tap",
353                    &["0.13.0_1"]
354                )]),
355                "yupana"
356            )
357            .as_deref(),
358            Some("0.13.0")
359        );
360    }
361
362    use super::*;
363
364    #[test]
365    fn debian_revision_and_epoch_are_not_part_of_the_pinned_version() {
366        assert_eq!(upstream_version("0.13.0"), "0.13.0");
367        assert_eq!(upstream_version("0.13.0-2"), "0.13.0");
368        assert_eq!(upstream_version("1:0.13.0-2"), "0.13.0");
369        assert_eq!(upstream_version("0.12.1-3"), "0.12.1");
370    }
371
372    #[test]
373    fn caboodle_self_and_untapped_tools_have_no_stack_formula() {
374        assert_eq!(brew_formula(ToolName::Bobbin), Some("bobbin"));
375        assert_eq!(brew_formula(ToolName::DesirePath), Some("desire-path"));
376        assert_eq!(brew_formula(ToolName::Member("seeds")), Some("seeds"));
377        assert_eq!(brew_formula(ToolName::Member("chaski")), Some("chaski"));
378        assert_eq!(brew_formula(ToolName::Camayoc), None);
379        assert_eq!(brew_formula(ToolName::Member("shuttle")), None);
380    }
381
382    #[test]
383    fn package_standing_never_reads_ahead_as_an_update() {
384        assert_eq!(standing("0.13.0", "quipu 0.13.0"), Standing::Current);
385        assert_eq!(standing("0.12.1", "quipu 0.13.0"), Standing::Behind);
386        assert_eq!(standing("0.28.0", "bobbin 0.27.3"), Standing::Ahead);
387        assert_eq!(standing("dev", "bobbin 0.27.3"), Standing::Unknown);
388        assert_eq!(standing("0.7.1", "dp v0.3.2 (1f457ec)"), Standing::Ahead);
389    }
390}
391
392#[cfg(test)]
393mod channel_tests {
394    use super::*;
395    use serde_json::json;
396
397    #[test]
398    fn stable_channel_selects_exact_stack_formula_and_retains_pin() {
399        let bytes=serde_json::to_vec(&json!({"formulae":[
400            {"full_name":"other/bobbin","tap":"other","versions":{"stable":"99.0.0"},"installed":[{"version":"99.0.0"}],"pinned":false},
401            {"full_name":"scbrown/tap/bobbin","tap":"scbrown/tap","versions":{"stable":"0.29.0"},"installed":[{"version":"0.28.0_2"}],"pinned":true}
402        ]})).unwrap();
403        assert_eq!(
404            channel_from_json(&bytes, "bobbin").unwrap(),
405            Some(BrewChannel {
406                installed: Some("0.28.0".into()),
407                stable: "0.29.0".into(),
408                pinned: true
409            })
410        );
411        assert_eq!(channel_from_json(&bytes, "quipu").unwrap(), None);
412    }
413
414    #[test]
415    fn malformed_channel_cannot_stand_in_for_a_receipt() {
416        for raw in [
417            r#"{"formulae":[{"full_name":"scbrown/tap/bobbin","tap":"scbrown/tap","versions":{"stable":"0.29.0"},"installed":[] }]}"#,
418            r#"{"formulae":[{"full_name":"scbrown/tap/bobbin","tap":"scbrown/tap","pinned":false,"installed":[] }]}"#,
419            r#"{"formulae":[{"full_name":"scbrown/tap/bobbin","tap":"scbrown/tap","pinned":false,"versions":{"stable":"0.29.0"},"installed":[{}]}]}"#,
420            r#"{"formulae":null}"#,
421            "not JSON",
422        ] {
423            assert!(
424                channel_from_json(raw.as_bytes(), "bobbin").is_err(),
425                "{raw}"
426            );
427        }
428    }
429}