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