1use std::collections::{BTreeMap, BTreeSet};
7use std::path::{Path, PathBuf};
8use std::sync::{Mutex, OnceLock};
9
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13
14use crate::error::ForgeError;
15use crate::model::{Agent, ArchiveFormat, InstallKind};
16use crate::util::{looks_like_git, resolve_command, valid_config_id};
17
18#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
19#[serde(default, deny_unknown_fields)]
20pub struct ConfigDocument {
22 pub policy: Policy,
24 #[serde(default, skip_serializing_if = "PreflightDef::is_empty")]
26 pub preflight: PreflightDef,
27 #[serde(default, skip_serializing_if = "String::is_empty")]
29 pub catalog: String,
30 #[schemars(skip)]
32 #[serde(
33 rename = "catalog-digest",
34 default,
35 skip_serializing_if = "String::is_empty"
36 )]
37 pub catalog_digest: String,
38 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
40 pub versions: BTreeMap<String, String>,
41 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
43 pub groups: BTreeMap<String, Vec<String>>,
44 #[serde(
46 rename = "cargo-tools",
47 default,
48 skip_serializing_if = "BTreeMap::is_empty"
49 )]
50 pub cargo_tools: BTreeMap<String, CargoToolInput>,
51 #[serde(
52 rename = "cargo-toolsets",
53 default,
54 skip_serializing_if = "BTreeMap::is_empty"
55 )]
56 pub cargo_toolsets: BTreeMap<String, BTreeMap<String, CargoToolInput>>,
58 #[serde(
60 rename = "package-tools",
61 default,
62 skip_serializing_if = "BTreeMap::is_empty"
63 )]
64 pub package_tools: BTreeMap<String, PackageToolDef>,
65 #[serde(
67 rename = "rustup-tools",
68 default,
69 skip_serializing_if = "BTreeMap::is_empty"
70 )]
71 pub rustup_tools: BTreeMap<String, RustupToolInput>,
72 pub sources: BTreeMap<String, SourceDef>,
74 pub profiles: BTreeMap<String, ProfileDef>,
76 pub components: Vec<ComponentDef>,
78 pub environment: EnvironmentDef,
80 pub apt_mirror: Option<AptMirrorDef>,
82 pub include: Vec<PathBuf>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
87#[serde(default, deny_unknown_fields)]
88pub struct Policy {
90 pub network: NetworkPolicy,
92 pub allow_shell: bool,
94 pub allow_unlocked_cargo: bool,
96 #[schemars(range(min = 1, max = 256))]
98 pub max_parallel: usize,
99 #[schemars(range(min = 1, max = 256))]
101 pub max_downloads: usize,
102 #[schemars(range(min = 512, max = 4294967295_u64))]
104 pub max_memory_mib: Option<u64>,
105}
106
107impl Default for Policy {
108 fn default() -> Self {
109 Self {
110 network: NetworkPolicy::Online,
111 allow_shell: false,
112 allow_unlocked_cargo: false,
113 max_parallel: 8,
114 max_downloads: 4,
115 max_memory_mib: None,
116 }
117 }
118}
119
120#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
121#[serde(rename_all = "kebab-case")]
122pub enum NetworkPolicy {
124 #[default]
126 Online,
127 CacheOnly,
129 Offline,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
134#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
135pub enum SourceDef {
137 CargoRegistry {
139 url: String,
141 },
142 ArchiveMirror {
144 base_url: String,
146 },
147 Git {
149 url: String,
151 },
152 NpmRegistry {
154 url: String,
156 },
157}
158
159#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
160#[serde(default, deny_unknown_fields)]
161pub struct ProfileDef {
163 pub inherits: Vec<String>,
165 pub components: Vec<String>,
167 #[serde(default, skip_serializing_if = "Vec::is_empty")]
169 pub add: Vec<String>,
170 #[serde(default, skip_serializing_if = "Vec::is_empty")]
172 pub remove: Vec<String>,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
177#[serde(untagged)]
178pub enum CargoToolInput {
179 Version(String),
181 Detailed(Box<CargoToolDef>),
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
187#[serde(deny_unknown_fields)]
188pub struct CargoToolDef {
189 pub version: String,
191 #[serde(rename = "crate", default)]
193 pub crate_name: Option<String>,
194 #[serde(default)]
196 pub bin: Option<String>,
197 #[serde(default)]
199 pub bins: Vec<String>,
200 #[serde(default)]
202 pub detect: Option<Vec<String>>,
203 #[serde(default)]
205 pub platforms: Vec<String>,
206 #[serde(default)]
208 pub requires: Vec<String>,
209 #[serde(default)]
211 pub provides: Vec<String>,
212 #[serde(default)]
214 pub conflicts: Vec<String>,
215 #[serde(default)]
217 pub optional: bool,
218 #[serde(default)]
220 pub features: Vec<String>,
221 #[serde(default)]
223 pub target: Option<String>,
224 #[serde(default)]
226 pub toolchain: Option<String>,
227 #[serde(default)]
229 pub profile: Option<String>,
230 #[serde(default)]
232 pub source: Option<String>,
233 #[serde(default)]
235 pub revision: Option<String>,
236 #[serde(default = "default_true")]
238 pub locked: bool,
239 #[serde(default)]
241 pub build_env_allow: Vec<String>,
242}
243
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
245#[serde(deny_unknown_fields)]
246pub struct PackageToolDef {
248 pub detect: CommandCheckInput,
250 #[serde(default)]
252 pub apt: Vec<String>,
253 #[serde(default)]
255 pub apt_update: bool,
256 #[serde(default)]
258 pub brew: Vec<String>,
259 #[serde(default)]
261 pub brew_detect: Option<CommandCheckInput>,
262 #[serde(default)]
264 pub winget: Option<String>,
265 #[serde(default)]
267 pub winget_arguments: Vec<String>,
268 #[serde(default)]
270 pub winget_detect: Option<CommandCheckInput>,
271 #[serde(default)]
273 pub display_name: Option<String>,
274 #[serde(default)]
276 pub requires: Vec<String>,
277 #[serde(default)]
279 pub provides: Vec<String>,
280 #[serde(default)]
282 pub conflicts: Vec<String>,
283 #[serde(default)]
285 pub optional: bool,
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
289#[serde(untagged)]
290pub enum RustupToolInput {
292 Component(String),
294 Detailed(Box<RustupToolDef>),
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
300#[serde(untagged)]
301pub enum CommandCheckInput {
302 Command(Vec<String>),
304 All(Vec<Vec<String>>),
306}
307
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
310#[serde(deny_unknown_fields)]
311pub struct RustupToolDef {
312 pub components: Vec<String>,
314 #[serde(default)]
316 pub detect: Option<CommandCheckInput>,
317 #[serde(rename = "detect-stdout-contains", default)]
319 pub detect_stdout_contains: Option<String>,
320 #[serde(default)]
322 pub toolchain: Option<String>,
323 #[serde(rename = "toolchain-ref", default)]
325 pub toolchain_ref: Option<String>,
326 #[serde(default)]
328 pub default: bool,
329 #[serde(default)]
331 pub bootstrap: Option<RustupBootstrap>,
332 #[serde(default)]
334 pub display_name: Option<String>,
335 #[serde(default)]
337 pub requires: Vec<String>,
338 #[serde(default)]
340 pub provides: Vec<String>,
341 #[serde(default)]
343 pub conflicts: Vec<String>,
344 #[serde(default)]
346 pub optional: bool,
347}
348
349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
350#[serde(deny_unknown_fields)]
351pub struct ComponentDef {
353 pub id: String,
355 #[serde(default = "default_component_kind")]
357 pub kind: InstallKind,
358 #[serde(default)]
360 pub display_name: Option<String>,
361 #[serde(default)]
363 pub version: Option<String>,
364 #[serde(default)]
366 pub requires: Vec<String>,
367 #[serde(default)]
369 pub provides: Vec<String>,
370 #[serde(default)]
372 pub conflicts: Vec<String>,
373 #[serde(default)]
375 pub optional: bool,
376 #[serde(default)]
379 pub allow_insecure_hosts: Vec<String>,
380 #[serde(default = "all_platforms")]
382 pub platforms: Vec<String>,
383 #[serde(default)]
385 pub detect: Option<CheckSpec>,
386 #[serde(default)]
388 pub install: Option<InstallSpec>,
389 #[serde(default)]
391 pub verify: Option<CheckSpec>,
392 #[serde(default)]
394 pub variants: Vec<VariantDef>,
395 #[serde(default)]
397 pub source: Option<String>,
398 #[serde(default)]
400 pub revision: Option<String>,
401 #[serde(default)]
403 pub agents: Vec<Agent>,
404}
405
406fn default_component_kind() -> InstallKind {
407 InstallKind::Tool
408}
409
410fn all_platforms() -> Vec<String> {
411 vec!["*".to_string()]
412}
413
414#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
415#[serde(deny_unknown_fields)]
416pub struct VariantDef {
418 pub id: String,
420 #[serde(default)]
422 pub version: Option<String>,
423 #[serde(default)]
425 pub requires: Vec<String>,
426 #[serde(default)]
428 pub provides: Vec<String>,
429 #[serde(default)]
431 pub conflicts: Vec<String>,
432 #[serde(default = "all_platforms")]
434 pub platforms: Vec<String>,
435 #[serde(default)]
437 pub detect: Option<CheckSpec>,
438 #[serde(default)]
440 pub install: Option<InstallSpec>,
441 #[serde(default)]
443 pub verify: Option<CheckSpec>,
444}
445
446#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
447#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
448pub enum CheckSpec {
450 Command {
452 program: String,
454 #[serde(default)]
456 args: Vec<String>,
457 #[serde(default, skip_serializing_if = "Option::is_none")]
459 stdout_contains: Option<String>,
460 #[serde(default = "zero_success")]
462 success_codes: Vec<i32>,
463 #[serde(default = "default_check_timeout")]
465 timeout_secs: Option<u64>,
466 },
467 Shell {
469 command: String,
471 #[serde(default = "default_check_timeout")]
473 timeout_secs: Option<u64>,
474 },
475 Path {
477 path: PathBuf,
479 },
480 All {
482 checks: Vec<CheckSpec>,
484 },
485}
486
487fn zero_success() -> Vec<i32> {
488 vec![0]
489}
490
491fn default_check_timeout() -> Option<u64> {
492 Some(10)
493}
494
495#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
496#[serde(tag = "backend", rename_all = "kebab-case", deny_unknown_fields)]
497pub enum InstallSpec {
499 Cargo(CargoInstall),
501 Apt(AptInstall),
503 Brew(BrewInstall),
505 Rustup(RustupInstall),
507 Npm(NpmInstall),
509 Pip(PipInstall),
511 UvTool(UvToolInstall),
513 Winget(WingetInstall),
515 Archive(ArchiveInstall),
517 Git(GitInstall),
519 Shell(ShellInstall),
521}
522
523#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
525#[serde(deny_unknown_fields)]
526pub struct CargoInstall {
527 #[serde(rename = "crate")]
529 pub crate_name: String,
530 pub version: String,
532 #[serde(default)]
534 pub source: Option<String>,
535 #[serde(default)]
537 pub revision: Option<String>,
538 #[serde(default = "default_true")]
540 pub locked: bool,
541 #[serde(default)]
543 pub features: Vec<String>,
544 #[serde(default)]
546 pub bins: Vec<String>,
547 #[serde(default)]
549 pub target: Option<String>,
550 #[serde(default)]
552 pub toolchain: Option<String>,
553 #[serde(default = "release_profile")]
555 pub profile: String,
556 #[serde(default)]
558 pub build_env_allow: Vec<String>,
559}
560
561fn default_true() -> bool {
562 true
563}
564
565fn release_profile() -> String {
566 "release".to_string()
567}
568
569#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
571#[serde(deny_unknown_fields)]
572pub struct AptInstall {
573 pub packages: Vec<String>,
575 #[serde(default)]
577 pub update: bool,
578}
579
580#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
582#[serde(deny_unknown_fields)]
583pub struct BrewInstall {
584 pub formulae: Vec<String>,
586}
587
588#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
590#[serde(deny_unknown_fields)]
591pub struct RustupInstall {
592 #[serde(default)]
594 pub toolchain: Option<String>,
595 #[serde(rename = "toolchain-ref", default)]
597 pub toolchain_ref: Option<String>,
598 #[serde(default)]
600 pub components: Vec<String>,
601 #[serde(default)]
603 pub default: bool,
604 #[serde(default)]
606 pub bootstrap: Option<RustupBootstrap>,
607}
608
609#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
611#[serde(deny_unknown_fields)]
612pub struct RustupBootstrap {
613 pub url: String,
615 pub sha256: BTreeMap<String, String>,
617}
618
619#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
621#[serde(deny_unknown_fields)]
622pub struct NpmInstall {
623 pub package: String,
625 pub version: String,
627 #[serde(default)]
629 pub source: Option<String>,
630}
631
632#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
634#[serde(deny_unknown_fields)]
635pub struct PipInstall {
636 pub package: String,
638 pub version: String,
640 #[serde(default = "default_python_program")]
642 pub python: String,
643 pub environment: PathBuf,
645 #[serde(default)]
647 pub index: Option<String>,
648}
649
650fn default_python_program() -> String {
651 "python3".to_string()
652}
653
654#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
656#[serde(deny_unknown_fields)]
657pub struct UvToolInstall {
658 pub package: String,
660 pub bins: Vec<String>,
662 #[serde(default)]
664 pub force: bool,
665 #[serde(default)]
667 pub index: Option<String>,
668}
669
670#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
672#[serde(deny_unknown_fields)]
673pub struct WingetInstall {
674 pub package: String,
676 #[serde(default)]
678 pub arguments: Vec<String>,
679}
680
681#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
683#[serde(deny_unknown_fields)]
684pub struct ArchiveInstall {
685 pub url: String,
687 pub sha256: String,
689 #[serde(default)]
691 pub format: ArchiveFormat,
692 pub target: PathBuf,
694 #[serde(default)]
696 pub strip_components: usize,
697 #[serde(default)]
699 pub allow_links: bool,
700}
701
702#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
703#[serde(default, deny_unknown_fields)]
704pub struct PreflightDef {
706 pub certificate: Option<CertificatePreflightDef>,
708}
709
710impl PreflightDef {
711 fn is_empty(&self) -> bool {
712 self.certificate.is_none()
713 }
714}
715
716#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
717#[serde(deny_unknown_fields)]
718pub struct CertificatePreflightDef {
720 #[serde(default = "all_platforms")]
722 pub platforms: Vec<String>,
723 pub detect: CheckSpec,
725 pub install: InstallSpec,
727 #[serde(default)]
729 pub verify: Option<CheckSpec>,
730}
731
732#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
734#[serde(deny_unknown_fields)]
735pub struct GitInstall {
736 pub url: String,
738 pub revision: String,
740 #[serde(default)]
742 pub subdirectory: Option<PathBuf>,
743 #[serde(default)]
745 pub bins: BTreeMap<String, PathBuf>,
746}
747
748#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
750#[serde(deny_unknown_fields)]
751pub struct ShellInstall {
752 pub command: String,
754 #[serde(default)]
756 pub resources: Vec<String>,
757 #[serde(default)]
759 pub rollback: Option<String>,
760 #[serde(default)]
762 pub timeout_secs: Option<u64>,
763 #[serde(default)]
765 pub inactivity_timeout_secs: Option<u64>,
766}
767
768#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
769#[serde(default, deny_unknown_fields)]
770pub struct EnvironmentDef {
772 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
774 pub paths: BTreeMap<String, String>,
775 pub mutations: Vec<EnvironmentMutation>,
777}
778
779#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
780#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
781pub enum EnvironmentMutation {
783 Variable {
785 id: String,
787 name: String,
789 value: String,
791 #[serde(default = "user_scope")]
793 scope: MutationScope,
794 #[serde(default = "all_platforms")]
796 platforms: Vec<String>,
797 },
798 PathPrepend {
800 id: String,
802 value: String,
804 #[serde(default = "user_scope")]
806 scope: MutationScope,
807 #[serde(default = "all_platforms")]
809 platforms: Vec<String>,
810 },
811 FileFragment {
813 id: String,
815 path: PathBuf,
817 lines: Vec<String>,
819 #[serde(default = "all_platforms")]
821 platforms: Vec<String>,
822 },
823}
824
825fn user_scope() -> MutationScope {
826 MutationScope::User
827}
828
829#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
830#[serde(rename_all = "kebab-case")]
831pub enum MutationScope {
833 Process,
835 User,
837}
838
839#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
841#[serde(deny_unknown_fields)]
842pub struct AptMirrorDef {
843 #[serde(default)]
845 pub uri: Option<String>,
846 #[serde(default)]
848 pub suites: Vec<String>,
849 #[serde(default)]
851 pub components: Vec<String>,
852 #[serde(default)]
854 pub architectures: Vec<String>,
855 #[serde(default)]
857 pub signed_by: Option<PathBuf>,
858 #[serde(default)]
860 pub source_file: Option<PathBuf>,
861 #[serde(default)]
863 pub rules: Vec<AptMirrorRuleDef>,
864}
865
866#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
868#[serde(default, deny_unknown_fields)]
869pub struct AptMirrorRuleDef {
870 pub distribution: Option<String>,
872 pub codename: Option<String>,
874 pub architecture: Option<String>,
876 pub uri: Option<String>,
878 pub suites: Vec<String>,
880 pub components: Vec<String>,
882 pub architectures: Vec<String>,
884 pub signed_by: Option<PathBuf>,
886 pub source_file: Option<PathBuf>,
888}
889
890#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
891pub struct OriginMap {
893 pub fields: BTreeMap<String, String>,
895}
896
897impl OriginMap {
898 pub(crate) fn record(&mut self, field: impl Into<String>, origin: impl Into<String>) {
900 self.fields.insert(field.into(), origin.into());
901 }
902}
903
904impl ConfigDocument {
905 pub fn parse(input: &str) -> Result<Self, ForgeError> {
912 let document = Self::parse_catalog(input)?;
913 if document.catalog.is_empty() {
914 return Err(ForgeError::Config(
915 "input configuration must declare catalog = \"rust-dev\"".to_string(),
916 ));
917 }
918 Ok(document)
919 }
920
921 pub(crate) fn parse_catalog(input: &str) -> Result<Self, ForgeError> {
922 let value: toml::Value = toml::from_str(input)
923 .map_err(|error| ForgeError::Parse(format!("invalid TOML: {error}")))?;
924 if value.get("catalog-digest").is_some() {
925 return Err(ForgeError::Config(
926 "input configuration cannot declare internal field catalog-digest".to_string(),
927 ));
928 }
929 reject_ambiguous_profile_modifiers(&value, "input")?;
930 let mut document: Self = toml::from_str(input)
931 .map_err(|error| ForgeError::Parse(format!("invalid TOML: {error}")))?;
932 document.expand_shorthands(false)?;
933 document.apply_profile_modifiers("input", &mut OriginMap::default())?;
934 document.validate()?;
935 Ok(document)
936 }
937
938 fn expand_cargo_tools(&mut self, replace_existing: bool) -> Result<Vec<String>, ForgeError> {
939 let tools = std::mem::take(&mut self.cargo_tools);
940 let mut expanded = Vec::with_capacity(tools.len());
941 for (id, input) in tools {
942 validate_id("cargo tool", &id)?;
943 let component = cargo_tool_component(&id, input)?;
944 if let Some(existing) = self.components.iter_mut().find(|item| item.id == id) {
945 if !replace_existing {
946 return Err(ForgeError::Config(format!(
947 "cargo-tools.{id} conflicts with a component of the same name"
948 )));
949 }
950 *existing = component;
951 } else {
952 self.components.push(component);
953 }
954 expanded.push(id);
955 }
956 self.components
957 .sort_by(|left, right| left.id.cmp(&right.id));
958 Ok(expanded)
959 }
960
961 fn expand_shorthands(
962 &mut self,
963 replace_existing: bool,
964 ) -> Result<Vec<(String, String)>, ForgeError> {
965 let mut expanded = self.expand_cargo_toolsets(replace_existing)?;
966 expanded.extend(
967 self.expand_cargo_tools(replace_existing)?
968 .into_iter()
969 .map(|id| (id, "cargo-tools".to_string())),
970 );
971 expanded.extend(
972 self.expand_package_tools(replace_existing)?
973 .into_iter()
974 .map(|id| (id, "package-tools".to_string())),
975 );
976 expanded.extend(
977 self.expand_rustup_tools(replace_existing)?
978 .into_iter()
979 .map(|id| (id, "rustup-tools".to_string())),
980 );
981 self.expand_environment_paths(replace_existing)?;
982 Ok(expanded)
983 }
984
985 fn expand_cargo_toolsets(
986 &mut self,
987 replace_existing: bool,
988 ) -> Result<Vec<(String, String)>, ForgeError> {
989 let toolsets = std::mem::take(&mut self.cargo_toolsets);
990 let mut expanded = Vec::new();
991 for (group, tools) in toolsets {
992 validate_id("cargo toolset", &group)?;
993 if tools.is_empty() {
994 return Err(ForgeError::Config(format!(
995 "cargo-toolsets.{group} cannot be empty"
996 )));
997 }
998 let members = self.groups.entry(group.clone()).or_default();
999 for (id, input) in tools {
1000 validate_id("cargo tool", &id)?;
1001 let component = cargo_tool_component(&id, input)?;
1002 replace_or_insert_component(
1003 &mut self.components,
1004 component,
1005 replace_existing,
1006 &id,
1007 )?;
1008 if !members.contains(&id) {
1009 members.push(id.clone());
1010 }
1011 expanded.push((id.clone(), format!("cargo-toolsets.{group}")));
1012 }
1013 }
1014 self.components
1015 .sort_by(|left, right| left.id.cmp(&right.id));
1016 Ok(expanded)
1017 }
1018
1019 fn expand_package_tools(&mut self, replace_existing: bool) -> Result<Vec<String>, ForgeError> {
1020 let tools = std::mem::take(&mut self.package_tools);
1021 let mut expanded = Vec::with_capacity(tools.len());
1022 for (id, input) in tools {
1023 validate_id("package tool", &id)?;
1024 let component = package_tool_component(&id, input)?;
1025 replace_or_insert_component(&mut self.components, component, replace_existing, &id)?;
1026 expanded.push(id);
1027 }
1028 self.components
1029 .sort_by(|left, right| left.id.cmp(&right.id));
1030 Ok(expanded)
1031 }
1032
1033 fn expand_rustup_tools(&mut self, replace_existing: bool) -> Result<Vec<String>, ForgeError> {
1034 let tools = std::mem::take(&mut self.rustup_tools);
1035 let mut expanded = Vec::with_capacity(tools.len());
1036 for (id, input) in tools {
1037 validate_id("rustup tool", &id)?;
1038 let component = rustup_tool_component(&id, input)?;
1039 replace_or_insert_component(&mut self.components, component, replace_existing, &id)?;
1040 expanded.push(id);
1041 }
1042 self.components
1043 .sort_by(|left, right| left.id.cmp(&right.id));
1044 Ok(expanded)
1045 }
1046
1047 fn expand_environment_paths(&mut self, replace_existing: bool) -> Result<(), ForgeError> {
1048 let paths = std::mem::take(&mut self.environment.paths);
1049 for (id, value) in paths {
1050 validate_id("environment path", &id)?;
1051 let mutation = EnvironmentMutation::PathPrepend {
1052 id: id.clone(),
1053 value,
1054 scope: MutationScope::User,
1055 platforms: all_platforms(),
1056 };
1057 if let Some(existing) = self
1058 .environment
1059 .mutations
1060 .iter_mut()
1061 .find(|mutation| mutation.id() == id)
1062 {
1063 if !replace_existing {
1064 return Err(ForgeError::Config(format!(
1065 "environment.paths.{id} conflicts with a mutation of the same name"
1066 )));
1067 }
1068 *existing = mutation;
1069 } else {
1070 self.environment.mutations.push(mutation);
1071 }
1072 }
1073 Ok(())
1074 }
1075
1076 pub fn validate(&self) -> Result<(), ForgeError> {
1083 if self.policy.max_parallel == 0
1084 || self.policy.max_parallel > 256
1085 || self.policy.max_downloads == 0
1086 || self.policy.max_downloads > 256
1087 {
1088 return Err(ForgeError::Config(
1089 "policy.max_parallel and max_downloads must be between 1 and 256".to_string(),
1090 ));
1091 }
1092 if self
1093 .policy
1094 .max_memory_mib
1095 .is_some_and(|memory| !(512..=u64::from(u32::MAX)).contains(&memory))
1096 {
1097 return Err(ForgeError::Config(format!(
1098 "policy.max_memory_mib must be between 512 and {}",
1099 u32::MAX
1100 )));
1101 }
1102 validate_ids(self)?;
1103 validate_sources(self)?;
1104 validate_catalogs(self)?;
1105 validate_preflight(self)?;
1106 validate_versions(self)?;
1107 validate_groups(self)?;
1108 validate_profiles(self)?;
1109 validate_components(self)?;
1110 validate_binary_providers(self)?;
1111 validate_dependency_graph(self)?;
1112 validate_environment(self)?;
1113 validate_apt_mirror(self.apt_mirror.as_ref())?;
1114 Ok(())
1115 }
1116
1117 pub fn merge_overlay_text(
1124 &mut self,
1125 input: &str,
1126 origin: &str,
1127 origins: &mut OriginMap,
1128 ) -> Result<(), ForgeError> {
1129 self.merge_layer_text(input, origin, origins, false)
1130 }
1131
1132 pub(crate) fn merge_primary_text(
1133 &mut self,
1134 input: &str,
1135 origin: &str,
1136 origins: &mut OriginMap,
1137 ) -> Result<(), ForgeError> {
1138 self.merge_layer_text(input, origin, origins, true)
1139 }
1140
1141 fn merge_layer_text(
1142 &mut self,
1143 input: &str,
1144 origin: &str,
1145 origins: &mut OriginMap,
1146 primary: bool,
1147 ) -> Result<(), ForgeError> {
1148 let overlay: toml::Value = toml::from_str(input)
1149 .map_err(|error| ForgeError::Parse(format!("invalid TOML: {error}")))?;
1150 reject_ambiguous_profile_modifiers(&overlay, origin)?;
1151 let forbidden = if primary {
1152 &["catalog-digest"][..]
1153 } else {
1154 &["catalog", "catalog-digest"][..]
1155 };
1156 for field in forbidden {
1157 if overlay.get(field).is_some() {
1158 return Err(ForgeError::Config(format!(
1159 "configuration layer {origin} cannot declare main-configuration-only field {field}"
1160 )));
1161 }
1162 }
1163 let mut effective = toml::Value::try_from(&*self).map_err(|error| {
1164 ForgeError::Config(format!("failed to merge configuration: {error}"))
1165 })?;
1166 merge_values("", &mut effective, overlay, origin, origins)?;
1167 let mut document: Self = effective
1168 .try_into()
1169 .map_err(|error| ForgeError::Parse(format!("invalid TOML: {error}")))?;
1170 let expanded = document.expand_shorthands(true)?;
1171 for (id, shorthand) in expanded {
1172 origins.record(
1173 format!("components.{id}"),
1174 format!("{origin} (expanded from {shorthand}.{id})"),
1175 );
1176 }
1177 document.apply_profile_modifiers(origin, origins)?;
1178 document.validate()?;
1179 *self = document;
1180 Ok(())
1181 }
1182
1183 pub fn component(&self, id: &str) -> Option<&ComponentDef> {
1185 self.components.iter().find(|component| component.id == id)
1186 }
1187
1188 pub(crate) fn expand_profile_entry(&self, entry: &str) -> Result<Vec<String>, ForgeError> {
1194 if let Some(group) = entry.strip_prefix("group:") {
1195 return self.groups.get(group).cloned().ok_or_else(|| {
1196 ForgeError::Config(format!(
1197 "profile references a group that does not exist: {group}"
1198 ))
1199 });
1200 }
1201 Ok(vec![entry.to_string()])
1202 }
1203
1204 pub(crate) fn resolve_version_references(&mut self) -> Result<(), ForgeError> {
1211 let versions = &self.versions;
1212 for component in &mut self.components {
1213 resolve_rustup_version(
1214 versions,
1215 component.install.as_mut(),
1216 &format!("component {}", component.id),
1217 )?;
1218 for variant in &mut component.variants {
1219 resolve_rustup_version(
1220 versions,
1221 variant.install.as_mut(),
1222 &format!("component {} variant {}", component.id, variant.id),
1223 )?;
1224 }
1225 }
1226 Ok(())
1227 }
1228
1229 pub(crate) fn resolve_source_references(
1236 &mut self,
1237 origins: &mut OriginMap,
1238 ) -> Result<(), ForgeError> {
1239 let mut referenced = BTreeSet::new();
1240 for component in &mut self.components {
1241 resolve_install_sources(
1242 &self.sources,
1243 &mut referenced,
1244 component.install.as_mut(),
1245 &format!("components.{}.install", component.id),
1246 origins,
1247 )?;
1248 for variant in &mut component.variants {
1249 resolve_install_sources(
1250 &self.sources,
1251 &mut referenced,
1252 variant.install.as_mut(),
1253 &format!(
1254 "components.{}.variants.{}.install",
1255 component.id, variant.id
1256 ),
1257 origins,
1258 )?;
1259 }
1260 }
1261 for name in self.sources.keys() {
1262 if !referenced.contains(name) {
1263 return Err(ForgeError::Config(format!(
1264 "sources.{name} is not referenced by any installation recipe"
1265 )));
1266 }
1267 }
1268 Ok(())
1269 }
1270
1271 fn apply_profile_modifiers(
1272 &mut self,
1273 origin: &str,
1274 origins: &mut OriginMap,
1275 ) -> Result<(), ForgeError> {
1276 let order = profile_topological_order(self)?;
1277 for name in order {
1278 let (add, remove) = {
1279 let profile = self.profiles.get_mut(&name).ok_or_else(|| {
1280 ForgeError::Config(format!(
1281 "profile topology contains an unknown profile: {name}"
1282 ))
1283 })?;
1284 (
1285 std::mem::take(&mut profile.add),
1286 std::mem::take(&mut profile.remove),
1287 )
1288 };
1289 if add.is_empty() && remove.is_empty() {
1290 continue;
1291 }
1292 let mut effective = effective_profile_components(self, &name)?;
1293 for entry in add {
1294 for component in self.expand_profile_entry(&entry)? {
1295 if !effective.contains(&component) {
1296 effective.push(component);
1297 }
1298 }
1299 }
1300 for entry in remove {
1301 for component in self.expand_profile_entry(&entry)? {
1302 let Some(index) = effective.iter().position(|item| item == &component) else {
1303 return Err(ForgeError::Config(format!(
1304 "profiles.{name}.remove references a component outside the valid profile: {component}"
1305 )));
1306 };
1307 effective.remove(index);
1308 }
1309 }
1310 let profile = self.profiles.get_mut(&name).ok_or_else(|| {
1311 ForgeError::Config(format!(
1312 "profile topology contains an unknown profile: {name}"
1313 ))
1314 })?;
1315 profile.inherits.clear();
1316 profile.components = effective;
1317 origins.record(
1318 format!("profiles.{name}.components"),
1319 format!("{origin} (expanded from profiles.{name}.add/remove)"),
1320 );
1321 }
1322 Ok(())
1323 }
1324}
1325
1326fn validate_preflight(config: &ConfigDocument) -> Result<(), ForgeError> {
1327 let Some(certificate) = config.preflight.certificate.as_ref() else {
1328 return Ok(());
1329 };
1330 let label = "preflight.certificate";
1331 validate_platforms(label, &certificate.platforms)?;
1332 validate_check(config, label, Some(&certificate.detect))?;
1333 validate_check(config, label, certificate.verify.as_ref())?;
1334 validate_install(config, label, Some(&certificate.install))?;
1335 if !matches!(certificate.install, InstallSpec::Shell(_)) {
1336 return Err(ForgeError::Config(
1337 "preflight.certificate.install only supports the shell backend".into(),
1338 ));
1339 }
1340 Ok(())
1341}
1342
1343fn resolve_rustup_version(
1344 versions: &BTreeMap<String, String>,
1345 install: Option<&mut InstallSpec>,
1346 owner: &str,
1347) -> Result<(), ForgeError> {
1348 let Some(InstallSpec::Rustup(rustup)) = install else {
1349 return Ok(());
1350 };
1351 let Some(reference) = rustup.toolchain_ref.take() else {
1352 return Ok(());
1353 };
1354 rustup.toolchain = Some(versions.get(&reference).cloned().ok_or_else(|| {
1355 ForgeError::Config(format!(
1356 "{owner} references a version that does not exist: {reference}"
1357 ))
1358 })?);
1359 Ok(())
1360}
1361
1362fn resolve_install_sources(
1363 sources: &BTreeMap<String, SourceDef>,
1364 referenced: &mut BTreeSet<String>,
1365 install: Option<&mut InstallSpec>,
1366 path: &str,
1367 origins: &mut OriginMap,
1368) -> Result<(), ForgeError> {
1369 match install {
1370 Some(InstallSpec::Cargo(cargo)) => {
1371 let Some(reference) = cargo.source.clone() else {
1372 return Ok(());
1373 };
1374 let Some(source) = sources.get(&reference) else {
1375 return Ok(());
1376 };
1377 referenced.insert(reference.clone());
1378 cargo.source = Some(match source {
1379 SourceDef::CargoRegistry { url } => format!("index+{url}"),
1380 SourceDef::Git { url } => format!("git+{url}"),
1381 _ => return Err(source_kind_error(&reference, "cargo-registry or git")),
1382 });
1383 origins.record(
1384 format!("{path}.source"),
1385 format!("expanded from sources.{reference}"),
1386 );
1387 }
1388 Some(InstallSpec::Npm(npm)) => {
1389 let Some(reference) = npm.source.clone() else {
1390 return Ok(());
1391 };
1392 let Some(source) = sources.get(&reference) else {
1393 return Ok(());
1394 };
1395 referenced.insert(reference.clone());
1396 npm.source = Some(match source {
1397 SourceDef::NpmRegistry { url } => url.clone(),
1398 _ => return Err(source_kind_error(&reference, "npm-registry")),
1399 });
1400 origins.record(
1401 format!("{path}.source"),
1402 format!("expanded from sources.{reference}"),
1403 );
1404 }
1405 Some(InstallSpec::Archive(archive)) => {
1406 let original = archive.url.clone();
1407 archive.url = resolve_source_url(
1408 sources,
1409 referenced,
1410 &archive.url,
1411 "archive-mirror",
1412 |source| match source {
1413 SourceDef::ArchiveMirror { base_url } => Some(base_url),
1414 _ => None,
1415 },
1416 )?;
1417 if let Some(reference) = original.strip_prefix("source:") {
1418 let name = reference.split('/').next().unwrap_or(reference);
1419 origins.record(
1420 format!("{path}.url"),
1421 format!("expanded from sources.{name}"),
1422 );
1423 }
1424 }
1425 Some(InstallSpec::Git(git)) => {
1426 let original = git.url.clone();
1427 git.url = resolve_source_url(
1428 sources,
1429 referenced,
1430 &git.url,
1431 "git",
1432 |source| match source {
1433 SourceDef::Git { url } => Some(url),
1434 _ => None,
1435 },
1436 )?;
1437 if let Some(reference) = original.strip_prefix("source:") {
1438 let name = reference.split('/').next().unwrap_or(reference);
1439 origins.record(
1440 format!("{path}.url"),
1441 format!("expanded from sources.{name}"),
1442 );
1443 }
1444 }
1445 _ => {}
1446 }
1447 Ok(())
1448}
1449
1450fn resolve_source_url<'a>(
1451 sources: &'a BTreeMap<String, SourceDef>,
1452 referenced: &mut BTreeSet<String>,
1453 value: &str,
1454 expected: &str,
1455 base_url: impl FnOnce(&'a SourceDef) -> Option<&'a String>,
1456) -> Result<String, ForgeError> {
1457 let Some(reference) = value.strip_prefix("source:") else {
1458 return Ok(value.to_string());
1459 };
1460 let (name, suffix) = reference.split_once('/').unwrap_or((reference, ""));
1461 let source = sources.get(name).ok_or_else(|| {
1462 ForgeError::Config(format!("references a source that does not exist: {name}"))
1463 })?;
1464 let base = base_url(source).ok_or_else(|| source_kind_error(name, expected))?;
1465 referenced.insert(name.to_string());
1466 if suffix.is_empty() {
1467 Ok(base.clone())
1468 } else {
1469 Ok(format!("{}/{}", base.trim_end_matches('/'), suffix))
1470 }
1471}
1472
1473fn source_kind_error(name: &str, expected: &str) -> ForgeError {
1474 ForgeError::Config(format!(
1475 "sources.{name} has the wrong type; expected {expected}"
1476 ))
1477}
1478
1479fn cargo_tool_component(id: &str, input: CargoToolInput) -> Result<ComponentDef, ForgeError> {
1480 let mut detail = match input {
1481 CargoToolInput::Version(version) => CargoToolDef {
1482 version,
1483 crate_name: None,
1484 bin: None,
1485 bins: Vec::new(),
1486 detect: None,
1487 platforms: Vec::new(),
1488 requires: Vec::new(),
1489 provides: Vec::new(),
1490 conflicts: Vec::new(),
1491 optional: false,
1492 features: Vec::new(),
1493 target: None,
1494 toolchain: None,
1495 profile: None,
1496 source: None,
1497 revision: None,
1498 locked: true,
1499 build_env_allow: Vec::new(),
1500 },
1501 CargoToolInput::Detailed(detail) => *detail,
1502 };
1503 let Some(version) = normalize_exact_cargo_version(&detail.version) else {
1504 return Err(ForgeError::Config(format!(
1505 "cargo-tools.{id}.version must be an exact x.y.z or =x.y.z version"
1506 )));
1507 };
1508 detail.version = version;
1509 if detail.bin.is_some() && !detail.bins.is_empty() {
1510 return Err(ForgeError::Config(format!(
1511 "cargo-tools.{id} cannot declare both bin and bins"
1512 )));
1513 }
1514 let bins = detail
1515 .bin
1516 .clone()
1517 .map(|bin| vec![bin])
1518 .or_else(|| (!detail.bins.is_empty()).then(|| detail.bins.clone()))
1519 .unwrap_or_else(|| vec![id.to_string()]);
1520 if bins.iter().any(|bin| bin.trim().is_empty()) {
1521 return Err(ForgeError::Config(format!(
1522 "cargo-tools.{id} binary name cannot be empty"
1523 )));
1524 }
1525 let detect_command = detail.detect.clone().unwrap_or_else(|| {
1526 id.strip_prefix("cargo-").map_or_else(
1527 || vec![bins[0].clone(), "--version".to_string()],
1528 |subcommand| {
1529 vec![
1530 "cargo".to_string(),
1531 subcommand.to_string(),
1532 "--version".to_string(),
1533 ]
1534 },
1535 )
1536 });
1537 let mut detect_command = detect_command;
1538 if let Some(toolchain) = detail.toolchain.as_ref()
1539 && detect_command
1540 .first()
1541 .is_some_and(|program| program == "cargo")
1542 {
1543 detect_command.insert(1, format!("+{toolchain}"));
1544 }
1545 let (program, args) = detect_command
1546 .split_first()
1547 .ok_or_else(|| ForgeError::Config(format!("cargo-tools.{id}.detect cannot be empty")))?;
1548 let platforms = if detail.platforms.is_empty() {
1549 all_platforms()
1550 } else {
1551 detail.platforms
1552 };
1553 Ok(ComponentDef {
1554 id: id.to_string(),
1555 kind: default_component_kind(),
1556 display_name: None,
1557 version: None,
1558 requires: detail.requires,
1559 provides: detail.provides,
1560 conflicts: detail.conflicts,
1561 optional: detail.optional,
1562 allow_insecure_hosts: Vec::new(),
1563 platforms,
1564 detect: Some(CheckSpec::Command {
1565 program: program.clone(),
1566 args: args.to_vec(),
1567 stdout_contains: None,
1568 success_codes: zero_success(),
1569 timeout_secs: Some(10),
1570 }),
1571 install: Some(InstallSpec::Cargo(CargoInstall {
1572 crate_name: detail.crate_name.unwrap_or_else(|| id.to_string()),
1573 version: detail.version,
1574 source: detail.source,
1575 revision: detail.revision,
1576 locked: detail.locked,
1577 features: detail.features,
1578 bins,
1579 target: detail.target,
1580 toolchain: detail.toolchain,
1581 profile: detail.profile.unwrap_or_else(release_profile),
1582 build_env_allow: detail.build_env_allow,
1583 })),
1584 verify: None,
1585 variants: Vec::new(),
1586 source: None,
1587 revision: None,
1588 agents: Vec::new(),
1589 })
1590}
1591
1592fn replace_or_insert_component(
1593 components: &mut Vec<ComponentDef>,
1594 component: ComponentDef,
1595 replace_existing: bool,
1596 id: &str,
1597) -> Result<(), ForgeError> {
1598 if let Some(existing) = components.iter_mut().find(|item| item.id == id) {
1599 if !replace_existing {
1600 return Err(ForgeError::Config(format!(
1601 "shorthand {id} conflicts with a component of the same name"
1602 )));
1603 }
1604 *existing = component;
1605 } else {
1606 components.push(component);
1607 }
1608 Ok(())
1609}
1610
1611fn command_check(path: &str, command: &[String]) -> Result<CheckSpec, ForgeError> {
1612 let (program, args) = command
1613 .split_first()
1614 .ok_or_else(|| ForgeError::Config(format!("{path} cannot be empty")))?;
1615 Ok(CheckSpec::Command {
1616 program: program.clone(),
1617 args: args.to_vec(),
1618 stdout_contains: None,
1619 success_codes: zero_success(),
1620 timeout_secs: default_check_timeout(),
1621 })
1622}
1623
1624fn command_check_input(path: &str, input: CommandCheckInput) -> Result<CheckSpec, ForgeError> {
1625 match input {
1626 CommandCheckInput::Command(command) => command_check(path, &command),
1627 CommandCheckInput::All(commands) => {
1628 if commands.is_empty() {
1629 return Err(ForgeError::Config(format!("{path} cannot be empty")));
1630 }
1631 Ok(CheckSpec::All {
1632 checks: commands
1633 .iter()
1634 .enumerate()
1635 .map(|(index, command)| command_check(&format!("{path}[{index}]"), command))
1636 .collect::<Result<Vec<_>, _>>()?,
1637 })
1638 }
1639 }
1640}
1641
1642fn package_tool_component(id: &str, detail: PackageToolDef) -> Result<ComponentDef, ForgeError> {
1643 if detail.apt.is_empty() && detail.brew.is_empty() && detail.winget.is_none() {
1644 return Err(ForgeError::Config(format!(
1645 "package-tools.{id} must declare apt, brew, or winget; use a full component for complex recipes"
1646 )));
1647 }
1648 if detail.brew.is_empty() && detail.brew_detect.is_some() {
1649 return Err(ForgeError::Config(format!(
1650 "package-tools.{id} can use brew_detect only when brew is declared"
1651 )));
1652 }
1653 if detail.winget.is_none()
1654 && (!detail.winget_arguments.is_empty() || detail.winget_detect.is_some())
1655 {
1656 return Err(ForgeError::Config(format!(
1657 "package-tools.{id} can use winget_arguments/winget_detect only when winget is declared"
1658 )));
1659 }
1660 let detect = command_check_input(&format!("package-tools.{id}.detect"), detail.detect)?;
1661 let mut backends = Vec::new();
1662 if !detail.apt.is_empty() {
1663 backends.push(VariantDef {
1664 id: "linux-apt".to_string(),
1665 version: None,
1666 requires: Vec::new(),
1667 provides: detail.provides.clone(),
1668 conflicts: Vec::new(),
1669 platforms: vec!["linux-*".to_string()],
1670 detect: Some(detect.clone()),
1671 install: Some(InstallSpec::Apt(AptInstall {
1672 packages: detail.apt.clone(),
1673 update: detail.apt_update,
1674 })),
1675 verify: None,
1676 });
1677 }
1678 if !detail.brew.is_empty() {
1679 backends.push(VariantDef {
1680 id: "macos-brew".to_string(),
1681 version: None,
1682 requires: Vec::new(),
1683 provides: detail.provides.clone(),
1684 conflicts: Vec::new(),
1685 platforms: vec!["macos-*".to_string()],
1686 detect: detail
1687 .brew_detect
1688 .map(|command| {
1689 command_check_input(&format!("package-tools.{id}.brew_detect"), command)
1690 })
1691 .transpose()?,
1692 install: Some(InstallSpec::Brew(BrewInstall {
1693 formulae: detail.brew,
1694 })),
1695 verify: None,
1696 });
1697 }
1698 if let Some(package) = &detail.winget {
1699 backends.push(VariantDef {
1700 id: "windows-winget".to_string(),
1701 version: None,
1702 requires: Vec::new(),
1703 provides: detail.provides.clone(),
1704 conflicts: Vec::new(),
1705 platforms: vec!["windows-*".to_string()],
1706 detect: detail
1707 .winget_detect
1708 .map(|command| {
1709 command_check_input(&format!("package-tools.{id}.winget_detect"), command)
1710 })
1711 .transpose()?,
1712 install: Some(InstallSpec::Winget(WingetInstall {
1713 package: package.clone(),
1714 arguments: detail.winget_arguments.clone(),
1715 })),
1716 verify: None,
1717 });
1718 }
1719 let mut base = backends.remove(0);
1720 Ok(ComponentDef {
1721 id: id.to_string(),
1722 kind: default_component_kind(),
1723 display_name: detail.display_name,
1724 version: None,
1725 requires: detail.requires,
1726 provides: detail.provides,
1727 conflicts: detail.conflicts,
1728 optional: detail.optional,
1729 allow_insecure_hosts: Vec::new(),
1730 platforms: base.platforms,
1731 detect: base.detect.take().or(Some(detect)),
1732 install: base.install,
1733 verify: None,
1734 variants: backends,
1735 source: None,
1736 revision: None,
1737 agents: Vec::new(),
1738 })
1739}
1740
1741fn rustup_tool_component(id: &str, input: RustupToolInput) -> Result<ComponentDef, ForgeError> {
1742 let detail = match input {
1743 RustupToolInput::Component(component) => RustupToolDef {
1744 components: vec![component],
1745 detect: None,
1746 detect_stdout_contains: None,
1747 toolchain: None,
1748 toolchain_ref: None,
1749 default: false,
1750 bootstrap: None,
1751 display_name: None,
1752 requires: Vec::new(),
1753 provides: Vec::new(),
1754 conflicts: Vec::new(),
1755 optional: false,
1756 },
1757 RustupToolInput::Detailed(detail) => *detail,
1758 };
1759 if detail.components.is_empty() {
1760 return Err(ForgeError::Config(format!(
1761 "rustup-tools.{id}.components cannot be empty"
1762 )));
1763 }
1764 let detect_input = detail
1765 .detect
1766 .unwrap_or_else(|| CommandCheckInput::Command(vec![id.to_string(), "--version".into()]));
1767 let mut detect = command_check_input(&format!("rustup-tools.{id}.detect"), detect_input)?;
1768 if let Some(needle) = detail.detect_stdout_contains {
1769 match &mut detect {
1770 CheckSpec::Command {
1771 stdout_contains, ..
1772 } => *stdout_contains = Some(needle),
1773 CheckSpec::All { .. } => {
1774 return Err(ForgeError::Config(format!(
1775 "rustup-tools.{id}.detect-stdout-contains can only be used with one detect command"
1776 )));
1777 }
1778 _ => unreachable!("rustup shorthand only creates command checks"),
1779 }
1780 }
1781 Ok(ComponentDef {
1782 id: id.to_string(),
1783 kind: default_component_kind(),
1784 display_name: detail.display_name,
1785 version: None,
1786 requires: detail.requires,
1787 provides: detail.provides,
1788 conflicts: detail.conflicts,
1789 optional: detail.optional,
1790 allow_insecure_hosts: Vec::new(),
1791 platforms: all_platforms(),
1792 detect: Some(detect),
1793 install: Some(InstallSpec::Rustup(RustupInstall {
1794 toolchain: detail.toolchain,
1795 toolchain_ref: detail.toolchain_ref,
1796 components: detail.components,
1797 default: detail.default,
1798 bootstrap: detail.bootstrap,
1799 })),
1800 verify: None,
1801 variants: Vec::new(),
1802 source: None,
1803 revision: None,
1804 agents: Vec::new(),
1805 })
1806}
1807
1808impl EnvironmentMutation {
1809 pub fn id(&self) -> &str {
1811 match self {
1812 Self::Variable { id, .. }
1813 | Self::PathPrepend { id, .. }
1814 | Self::FileFragment { id, .. } => id,
1815 }
1816 }
1817
1818 pub fn platforms(&self) -> &[String] {
1820 match self {
1821 Self::Variable { platforms, .. }
1822 | Self::PathPrepend { platforms, .. }
1823 | Self::FileFragment { platforms, .. } => platforms,
1824 }
1825 }
1826}
1827
1828impl CargoInstall {
1829 pub(crate) fn cache_digest(&self) -> String {
1831 let source = self.source.as_deref().unwrap_or("crates.io");
1832 let identity = if source.starts_with("git+") || source.starts_with("https://") {
1833 format!("git={}", source.strip_prefix("git+").unwrap_or(source))
1834 } else {
1835 format!("registry={source}")
1836 };
1837 hex_digest(Sha256::digest(identity.as_bytes()).as_slice())
1838 }
1839
1840 pub(crate) fn source_digest(&self) -> String {
1842 let version = self.version.trim_start_matches('=');
1843 let identity = format!(
1844 "crate={}\nversion={}\nsource={}\nrevision={}\n",
1845 self.crate_name,
1846 version,
1847 self.source.as_deref().unwrap_or("crates.io"),
1848 self.revision.as_deref().unwrap_or("registry")
1849 );
1850 hex_digest(Sha256::digest(identity.as_bytes()).as_slice())
1851 }
1852
1853 pub(crate) fn lock_digest(&self) -> String {
1855 hex_digest(
1856 Sha256::digest(
1857 format!(
1858 "{}@{}:locked={}",
1859 self.crate_name,
1860 self.version.trim_start_matches('='),
1861 self.locked
1862 )
1863 .as_bytes(),
1864 )
1865 .as_slice(),
1866 )
1867 }
1868
1869 pub(crate) fn fingerprint(&self, source_digest: &str, lock_digest: &str) -> String {
1871 self.fingerprint_with_build_identity(
1872 source_digest,
1873 lock_digest,
1874 "planning",
1875 &self.build_environment_digest(false),
1876 )
1877 }
1878
1879 pub(crate) fn artifact_fingerprint(&self, source_digest: &str, lock_digest: &str) -> String {
1880 self.fingerprint_with_build_identity(
1881 source_digest,
1882 lock_digest,
1883 &cargo_toolchain_identity(self.toolchain.as_deref()),
1884 &self.build_environment_digest(true),
1885 )
1886 }
1887
1888 fn fingerprint_with_build_identity(
1889 &self,
1890 source_digest: &str,
1891 lock_digest: &str,
1892 toolchain_identity: &str,
1893 environment_digest: &str,
1894 ) -> String {
1895 let mut features = self.features.clone();
1896 features.sort();
1897 let mut binaries = self.bins.clone();
1898 binaries.sort();
1899 let input = format!(
1900 "crate={}\nversion={}\nsource={}\nsource-digest={source_digest}\nrevision={}\nlocked={}\nlock-digest={lock_digest}\ntarget={}\ntoolchain={}\ntoolchain-identity={}\nprofile={}\nfeatures={}\nbins={}\nenv-digest={}\n",
1901 self.crate_name,
1902 self.version,
1903 self.source.as_deref().unwrap_or("crates-io"),
1904 self.revision.as_deref().unwrap_or("registry"),
1905 self.locked,
1906 self.target
1907 .clone()
1908 .unwrap_or_else(cargo_host_target_identity),
1909 self.toolchain.as_deref().unwrap_or("default"),
1910 toolchain_identity,
1911 self.profile,
1912 features.join(","),
1913 binaries.join(","),
1914 environment_digest
1915 );
1916 hex_digest(Sha256::digest(input.as_bytes()).as_slice())
1917 }
1918
1919 fn build_environment_digest(&self, include_values: bool) -> String {
1920 let mut environment = self.build_env_allow.clone();
1921 environment.sort();
1922 environment.dedup();
1923 let environment = environment
1924 .iter()
1925 .map(|name| {
1926 let value = include_values
1927 .then(|| std::env::var_os(name))
1928 .flatten()
1929 .map(|value| value.to_string_lossy().into_owned());
1930 (name, value)
1931 })
1932 .collect::<Vec<_>>();
1933 serde_json::to_vec(&environment)
1934 .map(|bytes| hex_digest(Sha256::digest(bytes).as_slice()))
1935 .unwrap_or_else(|_| "invalid-environment".to_string())
1936 }
1937}
1938
1939fn cargo_toolchain_identity(toolchain: Option<&str>) -> String {
1940 static IDENTITIES: OnceLock<Mutex<BTreeMap<String, String>>> = OnceLock::new();
1941 let key = toolchain.unwrap_or("default").to_string();
1942 let identities = IDENTITIES.get_or_init(|| Mutex::new(BTreeMap::new()));
1943 if let Some(identity) = identities
1944 .lock()
1945 .ok()
1946 .and_then(|values| values.get(&key).cloned())
1947 {
1948 return identity;
1949 }
1950 let mut command = std::process::Command::new(resolve_command("rustc"));
1951 command.env("RUSTUP_AUTO_INSTALL", "0");
1952 if let Some(toolchain) = toolchain {
1953 command.arg(format!("+{toolchain}"));
1954 }
1955 let identity = command
1956 .arg("-vV")
1957 .output()
1958 .ok()
1959 .filter(|output| output.status.success())
1960 .map(|output| hex_digest(Sha256::digest(&output.stdout).as_slice()))
1961 .unwrap_or_else(|| format!("unavailable:{key}"));
1962 if let Ok(mut values) = identities.lock() {
1963 values.insert(key, identity.clone());
1964 }
1965 identity
1966}
1967
1968fn cargo_host_target_identity() -> String {
1969 let arch = match std::env::consts::ARCH {
1970 "amd64" | "x64" => "x86_64",
1971 "arm64" => "aarch64",
1972 value => value,
1973 };
1974 if cfg!(target_os = "macos") {
1975 format!("{arch}-apple-darwin")
1976 } else if cfg!(windows) && cfg!(target_env = "gnu") {
1977 format!("{arch}-pc-windows-gnu")
1978 } else if cfg!(windows) {
1979 format!("{arch}-pc-windows-msvc")
1980 } else if cfg!(target_os = "linux") && cfg!(target_env = "musl") {
1981 format!("{arch}-unknown-linux-musl")
1982 } else if cfg!(target_os = "linux") {
1983 format!("{arch}-unknown-linux-gnu")
1984 } else {
1985 format!("{arch}-unknown-{}", std::env::consts::OS)
1986 }
1987}
1988
1989fn validate_ids(config: &ConfigDocument) -> Result<(), ForgeError> {
1990 let mut component_ids = BTreeSet::new();
1991 for component in &config.components {
1992 validate_id("component", &component.id)?;
1993 if !component_ids.insert(component.id.as_str()) {
1994 return Err(ForgeError::Config(format!(
1995 "components contain a duplicate id: {}",
1996 component.id
1997 )));
1998 }
1999 let mut variant_ids = BTreeSet::new();
2000 for variant in &component.variants {
2001 validate_id("variant", &variant.id)?;
2002 if !variant_ids.insert(variant.id.as_str()) {
2003 return Err(ForgeError::Config(format!(
2004 "component {} contains a duplicate variant: {}",
2005 component.id, variant.id
2006 )));
2007 }
2008 }
2009 }
2010 for name in config.profiles.keys() {
2011 validate_id("profile", name)?;
2012 }
2013 for name in config.sources.keys() {
2014 validate_id("source", name)?;
2015 }
2016 for name in config.versions.keys() {
2017 validate_id("version", name)?;
2018 }
2019 for name in config.groups.keys() {
2020 validate_id("group", name)?;
2021 }
2022 Ok(())
2023}
2024
2025fn validate_id(kind: &str, value: &str) -> Result<(), ForgeError> {
2026 if !valid_config_id(value) {
2027 return Err(ForgeError::Config(format!(
2028 "{kind} id must use lowercase kebab-case: {value}"
2029 )));
2030 }
2031 Ok(())
2032}
2033
2034fn validate_sources(config: &ConfigDocument) -> Result<(), ForgeError> {
2035 for (name, source) in &config.sources {
2036 let url = match source {
2037 SourceDef::CargoRegistry { url }
2038 | SourceDef::ArchiveMirror { base_url: url }
2039 | SourceDef::Git { url }
2040 | SourceDef::NpmRegistry { url } => url,
2041 };
2042 if !url.starts_with("https://") {
2043 return Err(ForgeError::Config(format!("source {name} must use HTTPS")));
2044 }
2045 }
2046 Ok(())
2047}
2048
2049fn validate_catalogs(config: &ConfigDocument) -> Result<(), ForgeError> {
2050 if !config.catalog.is_empty() && config.catalog != "rust-dev" {
2051 return Err(ForgeError::Config(format!(
2052 "unknown or untrusted catalog: {}",
2053 config.catalog
2054 )));
2055 }
2056 Ok(())
2057}
2058
2059fn validate_versions(config: &ConfigDocument) -> Result<(), ForgeError> {
2060 let referenced: BTreeSet<&str> = config
2061 .components
2062 .iter()
2063 .flat_map(|component| {
2064 std::iter::once(&component.install)
2065 .chain(component.variants.iter().map(|variant| &variant.install))
2066 })
2067 .filter_map(|install| match install {
2068 Some(InstallSpec::Rustup(rustup)) => rustup.toolchain_ref.as_deref(),
2069 _ => None,
2070 })
2071 .collect();
2072 for key in config.versions.keys() {
2073 if !referenced.contains(key.as_str()) {
2074 return Err(ForgeError::Config(format!(
2075 "versions.{key} is not referenced by any installation recipe"
2076 )));
2077 }
2078 validate_argument_value(
2079 "versions",
2080 &format!("versions.{key}"),
2081 &config.versions[key],
2082 )?;
2083 }
2084 Ok(())
2085}
2086
2087fn validate_groups(config: &ConfigDocument) -> Result<(), ForgeError> {
2088 let components: BTreeSet<&str> = config
2089 .components
2090 .iter()
2091 .map(|component| component.id.as_str())
2092 .collect();
2093 for (name, members) in &config.groups {
2094 if members.is_empty() {
2095 return Err(ForgeError::Config(format!("group {name} cannot be empty")));
2096 }
2097 for member in members {
2098 if !components.contains(member.as_str()) {
2099 return Err(ForgeError::Config(format!(
2100 "group {name} references a component that does not exist: {member}"
2101 )));
2102 }
2103 }
2104 }
2105 Ok(())
2106}
2107
2108fn validate_profiles(config: &ConfigDocument) -> Result<(), ForgeError> {
2109 let components: BTreeSet<&str> = config
2110 .components
2111 .iter()
2112 .map(|component| component.id.as_str())
2113 .collect();
2114 for (name, profile) in &config.profiles {
2115 for parent in &profile.inherits {
2116 if !config.profiles.contains_key(parent) {
2117 return Err(ForgeError::Config(format!(
2118 "profile {name} inherits from a profile that does not exist: {parent}"
2119 )));
2120 }
2121 }
2122 for component in &profile.components {
2123 let group_exists = component
2124 .strip_prefix("group:")
2125 .is_some_and(|group| config.groups.contains_key(group));
2126 if !components.contains(component.as_str())
2127 && !component.starts_with("capability:")
2128 && !group_exists
2129 {
2130 return Err(ForgeError::Config(format!(
2131 "profile {name} references a component that does not exist: {component}"
2132 )));
2133 }
2134 }
2135 }
2136 visit_cycles(
2137 config.profiles.keys().map(String::as_str),
2138 |name| config.profiles[name].inherits.iter().map(String::as_str),
2139 "profile inheritance",
2140 )
2141}
2142
2143fn validate_components(config: &ConfigDocument) -> Result<(), ForgeError> {
2144 let component_ids: BTreeSet<&str> = config
2145 .components
2146 .iter()
2147 .map(|component| component.id.as_str())
2148 .collect();
2149 let capabilities: BTreeSet<&str> = config
2150 .components
2151 .iter()
2152 .flat_map(|component| component.provides.iter())
2153 .chain(
2154 config
2155 .components
2156 .iter()
2157 .flat_map(|component| component.variants.iter())
2158 .flat_map(|variant| variant.provides.iter()),
2159 )
2160 .map(String::as_str)
2161 .collect();
2162 for component in &config.components {
2163 validate_platforms(&component.id, &component.platforms)?;
2164 validate_offered_version(&component.id, component.version.as_deref())?;
2165 for host in &component.allow_insecure_hosts {
2166 validate_insecure_host(&component.id, host)?;
2167 }
2168 validate_insecure_host_backends(component)?;
2169 validate_check(config, &component.id, component.detect.as_ref())?;
2170 validate_check(config, &component.id, component.verify.as_ref())?;
2171 validate_install(config, &component.id, component.install.as_ref())?;
2172 validate_references(
2173 &component.id,
2174 component.requires.iter().chain(component.conflicts.iter()),
2175 &component_ids,
2176 &capabilities,
2177 )?;
2178 if component.kind == InstallKind::Skill {
2179 let source = component.source.as_deref().unwrap_or_default();
2180 if source.is_empty() {
2181 return Err(ForgeError::Config(format!(
2182 "skill component {} is missing a source",
2183 component.id
2184 )));
2185 }
2186 if looks_like_git(source)
2187 && (!source.starts_with("https://")
2188 || component
2189 .revision
2190 .as_deref()
2191 .is_none_or(|revision| !valid_git_commit(revision)))
2192 {
2193 return Err(ForgeError::Config(format!(
2194 "skill component {} remote source must use HTTPS and a pinned commit",
2195 component.id
2196 )));
2197 }
2198 if component
2199 .revision
2200 .as_deref()
2201 .is_some_and(|revision| !valid_git_commit(revision))
2202 {
2203 return Err(ForgeError::Config(format!(
2204 "skill component {} revision must be an explicit commit",
2205 component.id
2206 )));
2207 }
2208 if component.agents.is_empty() {
2209 return Err(ForgeError::Config(format!(
2210 "skill component {} is missing agents",
2211 component.id
2212 )));
2213 }
2214 if component.detect.is_some()
2215 || component.install.is_some()
2216 || component.verify.is_some()
2217 || component.version.is_some()
2218 || !component.variants.is_empty()
2219 {
2220 return Err(ForgeError::Config(format!(
2221 "skill component {} does not support tool-only detect/install/verify/variants fields",
2222 component.id
2223 )));
2224 }
2225 } else if component.source.is_some()
2226 || component.revision.is_some()
2227 || !component.agents.is_empty()
2228 {
2229 return Err(ForgeError::Config(format!(
2230 "tool component {} does not support skill-only source/revision/agents fields",
2231 component.id
2232 )));
2233 }
2234 for variant in &component.variants {
2235 let label = format!("{}#{}", component.id, variant.id);
2236 validate_platforms(&label, &variant.platforms)?;
2237 validate_offered_version(&label, variant.version.as_deref())?;
2238 validate_check(config, &label, variant.detect.as_ref())?;
2239 validate_check(config, &label, variant.verify.as_ref())?;
2240 validate_install(config, &label, variant.install.as_ref())?;
2241 validate_references(
2242 &label,
2243 variant.requires.iter().chain(variant.conflicts.iter()),
2244 &component_ids,
2245 &capabilities,
2246 )?;
2247 }
2248 }
2249 Ok(())
2250}
2251
2252fn validate_offered_version(component: &str, version: Option<&str>) -> Result<(), ForgeError> {
2253 if version.is_some_and(|version| semver::Version::parse(version).is_err()) {
2254 return Err(ForgeError::Config(format!(
2255 "component {component} version must be an exact semantic version"
2256 )));
2257 }
2258 Ok(())
2259}
2260
2261fn validate_binary_providers(config: &ConfigDocument) -> Result<(), ForgeError> {
2262 let mut providers: BTreeMap<String, (&str, &str)> = BTreeMap::new();
2263 for component in &config.components {
2264 for install in std::iter::once(&component.install)
2265 .chain(component.variants.iter().map(|variant| &variant.install))
2266 {
2267 let binaries = match install {
2268 Some(InstallSpec::Cargo(cargo)) => cargo.bins.iter().map(String::as_str).collect(),
2269 Some(InstallSpec::Git(git)) => git.bins.keys().map(String::as_str).collect(),
2270 _ => Vec::new(),
2271 };
2272 let mut local = BTreeSet::new();
2273 for bin in binaries {
2274 let identity = managed_binary_identity(bin);
2275 if !local.insert(identity.clone()) {
2276 return Err(ForgeError::Config(format!(
2277 "component {} declares managed binary {bin} more than once",
2278 component.id
2279 )));
2280 }
2281 if let Some((previous, previous_bin)) =
2282 providers.insert(identity, (component.id.as_str(), bin))
2283 {
2284 if previous_bin != bin {
2285 return Err(ForgeError::Config(format!(
2286 "managed binary {bin} conflicts with {previous_bin} on a case-insensitive filesystem: component {previous} and {}",
2287 component.id
2288 )));
2289 }
2290 if previous != component.id {
2291 return Err(ForgeError::Config(format!(
2292 "managed binary {bin} is provided by both component {previous} and {}",
2293 component.id
2294 )));
2295 }
2296 }
2297 }
2298 }
2299 }
2300 Ok(())
2301}
2302
2303fn managed_binary_identity(name: &str) -> String {
2304 if cfg!(any(target_os = "macos", windows)) {
2305 name.to_ascii_lowercase()
2306 } else {
2307 name.to_string()
2308 }
2309}
2310
2311fn validate_dependency_graph(config: &ConfigDocument) -> Result<(), ForgeError> {
2312 let ids: BTreeSet<&str> = config
2313 .components
2314 .iter()
2315 .map(|component| component.id.as_str())
2316 .collect();
2317 visit_cycles(
2318 ids.iter().copied(),
2319 |name| {
2320 config
2321 .component(name)
2322 .into_iter()
2323 .flat_map(|component| component.requires.iter())
2324 .filter(|reference| !reference.starts_with("capability:"))
2325 .map(|reference| reference.strip_prefix("component:").unwrap_or(reference))
2326 },
2327 "component dependencies",
2328 )
2329}
2330
2331fn validate_environment(config: &ConfigDocument) -> Result<(), ForgeError> {
2332 let mut ids = BTreeSet::new();
2333 for mutation in &config.environment.mutations {
2334 validate_id("environment mutation", mutation.id())?;
2335 if !ids.insert(mutation.id()) {
2336 return Err(ForgeError::Config(format!(
2337 "duplicate environment mutation id: {}",
2338 mutation.id()
2339 )));
2340 }
2341 validate_platforms(mutation.id(), mutation.platforms())?;
2342 match mutation {
2343 EnvironmentMutation::FileFragment { path, .. }
2344 if path.is_absolute()
2345 || path.components().any(|component| {
2346 matches!(
2347 component,
2348 std::path::Component::ParentDir
2349 | std::path::Component::RootDir
2350 | std::path::Component::Prefix(_)
2351 )
2352 }) =>
2353 {
2354 return Err(ForgeError::Config(format!(
2355 "environment mutation {} file-fragment path must be relative to the user directory",
2356 mutation.id()
2357 )));
2358 }
2359 _ => {}
2360 }
2361 }
2362 Ok(())
2363}
2364
2365fn validate_apt_mirror(mirror: Option<&AptMirrorDef>) -> Result<(), ForgeError> {
2366 let Some(mirror) = mirror else {
2367 return Ok(());
2368 };
2369 if mirror.uri.is_none() && mirror.rules.is_empty() {
2370 return Err(ForgeError::Config(
2371 "apt_mirror must declare a uri or at least one rule".to_string(),
2372 ));
2373 }
2374 validate_apt_values("apt_mirror.suites", &mirror.suites)?;
2375 validate_apt_values("apt_mirror.components", &mirror.components)?;
2376 validate_apt_values("apt_mirror.architectures", &mirror.architectures)?;
2377 if let Some(uri) = &mirror.uri {
2378 validate_apt_uri("apt_mirror.uri", uri)?;
2379 }
2380 validate_apt_path("apt_mirror.signed_by", mirror.signed_by.as_ref(), false)?;
2381 validate_apt_path("apt_mirror.source_file", mirror.source_file.as_ref(), true)?;
2382
2383 let mut selectors = BTreeSet::new();
2384 for (index, rule) in mirror.rules.iter().enumerate() {
2385 let path = format!("apt_mirror.rules[{index}]");
2386 for (name, selector) in [
2387 ("distribution", &rule.distribution),
2388 ("codename", &rule.codename),
2389 ("architecture", &rule.architecture),
2390 ] {
2391 if let Some(selector) = selector {
2392 validate_plain_scalar(&format!("{path}.{name}"), selector)?;
2393 }
2394 }
2395 let key = (
2396 rule.distribution.as_deref(),
2397 rule.codename.as_deref(),
2398 rule.architecture.as_deref(),
2399 );
2400 if !selectors.insert(key) {
2401 return Err(ForgeError::Config(format!(
2402 "{path} duplicates a selector used by an earlier APT rule"
2403 )));
2404 }
2405 if key == (None, None, None) && index + 1 != mirror.rules.len() {
2406 return Err(ForgeError::Config(format!(
2407 "{path} is a catch-all rule and must be last"
2408 )));
2409 }
2410 if mirror.uri.is_none() && rule.uri.is_none() {
2411 return Err(ForgeError::Config(format!(
2412 "{path} cannot inherit uri from apt_mirror"
2413 )));
2414 }
2415 if mirror.suites.is_empty() && rule.suites.is_empty() {
2416 return Err(ForgeError::Config(format!(
2417 "{path} cannot inherit suites from apt_mirror"
2418 )));
2419 }
2420 if mirror.components.is_empty() && rule.components.is_empty() {
2421 return Err(ForgeError::Config(format!(
2422 "{path} cannot inherit components from apt_mirror"
2423 )));
2424 }
2425 if let Some(uri) = &rule.uri {
2426 validate_apt_uri(&format!("{path}.uri"), uri)?;
2427 }
2428 validate_apt_values(&format!("{path}.suites"), &rule.suites)?;
2429 validate_apt_values(&format!("{path}.components"), &rule.components)?;
2430 validate_apt_values(&format!("{path}.architectures"), &rule.architectures)?;
2431 validate_apt_path(&format!("{path}.signed_by"), rule.signed_by.as_ref(), false)?;
2432 validate_apt_path(
2433 &format!("{path}.source_file"),
2434 rule.source_file.as_ref(),
2435 true,
2436 )?;
2437 }
2438 if mirror.rules.is_empty() && (mirror.suites.is_empty() || mirror.components.is_empty()) {
2439 return Err(ForgeError::Config(
2440 "apt_mirror.suites and components cannot be empty".to_string(),
2441 ));
2442 }
2443 Ok(())
2444}
2445
2446fn validate_apt_uri(name: &str, value: &str) -> Result<(), ForgeError> {
2447 let expanded = validate_apt_template(name, value)?;
2448 if !expanded.starts_with("http://") && !expanded.starts_with("https://") {
2449 return Err(ForgeError::Config(format!(
2450 "{name} must start with http:// or https://"
2451 )));
2452 }
2453 Ok(())
2454}
2455
2456fn validate_apt_values(name: &str, values: &[String]) -> Result<(), ForgeError> {
2457 for value in values {
2458 validate_apt_template(name, value)?;
2459 }
2460 Ok(())
2461}
2462
2463fn validate_apt_path(
2464 name: &str,
2465 value: Option<&PathBuf>,
2466 deb822_source: bool,
2467) -> Result<(), ForgeError> {
2468 let Some(value) = value else {
2469 return Ok(());
2470 };
2471 let value = value
2472 .to_str()
2473 .ok_or_else(|| ForgeError::Config(format!("{name} must be a UTF-8 path")))?;
2474 let expanded = validate_apt_template(name, value)?;
2475 if !expanded.starts_with('/') {
2476 return Err(ForgeError::Config(format!(
2477 "{name} must be an absolute Linux path"
2478 )));
2479 }
2480 if deb822_source && !expanded.ends_with(".sources") {
2481 return Err(ForgeError::Config(format!("{name} must end with .sources")));
2482 }
2483 Ok(())
2484}
2485
2486fn validate_apt_template(name: &str, value: &str) -> Result<String, ForgeError> {
2487 let expanded = value
2488 .replace("{distribution}", "distribution")
2489 .replace("{codename}", "codename")
2490 .replace("{architecture}", "architecture");
2491 if expanded.contains(['{', '}']) {
2492 return Err(ForgeError::Config(format!(
2493 "{name} contains an unknown APT variable"
2494 )));
2495 }
2496 validate_plain_scalar(name, &expanded)?;
2497 Ok(expanded)
2498}
2499
2500fn validate_plain_scalar(name: &str, value: &str) -> Result<(), ForgeError> {
2501 if value.trim().is_empty() || value.chars().any(char::is_whitespace) {
2502 return Err(ForgeError::Config(format!(
2503 "{name} cannot be empty or contain whitespace"
2504 )));
2505 }
2506 Ok(())
2507}
2508
2509fn validate_check(
2510 config: &ConfigDocument,
2511 component: &str,
2512 check: Option<&CheckSpec>,
2513) -> Result<(), ForgeError> {
2514 match check {
2515 Some(CheckSpec::Command {
2516 program,
2517 success_codes,
2518 timeout_secs,
2519 ..
2520 }) => {
2521 if program.trim().is_empty() || program.contains(['/', '\\']) {
2522 return Err(ForgeError::Config(format!(
2523 "component {component} command program must be an executable file name"
2524 )));
2525 }
2526 if success_codes.is_empty() {
2527 return Err(ForgeError::Config(format!(
2528 "component {component} success_codes cannot be empty"
2529 )));
2530 }
2531 validate_timeout(component, *timeout_secs)?;
2532 }
2533 Some(CheckSpec::Shell {
2534 command,
2535 timeout_secs,
2536 }) => {
2537 if !config.policy.allow_shell {
2538 return Err(ForgeError::Config(format!(
2539 "component {component} uses a shell check but policy.allow_shell=false"
2540 )));
2541 }
2542 if command.trim().is_empty() {
2543 return Err(ForgeError::Config(format!(
2544 "component {component} shell check cannot be empty"
2545 )));
2546 }
2547 validate_timeout(component, *timeout_secs)?;
2548 }
2549 Some(CheckSpec::Path { path }) if path.as_os_str().is_empty() => {
2550 return Err(ForgeError::Config(format!(
2551 "component {component} path check cannot be empty"
2552 )));
2553 }
2554 Some(CheckSpec::All { checks }) => {
2555 if checks.is_empty() {
2556 return Err(ForgeError::Config(format!(
2557 "component {component} all check cannot be empty"
2558 )));
2559 }
2560 for check in checks {
2561 validate_check(config, component, Some(check))?;
2562 }
2563 }
2564 _ => {}
2565 }
2566 Ok(())
2567}
2568
2569fn validate_install(
2570 config: &ConfigDocument,
2571 component: &str,
2572 install: Option<&InstallSpec>,
2573) -> Result<(), ForgeError> {
2574 match install {
2575 Some(InstallSpec::Cargo(cargo)) => {
2576 if cargo.crate_name.trim().is_empty() || !valid_exact_cargo_version(&cargo.version) {
2577 return Err(ForgeError::Config(format!(
2578 "component {component} Cargo crate cannot be empty; version must be an exact version starting with ="
2579 )));
2580 }
2581 validate_argument_value(component, "Cargo crate", &cargo.crate_name)?;
2582 if cargo.bins.is_empty() {
2583 return Err(ForgeError::Config(format!(
2584 "component {component} Cargo backend must declare bins"
2585 )));
2586 }
2587 for bin in &cargo.bins {
2588 validate_id("Cargo binary", bin)?;
2589 }
2590 if let Some(toolchain) = &cargo.toolchain {
2591 validate_argument_value(component, "Cargo toolchain", toolchain)?;
2592 }
2593 if let Some(target) = &cargo.target {
2594 validate_argument_value(component, "Cargo target", target)?;
2595 }
2596 validate_argument_value(component, "Cargo profile", &cargo.profile)?;
2597 if !cargo.locked && !config.policy.allow_unlocked_cargo {
2598 return Err(ForgeError::Config(format!(
2599 "component {component} disables the Cargo lock but policy does not allow it"
2600 )));
2601 }
2602 if cargo
2603 .revision
2604 .as_deref()
2605 .is_some_and(|value| !valid_git_commit(value))
2606 {
2607 return Err(ForgeError::Config(format!(
2608 "component {component} Git revision must be an explicit commit"
2609 )));
2610 }
2611 if cargo.source.as_deref().is_some_and(|source| {
2612 (source.starts_with("git+") || source.starts_with("https://"))
2613 && cargo.revision.as_deref().is_none_or(str::is_empty)
2614 }) {
2615 return Err(ForgeError::Config(format!(
2616 "component {component} Cargo Git source must pin a revision"
2617 )));
2618 }
2619 }
2620 Some(InstallSpec::Apt(apt)) if apt.packages.is_empty() => {
2621 return Err(ForgeError::Config(format!(
2622 "component {component} apt packages cannot be empty"
2623 )));
2624 }
2625 Some(InstallSpec::Apt(apt))
2626 if apt.packages.iter().any(|package| package.trim().is_empty()) =>
2627 {
2628 return Err(ForgeError::Config(format!(
2629 "component {component} apt package cannot be empty"
2630 )));
2631 }
2632 Some(InstallSpec::Brew(brew)) => {
2633 if brew.formulae.is_empty() {
2634 return Err(ForgeError::Config(format!(
2635 "component {component} brew formulae cannot be empty"
2636 )));
2637 }
2638 for formula in &brew.formulae {
2639 validate_argument_value(component, "brew formula", formula)?;
2640 }
2641 }
2642 Some(InstallSpec::Rustup(rustup))
2643 if rustup.toolchain.is_none()
2644 && rustup.toolchain_ref.is_none()
2645 && rustup.components.is_empty() =>
2646 {
2647 return Err(ForgeError::Config(format!(
2648 "component {component} rustup install has no operation"
2649 )));
2650 }
2651 Some(InstallSpec::Rustup(rustup)) => {
2652 if rustup.toolchain.is_some() && rustup.toolchain_ref.is_some() {
2653 return Err(ForgeError::Config(format!(
2654 "component {component} rustup toolchain and toolchain-ref cannot both be declared"
2655 )));
2656 }
2657 if let Some(reference) = &rustup.toolchain_ref
2658 && !config.versions.contains_key(reference)
2659 {
2660 return Err(ForgeError::Config(format!(
2661 "component {component} references a version that does not exist: {reference}"
2662 )));
2663 }
2664 if rustup.default && rustup.toolchain.is_none() && rustup.toolchain_ref.is_none() {
2665 return Err(ForgeError::Config(format!(
2666 "component {component} rustup default=true must declare toolchain or toolchain-ref"
2667 )));
2668 }
2669 if let Some(toolchain) = &rustup.toolchain {
2670 validate_argument_value(component, "rustup toolchain", toolchain)?;
2671 }
2672 for rustup_component in &rustup.components {
2673 validate_argument_value(component, "rustup component", rustup_component)?;
2674 }
2675 if let Some(bootstrap) = &rustup.bootstrap {
2676 let template = bootstrap
2677 .url
2678 .replace("{target}", "target")
2679 .replace("{exe}", "");
2680 if !bootstrap.url.starts_with("https://")
2681 || template.contains(['{', '}'])
2682 || bootstrap.sha256.is_empty()
2683 {
2684 return Err(ForgeError::Config(format!(
2685 "component {component} rustup bootstrap must use HTTPS, contain only target/exe placeholders, and declare sha256"
2686 )));
2687 }
2688 for (platform, digest) in &bootstrap.sha256 {
2689 validate_platforms(
2690 &format!("component {component} rustup bootstrap {platform}"),
2691 std::slice::from_ref(platform),
2692 )?;
2693 if platform == "*" || platform.ends_with("-*") || !valid_sha256(digest) {
2694 return Err(ForgeError::Config(format!(
2695 "component {component} rustup bootstrap sha256 must declare a valid digest for the exact platform: {platform}"
2696 )));
2697 }
2698 }
2699 }
2700 }
2701 Some(InstallSpec::Npm(npm)) => {
2702 validate_argument_value(component, "npm package", &npm.package)?;
2703 if semver::Version::parse(&npm.version).is_err() {
2704 return Err(ForgeError::Config(format!(
2705 "component {component} npm version must be exact SemVer"
2706 )));
2707 }
2708 if let Some(source) = &npm.source {
2709 validate_argument_value(component, "npm registry", source)?;
2710 if !source.starts_with("https://") && !config.sources.contains_key(source) {
2711 return Err(ForgeError::Config(format!(
2712 "component {component} npm source must be an HTTPS URL or a declared named source"
2713 )));
2714 }
2715 }
2716 }
2717 Some(InstallSpec::Pip(pip)) => {
2718 validate_argument_value(component, "pip package", &pip.package)?;
2719 validate_argument_value(component, "pip version", &pip.version)?;
2720 validate_argument_value(component, "Python program", &pip.python)?;
2721 if pip
2722 .version
2723 .contains(['<', '>', '=', '!', '~', '^', '*', ',', ' '])
2724 {
2725 return Err(ForgeError::Config(format!(
2726 "component {component} pip version must be exact"
2727 )));
2728 }
2729 validate_managed_install_path(component, "pip environment", &pip.environment)?;
2730 if let Some(index) = &pip.index {
2731 validate_argument_value(component, "pip index", index)?;
2732 if !index.starts_with("https://") {
2733 return Err(ForgeError::Config(format!(
2734 "component {component} pip index must use HTTPS"
2735 )));
2736 }
2737 }
2738 }
2739 Some(InstallSpec::UvTool(uv)) => {
2740 validate_argument_value(component, "uv package", &uv.package)?;
2741 if uv.package.starts_with("git+") && !uv.package.starts_with("git+https://") {
2742 return Err(ForgeError::Config(format!(
2743 "component {component} uv Git package must use git+https://"
2744 )));
2745 }
2746 if uv.bins.is_empty() {
2747 return Err(ForgeError::Config(format!(
2748 "component {component} uv-tool backend must declare bins"
2749 )));
2750 }
2751 for bin in &uv.bins {
2752 validate_id("uv tool binary", bin)?;
2753 }
2754 if let Some(index) = &uv.index {
2755 validate_argument_value(component, "uv index", index)?;
2756 if !index.starts_with("https://") {
2757 return Err(ForgeError::Config(format!(
2758 "component {component} uv index must use HTTPS"
2759 )));
2760 }
2761 }
2762 }
2763 Some(InstallSpec::Winget(winget)) => {
2764 validate_argument_value(component, "winget package", &winget.package)?;
2765 }
2766 Some(InstallSpec::Archive(archive)) => {
2767 if !archive.url.starts_with("https://") && !archive.url.starts_with("source:") {
2768 return Err(ForgeError::Config(format!(
2769 "component {component} archive URL must use HTTPS"
2770 )));
2771 }
2772 if !valid_sha256(&archive.sha256) {
2773 return Err(ForgeError::Config(format!(
2774 "component {component} archive sha256 is invalid"
2775 )));
2776 }
2777 let mut target_parts = archive.target.components();
2778 let rooted_in_app_home = matches!(
2779 target_parts.next(),
2780 Some(std::path::Component::Normal(value)) if value == "$BOT_FORGE_HOME"
2781 );
2782 if !rooted_in_app_home
2783 || target_parts.clone().next().is_none()
2784 || target_parts.any(|part| {
2785 matches!(
2786 part,
2787 std::path::Component::ParentDir
2788 | std::path::Component::RootDir
2789 | std::path::Component::Prefix(_)
2790 )
2791 })
2792 {
2793 return Err(ForgeError::Config(format!(
2794 "component {component} archive target must be a safe path under $BOT_FORGE_HOME"
2795 )));
2796 }
2797 if archive.format == ArchiveFormat::File && archive.strip_components != 0 {
2798 return Err(ForgeError::Config(format!(
2799 "component {component} file archive does not support strip_components"
2800 )));
2801 }
2802 if archive.allow_links
2803 && !matches!(archive.format, ArchiveFormat::TarGz | ArchiveFormat::TarXz)
2804 {
2805 return Err(ForgeError::Config(format!(
2806 "component {component} allow_links only applies to TAR archives"
2807 )));
2808 }
2809 }
2810 Some(InstallSpec::Git(git)) => {
2811 if (!git.url.starts_with("https://") && !git.url.starts_with("source:"))
2812 || !valid_git_commit(&git.revision)
2813 {
2814 return Err(ForgeError::Config(format!(
2815 "component {component} Git source must use HTTPS and pin a commit"
2816 )));
2817 }
2818 if git.bins.is_empty() {
2819 return Err(ForgeError::Config(format!(
2820 "component {component} Git backend must map bin names to repository files"
2821 )));
2822 }
2823 if git.subdirectory.as_ref().is_some_and(|path| {
2824 path.is_absolute()
2825 || path.components().any(|part| {
2826 matches!(
2827 part,
2828 std::path::Component::ParentDir
2829 | std::path::Component::RootDir
2830 | std::path::Component::Prefix(_)
2831 )
2832 })
2833 }) {
2834 return Err(ForgeError::Config(format!(
2835 "component {component} Git subdirectory must be relative to the repository"
2836 )));
2837 }
2838 for (name, path) in &git.bins {
2839 validate_id("Git binary", name)?;
2840 if path.is_absolute()
2841 || path
2842 .components()
2843 .any(|part| matches!(part, std::path::Component::ParentDir))
2844 {
2845 return Err(ForgeError::Config(format!(
2846 "component {component} Git binary path must be relative to the repository"
2847 )));
2848 }
2849 }
2850 }
2851 Some(InstallSpec::Shell(shell)) => {
2852 if !config.policy.allow_shell {
2853 return Err(ForgeError::Config(format!(
2854 "component {component} uses a shell backend but policy.allow_shell=false"
2855 )));
2856 }
2857 if shell.command.trim().is_empty() || shell.resources.is_empty() {
2858 return Err(ForgeError::Config(format!(
2859 "component {component} shell backend must declare command and resources"
2860 )));
2861 }
2862 validate_timeout(component, shell.timeout_secs)?;
2863 validate_timeout(component, shell.inactivity_timeout_secs)?;
2864 }
2865 _ => {}
2866 }
2867 Ok(())
2868}
2869
2870fn valid_git_commit(value: &str) -> bool {
2871 (7..=64).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
2872}
2873
2874fn validate_managed_install_path(
2875 component: &str,
2876 field: &str,
2877 path: &Path,
2878) -> Result<(), ForgeError> {
2879 let mut parts = path.components();
2880 let managed = matches!(
2881 parts.next(),
2882 Some(std::path::Component::Normal(value)) if value == "$BOT_FORGE_HOME"
2883 ) && parts.clone().next().is_some()
2884 && !parts.any(|part| {
2885 matches!(
2886 part,
2887 std::path::Component::ParentDir
2888 | std::path::Component::RootDir
2889 | std::path::Component::Prefix(_)
2890 )
2891 });
2892 if managed {
2893 Ok(())
2894 } else {
2895 Err(ForgeError::Config(format!(
2896 "component {component} {field} must be a safe path under $BOT_FORGE_HOME"
2897 )))
2898 }
2899}
2900
2901fn validate_timeout(component: &str, timeout: Option<u64>) -> Result<(), ForgeError> {
2902 if timeout == Some(0) {
2903 return Err(ForgeError::Config(format!(
2904 "component {component} timeout must be greater than zero"
2905 )));
2906 }
2907 Ok(())
2908}
2909
2910fn validate_argument_value(component: &str, field: &str, value: &str) -> Result<(), ForgeError> {
2911 if value.trim().is_empty() || value.starts_with('-') || value.contains(['\0', '\n', '\r']) {
2912 return Err(ForgeError::Config(format!(
2913 "component {component} {field} cannot be empty, start with -, or contain control newlines"
2914 )));
2915 }
2916 Ok(())
2917}
2918
2919fn validate_insecure_host(component: &str, host: &str) -> Result<(), ForgeError> {
2920 validate_argument_value(component, "allow_insecure_hosts", host)?;
2921 if host == "*" {
2922 return Ok(());
2923 }
2924 if host.contains("://")
2925 || host.contains(['/', '\\', '?', '#'])
2926 || host.chars().any(char::is_whitespace)
2927 {
2928 return Err(ForgeError::Config(format!(
2929 "component {component} allow_insecure_hosts must contain host names or IP addresses with optional ports, without protocols, paths, or whitespace"
2930 )));
2931 }
2932 Ok(())
2933}
2934
2935fn validate_insecure_host_backends(component: &ComponentDef) -> Result<(), ForgeError> {
2936 if component.allow_insecure_hosts.is_empty() {
2937 return Ok(());
2938 }
2939 let mut found_install = false;
2940 for install in std::iter::once(component.install.as_ref())
2941 .chain(
2942 component
2943 .variants
2944 .iter()
2945 .filter_map(|variant| variant.install.as_ref())
2946 .map(Some),
2947 )
2948 .flatten()
2949 {
2950 found_install = true;
2951 match install {
2952 InstallSpec::Pip(_) | InstallSpec::UvTool(_) => {}
2953 _ => {
2954 return Err(ForgeError::Config(format!(
2955 "component {} allow_insecure_hosts only supports the pip and uv-tool backends",
2956 component.id
2957 )));
2958 }
2959 }
2960 }
2961 if !found_install {
2962 return Err(ForgeError::Config(format!(
2963 "component {} configures allow_insecure_hosts but has no pip or uv-tool install",
2964 component.id
2965 )));
2966 }
2967 Ok(())
2968}
2969
2970fn validate_platforms(component: &str, platforms: &[String]) -> Result<(), ForgeError> {
2971 if platforms.is_empty() {
2972 return Err(ForgeError::Config(format!(
2973 "{component} platforms cannot be empty"
2974 )));
2975 }
2976 for selector in platforms {
2977 let valid = selector == "*"
2978 || matches!(selector.as_str(), "linux-*" | "windows-*" | "macos-*")
2979 || matches!(
2980 selector.as_str(),
2981 "linux-x86_64-gnu"
2982 | "linux-aarch64-gnu"
2983 | "windows-x86_64-msvc"
2984 | "windows-aarch64-msvc"
2985 | "macos-x86_64"
2986 | "macos-aarch64"
2987 );
2988 if !valid {
2989 return Err(ForgeError::Config(format!(
2990 "{component} has an invalid platform selector: {selector}"
2991 )));
2992 }
2993 }
2994 Ok(())
2995}
2996
2997fn validate_references<'a>(
2998 component: &str,
2999 references: impl Iterator<Item = &'a String>,
3000 component_ids: &BTreeSet<&str>,
3001 capabilities: &BTreeSet<&str>,
3002) -> Result<(), ForgeError> {
3003 for reference in references {
3004 if let Some(capability) = reference.strip_prefix("capability:") {
3005 if !capabilities.contains(capability) {
3006 return Err(ForgeError::Config(format!(
3007 "component {component} references a capability without a provider: {capability}"
3008 )));
3009 }
3010 } else {
3011 let target = reference.strip_prefix("component:").unwrap_or(reference);
3012 if !component_ids.contains(target) {
3013 return Err(ForgeError::Config(format!(
3014 "component {component} references a component that does not exist: {target}"
3015 )));
3016 }
3017 }
3018 }
3019 Ok(())
3020}
3021
3022fn visit_cycles<'a, I, F, J>(roots: I, edges: F, label: &str) -> Result<(), ForgeError>
3023where
3024 I: IntoIterator<Item = &'a str>,
3025 F: Fn(&'a str) -> J,
3026 J: IntoIterator<Item = &'a str>,
3027{
3028 fn visit<'a, F, J>(
3029 node: &'a str,
3030 edges: &F,
3031 visiting: &mut Vec<&'a str>,
3032 complete: &mut BTreeSet<&'a str>,
3033 label: &str,
3034 ) -> Result<(), ForgeError>
3035 where
3036 F: Fn(&'a str) -> J,
3037 J: IntoIterator<Item = &'a str>,
3038 {
3039 if complete.contains(node) {
3040 return Ok(());
3041 }
3042 if let Some(index) = visiting.iter().position(|candidate| *candidate == node) {
3043 let mut cycle = visiting[index..].to_vec();
3044 cycle.push(node);
3045 return Err(ForgeError::Config(format!(
3046 "{label} contains a cycle: {}",
3047 cycle.join(" -> ")
3048 )));
3049 }
3050 visiting.push(node);
3051 for dependency in edges(node) {
3052 visit(dependency, edges, visiting, complete, label)?;
3053 }
3054 visiting.pop();
3055 complete.insert(node);
3056 Ok(())
3057 }
3058
3059 let mut complete = BTreeSet::new();
3060 for root in roots {
3061 visit(root, &edges, &mut Vec::new(), &mut complete, label)?;
3062 }
3063 Ok(())
3064}
3065
3066fn valid_sha256(value: &str) -> bool {
3067 value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
3068}
3069
3070fn valid_exact_cargo_version(value: &str) -> bool {
3071 value
3072 .strip_prefix('=')
3073 .is_some_and(|version| semver::Version::parse(version).is_ok())
3074}
3075
3076fn normalize_exact_cargo_version(value: &str) -> Option<String> {
3077 let version = value.strip_prefix('=').unwrap_or(value);
3078 semver::Version::parse(version)
3079 .ok()
3080 .map(|version| format!("={version}"))
3081}
3082
3083fn profile_topological_order(config: &ConfigDocument) -> Result<Vec<String>, ForgeError> {
3084 fn visit(
3085 name: &str,
3086 config: &ConfigDocument,
3087 visiting: &mut BTreeSet<String>,
3088 visited: &mut BTreeSet<String>,
3089 output: &mut Vec<String>,
3090 ) -> Result<(), ForgeError> {
3091 if visited.contains(name) {
3092 return Ok(());
3093 }
3094 if !visiting.insert(name.to_string()) {
3095 return Err(ForgeError::Config(format!(
3096 "profile inheritance contains a cycle: {name}"
3097 )));
3098 }
3099 let profile = config
3100 .profiles
3101 .get(name)
3102 .ok_or_else(|| ForgeError::Config(format!("profile does not exist: {name}")))?;
3103 for parent in &profile.inherits {
3104 visit(parent, config, visiting, visited, output)?;
3105 }
3106 visiting.remove(name);
3107 visited.insert(name.to_string());
3108 output.push(name.to_string());
3109 Ok(())
3110 }
3111
3112 let mut output = Vec::new();
3113 let mut visiting = BTreeSet::new();
3114 let mut visited = BTreeSet::new();
3115 for name in config.profiles.keys() {
3116 visit(name, config, &mut visiting, &mut visited, &mut output)?;
3117 }
3118 Ok(output)
3119}
3120
3121fn effective_profile_components(
3122 config: &ConfigDocument,
3123 name: &str,
3124) -> Result<Vec<String>, ForgeError> {
3125 fn expand(
3126 config: &ConfigDocument,
3127 name: &str,
3128 output: &mut Vec<String>,
3129 ) -> Result<(), ForgeError> {
3130 let profile = config
3131 .profiles
3132 .get(name)
3133 .ok_or_else(|| ForgeError::Config(format!("profile does not exist: {name}")))?;
3134 for parent in &profile.inherits {
3135 expand(config, parent, output)?;
3136 }
3137 for entry in &profile.components {
3138 for component in config.expand_profile_entry(entry)? {
3139 if !output.contains(&component) {
3140 output.push(component);
3141 }
3142 }
3143 }
3144 Ok(())
3145 }
3146
3147 let mut output = Vec::new();
3148 expand(config, name, &mut output)?;
3149 Ok(output)
3150}
3151
3152fn reject_ambiguous_profile_modifiers(layer: &toml::Value, origin: &str) -> Result<(), ForgeError> {
3153 let Some(profiles) = layer.get("profiles").and_then(toml::Value::as_table) else {
3154 return Ok(());
3155 };
3156 for (name, value) in profiles {
3157 let Some(profile) = value.as_table() else {
3158 continue;
3159 };
3160 let modifies = profile.contains_key("add") || profile.contains_key("remove");
3161 let replaces = profile.contains_key("inherits") || profile.contains_key("components");
3162 if modifies && replaces {
3163 return Err(ForgeError::Config(format!(
3164 "{origin} profiles.{name} cannot mix add/remove with inherits/components at the same layer"
3165 )));
3166 }
3167 }
3168 Ok(())
3169}
3170
3171fn merge_values(
3172 path: &str,
3173 base: &mut toml::Value,
3174 overlay: toml::Value,
3175 origin: &str,
3176 origins: &mut OriginMap,
3177) -> Result<(), ForgeError> {
3178 match (base, overlay) {
3179 (toml::Value::Table(base), toml::Value::Table(overlay)) => {
3180 for (key, value) in overlay {
3181 let child = if path.is_empty() {
3182 key.clone()
3183 } else {
3184 format!("{path}.{key}")
3185 };
3186 if let Some(existing) = base.get_mut(&key) {
3187 merge_values(&child, existing, value, origin, origins)?;
3188 } else {
3189 base.insert(key, value);
3190 origins.record(child, origin);
3191 }
3192 }
3193 }
3194 (toml::Value::Array(base), toml::Value::Array(overlay))
3195 if matches!(path, "components" | "environment.mutations") =>
3196 {
3197 for value in overlay {
3198 let id = value
3199 .get("id")
3200 .and_then(toml::Value::as_str)
3201 .ok_or_else(|| ForgeError::Config(format!("{path} overlay is missing id")))?;
3202 let record_path = format!("{path}.{id}");
3203 if let Some(index) = base
3204 .iter()
3205 .position(|item| item.get("id").and_then(toml::Value::as_str) == Some(id))
3206 {
3207 base[index] = value;
3208 } else {
3209 base.push(value);
3210 }
3211 origins.record(record_path, origin);
3212 }
3213 }
3214 (base, overlay) => {
3215 *base = overlay;
3216 origins.record(path, origin);
3217 }
3218 }
3219 Ok(())
3220}
3221
3222fn hex_digest(bytes: &[u8]) -> String {
3223 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
3224}
3225
3226#[cfg(test)]
3227#[path = "schema/tests.rs"]
3228mod tests;