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)]
package: Option<String>,
#[serde(default)]
profile: Option<String>,
#[serde(default)]
features: Vec<String>,
#[serde(default)]
install_to: Option<PathBuf>,
},
Docker {
tag: String,
#[serde(default)]
dockerfile: Option<PathBuf>,
#[serde(default)]
context: Option<PathBuf>,
},
Repolith {
#[serde(default)]
manifest: 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(())
}
fn check_contained_path(
path: &std::path::Path,
node: &str,
field: &str,
) -> Result<(), ManifestError> {
let s = path.to_string_lossy();
if s.is_empty() {
return Err(ManifestError::InvalidArg {
node: node.to_string(),
reason: format!("`{field}` is empty"),
});
}
if let Some(c) = s.chars().find(|c| c.is_control()) {
return Err(ManifestError::InvalidArg {
node: node.to_string(),
reason: format!("`{field}` contains control character U+{:04X}", c as u32),
});
}
if s.starts_with('-') {
return Err(ManifestError::InvalidArg {
node: node.to_string(),
reason: format!("`{field}` `{s}` starts with `-`, would be parsed as a CLI flag"),
});
}
if path.is_absolute() {
return Err(ManifestError::InvalidArg {
node: node.to_string(),
reason: format!("`{field}` `{s}` is absolute — must stay inside the node's checkout"),
});
}
if path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(ManifestError::InvalidArg {
node: node.to_string(),
reason: format!(
"`{field}` `{s}` contains `..` — path traversal outside the node's checkout"
),
});
}
Ok(())
}
fn check_docker_tag(tag: &str, node: &str) -> Result<(), ManifestError> {
if tag.is_empty() {
return Err(ManifestError::InvalidArg {
node: node.to_string(),
reason: "`tag` is empty".to_string(),
});
}
if tag.starts_with('-') {
return Err(ManifestError::InvalidArg {
node: node.to_string(),
reason: format!("`tag` `{tag}` starts with `-`, would be parsed as a CLI flag"),
});
}
if let Some(c) = tag
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '/' | '-')))
{
return Err(ManifestError::InvalidArg {
node: node.to_string(),
reason: format!("`tag` `{tag}` contains `{c}` — allowed charset is [a-zA-Z0-9._:/-]"),
});
}
Ok(())
}
fn check_cargo_install(
node: &NodeEntry,
crate_name: Option<&String>,
package: Option<&String>,
profile: Option<&String>,
features: &[String],
) -> Result<(), ManifestError> {
if let Some(name) = crate_name {
check_no_leading_dash(name, &node.id, "crate")?;
if name.contains(',') {
return Err(ManifestError::InvalidArg {
node: node.id.clone(),
reason: format!(
"`crate` name `{name}` contains `,` which would split cargo's --features list"
),
});
}
}
if let Some(pkg) = package {
check_no_leading_dash(pkg, &node.id, "package")?;
if pkg.is_empty() {
return Err(ManifestError::InvalidArg {
node: node.id.clone(),
reason: "`package` is empty".to_string(),
});
}
if pkg.contains('@') {
return Err(ManifestError::InvalidArg {
node: node.id.clone(),
reason: format!(
"`package` `{pkg}` contains `@`, which cargo parses as a version spec (`CRATE@VER`)"
),
});
}
}
if let Some(prof) = profile {
check_no_leading_dash(prof, &node.id, "profile")?;
if prof.is_empty() {
return Err(ManifestError::InvalidArg {
node: node.id.clone(),
reason: "`profile` is empty".to_string(),
});
}
if let Some(c) = prof
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '-')))
{
return Err(ManifestError::InvalidArg {
node: node.id.clone(),
reason: format!(
"`profile` `{prof}` contains `{c}` — allowed charset is [a-zA-Z0-9_-]"
),
});
}
}
for feature in features {
check_no_leading_dash(feature, &node.id, "features entry")?;
if feature.contains(',') {
return Err(ManifestError::InvalidArg {
node: node.id.clone(),
reason: format!(
"feature `{feature}` contains `,` which would split cargo's --features list"
),
});
}
}
Ok(())
}
fn check_action(node: &NodeEntry, action: &ActionEntry) -> Result<(), ManifestError> {
match action {
ActionEntry::CargoInstall {
crate_name,
package,
profile,
features,
..
} => check_cargo_install(
node,
crate_name.as_ref(),
package.as_ref(),
profile.as_ref(),
features,
)?,
ActionEntry::Docker {
tag,
dockerfile,
context,
} => {
check_docker_tag(tag, &node.id)?;
if let Some(df) = dockerfile {
check_contained_path(df, &node.id, "dockerfile")?;
}
if let Some(ctx) = context {
check_contained_path(ctx, &node.id, "context")?;
}
if node.path.is_none() {
return Err(ManifestError::InvalidArg {
node: node.id.clone(),
reason: "docker action requires `path` on the node (build context base)"
.to_string(),
});
}
}
ActionEntry::Repolith { manifest } => {
if let Some(m) = manifest {
check_contained_path(m, &node.id, "manifest")?;
}
if node.path.is_none() {
return Err(ManifestError::InvalidArg {
node: node.id.clone(),
reason: "repolith action requires `path` on the node (child stack root)"
.to_string(),
});
}
}
ActionEntry::GitClone => {}
}
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 {
check_action(n, action)?;
}
}
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",
ActionEntry::Docker { .. } => "docker",
ActionEntry::Repolith { .. } => "repolith",
}
}