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. Caboodle 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/// Install or upgrade a member from the stack tap. Brew runs as the user (no
180/// root), so caboodle drives it directly (aegis-6f1 ruling).
181pub fn brew_converge(tool: ToolName, upgrade: bool) -> anyhow::Result<()> {
182    let formula = brew_formula(tool).ok_or_else(|| anyhow::anyhow!("no stack formula"))?;
183    let brew = brew_program().ok_or_else(|| anyhow::anyhow!("brew is not available"))?;
184    let verb = if upgrade { "upgrade" } else { "install" };
185    let status = Command::new(&brew)
186        .args([verb, &format!("{STACK_TAP}/{formula}")])
187        .status()?;
188    if !status.success() {
189        anyhow::bail!("brew {verb} {STACK_TAP}/{formula} failed ({status})");
190    }
191    Ok(())
192}
193
194/// How a package-owned tool stands against its reviewed pin.
195#[derive(Debug, PartialEq, Eq)]
196pub enum Standing {
197    Current,
198    Ahead,
199    Behind,
200    /// One of the versions has no comparable semantic version.
201    Unknown,
202}
203
204#[must_use]
205pub fn standing(receipt_version: &str, desired: &str) -> Standing {
206    let installed = format!("pkg {receipt_version}");
207    match (
208        crate::release_update::behind_reviewed(&installed, desired),
209        crate::release_update::behind_reviewed(desired, &installed),
210    ) {
211        (Some(true), _) => Standing::Behind,
212        (Some(false), Some(true)) => Standing::Ahead,
213        (Some(false), Some(false)) => Standing::Current,
214        _ => Standing::Unknown,
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::installed_version;
221
222    fn entry(full_name: &str, tap: &str, installed: &[&str]) -> serde_json::Value {
223        serde_json::json!({
224            "full_name": full_name,
225            "tap": tap,
226            "installed": installed.iter().map(|v| serde_json::json!({"version": v})).collect::<Vec<_>>(),
227        })
228    }
229
230    fn info(entries: Vec<serde_json::Value>) -> Vec<u8> {
231        serde_json::to_vec(&serde_json::json!({"formulae": entries, "casks": []})).unwrap()
232    }
233
234    #[test]
235    fn receipt_reads_the_named_member_not_the_first_installed_formula() {
236        // aegis-fv4: `--installed` made brew list every formula; abseil came first.
237        let listing = info(vec![
238            entry("abseil", "homebrew/core", &["20250814.1"]),
239            entry("scbrown/tap/quipu", "scbrown/tap", &["0.14.1"]),
240        ]);
241        assert_eq!(
242            installed_version(&listing, "quipu").as_deref(),
243            Some("0.14.1")
244        );
245        assert_eq!(installed_version(&listing, "bobbin"), None);
246    }
247
248    #[test]
249    fn receipt_refuses_uninstalled_foreign_tap_and_malformed_answers() {
250        assert_eq!(
251            installed_version(
252                &info(vec![entry("scbrown/tap/bobbin", "scbrown/tap", &[])]),
253                "bobbin"
254            ),
255            None
256        );
257        assert_eq!(
258            installed_version(
259                &info(vec![entry("scbrown/tap/bobbin", "other/tap", &["1.0.0"])]),
260                "bobbin"
261            ),
262            None
263        );
264        assert_eq!(installed_version(b"not json", "bobbin"), None);
265        assert_eq!(
266            installed_version(
267                &info(vec![entry(
268                    "scbrown/tap/yupana",
269                    "scbrown/tap",
270                    &["0.13.0_1"]
271                )]),
272                "yupana"
273            )
274            .as_deref(),
275            Some("0.13.0")
276        );
277    }
278
279    use super::*;
280
281    #[test]
282    fn debian_revision_and_epoch_are_not_part_of_the_pinned_version() {
283        assert_eq!(upstream_version("0.13.0"), "0.13.0");
284        assert_eq!(upstream_version("0.13.0-2"), "0.13.0");
285        assert_eq!(upstream_version("1:0.13.0-2"), "0.13.0");
286        assert_eq!(upstream_version("0.12.1-3"), "0.12.1");
287    }
288
289    #[test]
290    fn caboodle_self_and_untapped_tools_have_no_stack_formula() {
291        assert_eq!(brew_formula(ToolName::Bobbin), Some("bobbin"));
292        assert_eq!(brew_formula(ToolName::DesirePath), Some("desire-path"));
293        assert_eq!(brew_formula(ToolName::Member("seeds")), Some("seeds"));
294        assert_eq!(brew_formula(ToolName::Member("chaski")), Some("chaski"));
295        assert_eq!(brew_formula(ToolName::Camayoc), None);
296        assert_eq!(brew_formula(ToolName::Member("shuttle")), None);
297    }
298
299    #[test]
300    fn package_standing_never_reads_ahead_as_an_update() {
301        assert_eq!(standing("0.13.0", "quipu 0.13.0"), Standing::Current);
302        assert_eq!(standing("0.12.1", "quipu 0.13.0"), Standing::Behind);
303        assert_eq!(standing("0.28.0", "bobbin 0.27.3"), Standing::Ahead);
304        assert_eq!(standing("dev", "bobbin 0.27.3"), Standing::Unknown);
305        assert_eq!(standing("0.7.1", "dp v0.3.2 (1f457ec)"), Standing::Ahead);
306    }
307}