use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::error::ForgeError;
use crate::model::{Agent, ArchiveFormat, InstallKind};
use crate::util::{looks_like_git, resolve_command, valid_config_id};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct ConfigDocument {
pub policy: Policy,
#[serde(default, skip_serializing_if = "PreflightDef::is_empty")]
pub preflight: PreflightDef,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub catalog: String,
#[schemars(skip)]
#[serde(
rename = "catalog-digest",
default,
skip_serializing_if = "String::is_empty"
)]
pub catalog_digest: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub versions: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub groups: BTreeMap<String, Vec<String>>,
#[serde(
rename = "cargo-tools",
default,
skip_serializing_if = "BTreeMap::is_empty"
)]
pub cargo_tools: BTreeMap<String, CargoToolInput>,
#[serde(
rename = "cargo-toolsets",
default,
skip_serializing_if = "BTreeMap::is_empty"
)]
pub cargo_toolsets: BTreeMap<String, BTreeMap<String, CargoToolInput>>,
#[serde(
rename = "package-tools",
default,
skip_serializing_if = "BTreeMap::is_empty"
)]
pub package_tools: BTreeMap<String, PackageToolDef>,
#[serde(
rename = "rustup-tools",
default,
skip_serializing_if = "BTreeMap::is_empty"
)]
pub rustup_tools: BTreeMap<String, RustupToolInput>,
pub sources: BTreeMap<String, SourceDef>,
pub profiles: BTreeMap<String, ProfileDef>,
pub components: Vec<ComponentDef>,
pub environment: EnvironmentDef,
pub apt_mirror: Option<AptMirrorDef>,
pub include: Vec<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct Policy {
pub network: NetworkPolicy,
pub allow_shell: bool,
pub allow_unlocked_cargo: bool,
#[schemars(range(min = 1, max = 256))]
pub max_parallel: usize,
#[schemars(range(min = 1, max = 256))]
pub max_downloads: usize,
#[schemars(range(min = 512, max = 4294967295_u64))]
pub max_memory_mib: Option<u64>,
}
impl Default for Policy {
fn default() -> Self {
Self {
network: NetworkPolicy::Online,
allow_shell: false,
allow_unlocked_cargo: false,
max_parallel: 8,
max_downloads: 4,
max_memory_mib: None,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum NetworkPolicy {
#[default]
Online,
CacheOnly,
Offline,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
pub enum SourceDef {
CargoRegistry {
url: String,
},
ArchiveMirror {
base_url: String,
},
Git {
url: String,
},
NpmRegistry {
url: String,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct ProfileDef {
pub inherits: Vec<String>,
pub components: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub add: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub remove: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum CargoToolInput {
Version(String),
Detailed(Box<CargoToolDef>),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CargoToolDef {
pub version: String,
#[serde(rename = "crate", default)]
pub crate_name: Option<String>,
#[serde(default)]
pub bin: Option<String>,
#[serde(default)]
pub bins: Vec<String>,
#[serde(default)]
pub detect: Option<Vec<String>>,
#[serde(default)]
pub platforms: Vec<String>,
#[serde(default)]
pub requires: Vec<String>,
#[serde(default)]
pub provides: Vec<String>,
#[serde(default)]
pub conflicts: Vec<String>,
#[serde(default)]
pub optional: bool,
#[serde(default)]
pub features: Vec<String>,
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub toolchain: Option<String>,
#[serde(default)]
pub profile: Option<String>,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub revision: Option<String>,
#[serde(default = "default_true")]
pub locked: bool,
#[serde(default)]
pub build_env_allow: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PackageToolDef {
pub detect: CommandCheckInput,
#[serde(default)]
pub apt: Vec<String>,
#[serde(default)]
pub apt_update: bool,
#[serde(default)]
pub brew: Vec<String>,
#[serde(default)]
pub brew_detect: Option<CommandCheckInput>,
#[serde(default)]
pub winget: Option<String>,
#[serde(default)]
pub winget_arguments: Vec<String>,
#[serde(default)]
pub winget_detect: Option<CommandCheckInput>,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub requires: Vec<String>,
#[serde(default)]
pub provides: Vec<String>,
#[serde(default)]
pub conflicts: Vec<String>,
#[serde(default)]
pub optional: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum RustupToolInput {
Component(String),
Detailed(Box<RustupToolDef>),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum CommandCheckInput {
Command(Vec<String>),
All(Vec<Vec<String>>),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RustupToolDef {
pub components: Vec<String>,
#[serde(default)]
pub detect: Option<CommandCheckInput>,
#[serde(rename = "detect-stdout-contains", default)]
pub detect_stdout_contains: Option<String>,
#[serde(default)]
pub toolchain: Option<String>,
#[serde(rename = "toolchain-ref", default)]
pub toolchain_ref: Option<String>,
#[serde(default)]
pub default: bool,
#[serde(default)]
pub bootstrap: Option<RustupBootstrap>,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub requires: Vec<String>,
#[serde(default)]
pub provides: Vec<String>,
#[serde(default)]
pub conflicts: Vec<String>,
#[serde(default)]
pub optional: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ComponentDef {
pub id: String,
#[serde(default = "default_component_kind")]
pub kind: InstallKind,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub requires: Vec<String>,
#[serde(default)]
pub provides: Vec<String>,
#[serde(default)]
pub conflicts: Vec<String>,
#[serde(default)]
pub optional: bool,
#[serde(default)]
pub allow_insecure_hosts: Vec<String>,
#[serde(default = "all_platforms")]
pub platforms: Vec<String>,
#[serde(default)]
pub detect: Option<CheckSpec>,
#[serde(default)]
pub install: Option<InstallSpec>,
#[serde(default)]
pub verify: Option<CheckSpec>,
#[serde(default)]
pub variants: Vec<VariantDef>,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub revision: Option<String>,
#[serde(default)]
pub agents: Vec<Agent>,
}
fn default_component_kind() -> InstallKind {
InstallKind::Tool
}
fn all_platforms() -> Vec<String> {
vec!["*".to_string()]
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct VariantDef {
pub id: String,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub requires: Vec<String>,
#[serde(default)]
pub provides: Vec<String>,
#[serde(default)]
pub conflicts: Vec<String>,
#[serde(default = "all_platforms")]
pub platforms: Vec<String>,
#[serde(default)]
pub detect: Option<CheckSpec>,
#[serde(default)]
pub install: Option<InstallSpec>,
#[serde(default)]
pub verify: Option<CheckSpec>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
pub enum CheckSpec {
Command {
program: String,
#[serde(default)]
args: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
stdout_contains: Option<String>,
#[serde(default = "zero_success")]
success_codes: Vec<i32>,
#[serde(default = "default_check_timeout")]
timeout_secs: Option<u64>,
},
Shell {
command: String,
#[serde(default = "default_check_timeout")]
timeout_secs: Option<u64>,
},
Path {
path: PathBuf,
},
All {
checks: Vec<CheckSpec>,
},
}
fn zero_success() -> Vec<i32> {
vec![0]
}
fn default_check_timeout() -> Option<u64> {
Some(10)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "backend", rename_all = "kebab-case", deny_unknown_fields)]
pub enum InstallSpec {
Cargo(CargoInstall),
Apt(AptInstall),
Brew(BrewInstall),
Rustup(RustupInstall),
Npm(NpmInstall),
Pip(PipInstall),
UvTool(UvToolInstall),
Winget(WingetInstall),
Archive(ArchiveInstall),
Git(GitInstall),
Shell(ShellInstall),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CargoInstall {
#[serde(rename = "crate")]
pub crate_name: String,
pub version: String,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub revision: Option<String>,
#[serde(default = "default_true")]
pub locked: bool,
#[serde(default)]
pub features: Vec<String>,
#[serde(default)]
pub bins: Vec<String>,
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub toolchain: Option<String>,
#[serde(default = "release_profile")]
pub profile: String,
#[serde(default)]
pub build_env_allow: Vec<String>,
}
fn default_true() -> bool {
true
}
fn release_profile() -> String {
"release".to_string()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AptInstall {
pub packages: Vec<String>,
#[serde(default)]
pub update: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct BrewInstall {
pub formulae: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RustupInstall {
#[serde(default)]
pub toolchain: Option<String>,
#[serde(rename = "toolchain-ref", default)]
pub toolchain_ref: Option<String>,
#[serde(default)]
pub components: Vec<String>,
#[serde(default)]
pub default: bool,
#[serde(default)]
pub bootstrap: Option<RustupBootstrap>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RustupBootstrap {
pub url: String,
pub sha256: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NpmInstall {
pub package: String,
pub version: String,
#[serde(default)]
pub source: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PipInstall {
pub package: String,
pub version: String,
#[serde(default = "default_python_program")]
pub python: String,
pub environment: PathBuf,
#[serde(default)]
pub index: Option<String>,
}
fn default_python_program() -> String {
"python3".to_string()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct UvToolInstall {
pub package: String,
pub bins: Vec<String>,
#[serde(default)]
pub force: bool,
#[serde(default)]
pub index: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WingetInstall {
pub package: String,
#[serde(default)]
pub arguments: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ArchiveInstall {
pub url: String,
pub sha256: String,
#[serde(default)]
pub format: ArchiveFormat,
pub target: PathBuf,
#[serde(default)]
pub strip_components: usize,
#[serde(default)]
pub allow_links: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct PreflightDef {
pub certificate: Option<CertificatePreflightDef>,
}
impl PreflightDef {
fn is_empty(&self) -> bool {
self.certificate.is_none()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CertificatePreflightDef {
#[serde(default = "all_platforms")]
pub platforms: Vec<String>,
pub detect: CheckSpec,
pub install: InstallSpec,
#[serde(default)]
pub verify: Option<CheckSpec>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct GitInstall {
pub url: String,
pub revision: String,
#[serde(default)]
pub subdirectory: Option<PathBuf>,
#[serde(default)]
pub bins: BTreeMap<String, PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ShellInstall {
pub command: String,
#[serde(default)]
pub resources: Vec<String>,
#[serde(default)]
pub rollback: Option<String>,
#[serde(default)]
pub timeout_secs: Option<u64>,
#[serde(default)]
pub inactivity_timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct EnvironmentDef {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub paths: BTreeMap<String, String>,
pub mutations: Vec<EnvironmentMutation>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
pub enum EnvironmentMutation {
Variable {
id: String,
name: String,
value: String,
#[serde(default = "user_scope")]
scope: MutationScope,
#[serde(default = "all_platforms")]
platforms: Vec<String>,
},
PathPrepend {
id: String,
value: String,
#[serde(default = "user_scope")]
scope: MutationScope,
#[serde(default = "all_platforms")]
platforms: Vec<String>,
},
FileFragment {
id: String,
path: PathBuf,
lines: Vec<String>,
#[serde(default = "all_platforms")]
platforms: Vec<String>,
},
}
fn user_scope() -> MutationScope {
MutationScope::User
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum MutationScope {
Process,
User,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AptMirrorDef {
#[serde(default)]
pub uri: Option<String>,
#[serde(default)]
pub suites: Vec<String>,
#[serde(default)]
pub components: Vec<String>,
#[serde(default)]
pub architectures: Vec<String>,
#[serde(default)]
pub signed_by: Option<PathBuf>,
#[serde(default)]
pub source_file: Option<PathBuf>,
#[serde(default)]
pub rules: Vec<AptMirrorRuleDef>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct AptMirrorRuleDef {
pub distribution: Option<String>,
pub codename: Option<String>,
pub architecture: Option<String>,
pub uri: Option<String>,
pub suites: Vec<String>,
pub components: Vec<String>,
pub architectures: Vec<String>,
pub signed_by: Option<PathBuf>,
pub source_file: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct OriginMap {
pub fields: BTreeMap<String, String>,
}
impl OriginMap {
pub(crate) fn record(&mut self, field: impl Into<String>, origin: impl Into<String>) {
self.fields.insert(field.into(), origin.into());
}
}
impl ConfigDocument {
pub fn parse(input: &str) -> Result<Self, ForgeError> {
let document = Self::parse_catalog(input)?;
if document.catalog.is_empty() {
return Err(ForgeError::Config(
"input configuration must declare catalog = \"rust-dev\"".to_string(),
));
}
Ok(document)
}
pub(crate) fn parse_catalog(input: &str) -> Result<Self, ForgeError> {
let value: toml::Value = toml::from_str(input)
.map_err(|error| ForgeError::Parse(format!("invalid TOML: {error}")))?;
if value.get("catalog-digest").is_some() {
return Err(ForgeError::Config(
"input configuration cannot declare internal field catalog-digest".to_string(),
));
}
reject_ambiguous_profile_modifiers(&value, "input")?;
let mut document: Self = toml::from_str(input)
.map_err(|error| ForgeError::Parse(format!("invalid TOML: {error}")))?;
document.expand_shorthands(false)?;
document.apply_profile_modifiers("input", &mut OriginMap::default())?;
document.validate()?;
Ok(document)
}
fn expand_cargo_tools(&mut self, replace_existing: bool) -> Result<Vec<String>, ForgeError> {
let tools = std::mem::take(&mut self.cargo_tools);
let mut expanded = Vec::with_capacity(tools.len());
for (id, input) in tools {
validate_id("cargo tool", &id)?;
let component = cargo_tool_component(&id, input)?;
if let Some(existing) = self.components.iter_mut().find(|item| item.id == id) {
if !replace_existing {
return Err(ForgeError::Config(format!(
"cargo-tools.{id} conflicts with a component of the same name"
)));
}
*existing = component;
} else {
self.components.push(component);
}
expanded.push(id);
}
self.components
.sort_by(|left, right| left.id.cmp(&right.id));
Ok(expanded)
}
fn expand_shorthands(
&mut self,
replace_existing: bool,
) -> Result<Vec<(String, String)>, ForgeError> {
let mut expanded = self.expand_cargo_toolsets(replace_existing)?;
expanded.extend(
self.expand_cargo_tools(replace_existing)?
.into_iter()
.map(|id| (id, "cargo-tools".to_string())),
);
expanded.extend(
self.expand_package_tools(replace_existing)?
.into_iter()
.map(|id| (id, "package-tools".to_string())),
);
expanded.extend(
self.expand_rustup_tools(replace_existing)?
.into_iter()
.map(|id| (id, "rustup-tools".to_string())),
);
self.expand_environment_paths(replace_existing)?;
Ok(expanded)
}
fn expand_cargo_toolsets(
&mut self,
replace_existing: bool,
) -> Result<Vec<(String, String)>, ForgeError> {
let toolsets = std::mem::take(&mut self.cargo_toolsets);
let mut expanded = Vec::new();
for (group, tools) in toolsets {
validate_id("cargo toolset", &group)?;
if tools.is_empty() {
return Err(ForgeError::Config(format!(
"cargo-toolsets.{group} cannot be empty"
)));
}
let members = self.groups.entry(group.clone()).or_default();
for (id, input) in tools {
validate_id("cargo tool", &id)?;
let component = cargo_tool_component(&id, input)?;
replace_or_insert_component(
&mut self.components,
component,
replace_existing,
&id,
)?;
if !members.contains(&id) {
members.push(id.clone());
}
expanded.push((id.clone(), format!("cargo-toolsets.{group}")));
}
}
self.components
.sort_by(|left, right| left.id.cmp(&right.id));
Ok(expanded)
}
fn expand_package_tools(&mut self, replace_existing: bool) -> Result<Vec<String>, ForgeError> {
let tools = std::mem::take(&mut self.package_tools);
let mut expanded = Vec::with_capacity(tools.len());
for (id, input) in tools {
validate_id("package tool", &id)?;
let component = package_tool_component(&id, input)?;
replace_or_insert_component(&mut self.components, component, replace_existing, &id)?;
expanded.push(id);
}
self.components
.sort_by(|left, right| left.id.cmp(&right.id));
Ok(expanded)
}
fn expand_rustup_tools(&mut self, replace_existing: bool) -> Result<Vec<String>, ForgeError> {
let tools = std::mem::take(&mut self.rustup_tools);
let mut expanded = Vec::with_capacity(tools.len());
for (id, input) in tools {
validate_id("rustup tool", &id)?;
let component = rustup_tool_component(&id, input)?;
replace_or_insert_component(&mut self.components, component, replace_existing, &id)?;
expanded.push(id);
}
self.components
.sort_by(|left, right| left.id.cmp(&right.id));
Ok(expanded)
}
fn expand_environment_paths(&mut self, replace_existing: bool) -> Result<(), ForgeError> {
let paths = std::mem::take(&mut self.environment.paths);
for (id, value) in paths {
validate_id("environment path", &id)?;
let mutation = EnvironmentMutation::PathPrepend {
id: id.clone(),
value,
scope: MutationScope::User,
platforms: all_platforms(),
};
if let Some(existing) = self
.environment
.mutations
.iter_mut()
.find(|mutation| mutation.id() == id)
{
if !replace_existing {
return Err(ForgeError::Config(format!(
"environment.paths.{id} conflicts with a mutation of the same name"
)));
}
*existing = mutation;
} else {
self.environment.mutations.push(mutation);
}
}
Ok(())
}
pub fn validate(&self) -> Result<(), ForgeError> {
if self.policy.max_parallel == 0
|| self.policy.max_parallel > 256
|| self.policy.max_downloads == 0
|| self.policy.max_downloads > 256
{
return Err(ForgeError::Config(
"policy.max_parallel and max_downloads must be between 1 and 256".to_string(),
));
}
if self
.policy
.max_memory_mib
.is_some_and(|memory| !(512..=u64::from(u32::MAX)).contains(&memory))
{
return Err(ForgeError::Config(format!(
"policy.max_memory_mib must be between 512 and {}",
u32::MAX
)));
}
validate_ids(self)?;
validate_sources(self)?;
validate_catalogs(self)?;
validate_preflight(self)?;
validate_versions(self)?;
validate_groups(self)?;
validate_profiles(self)?;
validate_components(self)?;
validate_binary_providers(self)?;
validate_dependency_graph(self)?;
validate_environment(self)?;
validate_apt_mirror(self.apt_mirror.as_ref())?;
Ok(())
}
pub fn merge_overlay_text(
&mut self,
input: &str,
origin: &str,
origins: &mut OriginMap,
) -> Result<(), ForgeError> {
self.merge_layer_text(input, origin, origins, false)
}
pub(crate) fn merge_primary_text(
&mut self,
input: &str,
origin: &str,
origins: &mut OriginMap,
) -> Result<(), ForgeError> {
self.merge_layer_text(input, origin, origins, true)
}
fn merge_layer_text(
&mut self,
input: &str,
origin: &str,
origins: &mut OriginMap,
primary: bool,
) -> Result<(), ForgeError> {
let overlay: toml::Value = toml::from_str(input)
.map_err(|error| ForgeError::Parse(format!("invalid TOML: {error}")))?;
reject_ambiguous_profile_modifiers(&overlay, origin)?;
let forbidden = if primary {
&["catalog-digest"][..]
} else {
&["catalog", "catalog-digest"][..]
};
for field in forbidden {
if overlay.get(field).is_some() {
return Err(ForgeError::Config(format!(
"configuration layer {origin} cannot declare main-configuration-only field {field}"
)));
}
}
let mut effective = toml::Value::try_from(&*self).map_err(|error| {
ForgeError::Config(format!("failed to merge configuration: {error}"))
})?;
merge_values("", &mut effective, overlay, origin, origins)?;
let mut document: Self = effective
.try_into()
.map_err(|error| ForgeError::Parse(format!("invalid TOML: {error}")))?;
let expanded = document.expand_shorthands(true)?;
for (id, shorthand) in expanded {
origins.record(
format!("components.{id}"),
format!("{origin} (expanded from {shorthand}.{id})"),
);
}
document.apply_profile_modifiers(origin, origins)?;
document.validate()?;
*self = document;
Ok(())
}
pub fn component(&self, id: &str) -> Option<&ComponentDef> {
self.components.iter().find(|component| component.id == id)
}
pub(crate) fn expand_profile_entry(&self, entry: &str) -> Result<Vec<String>, ForgeError> {
if let Some(group) = entry.strip_prefix("group:") {
return self.groups.get(group).cloned().ok_or_else(|| {
ForgeError::Config(format!(
"profile references a group that does not exist: {group}"
))
});
}
Ok(vec![entry.to_string()])
}
pub(crate) fn resolve_version_references(&mut self) -> Result<(), ForgeError> {
let versions = &self.versions;
for component in &mut self.components {
resolve_rustup_version(
versions,
component.install.as_mut(),
&format!("component {}", component.id),
)?;
for variant in &mut component.variants {
resolve_rustup_version(
versions,
variant.install.as_mut(),
&format!("component {} variant {}", component.id, variant.id),
)?;
}
}
Ok(())
}
pub(crate) fn resolve_source_references(
&mut self,
origins: &mut OriginMap,
) -> Result<(), ForgeError> {
let mut referenced = BTreeSet::new();
for component in &mut self.components {
resolve_install_sources(
&self.sources,
&mut referenced,
component.install.as_mut(),
&format!("components.{}.install", component.id),
origins,
)?;
for variant in &mut component.variants {
resolve_install_sources(
&self.sources,
&mut referenced,
variant.install.as_mut(),
&format!(
"components.{}.variants.{}.install",
component.id, variant.id
),
origins,
)?;
}
}
for name in self.sources.keys() {
if !referenced.contains(name) {
return Err(ForgeError::Config(format!(
"sources.{name} is not referenced by any installation recipe"
)));
}
}
Ok(())
}
fn apply_profile_modifiers(
&mut self,
origin: &str,
origins: &mut OriginMap,
) -> Result<(), ForgeError> {
let order = profile_topological_order(self)?;
for name in order {
let (add, remove) = {
let profile = self.profiles.get_mut(&name).ok_or_else(|| {
ForgeError::Config(format!(
"profile topology contains an unknown profile: {name}"
))
})?;
(
std::mem::take(&mut profile.add),
std::mem::take(&mut profile.remove),
)
};
if add.is_empty() && remove.is_empty() {
continue;
}
let mut effective = effective_profile_components(self, &name)?;
for entry in add {
for component in self.expand_profile_entry(&entry)? {
if !effective.contains(&component) {
effective.push(component);
}
}
}
for entry in remove {
for component in self.expand_profile_entry(&entry)? {
let Some(index) = effective.iter().position(|item| item == &component) else {
return Err(ForgeError::Config(format!(
"profiles.{name}.remove references a component outside the valid profile: {component}"
)));
};
effective.remove(index);
}
}
let profile = self.profiles.get_mut(&name).ok_or_else(|| {
ForgeError::Config(format!(
"profile topology contains an unknown profile: {name}"
))
})?;
profile.inherits.clear();
profile.components = effective;
origins.record(
format!("profiles.{name}.components"),
format!("{origin} (expanded from profiles.{name}.add/remove)"),
);
}
Ok(())
}
}
fn validate_preflight(config: &ConfigDocument) -> Result<(), ForgeError> {
let Some(certificate) = config.preflight.certificate.as_ref() else {
return Ok(());
};
let label = "preflight.certificate";
validate_platforms(label, &certificate.platforms)?;
validate_check(config, label, Some(&certificate.detect))?;
validate_check(config, label, certificate.verify.as_ref())?;
validate_install(config, label, Some(&certificate.install))?;
if !matches!(certificate.install, InstallSpec::Shell(_)) {
return Err(ForgeError::Config(
"preflight.certificate.install only supports the shell backend".into(),
));
}
Ok(())
}
fn resolve_rustup_version(
versions: &BTreeMap<String, String>,
install: Option<&mut InstallSpec>,
owner: &str,
) -> Result<(), ForgeError> {
let Some(InstallSpec::Rustup(rustup)) = install else {
return Ok(());
};
let Some(reference) = rustup.toolchain_ref.take() else {
return Ok(());
};
rustup.toolchain = Some(versions.get(&reference).cloned().ok_or_else(|| {
ForgeError::Config(format!(
"{owner} references a version that does not exist: {reference}"
))
})?);
Ok(())
}
fn resolve_install_sources(
sources: &BTreeMap<String, SourceDef>,
referenced: &mut BTreeSet<String>,
install: Option<&mut InstallSpec>,
path: &str,
origins: &mut OriginMap,
) -> Result<(), ForgeError> {
match install {
Some(InstallSpec::Cargo(cargo)) => {
let Some(reference) = cargo.source.clone() else {
return Ok(());
};
let Some(source) = sources.get(&reference) else {
return Ok(());
};
referenced.insert(reference.clone());
cargo.source = Some(match source {
SourceDef::CargoRegistry { url } => format!("index+{url}"),
SourceDef::Git { url } => format!("git+{url}"),
_ => return Err(source_kind_error(&reference, "cargo-registry or git")),
});
origins.record(
format!("{path}.source"),
format!("expanded from sources.{reference}"),
);
}
Some(InstallSpec::Npm(npm)) => {
let Some(reference) = npm.source.clone() else {
return Ok(());
};
let Some(source) = sources.get(&reference) else {
return Ok(());
};
referenced.insert(reference.clone());
npm.source = Some(match source {
SourceDef::NpmRegistry { url } => url.clone(),
_ => return Err(source_kind_error(&reference, "npm-registry")),
});
origins.record(
format!("{path}.source"),
format!("expanded from sources.{reference}"),
);
}
Some(InstallSpec::Archive(archive)) => {
let original = archive.url.clone();
archive.url = resolve_source_url(
sources,
referenced,
&archive.url,
"archive-mirror",
|source| match source {
SourceDef::ArchiveMirror { base_url } => Some(base_url),
_ => None,
},
)?;
if let Some(reference) = original.strip_prefix("source:") {
let name = reference.split('/').next().unwrap_or(reference);
origins.record(
format!("{path}.url"),
format!("expanded from sources.{name}"),
);
}
}
Some(InstallSpec::Git(git)) => {
let original = git.url.clone();
git.url = resolve_source_url(
sources,
referenced,
&git.url,
"git",
|source| match source {
SourceDef::Git { url } => Some(url),
_ => None,
},
)?;
if let Some(reference) = original.strip_prefix("source:") {
let name = reference.split('/').next().unwrap_or(reference);
origins.record(
format!("{path}.url"),
format!("expanded from sources.{name}"),
);
}
}
_ => {}
}
Ok(())
}
fn resolve_source_url<'a>(
sources: &'a BTreeMap<String, SourceDef>,
referenced: &mut BTreeSet<String>,
value: &str,
expected: &str,
base_url: impl FnOnce(&'a SourceDef) -> Option<&'a String>,
) -> Result<String, ForgeError> {
let Some(reference) = value.strip_prefix("source:") else {
return Ok(value.to_string());
};
let (name, suffix) = reference.split_once('/').unwrap_or((reference, ""));
let source = sources.get(name).ok_or_else(|| {
ForgeError::Config(format!("references a source that does not exist: {name}"))
})?;
let base = base_url(source).ok_or_else(|| source_kind_error(name, expected))?;
referenced.insert(name.to_string());
if suffix.is_empty() {
Ok(base.clone())
} else {
Ok(format!("{}/{}", base.trim_end_matches('/'), suffix))
}
}
fn source_kind_error(name: &str, expected: &str) -> ForgeError {
ForgeError::Config(format!(
"sources.{name} has the wrong type; expected {expected}"
))
}
fn cargo_tool_component(id: &str, input: CargoToolInput) -> Result<ComponentDef, ForgeError> {
let mut detail = match input {
CargoToolInput::Version(version) => CargoToolDef {
version,
crate_name: None,
bin: None,
bins: Vec::new(),
detect: None,
platforms: Vec::new(),
requires: Vec::new(),
provides: Vec::new(),
conflicts: Vec::new(),
optional: false,
features: Vec::new(),
target: None,
toolchain: None,
profile: None,
source: None,
revision: None,
locked: true,
build_env_allow: Vec::new(),
},
CargoToolInput::Detailed(detail) => *detail,
};
let Some(version) = normalize_exact_cargo_version(&detail.version) else {
return Err(ForgeError::Config(format!(
"cargo-tools.{id}.version must be an exact x.y.z or =x.y.z version"
)));
};
detail.version = version;
if detail.bin.is_some() && !detail.bins.is_empty() {
return Err(ForgeError::Config(format!(
"cargo-tools.{id} cannot declare both bin and bins"
)));
}
let bins = detail
.bin
.clone()
.map(|bin| vec![bin])
.or_else(|| (!detail.bins.is_empty()).then(|| detail.bins.clone()))
.unwrap_or_else(|| vec![id.to_string()]);
if bins.iter().any(|bin| bin.trim().is_empty()) {
return Err(ForgeError::Config(format!(
"cargo-tools.{id} binary name cannot be empty"
)));
}
let detect_command = detail.detect.clone().unwrap_or_else(|| {
id.strip_prefix("cargo-").map_or_else(
|| vec![bins[0].clone(), "--version".to_string()],
|subcommand| {
vec![
"cargo".to_string(),
subcommand.to_string(),
"--version".to_string(),
]
},
)
});
let mut detect_command = detect_command;
if let Some(toolchain) = detail.toolchain.as_ref()
&& detect_command
.first()
.is_some_and(|program| program == "cargo")
{
detect_command.insert(1, format!("+{toolchain}"));
}
let (program, args) = detect_command
.split_first()
.ok_or_else(|| ForgeError::Config(format!("cargo-tools.{id}.detect cannot be empty")))?;
let platforms = if detail.platforms.is_empty() {
all_platforms()
} else {
detail.platforms
};
Ok(ComponentDef {
id: id.to_string(),
kind: default_component_kind(),
display_name: None,
version: None,
requires: detail.requires,
provides: detail.provides,
conflicts: detail.conflicts,
optional: detail.optional,
allow_insecure_hosts: Vec::new(),
platforms,
detect: Some(CheckSpec::Command {
program: program.clone(),
args: args.to_vec(),
stdout_contains: None,
success_codes: zero_success(),
timeout_secs: Some(10),
}),
install: Some(InstallSpec::Cargo(CargoInstall {
crate_name: detail.crate_name.unwrap_or_else(|| id.to_string()),
version: detail.version,
source: detail.source,
revision: detail.revision,
locked: detail.locked,
features: detail.features,
bins,
target: detail.target,
toolchain: detail.toolchain,
profile: detail.profile.unwrap_or_else(release_profile),
build_env_allow: detail.build_env_allow,
})),
verify: None,
variants: Vec::new(),
source: None,
revision: None,
agents: Vec::new(),
})
}
fn replace_or_insert_component(
components: &mut Vec<ComponentDef>,
component: ComponentDef,
replace_existing: bool,
id: &str,
) -> Result<(), ForgeError> {
if let Some(existing) = components.iter_mut().find(|item| item.id == id) {
if !replace_existing {
return Err(ForgeError::Config(format!(
"shorthand {id} conflicts with a component of the same name"
)));
}
*existing = component;
} else {
components.push(component);
}
Ok(())
}
fn command_check(path: &str, command: &[String]) -> Result<CheckSpec, ForgeError> {
let (program, args) = command
.split_first()
.ok_or_else(|| ForgeError::Config(format!("{path} cannot be empty")))?;
Ok(CheckSpec::Command {
program: program.clone(),
args: args.to_vec(),
stdout_contains: None,
success_codes: zero_success(),
timeout_secs: default_check_timeout(),
})
}
fn command_check_input(path: &str, input: CommandCheckInput) -> Result<CheckSpec, ForgeError> {
match input {
CommandCheckInput::Command(command) => command_check(path, &command),
CommandCheckInput::All(commands) => {
if commands.is_empty() {
return Err(ForgeError::Config(format!("{path} cannot be empty")));
}
Ok(CheckSpec::All {
checks: commands
.iter()
.enumerate()
.map(|(index, command)| command_check(&format!("{path}[{index}]"), command))
.collect::<Result<Vec<_>, _>>()?,
})
}
}
}
fn package_tool_component(id: &str, detail: PackageToolDef) -> Result<ComponentDef, ForgeError> {
if detail.apt.is_empty() && detail.brew.is_empty() && detail.winget.is_none() {
return Err(ForgeError::Config(format!(
"package-tools.{id} must declare apt, brew, or winget; use a full component for complex recipes"
)));
}
if detail.brew.is_empty() && detail.brew_detect.is_some() {
return Err(ForgeError::Config(format!(
"package-tools.{id} can use brew_detect only when brew is declared"
)));
}
if detail.winget.is_none()
&& (!detail.winget_arguments.is_empty() || detail.winget_detect.is_some())
{
return Err(ForgeError::Config(format!(
"package-tools.{id} can use winget_arguments/winget_detect only when winget is declared"
)));
}
let detect = command_check_input(&format!("package-tools.{id}.detect"), detail.detect)?;
let mut backends = Vec::new();
if !detail.apt.is_empty() {
backends.push(VariantDef {
id: "linux-apt".to_string(),
version: None,
requires: Vec::new(),
provides: detail.provides.clone(),
conflicts: Vec::new(),
platforms: vec!["linux-*".to_string()],
detect: Some(detect.clone()),
install: Some(InstallSpec::Apt(AptInstall {
packages: detail.apt.clone(),
update: detail.apt_update,
})),
verify: None,
});
}
if !detail.brew.is_empty() {
backends.push(VariantDef {
id: "macos-brew".to_string(),
version: None,
requires: Vec::new(),
provides: detail.provides.clone(),
conflicts: Vec::new(),
platforms: vec!["macos-*".to_string()],
detect: detail
.brew_detect
.map(|command| {
command_check_input(&format!("package-tools.{id}.brew_detect"), command)
})
.transpose()?,
install: Some(InstallSpec::Brew(BrewInstall {
formulae: detail.brew,
})),
verify: None,
});
}
if let Some(package) = &detail.winget {
backends.push(VariantDef {
id: "windows-winget".to_string(),
version: None,
requires: Vec::new(),
provides: detail.provides.clone(),
conflicts: Vec::new(),
platforms: vec!["windows-*".to_string()],
detect: detail
.winget_detect
.map(|command| {
command_check_input(&format!("package-tools.{id}.winget_detect"), command)
})
.transpose()?,
install: Some(InstallSpec::Winget(WingetInstall {
package: package.clone(),
arguments: detail.winget_arguments.clone(),
})),
verify: None,
});
}
let mut base = backends.remove(0);
Ok(ComponentDef {
id: id.to_string(),
kind: default_component_kind(),
display_name: detail.display_name,
version: None,
requires: detail.requires,
provides: detail.provides,
conflicts: detail.conflicts,
optional: detail.optional,
allow_insecure_hosts: Vec::new(),
platforms: base.platforms,
detect: base.detect.take().or(Some(detect)),
install: base.install,
verify: None,
variants: backends,
source: None,
revision: None,
agents: Vec::new(),
})
}
fn rustup_tool_component(id: &str, input: RustupToolInput) -> Result<ComponentDef, ForgeError> {
let detail = match input {
RustupToolInput::Component(component) => RustupToolDef {
components: vec![component],
detect: None,
detect_stdout_contains: None,
toolchain: None,
toolchain_ref: None,
default: false,
bootstrap: None,
display_name: None,
requires: Vec::new(),
provides: Vec::new(),
conflicts: Vec::new(),
optional: false,
},
RustupToolInput::Detailed(detail) => *detail,
};
if detail.components.is_empty() {
return Err(ForgeError::Config(format!(
"rustup-tools.{id}.components cannot be empty"
)));
}
let detect_input = detail
.detect
.unwrap_or_else(|| CommandCheckInput::Command(vec![id.to_string(), "--version".into()]));
let mut detect = command_check_input(&format!("rustup-tools.{id}.detect"), detect_input)?;
if let Some(needle) = detail.detect_stdout_contains {
match &mut detect {
CheckSpec::Command {
stdout_contains, ..
} => *stdout_contains = Some(needle),
CheckSpec::All { .. } => {
return Err(ForgeError::Config(format!(
"rustup-tools.{id}.detect-stdout-contains can only be used with one detect command"
)));
}
_ => unreachable!("rustup shorthand only creates command checks"),
}
}
Ok(ComponentDef {
id: id.to_string(),
kind: default_component_kind(),
display_name: detail.display_name,
version: None,
requires: detail.requires,
provides: detail.provides,
conflicts: detail.conflicts,
optional: detail.optional,
allow_insecure_hosts: Vec::new(),
platforms: all_platforms(),
detect: Some(detect),
install: Some(InstallSpec::Rustup(RustupInstall {
toolchain: detail.toolchain,
toolchain_ref: detail.toolchain_ref,
components: detail.components,
default: detail.default,
bootstrap: detail.bootstrap,
})),
verify: None,
variants: Vec::new(),
source: None,
revision: None,
agents: Vec::new(),
})
}
impl EnvironmentMutation {
pub fn id(&self) -> &str {
match self {
Self::Variable { id, .. }
| Self::PathPrepend { id, .. }
| Self::FileFragment { id, .. } => id,
}
}
pub fn platforms(&self) -> &[String] {
match self {
Self::Variable { platforms, .. }
| Self::PathPrepend { platforms, .. }
| Self::FileFragment { platforms, .. } => platforms,
}
}
}
impl CargoInstall {
pub(crate) fn cache_digest(&self) -> String {
let source = self.source.as_deref().unwrap_or("crates.io");
let identity = if source.starts_with("git+") || source.starts_with("https://") {
format!("git={}", source.strip_prefix("git+").unwrap_or(source))
} else {
format!("registry={source}")
};
hex_digest(Sha256::digest(identity.as_bytes()).as_slice())
}
pub(crate) fn source_digest(&self) -> String {
let version = self.version.trim_start_matches('=');
let identity = format!(
"crate={}\nversion={}\nsource={}\nrevision={}\n",
self.crate_name,
version,
self.source.as_deref().unwrap_or("crates.io"),
self.revision.as_deref().unwrap_or("registry")
);
hex_digest(Sha256::digest(identity.as_bytes()).as_slice())
}
pub(crate) fn lock_digest(&self) -> String {
hex_digest(
Sha256::digest(
format!(
"{}@{}:locked={}",
self.crate_name,
self.version.trim_start_matches('='),
self.locked
)
.as_bytes(),
)
.as_slice(),
)
}
pub(crate) fn fingerprint(&self, source_digest: &str, lock_digest: &str) -> String {
self.fingerprint_with_build_identity(
source_digest,
lock_digest,
"planning",
&self.build_environment_digest(false),
)
}
pub(crate) fn artifact_fingerprint(&self, source_digest: &str, lock_digest: &str) -> String {
self.fingerprint_with_build_identity(
source_digest,
lock_digest,
&cargo_toolchain_identity(self.toolchain.as_deref()),
&self.build_environment_digest(true),
)
}
fn fingerprint_with_build_identity(
&self,
source_digest: &str,
lock_digest: &str,
toolchain_identity: &str,
environment_digest: &str,
) -> String {
let mut features = self.features.clone();
features.sort();
let mut binaries = self.bins.clone();
binaries.sort();
let input = format!(
"crate={}\nversion={}\nsource={}\nsource-digest={source_digest}\nrevision={}\nlocked={}\nlock-digest={lock_digest}\ntarget={}\ntoolchain={}\ntoolchain-identity={}\nprofile={}\nfeatures={}\nbins={}\nenv-digest={}\n",
self.crate_name,
self.version,
self.source.as_deref().unwrap_or("crates-io"),
self.revision.as_deref().unwrap_or("registry"),
self.locked,
self.target
.clone()
.unwrap_or_else(cargo_host_target_identity),
self.toolchain.as_deref().unwrap_or("default"),
toolchain_identity,
self.profile,
features.join(","),
binaries.join(","),
environment_digest
);
hex_digest(Sha256::digest(input.as_bytes()).as_slice())
}
fn build_environment_digest(&self, include_values: bool) -> String {
let mut environment = self.build_env_allow.clone();
environment.sort();
environment.dedup();
let environment = environment
.iter()
.map(|name| {
let value = include_values
.then(|| std::env::var_os(name))
.flatten()
.map(|value| value.to_string_lossy().into_owned());
(name, value)
})
.collect::<Vec<_>>();
serde_json::to_vec(&environment)
.map(|bytes| hex_digest(Sha256::digest(bytes).as_slice()))
.unwrap_or_else(|_| "invalid-environment".to_string())
}
}
fn cargo_toolchain_identity(toolchain: Option<&str>) -> String {
static IDENTITIES: OnceLock<Mutex<BTreeMap<String, String>>> = OnceLock::new();
let key = toolchain.unwrap_or("default").to_string();
let identities = IDENTITIES.get_or_init(|| Mutex::new(BTreeMap::new()));
if let Some(identity) = identities
.lock()
.ok()
.and_then(|values| values.get(&key).cloned())
{
return identity;
}
let mut command = std::process::Command::new(resolve_command("rustc"));
command.env("RUSTUP_AUTO_INSTALL", "0");
if let Some(toolchain) = toolchain {
command.arg(format!("+{toolchain}"));
}
let identity = command
.arg("-vV")
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| hex_digest(Sha256::digest(&output.stdout).as_slice()))
.unwrap_or_else(|| format!("unavailable:{key}"));
if let Ok(mut values) = identities.lock() {
values.insert(key, identity.clone());
}
identity
}
fn cargo_host_target_identity() -> String {
let arch = match std::env::consts::ARCH {
"amd64" | "x64" => "x86_64",
"arm64" => "aarch64",
value => value,
};
if cfg!(target_os = "macos") {
format!("{arch}-apple-darwin")
} else if cfg!(windows) && cfg!(target_env = "gnu") {
format!("{arch}-pc-windows-gnu")
} else if cfg!(windows) {
format!("{arch}-pc-windows-msvc")
} else if cfg!(target_os = "linux") && cfg!(target_env = "musl") {
format!("{arch}-unknown-linux-musl")
} else if cfg!(target_os = "linux") {
format!("{arch}-unknown-linux-gnu")
} else {
format!("{arch}-unknown-{}", std::env::consts::OS)
}
}
fn validate_ids(config: &ConfigDocument) -> Result<(), ForgeError> {
let mut component_ids = BTreeSet::new();
for component in &config.components {
validate_id("component", &component.id)?;
if !component_ids.insert(component.id.as_str()) {
return Err(ForgeError::Config(format!(
"components contain a duplicate id: {}",
component.id
)));
}
let mut variant_ids = BTreeSet::new();
for variant in &component.variants {
validate_id("variant", &variant.id)?;
if !variant_ids.insert(variant.id.as_str()) {
return Err(ForgeError::Config(format!(
"component {} contains a duplicate variant: {}",
component.id, variant.id
)));
}
}
}
for name in config.profiles.keys() {
validate_id("profile", name)?;
}
for name in config.sources.keys() {
validate_id("source", name)?;
}
for name in config.versions.keys() {
validate_id("version", name)?;
}
for name in config.groups.keys() {
validate_id("group", name)?;
}
Ok(())
}
fn validate_id(kind: &str, value: &str) -> Result<(), ForgeError> {
if !valid_config_id(value) {
return Err(ForgeError::Config(format!(
"{kind} id must use lowercase kebab-case: {value}"
)));
}
Ok(())
}
fn validate_sources(config: &ConfigDocument) -> Result<(), ForgeError> {
for (name, source) in &config.sources {
let url = match source {
SourceDef::CargoRegistry { url }
| SourceDef::ArchiveMirror { base_url: url }
| SourceDef::Git { url }
| SourceDef::NpmRegistry { url } => url,
};
if !url.starts_with("https://") {
return Err(ForgeError::Config(format!("source {name} must use HTTPS")));
}
}
Ok(())
}
fn validate_catalogs(config: &ConfigDocument) -> Result<(), ForgeError> {
if !config.catalog.is_empty() && config.catalog != "rust-dev" {
return Err(ForgeError::Config(format!(
"unknown or untrusted catalog: {}",
config.catalog
)));
}
Ok(())
}
fn validate_versions(config: &ConfigDocument) -> Result<(), ForgeError> {
let referenced: BTreeSet<&str> = config
.components
.iter()
.flat_map(|component| {
std::iter::once(&component.install)
.chain(component.variants.iter().map(|variant| &variant.install))
})
.filter_map(|install| match install {
Some(InstallSpec::Rustup(rustup)) => rustup.toolchain_ref.as_deref(),
_ => None,
})
.collect();
for key in config.versions.keys() {
if !referenced.contains(key.as_str()) {
return Err(ForgeError::Config(format!(
"versions.{key} is not referenced by any installation recipe"
)));
}
validate_argument_value(
"versions",
&format!("versions.{key}"),
&config.versions[key],
)?;
}
Ok(())
}
fn validate_groups(config: &ConfigDocument) -> Result<(), ForgeError> {
let components: BTreeSet<&str> = config
.components
.iter()
.map(|component| component.id.as_str())
.collect();
for (name, members) in &config.groups {
if members.is_empty() {
return Err(ForgeError::Config(format!("group {name} cannot be empty")));
}
for member in members {
if !components.contains(member.as_str()) {
return Err(ForgeError::Config(format!(
"group {name} references a component that does not exist: {member}"
)));
}
}
}
Ok(())
}
fn validate_profiles(config: &ConfigDocument) -> Result<(), ForgeError> {
let components: BTreeSet<&str> = config
.components
.iter()
.map(|component| component.id.as_str())
.collect();
for (name, profile) in &config.profiles {
for parent in &profile.inherits {
if !config.profiles.contains_key(parent) {
return Err(ForgeError::Config(format!(
"profile {name} inherits from a profile that does not exist: {parent}"
)));
}
}
for component in &profile.components {
let group_exists = component
.strip_prefix("group:")
.is_some_and(|group| config.groups.contains_key(group));
if !components.contains(component.as_str())
&& !component.starts_with("capability:")
&& !group_exists
{
return Err(ForgeError::Config(format!(
"profile {name} references a component that does not exist: {component}"
)));
}
}
}
visit_cycles(
config.profiles.keys().map(String::as_str),
|name| config.profiles[name].inherits.iter().map(String::as_str),
"profile inheritance",
)
}
fn validate_components(config: &ConfigDocument) -> Result<(), ForgeError> {
let component_ids: BTreeSet<&str> = config
.components
.iter()
.map(|component| component.id.as_str())
.collect();
let capabilities: BTreeSet<&str> = config
.components
.iter()
.flat_map(|component| component.provides.iter())
.chain(
config
.components
.iter()
.flat_map(|component| component.variants.iter())
.flat_map(|variant| variant.provides.iter()),
)
.map(String::as_str)
.collect();
for component in &config.components {
validate_platforms(&component.id, &component.platforms)?;
validate_offered_version(&component.id, component.version.as_deref())?;
for host in &component.allow_insecure_hosts {
validate_insecure_host(&component.id, host)?;
}
validate_insecure_host_backends(component)?;
validate_check(config, &component.id, component.detect.as_ref())?;
validate_check(config, &component.id, component.verify.as_ref())?;
validate_install(config, &component.id, component.install.as_ref())?;
validate_references(
&component.id,
component.requires.iter().chain(component.conflicts.iter()),
&component_ids,
&capabilities,
)?;
if component.kind == InstallKind::Skill {
let source = component.source.as_deref().unwrap_or_default();
if source.is_empty() {
return Err(ForgeError::Config(format!(
"skill component {} is missing a source",
component.id
)));
}
if looks_like_git(source)
&& (!source.starts_with("https://")
|| component
.revision
.as_deref()
.is_none_or(|revision| !valid_git_commit(revision)))
{
return Err(ForgeError::Config(format!(
"skill component {} remote source must use HTTPS and a pinned commit",
component.id
)));
}
if component
.revision
.as_deref()
.is_some_and(|revision| !valid_git_commit(revision))
{
return Err(ForgeError::Config(format!(
"skill component {} revision must be an explicit commit",
component.id
)));
}
if component.agents.is_empty() {
return Err(ForgeError::Config(format!(
"skill component {} is missing agents",
component.id
)));
}
if component.detect.is_some()
|| component.install.is_some()
|| component.verify.is_some()
|| component.version.is_some()
|| !component.variants.is_empty()
{
return Err(ForgeError::Config(format!(
"skill component {} does not support tool-only detect/install/verify/variants fields",
component.id
)));
}
} else if component.source.is_some()
|| component.revision.is_some()
|| !component.agents.is_empty()
{
return Err(ForgeError::Config(format!(
"tool component {} does not support skill-only source/revision/agents fields",
component.id
)));
}
for variant in &component.variants {
let label = format!("{}#{}", component.id, variant.id);
validate_platforms(&label, &variant.platforms)?;
validate_offered_version(&label, variant.version.as_deref())?;
validate_check(config, &label, variant.detect.as_ref())?;
validate_check(config, &label, variant.verify.as_ref())?;
validate_install(config, &label, variant.install.as_ref())?;
validate_references(
&label,
variant.requires.iter().chain(variant.conflicts.iter()),
&component_ids,
&capabilities,
)?;
}
}
Ok(())
}
fn validate_offered_version(component: &str, version: Option<&str>) -> Result<(), ForgeError> {
if version.is_some_and(|version| semver::Version::parse(version).is_err()) {
return Err(ForgeError::Config(format!(
"component {component} version must be an exact semantic version"
)));
}
Ok(())
}
fn validate_binary_providers(config: &ConfigDocument) -> Result<(), ForgeError> {
let mut providers: BTreeMap<String, (&str, &str)> = BTreeMap::new();
for component in &config.components {
for install in std::iter::once(&component.install)
.chain(component.variants.iter().map(|variant| &variant.install))
{
let binaries = match install {
Some(InstallSpec::Cargo(cargo)) => cargo.bins.iter().map(String::as_str).collect(),
Some(InstallSpec::Git(git)) => git.bins.keys().map(String::as_str).collect(),
_ => Vec::new(),
};
let mut local = BTreeSet::new();
for bin in binaries {
let identity = managed_binary_identity(bin);
if !local.insert(identity.clone()) {
return Err(ForgeError::Config(format!(
"component {} declares managed binary {bin} more than once",
component.id
)));
}
if let Some((previous, previous_bin)) =
providers.insert(identity, (component.id.as_str(), bin))
{
if previous_bin != bin {
return Err(ForgeError::Config(format!(
"managed binary {bin} conflicts with {previous_bin} on a case-insensitive filesystem: component {previous} and {}",
component.id
)));
}
if previous != component.id {
return Err(ForgeError::Config(format!(
"managed binary {bin} is provided by both component {previous} and {}",
component.id
)));
}
}
}
}
}
Ok(())
}
fn managed_binary_identity(name: &str) -> String {
if cfg!(any(target_os = "macos", windows)) {
name.to_ascii_lowercase()
} else {
name.to_string()
}
}
fn validate_dependency_graph(config: &ConfigDocument) -> Result<(), ForgeError> {
let ids: BTreeSet<&str> = config
.components
.iter()
.map(|component| component.id.as_str())
.collect();
visit_cycles(
ids.iter().copied(),
|name| {
config
.component(name)
.into_iter()
.flat_map(|component| component.requires.iter())
.filter(|reference| !reference.starts_with("capability:"))
.map(|reference| reference.strip_prefix("component:").unwrap_or(reference))
},
"component dependencies",
)
}
fn validate_environment(config: &ConfigDocument) -> Result<(), ForgeError> {
let mut ids = BTreeSet::new();
for mutation in &config.environment.mutations {
validate_id("environment mutation", mutation.id())?;
if !ids.insert(mutation.id()) {
return Err(ForgeError::Config(format!(
"duplicate environment mutation id: {}",
mutation.id()
)));
}
validate_platforms(mutation.id(), mutation.platforms())?;
match mutation {
EnvironmentMutation::FileFragment { path, .. }
if path.is_absolute()
|| path.components().any(|component| {
matches!(
component,
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_)
)
}) =>
{
return Err(ForgeError::Config(format!(
"environment mutation {} file-fragment path must be relative to the user directory",
mutation.id()
)));
}
_ => {}
}
}
Ok(())
}
fn validate_apt_mirror(mirror: Option<&AptMirrorDef>) -> Result<(), ForgeError> {
let Some(mirror) = mirror else {
return Ok(());
};
if mirror.uri.is_none() && mirror.rules.is_empty() {
return Err(ForgeError::Config(
"apt_mirror must declare a uri or at least one rule".to_string(),
));
}
validate_apt_values("apt_mirror.suites", &mirror.suites)?;
validate_apt_values("apt_mirror.components", &mirror.components)?;
validate_apt_values("apt_mirror.architectures", &mirror.architectures)?;
if let Some(uri) = &mirror.uri {
validate_apt_uri("apt_mirror.uri", uri)?;
}
validate_apt_path("apt_mirror.signed_by", mirror.signed_by.as_ref(), false)?;
validate_apt_path("apt_mirror.source_file", mirror.source_file.as_ref(), true)?;
let mut selectors = BTreeSet::new();
for (index, rule) in mirror.rules.iter().enumerate() {
let path = format!("apt_mirror.rules[{index}]");
for (name, selector) in [
("distribution", &rule.distribution),
("codename", &rule.codename),
("architecture", &rule.architecture),
] {
if let Some(selector) = selector {
validate_plain_scalar(&format!("{path}.{name}"), selector)?;
}
}
let key = (
rule.distribution.as_deref(),
rule.codename.as_deref(),
rule.architecture.as_deref(),
);
if !selectors.insert(key) {
return Err(ForgeError::Config(format!(
"{path} duplicates a selector used by an earlier APT rule"
)));
}
if key == (None, None, None) && index + 1 != mirror.rules.len() {
return Err(ForgeError::Config(format!(
"{path} is a catch-all rule and must be last"
)));
}
if mirror.uri.is_none() && rule.uri.is_none() {
return Err(ForgeError::Config(format!(
"{path} cannot inherit uri from apt_mirror"
)));
}
if mirror.suites.is_empty() && rule.suites.is_empty() {
return Err(ForgeError::Config(format!(
"{path} cannot inherit suites from apt_mirror"
)));
}
if mirror.components.is_empty() && rule.components.is_empty() {
return Err(ForgeError::Config(format!(
"{path} cannot inherit components from apt_mirror"
)));
}
if let Some(uri) = &rule.uri {
validate_apt_uri(&format!("{path}.uri"), uri)?;
}
validate_apt_values(&format!("{path}.suites"), &rule.suites)?;
validate_apt_values(&format!("{path}.components"), &rule.components)?;
validate_apt_values(&format!("{path}.architectures"), &rule.architectures)?;
validate_apt_path(&format!("{path}.signed_by"), rule.signed_by.as_ref(), false)?;
validate_apt_path(
&format!("{path}.source_file"),
rule.source_file.as_ref(),
true,
)?;
}
if mirror.rules.is_empty() && (mirror.suites.is_empty() || mirror.components.is_empty()) {
return Err(ForgeError::Config(
"apt_mirror.suites and components cannot be empty".to_string(),
));
}
Ok(())
}
fn validate_apt_uri(name: &str, value: &str) -> Result<(), ForgeError> {
let expanded = validate_apt_template(name, value)?;
if !expanded.starts_with("http://") && !expanded.starts_with("https://") {
return Err(ForgeError::Config(format!(
"{name} must start with http:// or https://"
)));
}
Ok(())
}
fn validate_apt_values(name: &str, values: &[String]) -> Result<(), ForgeError> {
for value in values {
validate_apt_template(name, value)?;
}
Ok(())
}
fn validate_apt_path(
name: &str,
value: Option<&PathBuf>,
deb822_source: bool,
) -> Result<(), ForgeError> {
let Some(value) = value else {
return Ok(());
};
let value = value
.to_str()
.ok_or_else(|| ForgeError::Config(format!("{name} must be a UTF-8 path")))?;
let expanded = validate_apt_template(name, value)?;
if !expanded.starts_with('/') {
return Err(ForgeError::Config(format!(
"{name} must be an absolute Linux path"
)));
}
if deb822_source && !expanded.ends_with(".sources") {
return Err(ForgeError::Config(format!("{name} must end with .sources")));
}
Ok(())
}
fn validate_apt_template(name: &str, value: &str) -> Result<String, ForgeError> {
let expanded = value
.replace("{distribution}", "distribution")
.replace("{codename}", "codename")
.replace("{architecture}", "architecture");
if expanded.contains(['{', '}']) {
return Err(ForgeError::Config(format!(
"{name} contains an unknown APT variable"
)));
}
validate_plain_scalar(name, &expanded)?;
Ok(expanded)
}
fn validate_plain_scalar(name: &str, value: &str) -> Result<(), ForgeError> {
if value.trim().is_empty() || value.chars().any(char::is_whitespace) {
return Err(ForgeError::Config(format!(
"{name} cannot be empty or contain whitespace"
)));
}
Ok(())
}
fn validate_check(
config: &ConfigDocument,
component: &str,
check: Option<&CheckSpec>,
) -> Result<(), ForgeError> {
match check {
Some(CheckSpec::Command {
program,
success_codes,
timeout_secs,
..
}) => {
if program.trim().is_empty() || program.contains(['/', '\\']) {
return Err(ForgeError::Config(format!(
"component {component} command program must be an executable file name"
)));
}
if success_codes.is_empty() {
return Err(ForgeError::Config(format!(
"component {component} success_codes cannot be empty"
)));
}
validate_timeout(component, *timeout_secs)?;
}
Some(CheckSpec::Shell {
command,
timeout_secs,
}) => {
if !config.policy.allow_shell {
return Err(ForgeError::Config(format!(
"component {component} uses a shell check but policy.allow_shell=false"
)));
}
if command.trim().is_empty() {
return Err(ForgeError::Config(format!(
"component {component} shell check cannot be empty"
)));
}
validate_timeout(component, *timeout_secs)?;
}
Some(CheckSpec::Path { path }) if path.as_os_str().is_empty() => {
return Err(ForgeError::Config(format!(
"component {component} path check cannot be empty"
)));
}
Some(CheckSpec::All { checks }) => {
if checks.is_empty() {
return Err(ForgeError::Config(format!(
"component {component} all check cannot be empty"
)));
}
for check in checks {
validate_check(config, component, Some(check))?;
}
}
_ => {}
}
Ok(())
}
fn validate_install(
config: &ConfigDocument,
component: &str,
install: Option<&InstallSpec>,
) -> Result<(), ForgeError> {
match install {
Some(InstallSpec::Cargo(cargo)) => {
if cargo.crate_name.trim().is_empty() || !valid_exact_cargo_version(&cargo.version) {
return Err(ForgeError::Config(format!(
"component {component} Cargo crate cannot be empty; version must be an exact version starting with ="
)));
}
validate_argument_value(component, "Cargo crate", &cargo.crate_name)?;
if cargo.bins.is_empty() {
return Err(ForgeError::Config(format!(
"component {component} Cargo backend must declare bins"
)));
}
for bin in &cargo.bins {
validate_id("Cargo binary", bin)?;
}
if let Some(toolchain) = &cargo.toolchain {
validate_argument_value(component, "Cargo toolchain", toolchain)?;
}
if let Some(target) = &cargo.target {
validate_argument_value(component, "Cargo target", target)?;
}
validate_argument_value(component, "Cargo profile", &cargo.profile)?;
if !cargo.locked && !config.policy.allow_unlocked_cargo {
return Err(ForgeError::Config(format!(
"component {component} disables the Cargo lock but policy does not allow it"
)));
}
if cargo
.revision
.as_deref()
.is_some_and(|value| !valid_git_commit(value))
{
return Err(ForgeError::Config(format!(
"component {component} Git revision must be an explicit commit"
)));
}
if cargo.source.as_deref().is_some_and(|source| {
(source.starts_with("git+") || source.starts_with("https://"))
&& cargo.revision.as_deref().is_none_or(str::is_empty)
}) {
return Err(ForgeError::Config(format!(
"component {component} Cargo Git source must pin a revision"
)));
}
}
Some(InstallSpec::Apt(apt)) if apt.packages.is_empty() => {
return Err(ForgeError::Config(format!(
"component {component} apt packages cannot be empty"
)));
}
Some(InstallSpec::Apt(apt))
if apt.packages.iter().any(|package| package.trim().is_empty()) =>
{
return Err(ForgeError::Config(format!(
"component {component} apt package cannot be empty"
)));
}
Some(InstallSpec::Brew(brew)) => {
if brew.formulae.is_empty() {
return Err(ForgeError::Config(format!(
"component {component} brew formulae cannot be empty"
)));
}
for formula in &brew.formulae {
validate_argument_value(component, "brew formula", formula)?;
}
}
Some(InstallSpec::Rustup(rustup))
if rustup.toolchain.is_none()
&& rustup.toolchain_ref.is_none()
&& rustup.components.is_empty() =>
{
return Err(ForgeError::Config(format!(
"component {component} rustup install has no operation"
)));
}
Some(InstallSpec::Rustup(rustup)) => {
if rustup.toolchain.is_some() && rustup.toolchain_ref.is_some() {
return Err(ForgeError::Config(format!(
"component {component} rustup toolchain and toolchain-ref cannot both be declared"
)));
}
if let Some(reference) = &rustup.toolchain_ref
&& !config.versions.contains_key(reference)
{
return Err(ForgeError::Config(format!(
"component {component} references a version that does not exist: {reference}"
)));
}
if rustup.default && rustup.toolchain.is_none() && rustup.toolchain_ref.is_none() {
return Err(ForgeError::Config(format!(
"component {component} rustup default=true must declare toolchain or toolchain-ref"
)));
}
if let Some(toolchain) = &rustup.toolchain {
validate_argument_value(component, "rustup toolchain", toolchain)?;
}
for rustup_component in &rustup.components {
validate_argument_value(component, "rustup component", rustup_component)?;
}
if let Some(bootstrap) = &rustup.bootstrap {
let template = bootstrap
.url
.replace("{target}", "target")
.replace("{exe}", "");
if !bootstrap.url.starts_with("https://")
|| template.contains(['{', '}'])
|| bootstrap.sha256.is_empty()
{
return Err(ForgeError::Config(format!(
"component {component} rustup bootstrap must use HTTPS, contain only target/exe placeholders, and declare sha256"
)));
}
for (platform, digest) in &bootstrap.sha256 {
validate_platforms(
&format!("component {component} rustup bootstrap {platform}"),
std::slice::from_ref(platform),
)?;
if platform == "*" || platform.ends_with("-*") || !valid_sha256(digest) {
return Err(ForgeError::Config(format!(
"component {component} rustup bootstrap sha256 must declare a valid digest for the exact platform: {platform}"
)));
}
}
}
}
Some(InstallSpec::Npm(npm)) => {
validate_argument_value(component, "npm package", &npm.package)?;
if semver::Version::parse(&npm.version).is_err() {
return Err(ForgeError::Config(format!(
"component {component} npm version must be exact SemVer"
)));
}
if let Some(source) = &npm.source {
validate_argument_value(component, "npm registry", source)?;
if !source.starts_with("https://") && !config.sources.contains_key(source) {
return Err(ForgeError::Config(format!(
"component {component} npm source must be an HTTPS URL or a declared named source"
)));
}
}
}
Some(InstallSpec::Pip(pip)) => {
validate_argument_value(component, "pip package", &pip.package)?;
validate_argument_value(component, "pip version", &pip.version)?;
validate_argument_value(component, "Python program", &pip.python)?;
if pip
.version
.contains(['<', '>', '=', '!', '~', '^', '*', ',', ' '])
{
return Err(ForgeError::Config(format!(
"component {component} pip version must be exact"
)));
}
validate_managed_install_path(component, "pip environment", &pip.environment)?;
if let Some(index) = &pip.index {
validate_argument_value(component, "pip index", index)?;
if !index.starts_with("https://") {
return Err(ForgeError::Config(format!(
"component {component} pip index must use HTTPS"
)));
}
}
}
Some(InstallSpec::UvTool(uv)) => {
validate_argument_value(component, "uv package", &uv.package)?;
if uv.package.starts_with("git+") && !uv.package.starts_with("git+https://") {
return Err(ForgeError::Config(format!(
"component {component} uv Git package must use git+https://"
)));
}
if uv.bins.is_empty() {
return Err(ForgeError::Config(format!(
"component {component} uv-tool backend must declare bins"
)));
}
for bin in &uv.bins {
validate_id("uv tool binary", bin)?;
}
if let Some(index) = &uv.index {
validate_argument_value(component, "uv index", index)?;
if !index.starts_with("https://") {
return Err(ForgeError::Config(format!(
"component {component} uv index must use HTTPS"
)));
}
}
}
Some(InstallSpec::Winget(winget)) => {
validate_argument_value(component, "winget package", &winget.package)?;
}
Some(InstallSpec::Archive(archive)) => {
if !archive.url.starts_with("https://") && !archive.url.starts_with("source:") {
return Err(ForgeError::Config(format!(
"component {component} archive URL must use HTTPS"
)));
}
if !valid_sha256(&archive.sha256) {
return Err(ForgeError::Config(format!(
"component {component} archive sha256 is invalid"
)));
}
let mut target_parts = archive.target.components();
let rooted_in_app_home = matches!(
target_parts.next(),
Some(std::path::Component::Normal(value)) if value == "$BOT_FORGE_HOME"
);
if !rooted_in_app_home
|| target_parts.clone().next().is_none()
|| target_parts.any(|part| {
matches!(
part,
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_)
)
})
{
return Err(ForgeError::Config(format!(
"component {component} archive target must be a safe path under $BOT_FORGE_HOME"
)));
}
if archive.format == ArchiveFormat::File && archive.strip_components != 0 {
return Err(ForgeError::Config(format!(
"component {component} file archive does not support strip_components"
)));
}
if archive.allow_links
&& !matches!(archive.format, ArchiveFormat::TarGz | ArchiveFormat::TarXz)
{
return Err(ForgeError::Config(format!(
"component {component} allow_links only applies to TAR archives"
)));
}
}
Some(InstallSpec::Git(git)) => {
if (!git.url.starts_with("https://") && !git.url.starts_with("source:"))
|| !valid_git_commit(&git.revision)
{
return Err(ForgeError::Config(format!(
"component {component} Git source must use HTTPS and pin a commit"
)));
}
if git.bins.is_empty() {
return Err(ForgeError::Config(format!(
"component {component} Git backend must map bin names to repository files"
)));
}
if git.subdirectory.as_ref().is_some_and(|path| {
path.is_absolute()
|| path.components().any(|part| {
matches!(
part,
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_)
)
})
}) {
return Err(ForgeError::Config(format!(
"component {component} Git subdirectory must be relative to the repository"
)));
}
for (name, path) in &git.bins {
validate_id("Git binary", name)?;
if path.is_absolute()
|| path
.components()
.any(|part| matches!(part, std::path::Component::ParentDir))
{
return Err(ForgeError::Config(format!(
"component {component} Git binary path must be relative to the repository"
)));
}
}
}
Some(InstallSpec::Shell(shell)) => {
if !config.policy.allow_shell {
return Err(ForgeError::Config(format!(
"component {component} uses a shell backend but policy.allow_shell=false"
)));
}
if shell.command.trim().is_empty() || shell.resources.is_empty() {
return Err(ForgeError::Config(format!(
"component {component} shell backend must declare command and resources"
)));
}
validate_timeout(component, shell.timeout_secs)?;
validate_timeout(component, shell.inactivity_timeout_secs)?;
}
_ => {}
}
Ok(())
}
fn valid_git_commit(value: &str) -> bool {
(7..=64).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn validate_managed_install_path(
component: &str,
field: &str,
path: &Path,
) -> Result<(), ForgeError> {
let mut parts = path.components();
let managed = matches!(
parts.next(),
Some(std::path::Component::Normal(value)) if value == "$BOT_FORGE_HOME"
) && parts.clone().next().is_some()
&& !parts.any(|part| {
matches!(
part,
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_)
)
});
if managed {
Ok(())
} else {
Err(ForgeError::Config(format!(
"component {component} {field} must be a safe path under $BOT_FORGE_HOME"
)))
}
}
fn validate_timeout(component: &str, timeout: Option<u64>) -> Result<(), ForgeError> {
if timeout == Some(0) {
return Err(ForgeError::Config(format!(
"component {component} timeout must be greater than zero"
)));
}
Ok(())
}
fn validate_argument_value(component: &str, field: &str, value: &str) -> Result<(), ForgeError> {
if value.trim().is_empty() || value.starts_with('-') || value.contains(['\0', '\n', '\r']) {
return Err(ForgeError::Config(format!(
"component {component} {field} cannot be empty, start with -, or contain control newlines"
)));
}
Ok(())
}
fn validate_insecure_host(component: &str, host: &str) -> Result<(), ForgeError> {
validate_argument_value(component, "allow_insecure_hosts", host)?;
if host == "*" {
return Ok(());
}
if host.contains("://")
|| host.contains(['/', '\\', '?', '#'])
|| host.chars().any(char::is_whitespace)
{
return Err(ForgeError::Config(format!(
"component {component} allow_insecure_hosts must contain host names or IP addresses with optional ports, without protocols, paths, or whitespace"
)));
}
Ok(())
}
fn validate_insecure_host_backends(component: &ComponentDef) -> Result<(), ForgeError> {
if component.allow_insecure_hosts.is_empty() {
return Ok(());
}
let mut found_install = false;
for install in std::iter::once(component.install.as_ref())
.chain(
component
.variants
.iter()
.filter_map(|variant| variant.install.as_ref())
.map(Some),
)
.flatten()
{
found_install = true;
match install {
InstallSpec::Pip(_) | InstallSpec::UvTool(_) => {}
_ => {
return Err(ForgeError::Config(format!(
"component {} allow_insecure_hosts only supports the pip and uv-tool backends",
component.id
)));
}
}
}
if !found_install {
return Err(ForgeError::Config(format!(
"component {} configures allow_insecure_hosts but has no pip or uv-tool install",
component.id
)));
}
Ok(())
}
fn validate_platforms(component: &str, platforms: &[String]) -> Result<(), ForgeError> {
if platforms.is_empty() {
return Err(ForgeError::Config(format!(
"{component} platforms cannot be empty"
)));
}
for selector in platforms {
let valid = selector == "*"
|| matches!(selector.as_str(), "linux-*" | "windows-*" | "macos-*")
|| matches!(
selector.as_str(),
"linux-x86_64-gnu"
| "linux-aarch64-gnu"
| "windows-x86_64-msvc"
| "windows-aarch64-msvc"
| "macos-x86_64"
| "macos-aarch64"
);
if !valid {
return Err(ForgeError::Config(format!(
"{component} has an invalid platform selector: {selector}"
)));
}
}
Ok(())
}
fn validate_references<'a>(
component: &str,
references: impl Iterator<Item = &'a String>,
component_ids: &BTreeSet<&str>,
capabilities: &BTreeSet<&str>,
) -> Result<(), ForgeError> {
for reference in references {
if let Some(capability) = reference.strip_prefix("capability:") {
if !capabilities.contains(capability) {
return Err(ForgeError::Config(format!(
"component {component} references a capability without a provider: {capability}"
)));
}
} else {
let target = reference.strip_prefix("component:").unwrap_or(reference);
if !component_ids.contains(target) {
return Err(ForgeError::Config(format!(
"component {component} references a component that does not exist: {target}"
)));
}
}
}
Ok(())
}
fn visit_cycles<'a, I, F, J>(roots: I, edges: F, label: &str) -> Result<(), ForgeError>
where
I: IntoIterator<Item = &'a str>,
F: Fn(&'a str) -> J,
J: IntoIterator<Item = &'a str>,
{
fn visit<'a, F, J>(
node: &'a str,
edges: &F,
visiting: &mut Vec<&'a str>,
complete: &mut BTreeSet<&'a str>,
label: &str,
) -> Result<(), ForgeError>
where
F: Fn(&'a str) -> J,
J: IntoIterator<Item = &'a str>,
{
if complete.contains(node) {
return Ok(());
}
if let Some(index) = visiting.iter().position(|candidate| *candidate == node) {
let mut cycle = visiting[index..].to_vec();
cycle.push(node);
return Err(ForgeError::Config(format!(
"{label} contains a cycle: {}",
cycle.join(" -> ")
)));
}
visiting.push(node);
for dependency in edges(node) {
visit(dependency, edges, visiting, complete, label)?;
}
visiting.pop();
complete.insert(node);
Ok(())
}
let mut complete = BTreeSet::new();
for root in roots {
visit(root, &edges, &mut Vec::new(), &mut complete, label)?;
}
Ok(())
}
fn valid_sha256(value: &str) -> bool {
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn valid_exact_cargo_version(value: &str) -> bool {
value
.strip_prefix('=')
.is_some_and(|version| semver::Version::parse(version).is_ok())
}
fn normalize_exact_cargo_version(value: &str) -> Option<String> {
let version = value.strip_prefix('=').unwrap_or(value);
semver::Version::parse(version)
.ok()
.map(|version| format!("={version}"))
}
fn profile_topological_order(config: &ConfigDocument) -> Result<Vec<String>, ForgeError> {
fn visit(
name: &str,
config: &ConfigDocument,
visiting: &mut BTreeSet<String>,
visited: &mut BTreeSet<String>,
output: &mut Vec<String>,
) -> Result<(), ForgeError> {
if visited.contains(name) {
return Ok(());
}
if !visiting.insert(name.to_string()) {
return Err(ForgeError::Config(format!(
"profile inheritance contains a cycle: {name}"
)));
}
let profile = config
.profiles
.get(name)
.ok_or_else(|| ForgeError::Config(format!("profile does not exist: {name}")))?;
for parent in &profile.inherits {
visit(parent, config, visiting, visited, output)?;
}
visiting.remove(name);
visited.insert(name.to_string());
output.push(name.to_string());
Ok(())
}
let mut output = Vec::new();
let mut visiting = BTreeSet::new();
let mut visited = BTreeSet::new();
for name in config.profiles.keys() {
visit(name, config, &mut visiting, &mut visited, &mut output)?;
}
Ok(output)
}
fn effective_profile_components(
config: &ConfigDocument,
name: &str,
) -> Result<Vec<String>, ForgeError> {
fn expand(
config: &ConfigDocument,
name: &str,
output: &mut Vec<String>,
) -> Result<(), ForgeError> {
let profile = config
.profiles
.get(name)
.ok_or_else(|| ForgeError::Config(format!("profile does not exist: {name}")))?;
for parent in &profile.inherits {
expand(config, parent, output)?;
}
for entry in &profile.components {
for component in config.expand_profile_entry(entry)? {
if !output.contains(&component) {
output.push(component);
}
}
}
Ok(())
}
let mut output = Vec::new();
expand(config, name, &mut output)?;
Ok(output)
}
fn reject_ambiguous_profile_modifiers(layer: &toml::Value, origin: &str) -> Result<(), ForgeError> {
let Some(profiles) = layer.get("profiles").and_then(toml::Value::as_table) else {
return Ok(());
};
for (name, value) in profiles {
let Some(profile) = value.as_table() else {
continue;
};
let modifies = profile.contains_key("add") || profile.contains_key("remove");
let replaces = profile.contains_key("inherits") || profile.contains_key("components");
if modifies && replaces {
return Err(ForgeError::Config(format!(
"{origin} profiles.{name} cannot mix add/remove with inherits/components at the same layer"
)));
}
}
Ok(())
}
fn merge_values(
path: &str,
base: &mut toml::Value,
overlay: toml::Value,
origin: &str,
origins: &mut OriginMap,
) -> Result<(), ForgeError> {
match (base, overlay) {
(toml::Value::Table(base), toml::Value::Table(overlay)) => {
for (key, value) in overlay {
let child = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
if let Some(existing) = base.get_mut(&key) {
merge_values(&child, existing, value, origin, origins)?;
} else {
base.insert(key, value);
origins.record(child, origin);
}
}
}
(toml::Value::Array(base), toml::Value::Array(overlay))
if matches!(path, "components" | "environment.mutations") =>
{
for value in overlay {
let id = value
.get("id")
.and_then(toml::Value::as_str)
.ok_or_else(|| ForgeError::Config(format!("{path} overlay is missing id")))?;
let record_path = format!("{path}.{id}");
if let Some(index) = base
.iter()
.position(|item| item.get("id").and_then(toml::Value::as_str) == Some(id))
{
base[index] = value;
} else {
base.push(value);
}
origins.record(record_path, origin);
}
}
(base, overlay) => {
*base = overlay;
origins.record(path, origin);
}
}
Ok(())
}
fn hex_digest(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
#[cfg(test)]
#[path = "schema/tests.rs"]
mod tests;