1use std::collections::BTreeMap;
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}
52
53#[derive(Debug, Clone, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct Step {
58 pub argv: Vec<String>,
59 #[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 pub repo: String,
77 pub version: String,
78 pub tag: String,
80 pub asset: String,
82 pub sha256: BTreeMap<String, String>,
85 pub sums_asset: String,
89 pub programs: Vec<Program>,
90 pub version_argv: Vec<String>,
92 #[serde(default)]
93 pub prerequisites: Vec<String>,
94 #[serde(default)]
96 pub profiles: Vec<Profile>,
97 pub verify: Vec<Step>,
99}
100
101impl Manifest {
102 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 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 if !safe(&self.version) || !safe(&self.tag.replace("{version}", &self.version)) {
136 bail!("{}: version and tag must be safe names", self.name);
137 }
138 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 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 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 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
260pub 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
267pub fn get(name: &str) -> Option<&'static Manifest> {
269 all().iter().find(|m| m.name == name)
270}
271
272pub 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
291pub 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 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 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 manifest(GOOD).unwrap();
398 }
399
400 #[test]
401 fn an_identity_that_is_only_name_and_version_is_refused() {
402 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 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 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 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}