use std::collections::HashSet;
use super::manifest::{DistroManifest, SCHEMA_VERSION};
fn is_valid_id(s: &str) -> bool {
let mut chars = s.chars();
let Some(first) = chars.next() else {
return false;
};
first.is_ascii_lowercase()
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
fn extract_variable_refs(template: &str) -> Vec<&str> {
template
.split("{{")
.skip(1)
.filter_map(|s| s.split_once("}}"))
.map(|(var, _)| var.trim())
.filter(|var| !var.is_empty())
.collect()
}
pub(crate) fn validate_manifest(manifest: &DistroManifest) -> anyhow::Result<()> {
if manifest.schema_version != SCHEMA_VERSION {
anyhow::bail!(
"unsupported schema-version {} (expected {SCHEMA_VERSION})",
manifest.schema_version,
);
}
if !is_valid_id(&manifest.distro.id) {
anyhow::bail!(
"distro.id '{}' is invalid (must match ^[a-z][a-z0-9-]*$)",
manifest.distro.id,
);
}
if semver::Version::parse(&manifest.distro.version).is_err() {
anyhow::bail!(
"distro.version '{}' is not valid semver",
manifest.distro.version,
);
}
if let Some(ref av) = manifest.distro.astrid_version
&& semver::VersionReq::parse(av).is_err()
{
anyhow::bail!("distro.astrid-version '{av}' is not a valid semver requirement");
}
for (ns, ifaces) in &manifest.distro.requires {
for (name, req) in ifaces {
if semver::VersionReq::parse(req).is_err() {
anyhow::bail!(
"distro.requires.{ns}.{name} '{req}' is not a valid semver requirement",
);
}
}
}
if manifest.capsules.is_empty() {
anyhow::bail!("distro must contain at least one capsule");
}
let mut seen_names = HashSet::new();
for cap in &manifest.capsules {
if !seen_names.insert(&cap.name) {
anyhow::bail!("duplicate capsule name '{}'", cap.name);
}
}
let has_uplink = manifest
.capsules
.iter()
.any(|c| c.role.as_deref() == Some("uplink"));
if !has_uplink {
anyhow::bail!("distro must have at least one capsule with role = \"uplink\" (a frontend)");
}
let defined_vars: HashSet<&str> = manifest.variables.keys().map(String::as_str).collect();
for cap in &manifest.capsules {
for (key, value) in &cap.env {
for var_ref in extract_variable_refs(value) {
if !defined_vars.contains(var_ref) {
anyhow::bail!(
"capsule '{}' env.{key} references undefined variable '{{{{ {var_ref} }}}}'",
cap.name,
);
}
}
}
}
if let Some(invites) = &manifest.invites {
if !invites.issuers.is_empty() && invites.default_group.is_none() {
anyhow::bail!(
"invites.issuers is non-empty but invites.default-group is unset — \
either configure both or remove the [invites] section"
);
}
if let Some(exp) = &invites.default_expires {
parse_invite_duration(exp).map_err(|e| anyhow::anyhow!(e))?;
}
if let Some(cap) = &invites.max_principals
&& cap != "unlimited"
&& cap.parse::<u32>().is_err()
{
anyhow::bail!(
"invites.max-principals must be \"unlimited\" or a non-negative integer (got {cap:?})",
);
}
}
if let Some(branding) = &manifest.branding {
if let Some(icon) = &branding.icon
&& icon.len() > 64 * 1024
{
anyhow::bail!(
"branding.icon is {} bytes — distros must not embed assets larger than 64 KiB",
icon.len()
);
}
if let Some(color) = &branding.primary_color {
validate_hex_color(color)
.map_err(|e| anyhow::anyhow!("branding.primary-color: {e}"))?;
}
if let Some(color) = &branding.accent_color {
validate_hex_color(color).map_err(|e| anyhow::anyhow!("branding.accent-color: {e}"))?;
}
}
Ok(())
}
pub(crate) fn parse_invite_duration(input: &str) -> Result<u64, String> {
let trimmed = input.trim();
let (num_str, multiplier_secs) = if let Some(s) = trimmed.strip_suffix('s') {
(s, 1_u64)
} else if let Some(s) = trimmed.strip_suffix('m') {
(s, 60)
} else if let Some(s) = trimmed.strip_suffix('h') {
(s, 3_600)
} else if let Some(s) = trimmed.strip_suffix('d') {
(s, 86_400)
} else {
return Err(format!(
"invalid duration {input:?} — must end with s/m/h/d (e.g. 24h, 7d, 30m, 30s)"
));
};
let n: u64 = num_str
.parse()
.map_err(|e| format!("invalid duration number in {input:?}: {e}"))?;
n.checked_mul(multiplier_secs)
.ok_or_else(|| format!("duration {input:?} overflows u64 seconds"))
}
fn validate_hex_color(input: &str) -> Result<(), String> {
let Some(rest) = input.strip_prefix('#') else {
return Err(format!("expected leading '#'; got {input:?}"));
};
if !matches!(rest.len(), 3 | 6) {
return Err(format!("{input:?} must be 3 or 6 hex digits after '#'"));
}
if !rest.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!("{input:?} contains non-hex characters"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_valid_id_accepts_lowercase() {
assert!(is_valid_id("astralis"));
assert!(is_valid_id("my-distro"));
assert!(is_valid_id("a1b2c3"));
}
#[test]
fn is_valid_id_rejects_invalid() {
assert!(!is_valid_id(""));
assert!(!is_valid_id("UPPER"));
assert!(!is_valid_id("1starts-with-digit"));
assert!(!is_valid_id("has space"));
assert!(!is_valid_id("under_score"));
}
#[test]
fn extract_refs_finds_variables() {
assert_eq!(extract_variable_refs("{{ foo }}"), vec!["foo"]);
assert_eq!(
extract_variable_refs("prefix-{{ bar }}-{{ baz }}-suffix"),
vec!["bar", "baz"]
);
assert_eq!(extract_variable_refs("no refs here"), Vec::<&str>::new());
assert_eq!(extract_variable_refs("{{}}"), Vec::<&str>::new());
}
#[test]
fn extract_refs_handles_whitespace() {
assert_eq!(extract_variable_refs("{{ spaced }}"), vec!["spaced"]);
assert_eq!(extract_variable_refs("{{no_space}}"), vec!["no_space"]);
}
#[test]
fn parse_invite_duration_accepts_units() {
assert_eq!(parse_invite_duration("30s"), Ok(30));
assert_eq!(parse_invite_duration("5m"), Ok(300));
assert_eq!(parse_invite_duration("24h"), Ok(86_400));
assert_eq!(parse_invite_duration("7d"), Ok(604_800));
assert_eq!(parse_invite_duration(" 12h "), Ok(43_200));
}
#[test]
fn parse_invite_duration_rejects_garbage() {
assert!(parse_invite_duration("").is_err());
assert!(parse_invite_duration("24").is_err());
assert!(parse_invite_duration("ten h").is_err());
assert!(parse_invite_duration("forever").is_err());
}
#[test]
fn hex_color_accepts_standard_forms() {
assert!(validate_hex_color("#abc").is_ok());
assert!(validate_hex_color("#ABCDEF").is_ok());
assert!(validate_hex_color("#123456").is_ok());
}
#[test]
fn hex_color_rejects_garbage() {
assert!(validate_hex_color("abc").is_err());
assert!(validate_hex_color("#abcd").is_err());
assert!(validate_hex_color("#xyz").is_err());
assert!(validate_hex_color("rgb(1,2,3)").is_err());
}
#[test]
fn invites_block_validates_coherence() {
let toml_src = r##"
schema-version = 1
[distro]
id = "tenancy-demo"
name = "Tenancy"
version = "0.1.0"
[[capsule]]
name = "astrid-capsule-cli"
source = "@unicity-astrid/capsule-cli"
version = "0.7.0"
role = "uplink"
[invites]
issuers = ["admin"]
"##;
let manifest: DistroManifest =
toml::from_str(toml_src).expect("manifest should parse syntactically");
let err = validate_manifest(&manifest).expect_err("missing default-group must reject");
assert!(
err.to_string().contains("default-group is unset"),
"got: {err}"
);
}
#[test]
fn branding_accepts_well_formed_colors() {
let toml_src = r##"
schema-version = 1
[distro]
id = "branded"
name = "Branded"
version = "0.1.0"
[[capsule]]
name = "astrid-capsule-cli"
source = "@unicity-astrid/capsule-cli"
version = "0.7.0"
role = "uplink"
[branding]
primary-color = "#ff8800"
accent-color = "#abc"
"##;
let manifest: DistroManifest = toml::from_str(toml_src).unwrap();
validate_manifest(&manifest).expect("branding accepts hex colours");
}
}