Skip to main content

caboodle/
package.rs

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