1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
22#[serde(rename_all = "kebab-case")]
23pub enum Kind {
24 RustRelease,
26 PythonWheel,
32}
33
34pub const ANY_TARGET: &str = "any";
36
37#[derive(Debug, Clone, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct Program {
46 pub name: String,
47 pub identity_argv: Vec<String>,
49 pub identity_contains: String,
51 #[serde(default)]
54 pub identity_alternatives: Vec<String>,
55}
56
57#[derive(Debug, Clone, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct Step {
62 pub argv: Vec<String>,
63 #[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 pub repo: String,
81 pub version: String,
82 pub tag: String,
84 pub asset: String,
86 pub sha256: BTreeMap<String, String>,
89 pub sums_asset: String,
93 pub programs: Vec<Program>,
94 pub version_argv: Vec<String>,
96 #[serde(default)]
97 pub prerequisites: Vec<String>,
98 #[serde(default)]
100 pub profiles: Vec<Profile>,
101 pub verify: Vec<Step>,
103}
104
105impl Manifest {
106 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 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 if !safe(&self.version) || !safe(&self.tag.replace("{version}", &self.version)) {
140 bail!("{}: version and tag must be safe names", self.name);
141 }
142 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 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 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 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
273pub 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
280pub fn get(name: &str) -> Option<&'static Manifest> {
282 all().iter().find(|m| m.name == name)
283}
284
285pub 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
304pub 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 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 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 manifest(GOOD).unwrap();
411 }
412
413 #[test]
414 fn an_identity_that_is_only_name_and_version_is_refused() {
415 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 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 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 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}