use crate::types::ActionId;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use thiserror::Error;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct OrchestratorMeta {
pub schema_version: String,
pub name: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct NodeEntry {
pub id: String,
pub git: Option<String>,
pub path: Option<PathBuf>,
#[serde(default, rename = "action")]
pub actions: Vec<ActionEntry>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Manifest {
pub orchestrator: OrchestratorMeta,
#[serde(default, rename = "node")]
pub nodes: Vec<NodeEntry>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum ActionEntry {
GitClone,
CargoInstall {
#[serde(default, rename = "crate")]
crate_name: Option<String>,
#[serde(default)]
features: Vec<String>,
#[serde(default)]
install_to: Option<PathBuf>,
},
}
#[derive(Debug, Error)]
pub enum ManifestError {
#[error("toml parse: {0}")]
Toml(#[from] toml::de::Error),
#[error("schema version {got}: requires ~0.1")]
SchemaMismatch {
got: String,
},
#[error("duplicate node id: {0}")]
DuplicateNodeId(String),
#[error("node {0} has neither `git` nor `path`")]
NodeMissingSource(String),
#[error("invalid URL in node `{node}`: {reason}")]
InvalidUrl {
node: String,
reason: String,
},
#[error("invalid argument in node `{node}`: {reason}")]
InvalidArg {
node: String,
reason: String,
},
}
const ALLOWED_URL_SCHEMES: &[&str] = &["https://", "http://", "ssh://", "git@", "file://"];
fn check_no_leading_dash(value: &str, node: &str, field: &str) -> Result<(), ManifestError> {
if value.starts_with('-') {
return Err(ManifestError::InvalidArg {
node: node.to_string(),
reason: format!("`{field}` starts with `-`, would be parsed as a CLI flag"),
});
}
Ok(())
}
fn check_node_id_safe(id: &str) -> Result<(), ManifestError> {
if id.is_empty() {
return Err(ManifestError::InvalidArg {
node: id.to_string(),
reason: "node id is empty".to_string(),
});
}
if let Some(c) = id.chars().find(|c| matches!(c, '/' | '\\' | ':')) {
return Err(ManifestError::InvalidArg {
node: id.to_string(),
reason: format!(
"node id `{id}` contains path separator `{c}` — would create nested, escaping, or drive-relative directories"
),
});
}
if let Some(c) = id.chars().find(|c| c.is_control()) {
return Err(ManifestError::InvalidArg {
node: id.to_string(),
reason: format!("node id contains control character U+{:04X}", c as u32),
});
}
if id == "." || id == ".." {
return Err(ManifestError::InvalidArg {
node: id.to_string(),
reason: format!("node id `{id}` is a path-traversal token"),
});
}
Ok(())
}
fn check_url(url: &str, node: &str) -> Result<(), ManifestError> {
if url.is_empty() {
return Err(ManifestError::InvalidUrl {
node: node.to_string(),
reason: "URL is empty".into(),
});
}
if let Some(c) = url.chars().find(|c| c.is_control()) {
return Err(ManifestError::InvalidUrl {
node: node.to_string(),
reason: format!("URL contains control character U+{:04X}", c as u32),
});
}
if url.starts_with('-') {
return Err(ManifestError::InvalidUrl {
node: node.to_string(),
reason: format!("`{url}` starts with `-`, would be parsed as a CLI flag"),
});
}
let scheme = ALLOWED_URL_SCHEMES
.iter()
.find(|s| url.starts_with(*s))
.ok_or_else(|| ManifestError::InvalidUrl {
node: node.to_string(),
reason: format!("scheme not in allowlist {ALLOWED_URL_SCHEMES:?} (got `{url}`)"),
})?;
if *scheme == "git@" {
check_scp_url(url, node)
} else {
check_rfc_url(url, node)
}
}
fn check_rfc_url(url: &str, node: &str) -> Result<(), ManifestError> {
let parsed = url::Url::parse(url).map_err(|e| ManifestError::InvalidUrl {
node: node.to_string(),
reason: format!("URL parse failed: {e}"),
})?;
let user = parsed.username();
if user.starts_with('-') {
return Err(ManifestError::InvalidUrl {
node: node.to_string(),
reason: format!("URL userinfo `{user}` starts with `-`"),
});
}
if let Some(host) = parsed.host_str()
&& host.starts_with('-')
{
return Err(ManifestError::InvalidUrl {
node: node.to_string(),
reason: format!("host `{host}` starts with `-`"),
});
}
if let Some(segments) = parsed.path_segments() {
for s in segments {
if s.starts_with('-') {
return Err(ManifestError::InvalidUrl {
node: node.to_string(),
reason: format!("path segment `{s}` starts with `-`"),
});
}
}
}
Ok(())
}
fn check_scp_url(url: &str, node: &str) -> Result<(), ManifestError> {
let after_at = &url["git@".len()..];
let (host, path) = after_at.split_once(':').unwrap_or((after_at, ""));
if host.is_empty() || host.starts_with('-') {
return Err(ManifestError::InvalidUrl {
node: node.to_string(),
reason: format!("host `{host}` is empty or starts with `-`"),
});
}
for segment in path.split('/') {
if segment.starts_with('-') {
return Err(ManifestError::InvalidUrl {
node: node.to_string(),
reason: format!("path segment `{segment}` starts with `-`"),
});
}
}
Ok(())
}
impl Manifest {
pub fn from_toml(src: &str) -> Result<Self, ManifestError> {
let m: Manifest = toml::from_str(src)?;
m.validate()?;
Ok(m)
}
fn validate(&self) -> Result<(), ManifestError> {
let req = semver::VersionReq::parse("~0.1").unwrap();
let v = semver::Version::parse(&format!("{}.0", self.orchestrator.schema_version))
.map_err(|_| ManifestError::SchemaMismatch {
got: self.orchestrator.schema_version.clone(),
})?;
if !req.matches(&v) {
return Err(ManifestError::SchemaMismatch {
got: self.orchestrator.schema_version.clone(),
});
}
let mut seen = std::collections::HashSet::new();
for n in &self.nodes {
check_node_id_safe(&n.id)?;
if !seen.insert(&n.id) {
return Err(ManifestError::DuplicateNodeId(n.id.clone()));
}
if n.git.is_none() && n.path.is_none() {
return Err(ManifestError::NodeMissingSource(n.id.clone()));
}
if let Some(url) = &n.git {
check_url(url, &n.id)?;
}
for action in &n.actions {
if let ActionEntry::CargoInstall {
crate_name,
features,
..
} = action
{
if let Some(name) = crate_name {
check_no_leading_dash(name, &n.id, "crate")?;
if name.contains(',') {
return Err(ManifestError::InvalidArg {
node: n.id.clone(),
reason: format!(
"`crate` name `{name}` contains `,` which would split cargo's --features list"
),
});
}
}
for feature in features {
check_no_leading_dash(feature, &n.id, "features entry")?;
if feature.contains(',') {
return Err(ManifestError::InvalidArg {
node: n.id.clone(),
reason: format!(
"feature `{feature}` contains `,` which would split cargo's --features list"
),
});
}
}
}
}
}
Ok(())
}
#[must_use]
pub fn action_ids(&self) -> Vec<ActionId> {
self.nodes
.iter()
.flat_map(|n| {
n.actions
.iter()
.enumerate()
.map(move |(i, a)| ActionId(format!("{}::{}::{}", n.id, action_kind(a), i)))
})
.collect()
}
}
#[must_use]
pub fn action_kind(a: &ActionEntry) -> &'static str {
match a {
ActionEntry::GitClone => "git-clone",
ActionEntry::CargoInstall { .. } => "cargo-install",
}
}