Skip to main content

bot_forge/config/
schema.rs

1//! Strict serialized configuration schema and canonical expansion rules.
2//!
3//! Unknown fields are rejected. Compact declarations are expanded before validation and planning;
4//! source and version references are resolved into the canonical [`ConfigDocument`].
5
6use 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)]
20/// Canonical configuration document consumed by the planner.
21pub struct ConfigDocument {
22    /// Global security, network, and resource policy.
23    pub policy: Policy,
24    /// Installation prerequisites executed before package mirrors and component detection.
25    #[serde(default, skip_serializing_if = "PreflightDef::is_empty")]
26    pub preflight: PreflightDef,
27    /// Built-in catalog identifier that supplies the base configuration.
28    #[serde(default, skip_serializing_if = "String::is_empty")]
29    pub catalog: String,
30    /// Digest of the expanded catalog used to bind plans to their configuration source.
31    #[schemars(skip)]
32    #[serde(
33        rename = "catalog-digest",
34        default,
35        skip_serializing_if = "String::is_empty"
36    )]
37    pub catalog_digest: String,
38    /// Named version values referenced by compact tool declarations.
39    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
40    pub versions: BTreeMap<String, String>,
41    /// Named component lists expanded by profiles and dependency references.
42    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
43    pub groups: BTreeMap<String, Vec<String>>,
44    /// Short, typed declarations expanded into canonical components at load time.
45    #[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    /// Named Cargo tool collections expanded into components and a same-named group.
57    pub cargo_toolsets: BTreeMap<String, BTreeMap<String, CargoToolInput>>,
58    /// Compact system-package declarations expanded into platform-specific components.
59    #[serde(
60        rename = "package-tools",
61        default,
62        skip_serializing_if = "BTreeMap::is_empty"
63    )]
64    pub package_tools: BTreeMap<String, PackageToolDef>,
65    /// Compact rustup declarations expanded into canonical components.
66    #[serde(
67        rename = "rustup-tools",
68        default,
69        skip_serializing_if = "BTreeMap::is_empty"
70    )]
71    pub rustup_tools: BTreeMap<String, RustupToolInput>,
72    /// Named upstream source definitions.
73    pub sources: BTreeMap<String, SourceDef>,
74    /// Named installation profiles.
75    pub profiles: BTreeMap<String, ProfileDef>,
76    /// Canonical component definitions available to the planner.
77    pub components: Vec<ComponentDef>,
78    /// Environment paths and mutations applied after installation.
79    pub environment: EnvironmentDef,
80    /// Optional APT mirror configuration.
81    pub apt_mirror: Option<AptMirrorDef>,
82    /// Relative local configuration fragments merged after the primary file.
83    pub include: Vec<PathBuf>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
87#[serde(default, deny_unknown_fields)]
88/// Global execution and installation policy.
89pub struct Policy {
90    /// Network access permitted during execution.
91    pub network: NetworkPolicy,
92    /// Whether arbitrary shell installation definitions are accepted.
93    pub allow_shell: bool,
94    /// Whether Cargo installs may omit lockfile enforcement.
95    pub allow_unlocked_cargo: bool,
96    /// Maximum number of concurrently scheduled nodes.
97    #[schemars(range(min = 1, max = 256))]
98    pub max_parallel: usize,
99    /// Maximum number of concurrent network transfers.
100    #[schemars(range(min = 1, max = 256))]
101    pub max_downloads: usize,
102    /// Optional memory budget shared by scheduled work, in MiB.
103    #[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")]
122/// Whether execution may use the network or only local state.
123pub enum NetworkPolicy {
124    /// Permit network access and local caches.
125    #[default]
126    Online,
127    /// Forbid upstream access while permitting previously cached content.
128    CacheOnly,
129    /// Forbid both upstream access and operations that require network-backed cache resolution.
130    Offline,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
134#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
135/// Named upstream source referenced by component installation definitions.
136pub enum SourceDef {
137    /// Alternative Cargo registry endpoint.
138    CargoRegistry {
139        /// Registry base URL.
140        url: String,
141    },
142    /// Base URL prepended to relative archive paths.
143    ArchiveMirror {
144        /// Mirror base URL.
145        base_url: String,
146    },
147    /// Named Git repository endpoint.
148    Git {
149        /// Repository URL.
150        url: String,
151    },
152    /// Alternative npm registry endpoint.
153    NpmRegistry {
154        /// Registry base URL.
155        url: String,
156    },
157}
158
159#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
160#[serde(default, deny_unknown_fields)]
161/// Profile inheritance and component membership changes.
162pub struct ProfileDef {
163    /// Profiles expanded before this profile's own membership changes.
164    pub inherits: Vec<String>,
165    /// Base component or group references selected by the profile.
166    pub components: Vec<String>,
167    /// Components to add to the fully expanded profile in this configuration layer.
168    #[serde(default, skip_serializing_if = "Vec::is_empty")]
169    pub add: Vec<String>,
170    /// Components to remove from the fully expanded profile in this configuration layer.
171    #[serde(default, skip_serializing_if = "Vec::is_empty")]
172    pub remove: Vec<String>,
173}
174
175/// Compact Cargo tool declaration expanded before planning.
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
177#[serde(untagged)]
178pub enum CargoToolInput {
179    /// Version-only shorthand using the map key as crate and component identity.
180    Version(String),
181    /// Full compact Cargo tool declaration.
182    Detailed(Box<CargoToolDef>),
183}
184
185/// Compact declaration expanded into a Cargo-backed component.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
187#[serde(deny_unknown_fields)]
188pub struct CargoToolDef {
189    /// Exact or normalized Cargo version requirement.
190    pub version: String,
191    /// Crate name when it differs from the declaration key.
192    #[serde(rename = "crate", default)]
193    pub crate_name: Option<String>,
194    /// Single expected binary name.
195    #[serde(default)]
196    pub bin: Option<String>,
197    /// Expected binary names when the crate installs more than one executable.
198    #[serde(default)]
199    pub bins: Vec<String>,
200    /// Command vector used to detect an existing installation.
201    #[serde(default)]
202    pub detect: Option<Vec<String>>,
203    /// Platform selectors on which the generated component is eligible.
204    #[serde(default)]
205    pub platforms: Vec<String>,
206    /// Component or capability dependencies.
207    #[serde(default)]
208    pub requires: Vec<String>,
209    /// Capability names supplied by the generated component.
210    #[serde(default)]
211    pub provides: Vec<String>,
212    /// Components or capabilities that cannot be selected together.
213    #[serde(default)]
214    pub conflicts: Vec<String>,
215    /// Whether interactive selection may omit this component.
216    #[serde(default)]
217    pub optional: bool,
218    /// Cargo features enabled for the build.
219    #[serde(default)]
220    pub features: Vec<String>,
221    /// Optional Cargo compilation target.
222    #[serde(default)]
223    pub target: Option<String>,
224    /// Optional Rust toolchain used to invoke Cargo.
225    #[serde(default)]
226    pub toolchain: Option<String>,
227    /// Cargo build profile; defaults to `release` after expansion.
228    #[serde(default)]
229    pub profile: Option<String>,
230    /// Named Cargo registry or Git source reference.
231    #[serde(default)]
232    pub source: Option<String>,
233    /// Pinned source revision for Git-backed Cargo installs.
234    #[serde(default)]
235    pub revision: Option<String>,
236    /// Whether Cargo must install with lockfile enforcement.
237    #[serde(default = "default_true")]
238    pub locked: bool,
239    /// Environment variable names forwarded into the Cargo build.
240    #[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)]
246/// Compact cross-platform system-package declaration expanded into one canonical component.
247pub struct PackageToolDef {
248    /// Default command check shared by generated platform variants.
249    pub detect: CommandCheckInput,
250    /// Debian/Ubuntu package names.
251    #[serde(default)]
252    pub apt: Vec<String>,
253    /// Whether the APT package index must be updated before installation.
254    #[serde(default)]
255    pub apt_update: bool,
256    /// Homebrew formula names.
257    #[serde(default)]
258    pub brew: Vec<String>,
259    /// Homebrew-specific detection override.
260    #[serde(default)]
261    pub brew_detect: Option<CommandCheckInput>,
262    /// winget package identifier.
263    #[serde(default)]
264    pub winget: Option<String>,
265    /// Additional arguments passed to winget installation.
266    #[serde(default)]
267    pub winget_arguments: Vec<String>,
268    /// winget-specific detection override.
269    #[serde(default)]
270    pub winget_detect: Option<CommandCheckInput>,
271    /// Optional human-readable component name.
272    #[serde(default)]
273    pub display_name: Option<String>,
274    /// Component or capability dependencies.
275    #[serde(default)]
276    pub requires: Vec<String>,
277    /// Capabilities supplied by the generated component.
278    #[serde(default)]
279    pub provides: Vec<String>,
280    /// Components or capabilities that cannot be selected together.
281    #[serde(default)]
282    pub conflicts: Vec<String>,
283    /// Whether interactive selection may omit this component.
284    #[serde(default)]
285    pub optional: bool,
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
289#[serde(untagged)]
290/// String or detailed shorthand for a rustup-backed component.
291pub enum RustupToolInput {
292    /// Single rustup component shorthand.
293    Component(String),
294    /// Full compact rustup tool declaration.
295    Detailed(Box<RustupToolDef>),
296}
297
298/// Command detection shorthand accepted by compact declarations.
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
300#[serde(untagged)]
301pub enum CommandCheckInput {
302    /// One program-and-arguments vector.
303    Command(Vec<String>),
304    /// Conjunction of command vectors; all checks must succeed.
305    All(Vec<Vec<String>>),
306}
307
308/// Compact declaration expanded into a rustup-backed component.
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
310#[serde(deny_unknown_fields)]
311pub struct RustupToolDef {
312    /// rustup components to install.
313    pub components: Vec<String>,
314    /// Optional command detection override.
315    #[serde(default)]
316    pub detect: Option<CommandCheckInput>,
317    /// Required substring in detection command stdout.
318    #[serde(rename = "detect-stdout-contains", default)]
319    pub detect_stdout_contains: Option<String>,
320    /// Explicit toolchain name.
321    #[serde(default)]
322    pub toolchain: Option<String>,
323    /// Reference resolved through the document's version map.
324    #[serde(rename = "toolchain-ref", default)]
325    pub toolchain_ref: Option<String>,
326    /// Whether the selected toolchain becomes rustup's default.
327    #[serde(default)]
328    pub default: bool,
329    /// Verified rustup bootstrap used when rustup is unavailable.
330    #[serde(default)]
331    pub bootstrap: Option<RustupBootstrap>,
332    /// Optional human-readable component name.
333    #[serde(default)]
334    pub display_name: Option<String>,
335    /// Component or capability dependencies.
336    #[serde(default)]
337    pub requires: Vec<String>,
338    /// Capabilities supplied by the generated component.
339    #[serde(default)]
340    pub provides: Vec<String>,
341    /// Components or capabilities that cannot be selected together.
342    #[serde(default)]
343    pub conflicts: Vec<String>,
344    /// Whether interactive selection may omit this component.
345    #[serde(default)]
346    pub optional: bool,
347}
348
349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
350#[serde(deny_unknown_fields)]
351/// Canonical component definition resolved by profiles and lowered into plan nodes.
352pub struct ComponentDef {
353    /// Stable component identifier.
354    pub id: String,
355    /// Whether the component installs a tool or an agent skill.
356    #[serde(default = "default_component_kind")]
357    pub kind: InstallKind,
358    /// Optional human-readable name.
359    #[serde(default)]
360    pub display_name: Option<String>,
361    /// Version supplied by this component, used to identify older existing installations.
362    #[serde(default)]
363    pub version: Option<String>,
364    /// Component or capability dependencies.
365    #[serde(default)]
366    pub requires: Vec<String>,
367    /// Capabilities supplied by this component.
368    #[serde(default)]
369    pub provides: Vec<String>,
370    /// Components or capabilities that cannot be selected together.
371    #[serde(default)]
372    pub conflicts: Vec<String>,
373    /// Whether interactive selection may omit this component.
374    #[serde(default)]
375    pub optional: bool,
376    /// Hosts for which this component may allow insecure TLS/HTTP access. Supported by pip and
377    /// uv-tool with concrete hosts or the `*` wildcard.
378    #[serde(default)]
379    pub allow_insecure_hosts: Vec<String>,
380    /// Platform selectors on which the component is eligible.
381    #[serde(default = "all_platforms")]
382    pub platforms: Vec<String>,
383    /// Check used to detect an existing installation.
384    #[serde(default)]
385    pub detect: Option<CheckSpec>,
386    /// Typed installation operation.
387    #[serde(default)]
388    pub install: Option<InstallSpec>,
389    /// Check required to accept a completed installation.
390    #[serde(default)]
391    pub verify: Option<CheckSpec>,
392    /// Platform-specific alternatives merged during resolution.
393    #[serde(default)]
394    pub variants: Vec<VariantDef>,
395    /// Named source reference consumed by the selected backend.
396    #[serde(default)]
397    pub source: Option<String>,
398    /// Pinned upstream revision recorded with the installation.
399    #[serde(default)]
400    pub revision: Option<String>,
401    /// Agent destinations for skill components.
402    #[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)]
416/// Platform-specific override merged into a canonical component.
417pub struct VariantDef {
418    /// Variant identifier recorded in resolved plans.
419    pub id: String,
420    /// Variant-specific supplied version override.
421    #[serde(default)]
422    pub version: Option<String>,
423    /// Additional dependencies introduced by this variant.
424    #[serde(default)]
425    pub requires: Vec<String>,
426    /// Additional capabilities supplied by this variant.
427    #[serde(default)]
428    pub provides: Vec<String>,
429    /// Additional conflicts introduced by this variant.
430    #[serde(default)]
431    pub conflicts: Vec<String>,
432    /// Platform selectors that make the variant eligible.
433    #[serde(default = "all_platforms")]
434    pub platforms: Vec<String>,
435    /// Variant-specific detection override.
436    #[serde(default)]
437    pub detect: Option<CheckSpec>,
438    /// Variant-specific installation override.
439    #[serde(default)]
440    pub install: Option<InstallSpec>,
441    /// Variant-specific verification override.
442    #[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)]
448/// Typed predicate used for installation detection and post-install verification.
449pub enum CheckSpec {
450    /// Execute a program directly and validate its status and optional stdout predicate.
451    Command {
452        /// Program name or path.
453        program: String,
454        /// Arguments passed without shell parsing.
455        #[serde(default)]
456        args: Vec<String>,
457        /// Optional output predicate for commands whose exit status alone is insufficient.
458        #[serde(default, skip_serializing_if = "Option::is_none")]
459        stdout_contains: Option<String>,
460        /// Exit codes accepted as success.
461        #[serde(default = "zero_success")]
462        success_codes: Vec<i32>,
463        /// Maximum execution duration in seconds; `None` disables the check timeout.
464        #[serde(default = "default_check_timeout")]
465        timeout_secs: Option<u64>,
466    },
467    /// Execute a shell expression when shell policy permits it.
468    Shell {
469        /// Shell expression to execute.
470        command: String,
471        /// Maximum execution duration in seconds; `None` disables the check timeout.
472        #[serde(default = "default_check_timeout")]
473        timeout_secs: Option<u64>,
474    },
475    /// Test whether a filesystem path exists.
476    Path {
477        /// Path whose existence indicates success.
478        path: PathBuf,
479    },
480    /// Require every nested check to succeed.
481    All {
482        /// Checks evaluated as a conjunction.
483        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)]
497/// Typed installation operation selected for a component.
498pub enum InstallSpec {
499    /// Managed Cargo build and activation.
500    Cargo(CargoInstall),
501    /// APT package installation.
502    Apt(AptInstall),
503    /// Homebrew formula installation.
504    Brew(BrewInstall),
505    /// rustup toolchain or component installation.
506    Rustup(RustupInstall),
507    /// npm global package installation.
508    Npm(NpmInstall),
509    /// Python package installation into a managed virtual environment.
510    Pip(PipInstall),
511    /// Python CLI installation through uv's isolated tool manager.
512    UvTool(UvToolInstall),
513    /// Windows Package Manager installation.
514    Winget(WingetInstall),
515    /// Verified archive download and extraction.
516    Archive(ArchiveInstall),
517    /// Pinned Git source build and activation.
518    Git(GitInstall),
519    /// Explicitly authorized shell installation.
520    Shell(ShellInstall),
521}
522
523/// Inputs for a managed Cargo installation.
524#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
525#[serde(deny_unknown_fields)]
526pub struct CargoInstall {
527    /// Crate package name.
528    #[serde(rename = "crate")]
529    pub crate_name: String,
530    /// Cargo version requirement.
531    pub version: String,
532    /// Optional named registry or Git source.
533    #[serde(default)]
534    pub source: Option<String>,
535    /// Pinned revision for Git-backed sources.
536    #[serde(default)]
537    pub revision: Option<String>,
538    /// Whether Cargo must honor the package lockfile.
539    #[serde(default = "default_true")]
540    pub locked: bool,
541    /// Cargo features enabled for the build.
542    #[serde(default)]
543    pub features: Vec<String>,
544    /// Expected executable names activated from the result.
545    #[serde(default)]
546    pub bins: Vec<String>,
547    /// Optional compilation target triple.
548    #[serde(default)]
549    pub target: Option<String>,
550    /// Optional Rust toolchain used to invoke Cargo.
551    #[serde(default)]
552    pub toolchain: Option<String>,
553    /// Cargo build profile.
554    #[serde(default = "release_profile")]
555    pub profile: String,
556    /// Environment variable names forwarded into the build.
557    #[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/// Inputs for an APT package installation.
570#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
571#[serde(deny_unknown_fields)]
572pub struct AptInstall {
573    /// Package names passed to APT.
574    pub packages: Vec<String>,
575    /// Whether to update package indexes before installation.
576    #[serde(default)]
577    pub update: bool,
578}
579
580/// Inputs for a Homebrew installation.
581#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
582#[serde(deny_unknown_fields)]
583pub struct BrewInstall {
584    /// Formula names passed to Homebrew.
585    pub formulae: Vec<String>,
586}
587
588/// Inputs for rustup installation and optional verified bootstrap.
589#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
590#[serde(deny_unknown_fields)]
591pub struct RustupInstall {
592    /// Explicit toolchain name.
593    #[serde(default)]
594    pub toolchain: Option<String>,
595    /// Reference resolved through the configuration version map.
596    #[serde(rename = "toolchain-ref", default)]
597    pub toolchain_ref: Option<String>,
598    /// Components installed into the selected toolchain.
599    #[serde(default)]
600    pub components: Vec<String>,
601    /// Whether the toolchain becomes rustup's default.
602    #[serde(default)]
603    pub default: bool,
604    /// Verified bootstrap used when rustup is unavailable.
605    #[serde(default)]
606    pub bootstrap: Option<RustupBootstrap>,
607}
608
609/// Platform-indexed verified rustup bootstrap download.
610#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
611#[serde(deny_unknown_fields)]
612pub struct RustupBootstrap {
613    /// Download URL, which may contain the selected target placeholder.
614    pub url: String,
615    /// SHA-256 digests keyed by target selector.
616    pub sha256: BTreeMap<String, String>,
617}
618
619/// Inputs for a global npm package installation.
620#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
621#[serde(deny_unknown_fields)]
622pub struct NpmInstall {
623    /// npm package name.
624    pub package: String,
625    /// Exact package version.
626    pub version: String,
627    /// Optional named npm registry source.
628    #[serde(default)]
629    pub source: Option<String>,
630}
631
632/// Inputs for a Python package installed into a managed virtual environment.
633#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
634#[serde(deny_unknown_fields)]
635pub struct PipInstall {
636    /// Python distribution package name.
637    pub package: String,
638    /// Exact package version.
639    pub version: String,
640    /// Python interpreter used to create the virtual environment.
641    #[serde(default = "default_python_program")]
642    pub python: String,
643    /// Managed virtual-environment path below `$BOT_FORGE_HOME`.
644    pub environment: PathBuf,
645    /// Optional HTTPS Python package index.
646    #[serde(default)]
647    pub index: Option<String>,
648}
649
650fn default_python_program() -> String {
651    "python3".to_string()
652}
653
654/// Inputs for an isolated Python CLI installation managed by uv.
655#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
656#[serde(deny_unknown_fields)]
657pub struct UvToolInstall {
658    /// Package requirement accepted by `uv tool install`.
659    pub package: String,
660    /// Executables expected from the installed tool.
661    pub bins: Vec<String>,
662    /// Replace existing executable entries when necessary.
663    #[serde(default)]
664    pub force: bool,
665    /// Optional HTTPS Python package index.
666    #[serde(default)]
667    pub index: Option<String>,
668}
669
670/// Inputs for a Windows Package Manager installation.
671#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
672#[serde(deny_unknown_fields)]
673pub struct WingetInstall {
674    /// winget package identifier.
675    pub package: String,
676    /// Additional winget arguments.
677    #[serde(default)]
678    pub arguments: Vec<String>,
679}
680
681/// Inputs for a verified archive installation.
682#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
683#[serde(deny_unknown_fields)]
684pub struct ArchiveInstall {
685    /// Download URL.
686    pub url: String,
687    /// Expected lowercase SHA-256 digest.
688    pub sha256: String,
689    /// Payload format.
690    #[serde(default)]
691    pub format: ArchiveFormat,
692    /// Managed destination path.
693    pub target: PathBuf,
694    /// Leading archive path components removed during extraction.
695    #[serde(default)]
696    pub strip_components: usize,
697    /// Permit TAR symlinks and hardlinks whose resolved targets remain inside the destination.
698    #[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)]
704/// Installation prerequisites that must be satisfied before ordinary installation work begins.
705pub struct PreflightDef {
706    /// Optional machine certificate prerequisite.
707    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)]
718/// Detect, install, and verify a certificate required for subsequent network access.
719pub struct CertificatePreflightDef {
720    /// Platform selectors on which this prerequisite applies.
721    #[serde(default = "all_platforms")]
722    pub platforms: Vec<String>,
723    /// Check that succeeds when the required certificate is already available.
724    pub detect: CheckSpec,
725    /// Policy-gated installation operation run only when detection fails.
726    pub install: InstallSpec,
727    /// Optional stricter post-install check; detection is repeated when omitted.
728    #[serde(default)]
729    pub verify: Option<CheckSpec>,
730}
731
732/// Inputs for a pinned Git installation.
733#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
734#[serde(deny_unknown_fields)]
735pub struct GitInstall {
736    /// Repository URL.
737    pub url: String,
738    /// Immutable commit or tag revision.
739    pub revision: String,
740    /// Optional repository-relative build directory.
741    #[serde(default)]
742    pub subdirectory: Option<PathBuf>,
743    /// Activated binary name to repository-relative output path mappings.
744    #[serde(default)]
745    pub bins: BTreeMap<String, PathBuf>,
746}
747
748/// Inputs for an explicitly authorized shell installation.
749#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
750#[serde(deny_unknown_fields)]
751pub struct ShellInstall {
752    /// Shell expression to execute.
753    pub command: String,
754    /// Additional named resources acquired for this operation.
755    #[serde(default)]
756    pub resources: Vec<String>,
757    /// Optional rollback expression invoked after a failed transaction.
758    #[serde(default)]
759    pub rollback: Option<String>,
760    /// Maximum total execution duration in seconds.
761    #[serde(default)]
762    pub timeout_secs: Option<u64>,
763    /// Maximum duration without captured output in seconds.
764    #[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)]
770/// Named path values and ordered environment mutations.
771pub struct EnvironmentDef {
772    /// Named path values referenced by environment mutations.
773    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
774    pub paths: BTreeMap<String, String>,
775    /// Ordered environment changes applied after installation.
776    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)]
781/// Typed environment mutation with explicit scope and platform selectors.
782pub enum EnvironmentMutation {
783    /// Set an environment variable.
784    Variable {
785        /// Stable mutation identifier.
786        id: String,
787        /// Environment variable name.
788        name: String,
789        /// Value after path and environment expansion.
790        value: String,
791        /// Process-only or persistent user scope.
792        #[serde(default = "user_scope")]
793        scope: MutationScope,
794        /// Eligible target selectors.
795        #[serde(default = "all_platforms")]
796        platforms: Vec<String>,
797    },
798    /// Prepend one value to the platform path variable.
799    PathPrepend {
800        /// Stable mutation identifier.
801        id: String,
802        /// Path value after expansion.
803        value: String,
804        /// Process-only or persistent user scope.
805        #[serde(default = "user_scope")]
806        scope: MutationScope,
807        /// Eligible target selectors.
808        #[serde(default = "all_platforms")]
809        platforms: Vec<String>,
810    },
811    /// Maintain a marked fragment in a text file.
812    FileFragment {
813        /// Stable mutation identifier used for fragment markers.
814        id: String,
815        /// File to update.
816        path: PathBuf,
817        /// Lines maintained inside the managed fragment.
818        lines: Vec<String>,
819        /// Eligible target selectors.
820        #[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")]
831/// Persistence scope for an environment mutation.
832pub enum MutationScope {
833    /// Change only the current bot-forge process environment.
834    Process,
835    /// Persist the change in the current user's environment configuration.
836    User,
837}
838
839/// Default APT mirror values and platform-specific override rules.
840#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
841#[serde(deny_unknown_fields)]
842pub struct AptMirrorDef {
843    /// Default repository URI.
844    #[serde(default)]
845    pub uri: Option<String>,
846    /// Default distribution suites.
847    #[serde(default)]
848    pub suites: Vec<String>,
849    /// Default repository components.
850    #[serde(default)]
851    pub components: Vec<String>,
852    /// Default repository architectures.
853    #[serde(default)]
854    pub architectures: Vec<String>,
855    /// Default keyring path used by `Signed-By`.
856    #[serde(default)]
857    pub signed_by: Option<PathBuf>,
858    /// Default managed Deb822 source file.
859    #[serde(default)]
860    pub source_file: Option<PathBuf>,
861    /// Ordered platform-specific overrides.
862    #[serde(default)]
863    pub rules: Vec<AptMirrorRuleDef>,
864}
865
866/// Conditional APT mirror override matched against detected system attributes.
867#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
868#[serde(default, deny_unknown_fields)]
869pub struct AptMirrorRuleDef {
870    /// Optional distribution identifier predicate.
871    pub distribution: Option<String>,
872    /// Optional distribution codename predicate.
873    pub codename: Option<String>,
874    /// Optional APT architecture predicate.
875    pub architecture: Option<String>,
876    /// Repository URI override.
877    pub uri: Option<String>,
878    /// Distribution suite override.
879    pub suites: Vec<String>,
880    /// Repository component override.
881    pub components: Vec<String>,
882    /// Repository architecture override.
883    pub architectures: Vec<String>,
884    /// `Signed-By` keyring override.
885    pub signed_by: Option<PathBuf>,
886    /// Managed source file override.
887    pub source_file: Option<PathBuf>,
888}
889
890#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
891/// Provenance map from canonical configuration field paths to source labels.
892pub struct OriginMap {
893    /// Canonical field path to origin label mappings.
894    pub fields: BTreeMap<String, String>,
895}
896
897impl OriginMap {
898    /// Record or replace the origin for a canonical field path.
899    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    /// Parse and validate a user configuration that selects the supported built-in catalog.
906    ///
907    /// # Errors
908    ///
909    /// Returns [`ForgeError`] for invalid TOML, unknown fields, an absent catalog selection, or
910    /// any semantic validation failure.
911    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    /// Validate identifiers, references, policy limits, platform selectors, and backend contracts.
1077    ///
1078    /// # Errors
1079    ///
1080    /// Returns [`ForgeError::Config`] for the first rejected configuration
1081    /// invariant.
1082    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    /// Merge one overlay while recording the source of every changed canonical field.
1118    ///
1119    /// # Errors
1120    ///
1121    /// Returns [`ForgeError`] when the overlay cannot be parsed, attempts an unsupported merge, or
1122    /// leaves the document semantically invalid.
1123    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    /// Find a canonical component by its stable identifier.
1184    pub fn component(&self, id: &str) -> Option<&ComponentDef> {
1185        self.components.iter().find(|component| component.id == id)
1186    }
1187
1188    /// Expand a profile entry into canonical component references.
1189    ///
1190    /// # Errors
1191    ///
1192    /// Returns [`ForgeError`] when a referenced group is missing or expands recursively.
1193    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    /// Replace version references in compact and canonical installation declarations.
1205    ///
1206    /// # Errors
1207    ///
1208    /// Returns [`ForgeError`] for missing references or version values rejected by the consuming
1209    /// backend.
1210    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    /// Resolve named source definitions into backend-specific canonical fields.
1230    ///
1231    /// # Errors
1232    ///
1233    /// Returns [`ForgeError`] for missing or incompatible source references and invalid resolved
1234    /// source contracts.
1235    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    /// Return the stable identity used to replace or deduplicate this mutation.
1810    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    /// Return the target selectors controlling whether this mutation is applied.
1819    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    /// Return the digest used to partition reusable Cargo source caches.
1830    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    /// Return the digest of crate, version, source, and revision identity.
1841    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    /// Return the digest of reproducibility inputs that affect Cargo output.
1854    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    /// Combine installation, source, and lock identities into an artifact fingerprint.
1870    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;