Skip to main content

caboodle/
members.rs

1//! Stack members declared as DATA (aegis-1i5h1j C1).
2//!
3//! A member is one reviewed `members/<name>.toml`, embedded at build time by
4//! `build.rs`, so the version and per-target digests a user installs are the
5//! ones this caboodle build was reviewed with. Adding a member is a data file,
6//! never a Rust match arm: every site that dispatches on `ToolName` has one
7//! explicit `Member` arm that reads from here.
8//!
9//! Every manifest is parsed and validated by a unit test, so a malformed or
10//! unsafe manifest fails caboodle's CI and never reaches a user.
11
12use std::collections::BTreeMap;
13use std::sync::OnceLock;
14
15use anyhow::{bail, Context, Result};
16use serde::Deserialize;
17
18use crate::model::{Profile, ToolName};
19
20/// How a member is delivered. A new kind is a code change; a new member is data.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
22#[serde(rename_all = "kebab-case")]
23pub enum Kind {
24    /// A `.tar.gz` GitHub release asset per target, holding the programs.
25    RustRelease,
26    /// One pure-Python `py3-none-any` wheel for every host, recorded under the
27    /// target key [`ANY_TARGET`]. Install puts it in a caboodle-owned venv and
28    /// links its console scripts. The wheel is digest-pinned; its declared
29    /// dependencies are resolved by pip from the package index, so the pin
30    /// covers the member's own code and NOT its dependencies.
31    PythonWheel,
32}
33
34/// The single `[sha256]` key of a host-independent (`python-wheel`) member.
35pub const ANY_TARGET: &str = "any";
36
37/// One program the member puts on PATH, and how to tell it from a program of
38/// the same name that is NOT this member (seeds' `sd` vs chmln/sd, A2).
39///
40/// A version line cannot do that: both print `sd <semver>`. So the identity is
41/// text only this member prints (seeds: a line of its `--help`), and a bare
42/// name or name-plus-version identity is refused (wu F1).
43#[derive(Debug, Clone, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct Program {
46    pub name: String,
47    /// Arguments that print the identity text, e.g. `["--help"]`.
48    pub identity_argv: Vec<String>,
49    /// The identity output (stdout, then stderr) must CONTAIN this literal text.
50    pub identity_contains: String,
51}
52
53/// One verify step. It must exit 0; `absent` / `present` then test its stdout
54/// for a substring. `{marker}` in any field is a fresh per-run marker.
55#[derive(Debug, Clone, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct Step {
58    pub argv: Vec<String>,
59    /// Text written to the step's stdin, with `{marker}` substituted. Lets a
60    /// step hand a member a file's worth of input (a workflow definition)
61    /// without writing outside the hermetic root.
62    #[serde(default)]
63    pub stdin: Option<String>,
64    #[serde(default)]
65    pub absent: Option<String>,
66    #[serde(default)]
67    pub present: Option<String>,
68}
69
70#[derive(Debug, Clone, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct Manifest {
73    pub name: String,
74    pub kind: Kind,
75    /// GitHub `owner/repo` the release is published under.
76    pub repo: String,
77    pub version: String,
78    /// Release tag, with `{version}` substituted (A1: not derivable in general).
79    pub tag: String,
80    /// Asset file name, with `{tag}`, `{version}` and `{target}` substituted.
81    pub asset: String,
82    /// Recorded lowercase-hex SHA-256 of the asset, per target triple. Install
83    /// verifies against THIS, never against a checksum file fetched at install.
84    pub sha256: BTreeMap<String, String>,
85    /// The release asset listing every asset's SHA-256, e.g. `SHA256SUMS.txt`.
86    /// Read only by `bump-member`, which records its digests here for review;
87    /// install never trusts it (wu M2).
88    pub sums_asset: String,
89    pub programs: Vec<Program>,
90    /// Arguments printing the installed version.
91    pub version_argv: Vec<String>,
92    #[serde(default)]
93    pub prerequisites: Vec<String>,
94    /// Profiles this member joins (see `Profile::tools`).
95    #[serde(default)]
96    pub profiles: Vec<Profile>,
97    /// Verify steps, run in a hermetic temp HOME (A3).
98    pub verify: Vec<Step>,
99}
100
101impl Manifest {
102    /// The concrete asset name for `target`.
103    pub fn asset_for(&self, target: &str) -> String {
104        let tag = self.tag.replace("{version}", &self.version);
105        self.asset
106            .replace("{tag}", &tag)
107            .replace("{version}", &self.version)
108            .replace("{target}", target)
109    }
110
111    pub fn tag(&self) -> String {
112        self.tag.replace("{version}", &self.version)
113    }
114
115    /// Parse and validate one manifest's text.
116    pub fn parse(text: &str) -> Result<Self> {
117        let m: Manifest = toml::from_str(text).context("invalid member manifest")?;
118        m.validate()?;
119        Ok(m)
120    }
121
122    fn validate(&self) -> Result<()> {
123        let safe = |s: &str| {
124            !s.is_empty()
125                && s.chars()
126                    .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
127        };
128        if !safe(&self.name) || ToolName::BUILTINS.iter().any(|t| t.as_str() == self.name) {
129            bail!(
130                "member name '{}' is unsafe or shadows a built-in tool",
131                self.name
132            );
133        }
134        // Both are substituted into the download URL (malcolm N4).
135        if !safe(&self.version) || !safe(&self.tag.replace("{version}", &self.version)) {
136            bail!("{}: version and tag must be safe names", self.name);
137        }
138        // bump-member recovers the version from a release tag through this.
139        if self.tag.matches("{version}").count() != 1 {
140            bail!("{}: tag must contain {{version}} exactly once", self.name);
141        }
142        if !safe(&self.sums_asset) {
143            bail!("{}: sums_asset must be a safe name", self.name);
144        }
145        if self.repo.split('/').count() != 2 || !self.repo.split('/').all(safe) {
146            bail!("{}: repo must be owner/name", self.name);
147        }
148        if self.sha256.is_empty() {
149            bail!("{}: at least one target digest is required", self.name);
150        }
151        match self.kind {
152            Kind::PythonWheel => {
153                if self.sha256.len() != 1 || !self.sha256.contains_key(ANY_TARGET) {
154                    bail!(
155                        "{}: a python-wheel member records exactly one digest, under \"{ANY_TARGET}\"",
156                        self.name
157                    );
158                }
159                if !self.asset.ends_with("-py3-none-any.whl") || self.asset.contains("{target}") {
160                    bail!(
161                        "{}: a python-wheel asset is one pure-Python wheel (*-py3-none-any.whl), \
162                         not per target",
163                        self.name
164                    );
165                }
166            }
167            Kind::RustRelease => {
168                if self.sha256.contains_key(ANY_TARGET) {
169                    bail!(
170                        "{}: \"{ANY_TARGET}\" is not a target triple; a rust-release digest is per target",
171                        self.name
172                    );
173                }
174            }
175        }
176        for (target, digest) in &self.sha256 {
177            if !safe(target)
178                || digest.len() != 64
179                || !digest
180                    .bytes()
181                    .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
182            {
183                bail!(
184                    "{}: sha256 for {target} must be 64 lowercase hex",
185                    self.name
186                );
187            }
188        }
189        if self.programs.is_empty() || !self.programs.iter().all(|p| safe(&p.name)) {
190            bail!("{}: programs must be non-empty safe names", self.name);
191        }
192        for p in &self.programs {
193            // After the program's own name, the identity must still say
194            // something a same-named program would not: a word, not a version.
195            let rest = p.identity_contains.replace(&p.name, "");
196            let is_version = |w: &str| {
197                w.trim_start_matches('v')
198                    .starts_with(|c: char| c.is_ascii_digit())
199            };
200            if !rest
201                .split_whitespace()
202                .filter(|w| !is_version(w))
203                .any(|w| w.chars().any(char::is_alphabetic))
204            {
205                bail!(
206                    "{}: identity_contains {:?} for `{}` is only its name and a version, which a \
207                     different `{}` also prints (F1); use text only this member prints",
208                    self.name,
209                    p.identity_contains,
210                    p.name,
211                    p.name
212                );
213            }
214        }
215        // A4: a verify that only ever sees the marker PRESENT would pass for a
216        // program that prints everything. Require an absent-then-present pair.
217        let absent_at = self.verify.iter().position(|s| s.absent.is_some());
218        let present_at = self.verify.iter().rposition(|s| s.present.is_some());
219        match (absent_at, present_at) {
220            (Some(a), Some(p)) if a < p => {}
221            _ => bail!(
222                "{}: verify needs a step asserting a marker ABSENT before one asserting it PRESENT (A4)",
223                self.name
224            ),
225        }
226        if self.verify.iter().any(|s| s.argv.is_empty()) {
227            bail!("{}: every verify step needs argv", self.name);
228        }
229        // B3: a step handed the marker can pass `present` by echoing its
230        // arguments, storing nothing. The read-back must be a separate step.
231        if self.verify.iter().any(|s| {
232            s.present.is_some()
233                && (s.argv.iter().any(|a| a.contains("{marker}"))
234                    || s.stdin.as_deref().is_some_and(|i| i.contains("{marker}")))
235        }) {
236            bail!(
237                "{}: a verify step asserting the marker PRESENT must not be given it in argv (B3)",
238                self.name
239            );
240        }
241        Ok(())
242    }
243}
244
245const EMBEDDED: &[&str] = include!(concat!(env!("OUT_DIR"), "/members_embedded.rs"));
246
247fn parse_all() -> Result<Vec<Manifest>> {
248    let mut out: Vec<Manifest> = Vec::new();
249    for text in EMBEDDED {
250        let m = Manifest::parse(text)?;
251        if out.iter().any(|o| o.name == m.name) {
252            bail!("duplicate member '{}'", m.name);
253        }
254        out.push(m);
255    }
256    out.sort_by(|a, b| a.name.cmp(&b.name));
257    Ok(out)
258}
259
260/// Every embedded member, validated, in name order. A failure here is a build
261/// defect (the unit test below catches it in CI), so it panics loudly.
262pub fn all() -> &'static [Manifest] {
263    static REGISTRY: OnceLock<Vec<Manifest>> = OnceLock::new();
264    REGISTRY.get_or_init(|| parse_all().expect("embedded member manifests are invalid"))
265}
266
267/// The member named `name`, if one is embedded.
268pub fn get(name: &str) -> Option<&'static Manifest> {
269    all().iter().find(|m| m.name == name)
270}
271
272/// Program names of `name`, for callers that need a `'static` slice.
273pub fn programs(name: &str) -> &'static [&'static str] {
274    static PROGRAMS: OnceLock<BTreeMap<String, Vec<&'static str>>> = OnceLock::new();
275    PROGRAMS
276        .get_or_init(|| {
277            all()
278                .iter()
279                .map(|m| {
280                    (
281                        m.name.clone(),
282                        m.programs.iter().map(|p| p.name.as_str()).collect(),
283                    )
284                })
285                .collect()
286        })
287        .get(name)
288        .map_or(&[], Vec::as_slice)
289}
290
291/// Prerequisites of `name` as a `'static` slice.
292pub fn prerequisites(name: &str) -> &'static [&'static str] {
293    static PREREQS: OnceLock<BTreeMap<String, Vec<&'static str>>> = OnceLock::new();
294    PREREQS
295        .get_or_init(|| {
296            all()
297                .iter()
298                .map(|m| {
299                    // What ManifestAdapter::install itself shells out to (the digest
300                    // is computed in-process), then the member's own.
301                    let mut all: Vec<&'static str> = match m.kind {
302                        Kind::RustRelease => vec!["curl", "tar"],
303                        Kind::PythonWheel => vec!["curl", "python3"],
304                    };
305                    all.extend(m.prerequisites.iter().map(String::as_str));
306                    (m.name.clone(), all)
307                })
308                .collect()
309        })
310        .get(name)
311        .map_or(&[], Vec::as_slice)
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn every_embedded_member_parses_and_validates() {
320        parse_all().expect("embedded manifests");
321    }
322
323    fn manifest(verify: &str) -> Result<Manifest> {
324        let text = format!(
325            r#"name = "demo"
326kind = "rust-release"
327repo = "owner/demo"
328version = "1.2.3"
329tag = "demo-v{{version}}"
330asset = "demo-{{tag}}-{{target}}.tar.gz"
331version_argv = ["--version"]
332sums_asset = "SHA256SUMS.txt"
333[sha256]
334x86_64-unknown-linux-gnu = "{}"
335[[programs]]
336name = "demo"
337identity_argv = ["--help"]
338identity_contains = "demo is the demo member"
339{verify}"#,
340            "a".repeat(64)
341        );
342        let m: Manifest = toml::from_str(&text)?;
343        m.validate()?;
344        Ok(m)
345    }
346
347    const GOOD: &str = r#"[[verify]]
348argv = ["demo", "list"]
349absent = "{marker}"
350[[verify]]
351argv = ["demo", "add", "{marker}"]
352[[verify]]
353argv = ["demo", "list"]
354present = "{marker}"
355"#;
356
357    #[test]
358    fn a_well_formed_manifest_validates_and_templates_its_asset() {
359        let m = manifest(GOOD).unwrap();
360        assert_eq!(m.tag(), "demo-v1.2.3");
361        assert_eq!(
362            m.asset_for("x86_64-unknown-linux-gnu"),
363            "demo-demo-v1.2.3-x86_64-unknown-linux-gnu.tar.gz"
364        );
365    }
366
367    #[test]
368    fn verify_without_an_absent_then_present_pair_is_refused() {
369        let present_only = r#"[[verify]]
370argv = ["demo", "list"]
371present = "{marker}"
372"#;
373        assert!(manifest(present_only).is_err());
374        let reversed = r#"[[verify]]
375argv = ["demo", "list"]
376present = "{marker}"
377[[verify]]
378argv = ["demo", "list"]
379absent = "{marker}"
380"#;
381        assert!(manifest(reversed).is_err());
382    }
383
384    #[test]
385    fn present_on_the_step_that_is_handed_the_marker_is_refused() {
386        // B3: an argument-echoing program would pass this without storing anything.
387        let echo_pass = r#"[[verify]]
388argv = ["demo", "list"]
389absent = "{marker}"
390[[verify]]
391argv = ["demo", "add", "{marker}"]
392present = "{marker}"
393"#;
394        let e = manifest(echo_pass).unwrap_err().to_string();
395        assert!(e.contains("B3"), "{e}");
396        // Control: the same steps with a separate read-back validate.
397        manifest(GOOD).unwrap();
398    }
399
400    #[test]
401    fn an_identity_that_is_only_name_and_version_is_refused() {
402        // F1: seeds `sd 0.0.2` and chmln/sd `sd 1.0.0` share this shape.
403        let mut m = manifest(GOOD).unwrap();
404        for weak in ["demo", "demo ", "demo 1.2.3", "v1.2.3"] {
405            m.programs[0].identity_contains = weak.into();
406            assert!(m.validate().is_err(), "{weak:?} must be refused");
407        }
408        m.programs[0].identity_contains = "demo is the demo member".into();
409        m.validate().unwrap();
410    }
411
412    #[test]
413    fn an_unsafe_version_or_tag_is_refused() {
414        let mut m = manifest(GOOD).unwrap();
415        m.version = "1.2.3/../x".into();
416        assert!(m.validate().is_err());
417        let mut m = manifest(GOOD).unwrap();
418        m.tag = "v{version}?x=1".into();
419        assert!(m.validate().is_err());
420    }
421
422    fn wheel(sha256: &str, asset: &str) -> Result<Manifest> {
423        let text = format!(
424            r#"name = "demo"
425kind = "python-wheel"
426repo = "owner/demo"
427version = "1.2.3"
428tag = "v{{version}}"
429asset = "{asset}"
430version_argv = ["--version"]
431sums_asset = "checksums.txt"
432[sha256]
433{sha256}
434[[programs]]
435name = "demo"
436identity_argv = ["--help"]
437identity_contains = "demo is the demo member"
438{GOOD}"#
439        );
440        let m: Manifest = toml::from_str(&text)?;
441        m.validate()?;
442        Ok(m)
443    }
444
445    #[test]
446    fn a_python_wheel_has_one_any_digest_and_one_pure_wheel() {
447        let any = format!("any = \"{}\"", "a".repeat(64));
448        let m = wheel(&any, "demo-{version}-py3-none-any.whl").unwrap();
449        assert_eq!(m.asset_for(ANY_TARGET), "demo-1.2.3-py3-none-any.whl");
450        // A per-target digest, a second key, a platform wheel or a {target}
451        // template are all refused.
452        let triple = format!("x86_64-unknown-linux-gnu = \"{}\"", "a".repeat(64));
453        assert!(wheel(&triple, "demo-{version}-py3-none-any.whl").is_err());
454        assert!(wheel(
455            &format!("{any}\n{triple}"),
456            "demo-{version}-py3-none-any.whl"
457        )
458        .is_err());
459        assert!(wheel(&any, "demo-{version}-cp312-abi3-linux_x86_64.whl").is_err());
460        assert!(wheel(&any, "demo-{version}-{target}-py3-none-any.whl").is_err());
461    }
462
463    #[test]
464    fn a_rust_release_may_not_use_the_any_key() {
465        let mut m = manifest(GOOD).unwrap();
466        m.sha256.insert(ANY_TARGET.into(), "a".repeat(64));
467        assert!(m.validate().is_err());
468    }
469
470    #[test]
471    fn present_on_a_step_handed_the_marker_on_stdin_is_refused() {
472        // B3 through stdin: an echoing program would pass without storing.
473        let echo_pass = r#"[[verify]]
474argv = ["demo", "list"]
475absent = "{marker}"
476[[verify]]
477argv = ["demo", "add", "-"]
478stdin = "{marker}"
479present = "{marker}"
480"#;
481        assert!(manifest(echo_pass).unwrap_err().to_string().contains("B3"));
482        let separate = r#"[[verify]]
483argv = ["demo", "list"]
484absent = "{marker}"
485[[verify]]
486argv = ["demo", "add", "-"]
487stdin = "{marker}"
488[[verify]]
489argv = ["demo", "list"]
490present = "{marker}"
491"#;
492        manifest(separate).unwrap();
493    }
494
495    #[test]
496    fn a_bad_digest_an_unsafe_name_or_a_shadowing_name_is_refused() {
497        let bad_digest = GOOD.to_owned();
498        let m = manifest(&bad_digest).unwrap();
499        let mut bad = m.clone();
500        bad.sha256.insert("x".into(), "ABC".into());
501        assert!(bad.validate().is_err());
502        for builtin in ToolName::BUILTINS {
503            let mut shadow = m.clone();
504            shadow.name = builtin.as_str().into();
505            assert!(shadow.validate().is_err(), "{}", builtin.as_str());
506        }
507        let mut unsafe_name = m;
508        unsafe_name.name = "../x".into();
509        assert!(unsafe_name.validate().is_err());
510    }
511}
512
513#[cfg(all(test, feature = "fixture-members"))]
514mod profile_tests {
515    use crate::model::{Profile, ToolName};
516
517    fn fixture() -> ToolName {
518        ToolName::parse("fixture-demo").expect("fixture member is embedded")
519    }
520
521    #[test]
522    fn a_new_plan_for_the_profile_includes_its_members() {
523        assert!(Profile::Everything.tools().contains(&fixture()));
524        assert!(!Profile::Kg.tools().contains(&fixture()));
525    }
526
527    #[test]
528    fn a_selection_saved_before_the_member_existed_is_still_accepted() {
529        // wu M1: resume after an upgrade that added a member.
530        assert!(Profile::Everything.accepts(&Profile::Everything.builtin_tools()));
531        assert!(Profile::Everything.accepts(&Profile::Everything.tools()));
532    }
533
534    #[test]
535    fn an_undeclared_duplicate_or_misplaced_member_is_refused() {
536        let mut kg = Profile::Kg.builtin_tools();
537        kg.push(fixture());
538        assert!(!Profile::Kg.accepts(&kg), "the fixture does not declare kg");
539        let mut dup = Profile::Everything.tools();
540        dup.push(fixture());
541        assert!(!Profile::Everything.accepts(&dup));
542        let mut misplaced = vec![fixture()];
543        misplaced.extend(Profile::Everything.builtin_tools());
544        assert!(!Profile::Everything.accepts(&misplaced));
545    }
546
547    #[test]
548    fn a_member_round_trips_through_serde_and_an_unknown_name_is_refused() {
549        let json = serde_json::to_string(&fixture()).unwrap();
550        assert_eq!(json, "\"fixture-demo\"");
551        assert_eq!(serde_json::from_str::<ToolName>(&json).unwrap(), fixture());
552        assert!(serde_json::from_str::<ToolName>("\"not-a-member\"").is_err());
553        assert_eq!(
554            serde_json::from_str::<ToolName>("\"desire-path\"").unwrap(),
555            ToolName::DesirePath
556        );
557    }
558}