use std::collections::{BTreeMap, BTreeSet};
use std::sync::OnceLock;
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use crate::model::{Profile, ToolName};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Kind {
RustRelease,
PythonWheel,
}
pub const ANY_TARGET: &str = "any";
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Program {
pub name: String,
pub identity_argv: Vec<String>,
pub identity_contains: String,
#[serde(default)]
pub identity_alternatives: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Step {
pub argv: Vec<String>,
#[serde(default)]
pub stdin: Option<String>,
#[serde(default)]
pub absent: Option<String>,
#[serde(default)]
pub present: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub name: String,
pub kind: Kind,
pub repo: String,
pub version: String,
pub tag: String,
pub asset: String,
pub sha256: BTreeMap<String, String>,
pub sums_asset: String,
pub programs: Vec<Program>,
pub version_argv: Vec<String>,
#[serde(default)]
pub prerequisites: Vec<String>,
#[serde(default)]
pub profiles: Vec<Profile>,
pub verify: Vec<Step>,
}
impl Manifest {
pub fn asset_for(&self, target: &str) -> String {
let tag = self.tag.replace("{version}", &self.version);
self.asset
.replace("{tag}", &tag)
.replace("{version}", &self.version)
.replace("{target}", target)
}
pub fn tag(&self) -> String {
self.tag.replace("{version}", &self.version)
}
pub fn parse(text: &str) -> Result<Self> {
let m: Manifest = toml::from_str(text).context("invalid member manifest")?;
m.validate()?;
Ok(m)
}
fn validate(&self) -> Result<()> {
let safe = |s: &str| {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
};
if !safe(&self.name) || ToolName::BUILTINS.iter().any(|t| t.as_str() == self.name) {
bail!(
"member name '{}' is unsafe or shadows a built-in tool",
self.name
);
}
if !safe(&self.version) || !safe(&self.tag.replace("{version}", &self.version)) {
bail!("{}: version and tag must be safe names", self.name);
}
if self.tag.matches("{version}").count() != 1 {
bail!("{}: tag must contain {{version}} exactly once", self.name);
}
if !safe(&self.sums_asset) {
bail!("{}: sums_asset must be a safe name", self.name);
}
if self.repo.split('/').count() != 2 || !self.repo.split('/').all(safe) {
bail!("{}: repo must be owner/name", self.name);
}
if self.sha256.is_empty() {
bail!("{}: at least one target digest is required", self.name);
}
match self.kind {
Kind::PythonWheel => {
if self.sha256.len() != 1 || !self.sha256.contains_key(ANY_TARGET) {
bail!(
"{}: a python-wheel member records exactly one digest, under \"{ANY_TARGET}\"",
self.name
);
}
if !self.asset.ends_with("-py3-none-any.whl") || self.asset.contains("{target}") {
bail!(
"{}: a python-wheel asset is one pure-Python wheel (*-py3-none-any.whl), \
not per target",
self.name
);
}
}
Kind::RustRelease => {
if self.sha256.contains_key(ANY_TARGET) {
bail!(
"{}: \"{ANY_TARGET}\" is not a target triple; a rust-release digest is per target",
self.name
);
}
}
}
for (target, digest) in &self.sha256 {
if !safe(target)
|| digest.len() != 64
|| !digest
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
{
bail!(
"{}: sha256 for {target} must be 64 lowercase hex",
self.name
);
}
}
if self.programs.is_empty() || !self.programs.iter().all(|p| safe(&p.name)) {
bail!("{}: programs must be non-empty safe names", self.name);
}
for p in &self.programs {
if p.identity_alternatives.len() > 8 {
bail!("{}: too many identity alternatives", self.name);
}
let mut seen = BTreeSet::new();
for identity in std::iter::once(&p.identity_contains).chain(&p.identity_alternatives) {
if !seen.insert(identity) {
bail!("{}: duplicate identity alternative", self.name);
}
let rest = identity.replace(&p.name, "");
let is_version = |w: &str| {
w.trim_start_matches('v')
.starts_with(|c: char| c.is_ascii_digit())
};
if !rest
.split_whitespace()
.filter(|w| !is_version(w))
.any(|w| w.chars().any(char::is_alphabetic))
{
bail!(
"{}: identity_contains {:?} for `{}` is only its name and a version, which a \
different `{}` also prints (F1); use text only this member prints",
self.name,
identity,
p.name,
p.name
);
}
}
}
let absent_at = self.verify.iter().position(|s| s.absent.is_some());
let present_at = self.verify.iter().rposition(|s| s.present.is_some());
match (absent_at, present_at) {
(Some(a), Some(p)) if a < p => {}
_ => bail!(
"{}: verify needs a step asserting a marker ABSENT before one asserting it PRESENT (A4)",
self.name
),
}
if self.verify.iter().any(|s| s.argv.is_empty()) {
bail!("{}: every verify step needs argv", self.name);
}
if self.verify.iter().any(|s| {
s.present.is_some()
&& (s.argv.iter().any(|a| a.contains("{marker}"))
|| s.stdin.as_deref().is_some_and(|i| i.contains("{marker}")))
}) {
bail!(
"{}: a verify step asserting the marker PRESENT must not be given it in argv (B3)",
self.name
);
}
Ok(())
}
}
const EMBEDDED: &[&str] = include!(concat!(env!("OUT_DIR"), "/members_embedded.rs"));
fn parse_all() -> Result<Vec<Manifest>> {
let mut out: Vec<Manifest> = Vec::new();
for text in EMBEDDED {
let m = Manifest::parse(text)?;
if out.iter().any(|o| o.name == m.name) {
bail!("duplicate member '{}'", m.name);
}
out.push(m);
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
pub fn all() -> &'static [Manifest] {
static REGISTRY: OnceLock<Vec<Manifest>> = OnceLock::new();
REGISTRY.get_or_init(|| parse_all().expect("embedded member manifests are invalid"))
}
pub fn get(name: &str) -> Option<&'static Manifest> {
all().iter().find(|m| m.name == name)
}
pub fn programs(name: &str) -> &'static [&'static str] {
static PROGRAMS: OnceLock<BTreeMap<String, Vec<&'static str>>> = OnceLock::new();
PROGRAMS
.get_or_init(|| {
all()
.iter()
.map(|m| {
(
m.name.clone(),
m.programs.iter().map(|p| p.name.as_str()).collect(),
)
})
.collect()
})
.get(name)
.map_or(&[], Vec::as_slice)
}
pub fn prerequisites(name: &str) -> &'static [&'static str] {
static PREREQS: OnceLock<BTreeMap<String, Vec<&'static str>>> = OnceLock::new();
PREREQS
.get_or_init(|| {
all()
.iter()
.map(|m| {
let mut all: Vec<&'static str> = match m.kind {
Kind::RustRelease => vec!["curl", "tar"],
Kind::PythonWheel => vec!["curl", "python3"],
};
all.extend(m.prerequisites.iter().map(String::as_str));
(m.name.clone(), all)
})
.collect()
})
.get(name)
.map_or(&[], Vec::as_slice)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_embedded_member_parses_and_validates() {
parse_all().expect("embedded manifests");
}
fn manifest(verify: &str) -> Result<Manifest> {
let text = format!(
r#"name = "demo"
kind = "rust-release"
repo = "owner/demo"
version = "1.2.3"
tag = "demo-v{{version}}"
asset = "demo-{{tag}}-{{target}}.tar.gz"
version_argv = ["--version"]
sums_asset = "SHA256SUMS.txt"
[sha256]
x86_64-unknown-linux-gnu = "{}"
[[programs]]
name = "demo"
identity_argv = ["--help"]
identity_contains = "demo is the demo member"
{verify}"#,
"a".repeat(64)
);
let m: Manifest = toml::from_str(&text)?;
m.validate()?;
Ok(m)
}
const GOOD: &str = r#"[[verify]]
argv = ["demo", "list"]
absent = "{marker}"
[[verify]]
argv = ["demo", "add", "{marker}"]
[[verify]]
argv = ["demo", "list"]
present = "{marker}"
"#;
#[test]
fn a_well_formed_manifest_validates_and_templates_its_asset() {
let m = manifest(GOOD).unwrap();
assert_eq!(m.tag(), "demo-v1.2.3");
assert_eq!(
m.asset_for("x86_64-unknown-linux-gnu"),
"demo-demo-v1.2.3-x86_64-unknown-linux-gnu.tar.gz"
);
}
#[test]
fn verify_without_an_absent_then_present_pair_is_refused() {
let present_only = r#"[[verify]]
argv = ["demo", "list"]
present = "{marker}"
"#;
assert!(manifest(present_only).is_err());
let reversed = r#"[[verify]]
argv = ["demo", "list"]
present = "{marker}"
[[verify]]
argv = ["demo", "list"]
absent = "{marker}"
"#;
assert!(manifest(reversed).is_err());
}
#[test]
fn present_on_the_step_that_is_handed_the_marker_is_refused() {
let echo_pass = r#"[[verify]]
argv = ["demo", "list"]
absent = "{marker}"
[[verify]]
argv = ["demo", "add", "{marker}"]
present = "{marker}"
"#;
let e = manifest(echo_pass).unwrap_err().to_string();
assert!(e.contains("B3"), "{e}");
manifest(GOOD).unwrap();
}
#[test]
fn an_identity_that_is_only_name_and_version_is_refused() {
let mut m = manifest(GOOD).unwrap();
for weak in ["demo", "demo ", "demo 1.2.3", "v1.2.3"] {
m.programs[0].identity_contains = weak.into();
assert!(m.validate().is_err(), "{weak:?} must be refused");
}
m.programs[0].identity_contains = "demo is the demo member".into();
m.validate().unwrap();
}
#[test]
fn identity_alternatives_are_explicit_strong_unique_and_bounded() {
let mut m = manifest(GOOD).unwrap();
m.programs[0].identity_alternatives = vec!["demo is the updated fixture member".into()];
m.validate().unwrap();
for weak in ["", "demo", "demo 1.2.3", "v1.2.3"] {
m.programs[0].identity_alternatives = vec![weak.into()];
assert!(m.validate().is_err(), "{weak:?}");
}
m.programs[0].identity_alternatives = vec![m.programs[0].identity_contains.clone()];
assert!(m.validate().is_err());
m.programs[0].identity_alternatives = (0..9)
.map(|n| format!("demo fixture edition {n}"))
.collect();
assert!(m.validate().is_err());
}
#[test]
fn an_unsafe_version_or_tag_is_refused() {
let mut m = manifest(GOOD).unwrap();
m.version = "1.2.3/../x".into();
assert!(m.validate().is_err());
let mut m = manifest(GOOD).unwrap();
m.tag = "v{version}?x=1".into();
assert!(m.validate().is_err());
}
fn wheel(sha256: &str, asset: &str) -> Result<Manifest> {
let text = format!(
r#"name = "demo"
kind = "python-wheel"
repo = "owner/demo"
version = "1.2.3"
tag = "v{{version}}"
asset = "{asset}"
version_argv = ["--version"]
sums_asset = "checksums.txt"
[sha256]
{sha256}
[[programs]]
name = "demo"
identity_argv = ["--help"]
identity_contains = "demo is the demo member"
{GOOD}"#
);
let m: Manifest = toml::from_str(&text)?;
m.validate()?;
Ok(m)
}
#[test]
fn a_python_wheel_has_one_any_digest_and_one_pure_wheel() {
let any = format!("any = \"{}\"", "a".repeat(64));
let m = wheel(&any, "demo-{version}-py3-none-any.whl").unwrap();
assert_eq!(m.asset_for(ANY_TARGET), "demo-1.2.3-py3-none-any.whl");
let triple = format!("x86_64-unknown-linux-gnu = \"{}\"", "a".repeat(64));
assert!(wheel(&triple, "demo-{version}-py3-none-any.whl").is_err());
assert!(wheel(
&format!("{any}\n{triple}"),
"demo-{version}-py3-none-any.whl"
)
.is_err());
assert!(wheel(&any, "demo-{version}-cp312-abi3-linux_x86_64.whl").is_err());
assert!(wheel(&any, "demo-{version}-{target}-py3-none-any.whl").is_err());
}
#[test]
fn a_rust_release_may_not_use_the_any_key() {
let mut m = manifest(GOOD).unwrap();
m.sha256.insert(ANY_TARGET.into(), "a".repeat(64));
assert!(m.validate().is_err());
}
#[test]
fn present_on_a_step_handed_the_marker_on_stdin_is_refused() {
let echo_pass = r#"[[verify]]
argv = ["demo", "list"]
absent = "{marker}"
[[verify]]
argv = ["demo", "add", "-"]
stdin = "{marker}"
present = "{marker}"
"#;
assert!(manifest(echo_pass).unwrap_err().to_string().contains("B3"));
let separate = r#"[[verify]]
argv = ["demo", "list"]
absent = "{marker}"
[[verify]]
argv = ["demo", "add", "-"]
stdin = "{marker}"
[[verify]]
argv = ["demo", "list"]
present = "{marker}"
"#;
manifest(separate).unwrap();
}
#[test]
fn a_bad_digest_an_unsafe_name_or_a_shadowing_name_is_refused() {
let bad_digest = GOOD.to_owned();
let m = manifest(&bad_digest).unwrap();
let mut bad = m.clone();
bad.sha256.insert("x".into(), "ABC".into());
assert!(bad.validate().is_err());
for builtin in ToolName::BUILTINS {
let mut shadow = m.clone();
shadow.name = builtin.as_str().into();
assert!(shadow.validate().is_err(), "{}", builtin.as_str());
}
let mut unsafe_name = m;
unsafe_name.name = "../x".into();
assert!(unsafe_name.validate().is_err());
}
}
#[cfg(all(test, feature = "fixture-members"))]
mod profile_tests {
use crate::model::{Profile, ToolName};
fn fixture() -> ToolName {
ToolName::parse("fixture-demo").expect("fixture member is embedded")
}
#[test]
fn a_new_plan_for_the_profile_includes_its_members() {
assert!(Profile::Everything.tools().contains(&fixture()));
assert!(!Profile::Kg.tools().contains(&fixture()));
}
#[test]
fn a_selection_saved_before_the_member_existed_is_still_accepted() {
assert!(Profile::Everything.accepts(&Profile::Everything.builtin_tools()));
assert!(Profile::Everything.accepts(&Profile::Everything.tools()));
}
#[test]
fn an_undeclared_duplicate_or_misplaced_member_is_refused() {
let mut kg = Profile::Kg.builtin_tools();
kg.push(fixture());
assert!(!Profile::Kg.accepts(&kg), "the fixture does not declare kg");
let mut dup = Profile::Everything.tools();
dup.push(fixture());
assert!(!Profile::Everything.accepts(&dup));
let mut misplaced = vec![fixture()];
misplaced.extend(Profile::Everything.builtin_tools());
assert!(!Profile::Everything.accepts(&misplaced));
}
#[test]
fn a_member_round_trips_through_serde_and_an_unknown_name_is_refused() {
let json = serde_json::to_string(&fixture()).unwrap();
assert_eq!(json, "\"fixture-demo\"");
assert_eq!(serde_json::from_str::<ToolName>(&json).unwrap(), fixture());
assert!(serde_json::from_str::<ToolName>("\"not-a-member\"").is_err());
assert_eq!(
serde_json::from_str::<ToolName>("\"desire-path\"").unwrap(),
ToolName::DesirePath
);
}
}