Skip to main content

osdk_core/config/
mod.rs

1//! Layered configuration.
2//!
3//! Precedence (highest wins): CLI flags → env (`OSDK_*`) → project config
4//! (`osdk.toml`, discovered by walking up) → user global config
5//! (`$OSDK_CONFIG_DIR/config.toml`) → built-in defaults.
6//!
7//! This module owns the persisted settings shape. CLI-flag overlay is applied
8//! by the caller (osdk-cli) on top of [`Config::load`].
9
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13use serde::{Deserialize, Serialize};
14
15use crate::error::{Error, Result};
16use crate::source::{Selection, Source};
17use crate::store::link::LinkMode;
18
19pub const PROJECT_CONFIG_NAMES: &[&str] = &["osdk.toml", ".osdk.toml"];
20
21/// Fully-resolved settings after merging all layers.
22#[derive(Debug, Clone)]
23pub struct Config {
24    pub settings: Settings,
25    pub sources: SourcesConfig,
26    /// Tool pins gathered from config files (backend id -> version spec string).
27    pub tools: BTreeMap<String, String>,
28    /// Merged tool config entries with structured options preserved.
29    pub tool_configs: BTreeMap<String, ToolConfigEntry>,
30    /// Tool pins contributed by the user-global config before project merging.
31    pub global_tools: BTreeMap<String, String>,
32    /// Structured user-global tool entries before project merging.
33    pub global_tool_configs: BTreeMap<String, ToolConfigEntry>,
34    /// Origin of each winning entry in [`Config::tools`].
35    pub tool_origins: BTreeMap<String, ToolConfigOrigin>,
36    /// User-defined version aliases: tool -> alias -> version spec.
37    pub aliases: BTreeMap<String, BTreeMap<String, String>>,
38    /// Path of the nearest discovered project config, if any.
39    pub project_config_path: Option<PathBuf>,
40}
41
42/// Source layer that contributed an effective tool entry.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum ToolConfigOrigin {
45    GlobalConfig(PathBuf),
46    ProjectConfig(PathBuf),
47    ToolVersions(PathBuf),
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(default)]
52pub struct Settings {
53    /// How store blobs are materialized into install dirs.
54    pub link_mode: LinkMode,
55    /// Max concurrent downloads / installs.
56    pub jobs: usize,
57    /// Assume-yes for prompts.
58    pub yes: bool,
59    /// Whether to verify signatures when a backend provides them.
60    pub verify_signatures: bool,
61    /// Reject artifacts when no checksum is available.
62    pub require_checksums: bool,
63    /// GitHub artifact attestation verification policy.
64    pub attestations: AttestationPolicy,
65    /// Never make network requests; use cached metadata and archives only.
66    pub offline: bool,
67    /// Output language override (`en`/`zh`). None = auto-detect from locale.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub lang: Option<String>,
70    /// Node-specific installation behavior.
71    pub node: NodeSettings,
72    /// Python catalog refresh and verification.
73    pub python: PythonSettings,
74    /// Java runtime metadata endpoint.
75    pub java: JavaSettings,
76    /// Pre-release resolution policy shared by supporting backends.
77    pub prerelease: PrereleasePolicy,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, Default)]
81#[serde(default)]
82pub struct NodeSettings {
83    /// Run the installed Node's own `corepack enable` after installation.
84    pub corepack: bool,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88#[serde(default)]
89pub struct PythonSettings {
90    /// Optional JSON catalog URL or local path.
91    pub catalog_url: Option<String>,
92    /// Required SHA-256 for the exact catalog bytes.
93    pub catalog_sha256: Option<String>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, Default)]
97#[serde(default)]
98pub struct JavaSettings {
99    /// Foojay-compatible `/packages` endpoint or static mirror.
100    pub catalog_url: Option<String>,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
104#[serde(rename_all = "kebab-case")]
105pub enum PrereleasePolicy {
106    #[default]
107    IfExplicit,
108    Never,
109    Allow,
110}
111
112impl std::str::FromStr for PrereleasePolicy {
113    type Err = Error;
114
115    fn from_str(value: &str) -> Result<Self> {
116        match value.trim().to_ascii_lowercase().as_str() {
117            "never" => Ok(Self::Never),
118            "if-explicit" | "explicit" | "auto" => Ok(Self::IfExplicit),
119            "allow" | "always" => Ok(Self::Allow),
120            other => Err(Error::config(format!(
121                "invalid prerelease policy `{other}` (expected never|if-explicit|allow)"
122            ))),
123        }
124    }
125}
126
127impl std::fmt::Display for PrereleasePolicy {
128    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        formatter.write_str(match self {
130            Self::Never => "never",
131            Self::IfExplicit => "if-explicit",
132            Self::Allow => "allow",
133        })
134    }
135}
136
137impl Default for Settings {
138    fn default() -> Self {
139        Settings {
140            link_mode: LinkMode::Auto,
141            jobs: default_jobs(),
142            yes: false,
143            verify_signatures: true,
144            require_checksums: false,
145            attestations: AttestationPolicy::Off,
146            offline: false,
147            lang: None,
148            node: NodeSettings::default(),
149            python: PythonSettings::default(),
150            java: JavaSettings::default(),
151            prerelease: PrereleasePolicy::default(),
152        }
153    }
154}
155
156fn default_jobs() -> usize {
157    std::thread::available_parallelism()
158        .map(|n| n.get())
159        .unwrap_or(4)
160        .min(8)
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
164#[serde(default)]
165pub struct SourcesConfig {
166    pub selection: Selection,
167    pub probe_timeout_ms: u64,
168    /// TTL for cached probe results, as a human string like "6h".
169    pub cache_ttl: String,
170    /// Per-tool source overrides.
171    #[serde(flatten)]
172    pub per_tool: BTreeMap<String, ToolSources>,
173    /// Package-registry configuration is persisted under the top-level
174    /// `[registries]` table. It lives here internally so adding it does not
175    /// break callers that construct [`Config`] directly.
176    #[doc(hidden)]
177    #[serde(skip)]
178    pub registries: RegistriesConfig,
179    /// Native-container configuration is persisted under the separate
180    /// top-level `[containers]` table. It lives here internally so adding it
181    /// does not break callers that construct [`Config`] directly.
182    #[doc(hidden)]
183    #[serde(skip)]
184    pub containers: ContainersConfig,
185}
186
187impl Default for SourcesConfig {
188    fn default() -> Self {
189        SourcesConfig {
190            selection: Selection::Auto,
191            probe_timeout_ms: 1500,
192            cache_ttl: "6h".to_string(),
193            per_tool: BTreeMap::new(),
194            registries: RegistriesConfig::default(),
195            containers: ContainersConfig::default(),
196        }
197    }
198}
199
200impl SourcesConfig {
201    /// Parse the cache TTL string into seconds. Defaults to 6h on parse error.
202    pub fn cache_ttl_secs(&self) -> u64 {
203        parse_duration_secs(&self.cache_ttl).unwrap_or(6 * 3600)
204    }
205}
206
207/// Registry preflight settings. Package registry URLs are deliberately kept
208/// separate from SDK download sources because they affect delegated package
209/// manager commands rather than osdk's own downloads.
210#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
211#[serde(default)]
212pub struct RegistriesConfig {
213    pub npm: NpmRegistryConfig,
214}
215
216/// Candidate registries for npm-compatible package managers. An empty list
217/// means to use osdk's built-in public candidates.
218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
219#[serde(default)]
220pub struct NpmRegistryConfig {
221    pub urls: Vec<String>,
222    pub probe_timeout_ms: u64,
223}
224
225impl Default for NpmRegistryConfig {
226    fn default() -> Self {
227        Self {
228            urls: Vec::new(),
229            probe_timeout_ms: 1500,
230        }
231    }
232}
233
234/// Native-only container integration settings. Higher-precedence configuration
235/// files replace this section as a unit instead of merging registry policies.
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
237#[serde(default, deny_unknown_fields)]
238pub struct ContainersConfig {
239    pub runtime: ContainerRuntime,
240    /// `auto` or an explicit native builder name.
241    pub builder: crate::container::BuildxBuilderSelector,
242    /// `runtime` or a strict OCI `OS/ARCH[/VARIANT]` selector.
243    pub platform: ContainerPlatform,
244    pub probe_timeout_ms: u64,
245    pub registries: BTreeMap<String, ContainerRegistryConfig>,
246}
247
248impl Default for ContainersConfig {
249    fn default() -> Self {
250        Self {
251            runtime: ContainerRuntime::Auto,
252            builder: crate::container::BuildxBuilderSelector::Auto,
253            platform: ContainerPlatform::Runtime,
254            probe_timeout_ms: 1500,
255            registries: BTreeMap::new(),
256        }
257    }
258}
259
260/// Native runtime selected for container inspection and operations.
261#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
262#[serde(rename_all = "lowercase")]
263pub enum ContainerRuntime {
264    #[default]
265    Auto,
266    Docker,
267    Containerd,
268}
269
270impl std::str::FromStr for ContainerRuntime {
271    type Err = Error;
272
273    fn from_str(value: &str) -> Result<Self> {
274        match value.trim().to_ascii_lowercase().as_str() {
275            "auto" => Ok(Self::Auto),
276            "docker" => Ok(Self::Docker),
277            "containerd" => Ok(Self::Containerd),
278            _ => Err(Error::config(
279                "invalid container runtime (expected auto|docker|containerd)",
280            )),
281        }
282    }
283}
284
285impl std::fmt::Display for ContainerRuntime {
286    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287        formatter.write_str(match self {
288            Self::Auto => "auto",
289            Self::Docker => "docker",
290            Self::Containerd => "containerd",
291        })
292    }
293}
294
295impl<'de> Deserialize<'de> for ContainerRuntime {
296    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
297    where
298        D: serde::Deserializer<'de>,
299    {
300        let value = String::deserialize(deserializer)?;
301        value.parse().map_err(|_| {
302            serde::de::Error::custom("invalid container runtime (expected auto|docker|containerd)")
303        })
304    }
305}
306
307/// Target platform chosen from the native runtime or an explicit OCI tuple.
308#[derive(Debug, Clone, Default, PartialEq, Eq)]
309pub enum ContainerPlatform {
310    #[default]
311    Runtime,
312    Explicit {
313        os: String,
314        arch: String,
315        variant: Option<String>,
316    },
317}
318
319impl std::str::FromStr for ContainerPlatform {
320    type Err = Error;
321
322    fn from_str(value: &str) -> Result<Self> {
323        let value = value.trim();
324        if value == "runtime" {
325            return Ok(Self::Runtime);
326        }
327
328        let components = value.split('/').collect::<Vec<_>>();
329        if !(2..=3).contains(&components.len())
330            || components
331                .iter()
332                .any(|component| !is_oci_platform_component(component))
333        {
334            return Err(Error::config(
335                "invalid container platform (expected runtime or OS/ARCH[/VARIANT])",
336            ));
337        }
338
339        Ok(Self::Explicit {
340            os: components[0].to_string(),
341            arch: components[1].to_string(),
342            variant: components.get(2).map(|value| (*value).to_string()),
343        })
344    }
345}
346
347impl std::fmt::Display for ContainerPlatform {
348    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
349        match self {
350            Self::Runtime => formatter.write_str("runtime"),
351            Self::Explicit { os, arch, variant } => {
352                write!(formatter, "{os}/{arch}")?;
353                if let Some(variant) = variant {
354                    write!(formatter, "/{variant}")?;
355                }
356                Ok(())
357            }
358        }
359    }
360}
361
362impl Serialize for ContainerPlatform {
363    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
364    where
365        S: serde::Serializer,
366    {
367        serializer.serialize_str(&self.to_string())
368    }
369}
370
371impl<'de> Deserialize<'de> for ContainerPlatform {
372    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
373    where
374        D: serde::Deserializer<'de>,
375    {
376        let value = String::deserialize(deserializer)?;
377        value.parse().map_err(serde::de::Error::custom)
378    }
379}
380
381fn is_oci_platform_component(value: &str) -> bool {
382    !value.is_empty()
383        && value.bytes().all(|byte| {
384            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'.' | b'-')
385        })
386}
387
388/// Mirror policy for one upstream OCI registry namespace.
389#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
390#[serde(default, deny_unknown_fields)]
391pub struct ContainerRegistryConfig {
392    pub mirrors: Vec<String>,
393    pub anonymous_only: bool,
394    pub resolve: ContainerResolve,
395}
396
397impl Default for ContainerRegistryConfig {
398    fn default() -> Self {
399        Self {
400            mirrors: Vec::new(),
401            anonymous_only: true,
402            resolve: ContainerResolve::Upstream,
403        }
404    }
405}
406
407/// Whether tags are resolved by the origin registry or by a configured mirror.
408#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
409#[serde(rename_all = "lowercase")]
410pub enum ContainerResolve {
411    #[default]
412    Upstream,
413    Mirror,
414}
415
416/// Per-tool source config: an optional pin and any user-added custom sources.
417#[derive(Debug, Clone, Default, Serialize, Deserialize)]
418pub struct ToolSources {
419    /// Pin to a specific source id (overrides auto/ordered).
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub pin: Option<String>,
422    /// Disabled built-in source ids.
423    #[serde(default, skip_serializing_if = "Vec::is_empty")]
424    pub disable: Vec<String>,
425    /// User-added custom sources.
426    #[serde(default, skip_serializing_if = "Vec::is_empty")]
427    pub custom: Vec<Source>,
428    /// Export this model provider's endpoint/cache through shell activation.
429    #[serde(default)]
430    pub env: bool,
431    /// Override pre-existing provider environment variables.
432    #[serde(default)]
433    pub env_force: bool,
434}
435
436/// Persisted `[tools]` entry. Legacy strings remain supported, while structured
437/// objects can carry extra backend-specific options.
438#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
439#[serde(untagged)]
440pub enum ToolConfigEntry {
441    Legacy(String),
442    Structured(StructuredToolConfig),
443}
444
445impl ToolConfigEntry {
446    pub fn legacy(version: impl Into<String>) -> Self {
447        Self::Legacy(version.into())
448    }
449
450    pub fn structured(
451        version: impl Into<String>,
452        options: BTreeMap<String, ToolConfigValue>,
453    ) -> Self {
454        Self::Structured(StructuredToolConfig {
455            version: version.into(),
456            options,
457        })
458    }
459
460    pub fn version(&self) -> &str {
461        match self {
462            Self::Legacy(version) => version,
463            Self::Structured(config) => &config.version,
464        }
465    }
466
467    pub fn options(&self) -> Option<&BTreeMap<String, ToolConfigValue>> {
468        match self {
469            Self::Legacy(_) => None,
470            Self::Structured(config) => Some(&config.options),
471        }
472    }
473
474    pub fn structured_config(&self) -> Option<&StructuredToolConfig> {
475        match self {
476            Self::Legacy(_) => None,
477            Self::Structured(config) => Some(config),
478        }
479    }
480
481    pub fn to_cli_option_strings(&self) -> Vec<String> {
482        match self {
483            Self::Legacy(_) => Vec::new(),
484            Self::Structured(config) => config.to_cli_option_strings(),
485        }
486    }
487
488    pub fn to_request_options(&self) -> BTreeMap<String, String> {
489        match self {
490            Self::Legacy(_) => BTreeMap::new(),
491            Self::Structured(config) => config.to_request_options(),
492        }
493    }
494}
495
496/// Structured `[tools.<tool>]` object with a required version plus arbitrary
497/// backend-specific options.
498#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
499pub struct StructuredToolConfig {
500    pub version: String,
501    #[serde(flatten)]
502    pub options: BTreeMap<String, ToolConfigValue>,
503}
504
505impl StructuredToolConfig {
506    pub fn to_cli_option_strings(&self) -> Vec<String> {
507        self.options
508            .iter()
509            .map(|(key, value)| format!("{key}={}", value.as_cli_value()))
510            .collect()
511    }
512
513    pub fn to_request_options(&self) -> BTreeMap<String, String> {
514        self.options
515            .iter()
516            .map(|(key, value)| (key.clone(), value.as_cli_value()))
517            .collect()
518    }
519}
520
521/// Arbitrary structured tool option value.
522#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
523#[serde(untagged)]
524pub enum ToolConfigValue {
525    String(String),
526    Bool(bool),
527    Array(Vec<String>),
528}
529
530impl ToolConfigValue {
531    pub fn as_cli_value(&self) -> String {
532        match self {
533            Self::String(value) => value.clone(),
534            Self::Bool(value) => value.to_string(),
535            Self::Array(values) => values.join(","),
536        }
537    }
538}
539
540/// On-disk config file shape (a subset that users edit).
541#[derive(Debug, Clone, Default, Serialize, Deserialize)]
542#[serde(default)]
543struct ConfigFile {
544    settings: Option<Settings>,
545    sources: Option<SourcesConfig>,
546    registries: Option<RegistriesConfig>,
547    containers: Option<ContainersConfig>,
548    tools: BTreeMap<String, ToolConfigEntry>,
549    aliases: BTreeMap<String, BTreeMap<String, String>>,
550}
551
552impl Config {
553    /// Load config by merging user global + project files, then env overrides.
554    /// `start_dir` is where project-config discovery begins (usually cwd).
555    pub fn load(user_config_file: &Path, start_dir: &Path) -> Result<Config> {
556        load_layers_internal(user_config_file, Some(start_dir))
557    }
558
559    /// Load only user-global configuration and environment overrides. Trust
560    /// management uses this so an untrusted project cannot influence the
561    /// decision to trust itself.
562    pub fn load_user(user_config_file: &Path) -> Result<Config> {
563        load_layers_internal(user_config_file, None)
564    }
565
566    fn apply_file(&mut self, file: ConfigFile, allow_model_env: bool) {
567        if let Some(s) = file.settings {
568            self.settings = s;
569        }
570        if let Some(src) = file.sources {
571            // merge: file replaces top-level knobs, per-tool maps merge
572            let mut merged = self.sources.per_tool.clone();
573            for (k, mut v) in src.per_tool {
574                if !allow_model_env {
575                    let global = merged.get(&k);
576                    v.env = global.is_some_and(|config| config.env);
577                    v.env_force = global.is_some_and(|config| config.env_force);
578                }
579                merged.insert(k, v);
580            }
581            self.sources = SourcesConfig {
582                selection: src.selection,
583                probe_timeout_ms: src.probe_timeout_ms,
584                cache_ttl: src.cache_ttl,
585                per_tool: merged,
586                registries: self.sources.registries.clone(),
587                containers: self.sources.containers.clone(),
588            };
589        }
590        if let Some(registries) = file.registries {
591            // Registry sections replace the lower-precedence layer as a unit.
592            self.sources.registries = registries;
593        }
594        if let Some(containers) = file.containers {
595            // Container sections replace the lower-precedence layer as a unit.
596            self.sources.containers = containers;
597        }
598        self.apply_tool_configs(&file.tools);
599        for (tool, aliases) in file.aliases {
600            self.aliases.entry(tool).or_default().extend(aliases);
601        }
602    }
603
604    fn apply_tool_configs(&mut self, tools: &BTreeMap<String, ToolConfigEntry>) {
605        for (tool, entry) in tools {
606            self.tools.insert(tool.clone(), entry.version().to_string());
607            self.tool_configs.insert(tool.clone(), entry.clone());
608        }
609    }
610
611    /// Apply `OSDK_*` env overrides. Exposed for testing.
612    pub fn apply_env(&mut self, getenv: impl Fn(&str) -> Option<String>) {
613        if let Some(v) = getenv("OSDK_LINK_MODE") {
614            if let Ok(m) = v.parse::<LinkMode>() {
615                self.settings.link_mode = m;
616            }
617        }
618        if let Some(v) = getenv("OSDK_JOBS") {
619            if let Ok(n) = v.parse::<usize>() {
620                if n > 0 {
621                    self.settings.jobs = n;
622                }
623            }
624        }
625        if let Some(v) = getenv("OSDK_YES") {
626            self.settings.yes = truthy(&v);
627        }
628        if let Some(v) = getenv("OSDK_VERIFY_SIGNATURES") {
629            self.settings.verify_signatures = truthy(&v);
630        }
631        if let Some(v) = getenv("OSDK_REQUIRE_CHECKSUMS") {
632            self.settings.require_checksums = truthy(&v);
633        }
634        if let Some(v) = getenv("OSDK_ATTESTATIONS") {
635            if let Ok(policy) = v.parse() {
636                self.settings.attestations = policy;
637            }
638        }
639        if let Some(v) = getenv("OSDK_OFFLINE") {
640            self.settings.offline = truthy(&v);
641        }
642        if let Some(v) = getenv("OSDK_PRERELEASE") {
643            if let Ok(policy) = v.parse() {
644                self.settings.prerelease = policy;
645            }
646        }
647        if let Some(v) = getenv("OSDK_PYTHON_CATALOG_URL") {
648            self.settings.python.catalog_url = Some(v);
649        }
650        if let Some(v) = getenv("OSDK_PYTHON_CATALOG_SHA256") {
651            self.settings.python.catalog_sha256 = Some(v);
652        }
653        if let Some(v) = getenv("OSDK_JAVA_CATALOG_URL") {
654            self.settings.java.catalog_url = Some(v);
655        }
656        if let Some(v) = getenv("OSDK_SELECTION") {
657            self.sources.selection = match v.to_ascii_lowercase().as_str() {
658                "pinned" => Selection::Pinned,
659                "ordered" => Selection::Ordered,
660                _ => Selection::Auto,
661            };
662        }
663        if let Some(v) = getenv("OSDK_CONTAINER_RUNTIME") {
664            if let Ok(runtime) = v.parse() {
665                self.sources.containers.runtime = runtime;
666            }
667        }
668        if let Some(v) = getenv("OSDK_CONTAINER_BUILDER") {
669            if let Ok(builder) = v.parse() {
670                self.sources.containers.builder = builder;
671            }
672        }
673        if let Some(v) = getenv("OSDK_CONTAINER_PLATFORM") {
674            if let Ok(platform) = v.parse() {
675                self.sources.containers.platform = platform;
676            }
677        }
678    }
679
680    pub fn tool_sources(&self, tool: &str) -> Option<&ToolSources> {
681        self.sources.per_tool.get(tool)
682    }
683
684    /// Provenance of the effective merged tool entry.
685    pub fn tool_origin(&self, tool: &str) -> Option<&ToolConfigOrigin> {
686        self.tool_origins.get(tool)
687    }
688
689    /// Effective package-registry configuration after user/project layering.
690    pub fn registries(&self) -> &RegistriesConfig {
691        &self.sources.registries
692    }
693
694    /// Effective native-container configuration after user/project layering.
695    pub fn containers(&self) -> &ContainersConfig {
696        &self.sources.containers
697    }
698
699    pub fn expand_alias(&self, tool: &str, spec: &str) -> Result<String> {
700        let Some(aliases) = self.aliases.get(tool) else {
701            return Ok(spec.to_string());
702        };
703        expand_alias(aliases, spec)
704    }
705}
706
707#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
708#[serde(rename_all = "kebab-case")]
709pub enum AttestationPolicy {
710    #[default]
711    Off,
712    IfAvailable,
713    Required,
714}
715
716impl std::str::FromStr for AttestationPolicy {
717    type Err = Error;
718
719    fn from_str(value: &str) -> Result<Self> {
720        match value.trim().to_ascii_lowercase().as_str() {
721            "off" | "false" | "0" => Ok(Self::Off),
722            "if-available" | "available" | "auto" => Ok(Self::IfAvailable),
723            "required" | "require" | "true" | "1" => Ok(Self::Required),
724            other => Err(Error::config(format!(
725                "invalid attestation policy `{other}` (expected off|if-available|required)"
726            ))),
727        }
728    }
729}
730
731impl std::fmt::Display for AttestationPolicy {
732    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
733        formatter.write_str(match self {
734            Self::Off => "off",
735            Self::IfAvailable => "if-available",
736            Self::Required => "required",
737        })
738    }
739}
740
741pub fn validate_alias_name(name: &str) -> Result<()> {
742    let name = name.trim();
743    if name.is_empty()
744        || matches!(
745            name.to_ascii_lowercase().as_str(),
746            "latest" | "current" | "stable" | "system" | "lts" | "lts/*" | "lts-latest"
747        )
748        || name.starts_with("lts/")
749        || name.starts_with("lts-")
750    {
751        return Err(Error::config(format!(
752            "`{name}` is reserved and cannot be used as a version alias"
753        )));
754    }
755    if name.contains(char::is_whitespace) || name.contains('@') {
756        return Err(Error::config(format!("invalid version alias `{name}`")));
757    }
758    Ok(())
759}
760
761pub fn expand_alias(aliases: &BTreeMap<String, String>, spec: &str) -> Result<String> {
762    let mut current = spec.to_string();
763    let mut seen = std::collections::BTreeSet::new();
764    while let Some(next) = aliases.get(&current) {
765        if !seen.insert(current.clone()) {
766            let mut chain = seen.into_iter().collect::<Vec<_>>();
767            chain.push(current);
768            return Err(Error::config(format!(
769                "version alias cycle: {}",
770                chain.join(" -> ")
771            )));
772        }
773        current = next.clone();
774    }
775    Ok(current)
776}
777
778fn truthy(s: &str) -> bool {
779    matches!(s.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")
780}
781
782fn read_config_file(path: &Path) -> Result<ConfigFile> {
783    let text = std::fs::read_to_string(path).map_err(|e| Error::io(path, e))?;
784    let mut file: ConfigFile = toml::from_str(&text).map_err(sanitize_config_parse_error)?;
785    if let Some(registries) = &mut file.registries {
786        normalize_registry_urls(&mut registries.npm.urls)?;
787    }
788    if let Some(containers) = &mut file.containers {
789        validate_containers_config(containers)?;
790    }
791    Ok(file)
792}
793
794fn sanitize_config_parse_error(error: toml::de::Error) -> Error {
795    let message = error.message();
796    if message.contains("invalid container runtime") {
797        Error::config("invalid container runtime (expected auto|docker|containerd)")
798    } else if message.contains("invalid Buildx builder selector") {
799        Error::config("invalid container builder (expected auto or a safe ASCII name)")
800    } else if message.contains("invalid container platform") {
801        Error::config("invalid container platform (expected runtime or OS/ARCH[/VARIANT])")
802    } else {
803        Error::TomlDe(error)
804    }
805}
806
807fn validate_containers_config(config: &mut ContainersConfig) -> Result<()> {
808    if config.probe_timeout_ms == 0 {
809        return Err(Error::config(
810            "container probe_timeout_ms must be greater than zero",
811        ));
812    }
813
814    let registries = std::mem::take(&mut config.registries);
815    let mut canonical_registries = BTreeMap::new();
816    for (registry, mut policy) in registries {
817        let registry = canonical_container_registry_name(&registry)?;
818        let mut normalized = Vec::with_capacity(policy.mirrors.len());
819        for mirror in &policy.mirrors {
820            let mirror = normalize_container_mirror_url(mirror)?;
821            if !normalized.contains(&mirror) {
822                normalized.push(mirror);
823            }
824        }
825        if normalized.len() > 8 {
826            return Err(Error::config(
827                "container registry policy supports at most 8 mirrors",
828            ));
829        }
830        policy.mirrors = normalized;
831        if canonical_registries.insert(registry, policy).is_some() {
832            return Err(Error::config(
833                "container registry keys collide after canonicalization",
834            ));
835        }
836    }
837    config.registries = canonical_registries;
838    Ok(())
839}
840
841fn canonical_container_registry_name(value: &str) -> Result<String> {
842    let value = value.trim();
843    if value.is_empty()
844        || value.contains("://")
845        || value.contains(['/', '?', '#', '@'])
846        || value.bytes().any(|byte| byte.is_ascii_whitespace())
847    {
848        return Err(Error::config(
849            "invalid container registry (expected a host name with optional port)",
850        ));
851    }
852
853    // URL parsing gives us strict host and port validation while the fixed
854    // scheme prevents registry keys from embedding credentials or paths.
855    let parsed = reqwest::Url::parse(&format!("https://{value}/"))
856        .map_err(|_| Error::config("invalid container registry"))?;
857    if parsed.host_str().is_none() || parsed.port().is_none() && value.ends_with(':') {
858        return Err(Error::config(
859            "invalid container registry (expected a host name with optional port)",
860        ));
861    }
862    let host = parsed
863        .host_str()
864        .expect("host checked above")
865        .trim_start_matches('[')
866        .trim_end_matches(']')
867        .trim_end_matches('.')
868        .to_ascii_lowercase();
869    if host.is_empty() {
870        return Err(Error::config("invalid container registry"));
871    }
872    Ok(match parsed.port() {
873        Some(port) if host.contains(':') => format!("[{host}]:{port}"),
874        Some(port) => format!("{host}:{port}"),
875        None if host.contains(':') => format!("[{host}]"),
876        None => host,
877    })
878}
879
880/// Validate and canonicalize a persisted OCI mirror URL. Persisted mirror
881/// policy is HTTPS-only and never carries credentials, query, or fragment.
882pub fn normalize_container_mirror_url(value: &str) -> Result<String> {
883    let original = value.trim();
884    let mut url =
885        reqwest::Url::parse(original).map_err(|_| Error::config("invalid container mirror URL"))?;
886    if url.scheme() != "https" || url.host_str().is_none() {
887        return Err(Error::config(
888            "container mirror URL must use https and include a host",
889        ));
890    }
891    if !url.username().is_empty() || url.password().is_some() {
892        return Err(Error::config(
893            "container mirror URL must not contain credentials",
894        ));
895    }
896    if url.query().is_some() || url.fragment().is_some() {
897        return Err(Error::config(
898            "container mirror URL must not contain a query string or fragment",
899        ));
900    }
901    let path = url.path().trim_end_matches('/').to_string();
902    url.set_path(&format!("{path}/"));
903    Ok(url.to_string())
904}
905
906fn load_layers_internal(user_config_file: &Path, start_dir: Option<&Path>) -> Result<Config> {
907    let mut cfg = Config {
908        settings: Settings::default(),
909        sources: SourcesConfig::default(),
910        tools: BTreeMap::new(),
911        tool_configs: BTreeMap::new(),
912        global_tools: BTreeMap::new(),
913        global_tool_configs: BTreeMap::new(),
914        tool_origins: BTreeMap::new(),
915        aliases: BTreeMap::new(),
916        project_config_path: None,
917    };
918
919    if user_config_file.exists() {
920        let file = read_config_file(user_config_file)?;
921        cfg.global_tool_configs = file.tools.clone();
922        cfg.global_tools = file
923            .tools
924            .iter()
925            .map(|(tool, entry)| (tool.clone(), entry.version().to_string()))
926            .collect();
927        cfg.tool_origins.extend(file.tools.keys().map(|tool| {
928            (
929                tool.clone(),
930                ToolConfigOrigin::GlobalConfig(user_config_file.to_path_buf()),
931            )
932        }));
933        cfg.apply_file(file, true);
934    }
935
936    if let Some(start_dir) = start_dir {
937        if let Some((path, file)) = find_project_config(start_dir)? {
938            cfg.tool_origins.extend(
939                file.tools
940                    .keys()
941                    .map(|tool| (tool.clone(), ToolConfigOrigin::ProjectConfig(path.clone()))),
942            );
943            cfg.apply_file(file, false);
944            cfg.project_config_path = Some(path);
945        }
946        if let Some((path, tv)) = find_tool_versions(start_dir)? {
947            for (tool, version) in tv {
948                if !cfg.tools.contains_key(&tool) {
949                    cfg.tools.insert(tool.clone(), version.clone());
950                    cfg.tool_configs
951                        .insert(tool.clone(), ToolConfigEntry::legacy(version));
952                    cfg.tool_origins
953                        .insert(tool, ToolConfigOrigin::ToolVersions(path.clone()));
954                }
955            }
956        }
957    }
958
959    cfg.apply_env(|k| std::env::var(k).ok());
960
961    Ok(cfg)
962}
963
964fn normalize_registry_urls(urls: &mut Vec<String>) -> Result<()> {
965    let mut normalized = Vec::with_capacity(urls.len());
966    for value in urls.iter() {
967        let value = normalize_registry_url(value)?;
968        if !normalized.contains(&value) {
969            normalized.push(value);
970        }
971    }
972    *urls = normalized;
973    Ok(())
974}
975
976/// Validate and canonicalize an npm-compatible registry base URL. Credentials,
977/// query strings, and fragments are rejected.
978pub fn normalize_registry_url(value: &str) -> Result<String> {
979    let original = value.trim();
980    let mut url =
981        reqwest::Url::parse(original).map_err(|_| Error::config("invalid registry URL"))?;
982    if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
983        return Err(Error::config(
984            "registry URL must use http or https and include a host",
985        ));
986    }
987    if !url.username().is_empty() || url.password().is_some() {
988        return Err(Error::config("registry URL must not contain credentials"));
989    }
990    if url.query().is_some() || url.fragment().is_some() {
991        return Err(Error::config(
992            "registry URL must not contain a query string or fragment",
993        ));
994    }
995    let path = url.path().trim_end_matches('/').to_string();
996    url.set_path(&format!("{path}/"));
997    Ok(url.to_string())
998}
999
1000/// Walk up from `start_dir` looking for a project config file.
1001fn find_project_config(start_dir: &Path) -> Result<Option<(PathBuf, ConfigFile)>> {
1002    let mut cur = Some(start_dir);
1003    while let Some(dir) = cur {
1004        for name in PROJECT_CONFIG_NAMES {
1005            let candidate = dir.join(name);
1006            if candidate.is_file() {
1007                let file = read_config_file(&candidate)?;
1008                return Ok(Some((candidate, file)));
1009            }
1010        }
1011        cur = dir.parent();
1012    }
1013    Ok(None)
1014}
1015
1016/// Walk up looking for a `.tool-versions` file (asdf-compatible). Each line is
1017/// `<tool> <version>`; comments start with `#`.
1018fn find_tool_versions(start_dir: &Path) -> Result<Option<(PathBuf, BTreeMap<String, String>)>> {
1019    let mut cur = Some(start_dir);
1020    while let Some(dir) = cur {
1021        let candidate = dir.join(".tool-versions");
1022        if candidate.is_file() {
1023            let text = std::fs::read_to_string(&candidate).map_err(|e| Error::io(&candidate, e))?;
1024            return Ok(Some((candidate, parse_tool_versions(&text))));
1025        }
1026        cur = dir.parent();
1027    }
1028    Ok(None)
1029}
1030
1031pub fn parse_tool_versions(text: &str) -> BTreeMap<String, String> {
1032    let mut map = BTreeMap::new();
1033    for line in text.lines() {
1034        let line = line.split('#').next().unwrap_or("").trim();
1035        if line.is_empty() {
1036            continue;
1037        }
1038        let mut it = line.split_whitespace();
1039        if let (Some(tool), Some(ver)) = (it.next(), it.next()) {
1040            map.insert(tool.to_string(), ver.to_string());
1041        }
1042    }
1043    map
1044}
1045
1046/// Parse a duration like "6h", "30m", "90s", "1d" into seconds.
1047pub fn parse_duration_secs(s: &str) -> Option<u64> {
1048    let s = s.trim();
1049    if s.is_empty() {
1050        return None;
1051    }
1052    let (num, unit) = s.split_at(s.find(|c: char| c.is_alphabetic()).unwrap_or(s.len()));
1053    let n: u64 = num.trim().parse().ok()?;
1054    let mult = match unit.trim() {
1055        "" | "s" | "sec" | "secs" => 1,
1056        "m" | "min" | "mins" => 60,
1057        "h" | "hr" | "hrs" => 3600,
1058        "d" | "day" | "days" => 86400,
1059        _ => return None,
1060    };
1061    Some(n * mult)
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066    use super::*;
1067    use std::io::Write;
1068
1069    #[test]
1070    fn env_overrides_beat_file() {
1071        let mut cfg = Config {
1072            settings: Settings::default(),
1073            sources: SourcesConfig::default(),
1074            tools: BTreeMap::new(),
1075            tool_configs: BTreeMap::new(),
1076            global_tools: BTreeMap::new(),
1077            global_tool_configs: BTreeMap::new(),
1078            tool_origins: BTreeMap::new(),
1079            aliases: BTreeMap::new(),
1080            project_config_path: None,
1081        };
1082        cfg.settings.link_mode = LinkMode::Hardlink;
1083        cfg.apply_env(|k| match k {
1084            "OSDK_LINK_MODE" => Some("copy".to_string()),
1085            "OSDK_JOBS" => Some("3".to_string()),
1086            "OSDK_YES" => Some("true".to_string()),
1087            "OSDK_VERIFY_SIGNATURES" => Some("false".to_string()),
1088            "OSDK_REQUIRE_CHECKSUMS" => Some("true".to_string()),
1089            "OSDK_ATTESTATIONS" => Some("required".to_string()),
1090            "OSDK_OFFLINE" => Some("true".to_string()),
1091            _ => None,
1092        });
1093        assert_eq!(cfg.settings.link_mode, LinkMode::Copy);
1094        assert_eq!(cfg.settings.jobs, 3);
1095        assert!(cfg.settings.yes);
1096        assert!(!cfg.settings.verify_signatures);
1097        assert!(cfg.settings.require_checksums);
1098        assert_eq!(cfg.settings.attestations, AttestationPolicy::Required);
1099        assert!(cfg.settings.offline);
1100    }
1101
1102    #[test]
1103    fn container_config_parses_strict_native_settings() {
1104        let temporary = tempfile::tempdir().unwrap();
1105        let config_file = temporary.path().join("config.toml");
1106        std::fs::write(
1107            &config_file,
1108            r#"
1109[containers]
1110runtime = "containerd"
1111builder = "remote-builder_1"
1112platform = "linux/arm64/v8"
1113probe_timeout_ms = 750
1114
1115[containers.registries."docker.io"]
1116mirrors = ["https://mirror.example/cache", "https://mirror.example/cache/"]
1117anonymous_only = false
1118resolve = "mirror"
1119"#,
1120        )
1121        .unwrap();
1122
1123        let config = Config::load_user(&config_file).unwrap();
1124        assert_eq!(config.containers().runtime, ContainerRuntime::Containerd);
1125        assert_eq!(
1126            config.containers().builder.as_name(),
1127            Some("remote-builder_1")
1128        );
1129        assert_eq!(
1130            config.containers().platform,
1131            ContainerPlatform::Explicit {
1132                os: "linux".to_string(),
1133                arch: "arm64".to_string(),
1134                variant: Some("v8".to_string()),
1135            }
1136        );
1137        assert_eq!(config.containers().probe_timeout_ms, 750);
1138        assert_eq!(
1139            config.containers().registries["docker.io"].mirrors,
1140            ["https://mirror.example/cache/"]
1141        );
1142        assert!(!config.containers().registries["docker.io"].anonymous_only);
1143        assert_eq!(
1144            config.containers().registries["docker.io"].resolve,
1145            ContainerResolve::Mirror
1146        );
1147    }
1148
1149    #[test]
1150    fn project_container_config_replaces_user_section_as_a_unit() {
1151        let temporary = tempfile::tempdir().unwrap();
1152        let project = temporary.path().join("project");
1153        std::fs::create_dir_all(&project).unwrap();
1154        let user_config = temporary.path().join("config.toml");
1155        std::fs::write(
1156            &user_config,
1157            r#"
1158[containers]
1159runtime = "docker"
1160builder = "global-builder"
1161platform = "linux/amd64"
1162probe_timeout_ms = 900
1163
1164[containers.registries."docker.io"]
1165mirrors = ["https://global.example"]
1166anonymous_only = false
1167resolve = "mirror"
1168"#,
1169        )
1170        .unwrap();
1171        std::fs::write(
1172            project.join("osdk.toml"),
1173            r#"
1174[containers]
1175runtime = "containerd"
1176
1177[containers.registries."ghcr.io"]
1178mirrors = ["https://project.example"]
1179"#,
1180        )
1181        .unwrap();
1182
1183        let config = Config::load(&user_config, &project).unwrap();
1184        let containers = config.containers();
1185        assert_eq!(containers.runtime, ContainerRuntime::Containerd);
1186        assert_eq!(
1187            containers.builder,
1188            crate::container::BuildxBuilderSelector::Auto
1189        );
1190        assert_eq!(containers.platform, ContainerPlatform::Runtime);
1191        assert_eq!(containers.probe_timeout_ms, 1500);
1192        assert!(!containers.registries.contains_key("docker.io"));
1193        assert_eq!(
1194            containers.registries["ghcr.io"].mirrors,
1195            ["https://project.example/"]
1196        );
1197        assert!(containers.registries["ghcr.io"].anonymous_only);
1198        assert_eq!(
1199            containers.registries["ghcr.io"].resolve,
1200            ContainerResolve::Upstream
1201        );
1202    }
1203
1204    #[test]
1205    fn project_container_config_requires_trust() {
1206        let temporary = tempfile::tempdir().unwrap();
1207        let project_config = temporary.path().join("osdk.toml");
1208        std::fs::write(&project_config, "[containers]\nruntime = \"auto\"\n").unwrap();
1209
1210        assert!(crate::trust::requires_trust(&project_config).unwrap());
1211    }
1212
1213    #[test]
1214    fn container_env_overrides_only_native_selectors() {
1215        let mut cfg = Config {
1216            settings: Settings::default(),
1217            sources: SourcesConfig::default(),
1218            tools: BTreeMap::new(),
1219            tool_configs: BTreeMap::new(),
1220            global_tools: BTreeMap::new(),
1221            global_tool_configs: BTreeMap::new(),
1222            tool_origins: BTreeMap::new(),
1223            aliases: BTreeMap::new(),
1224            project_config_path: None,
1225        };
1226        cfg.sources.containers.registries.insert(
1227            "docker.io".to_string(),
1228            ContainerRegistryConfig {
1229                mirrors: vec!["https://mirror.example/".to_string()],
1230                anonymous_only: true,
1231                resolve: ContainerResolve::Upstream,
1232            },
1233        );
1234
1235        cfg.apply_env(|key| match key {
1236            "OSDK_CONTAINER_RUNTIME" => Some("docker".to_string()),
1237            "OSDK_CONTAINER_BUILDER" => Some("ci-builder".to_string()),
1238            "OSDK_CONTAINER_PLATFORM" => Some("linux/amd64".to_string()),
1239            // These deliberately have no supported environment surface.
1240            "OSDK_CONTAINER_MIRRORS" => Some("https://evil.example".to_string()),
1241            "OSDK_CONTAINER_RESOLVE" => Some("mirror".to_string()),
1242            _ => None,
1243        });
1244
1245        assert_eq!(cfg.containers().runtime, ContainerRuntime::Docker);
1246        assert_eq!(cfg.containers().builder.as_name(), Some("ci-builder"));
1247        assert_eq!(cfg.containers().platform.to_string(), "linux/amd64");
1248        assert_eq!(
1249            cfg.containers().registries["docker.io"].mirrors,
1250            ["https://mirror.example/"]
1251        );
1252        assert_eq!(
1253            cfg.containers().registries["docker.io"].resolve,
1254            ContainerResolve::Upstream
1255        );
1256    }
1257
1258    #[test]
1259    fn invalid_container_env_selectors_leave_config_unchanged() {
1260        let mut cfg = Config {
1261            settings: Settings::default(),
1262            sources: SourcesConfig::default(),
1263            tools: BTreeMap::new(),
1264            tool_configs: BTreeMap::new(),
1265            global_tools: BTreeMap::new(),
1266            global_tool_configs: BTreeMap::new(),
1267            tool_origins: BTreeMap::new(),
1268            aliases: BTreeMap::new(),
1269            project_config_path: None,
1270        };
1271
1272        cfg.apply_env(|key| match key {
1273            "OSDK_CONTAINER_RUNTIME" => Some("podman".to_string()),
1274            "OSDK_CONTAINER_BUILDER" => Some("name with spaces".to_string()),
1275            "OSDK_CONTAINER_PLATFORM" => Some("linux".to_string()),
1276            _ => None,
1277        });
1278
1279        assert_eq!(cfg.containers(), &ContainersConfig::default());
1280    }
1281
1282    #[test]
1283    fn container_config_rejects_invalid_values_and_unsafe_mirrors() {
1284        let temporary = tempfile::tempdir().unwrap();
1285        let config_file = temporary.path().join("config.toml");
1286        let invalid = [
1287            "[containers]\nruntime = \"podman\"\n",
1288            "[containers]\nbuilder = \"name with spaces\"\n",
1289            "[containers]\nplatform = \"linux\"\n",
1290            "[containers]\nplatform = \"Linux/amd64\"\n",
1291            "[containers]\nplatform = \"linux/amd64/v8/extra\"\n",
1292            "[containers]\nprobe_timeout_ms = 0\n",
1293            "[containers]\nunknown = true\n",
1294            "[containers.registries.\"docker.io\"]\nresolve = \"fastest\"\n",
1295            "[containers.registries.\"docker.io\"]\nmirrors = [\"http://127.0.0.1:5000\"]\n",
1296            "[containers.registries.\"docker.io\"]\nmirrors = [\"https://user:secret@example.test\"]\n",
1297            "[containers.registries.\"docker.io\"]\nmirrors = [\"https://example.test?token=secret\"]\n",
1298            "[containers.registries.\"docker.io\"]\nmirrors = [\"https://example.test#fragment\"]\n",
1299            "[containers.registries.\"https://docker.io/path\"]\nmirrors = [\"https://example.test\"]\n",
1300        ];
1301
1302        for contents in invalid {
1303            std::fs::write(&config_file, contents).unwrap();
1304            assert!(
1305                Config::load_user(&config_file).is_err(),
1306                "accepted invalid container config: {contents}"
1307            );
1308        }
1309
1310        let mirrors = (0..9)
1311            .map(|index| format!("\"https://mirror-{index}.example/\""))
1312            .collect::<Vec<_>>()
1313            .join(", ");
1314        std::fs::write(
1315            &config_file,
1316            format!("[containers.registries.\"docker.io\"]\nmirrors = [{mirrors}]\n"),
1317        )
1318        .unwrap();
1319        let error = Config::load_user(&config_file).unwrap_err();
1320        assert!(error.to_string().contains("at most 8 mirrors"));
1321    }
1322
1323    #[test]
1324    fn container_registry_keys_are_canonicalized_without_losing_ports() {
1325        let temporary = tempfile::tempdir().unwrap();
1326        let config_file = temporary.path().join("config.toml");
1327        std::fs::write(
1328            &config_file,
1329            r#"
1330[containers.registries."EXAMPLE.COM."]
1331[containers.registries."Registry.Example:5443"]
1332[containers.registries."[2001:DB8::1]:5000"]
1333"#,
1334        )
1335        .unwrap();
1336
1337        let config = Config::load_user(&config_file).unwrap();
1338        let registries = &config.containers().registries;
1339        assert!(registries.contains_key("example.com"));
1340        assert!(registries.contains_key("registry.example:5443"));
1341        assert!(registries.contains_key("[2001:db8::1]:5000"));
1342    }
1343
1344    #[test]
1345    fn canonical_container_registry_collisions_are_rejected() {
1346        let temporary = tempfile::tempdir().unwrap();
1347        let config_file = temporary.path().join("config.toml");
1348        std::fs::write(
1349            &config_file,
1350            r#"
1351[containers.registries."docker.io"]
1352[containers.registries."DOCKER.IO."]
1353"#,
1354        )
1355        .unwrap();
1356
1357        let error = Config::load_user(&config_file).unwrap_err();
1358        assert_eq!(
1359            error.to_string(),
1360            "config error: container registry keys collide after canonicalization"
1361        );
1362    }
1363
1364    #[test]
1365    fn container_validation_errors_do_not_echo_rejected_values() {
1366        let cases = [
1367            ("runtime", "token-runtime-7d9"),
1368            ("builder", "token builder 7d9"),
1369            ("platform", "token-platform-7d9"),
1370        ];
1371        for (field, secret) in cases {
1372            let temporary = tempfile::tempdir().unwrap();
1373            let config_file = temporary.path().join("config.toml");
1374            std::fs::write(
1375                &config_file,
1376                format!("[containers]\n{field} = {secret:?}\n"),
1377            )
1378            .unwrap();
1379            let error = Config::load_user(&config_file).unwrap_err();
1380            assert!(!error.to_string().contains(secret));
1381            assert!(!format!("{error:?}").contains(secret));
1382        }
1383
1384        for (contents, secret) in [
1385            (
1386                "[containers.registries.\"user:registry-secret-7d9@example.test\"]\n",
1387                "registry-secret-7d9",
1388            ),
1389            (
1390                "[containers.registries.\"docker.io\"]\nmirrors = [\"https://user:mirror-secret-7d9@example.test\"]\n",
1391                "mirror-secret-7d9",
1392            ),
1393        ] {
1394            let temporary = tempfile::tempdir().unwrap();
1395            let config_file = temporary.path().join("config.toml");
1396            std::fs::write(&config_file, contents).unwrap();
1397            let error = Config::load_user(&config_file).unwrap_err();
1398            assert!(!error.to_string().contains(secret));
1399            assert!(!format!("{error:?}").contains(secret));
1400        }
1401    }
1402
1403    #[test]
1404    fn containers_default_to_runtime_platform() {
1405        assert_eq!(
1406            ContainersConfig::default().platform,
1407            ContainerPlatform::Runtime
1408        );
1409        let temporary = tempfile::tempdir().unwrap();
1410        let config_file = temporary.path().join("config.toml");
1411        std::fs::write(&config_file, "[containers]\nruntime = \"docker\"\n").unwrap();
1412        assert_eq!(
1413            Config::load_user(&config_file)
1414                .unwrap()
1415                .containers()
1416                .platform,
1417            ContainerPlatform::Runtime
1418        );
1419    }
1420
1421    #[test]
1422    fn project_config_found_by_walkup() {
1423        let td = tempfile::tempdir().unwrap();
1424        let nested = td.path().join("a/b/c");
1425        std::fs::create_dir_all(&nested).unwrap();
1426        let cfg_path = td.path().join("a/osdk.toml");
1427        let mut f = std::fs::File::create(&cfg_path).unwrap();
1428        writeln!(f, "[tools]\nnode = \"20\"\n").unwrap();
1429
1430        let found = find_project_config(&nested).unwrap();
1431        assert!(found.is_some());
1432        let (path, file) = found.unwrap();
1433        assert_eq!(path, cfg_path);
1434        assert_eq!(
1435            file.tools.get("node").map(ToolConfigEntry::version),
1436            Some("20")
1437        );
1438    }
1439
1440    #[test]
1441    fn structured_tool_configs_support_legacy_inline_table_and_scoped_keys() {
1442        let temporary = tempfile::tempdir().unwrap();
1443        let project = temporary.path().join("project/nested");
1444        std::fs::create_dir_all(&project).unwrap();
1445        let user_config = temporary.path().join("config.toml");
1446        std::fs::write(
1447            &user_config,
1448            r#"
1449[tools]
1450node = "20"
1451npm = { version = "11.5.2", allow_builds = ["esbuild", "sharp"], engine = "node", frozen = true }
1452"@scope/tool" = { version = "1.2.3", allow_builds = ["pkg-a"] }
1453"#,
1454        )
1455        .unwrap();
1456
1457        let config = Config::load(&user_config, &project).unwrap();
1458        let tools = &config.tool_configs;
1459        assert_eq!(tools["node"].version(), "20");
1460        assert_eq!(tools["npm"].version(), "11.5.2");
1461        assert_eq!(
1462            tools["@scope/tool"]
1463                .structured_config()
1464                .unwrap()
1465                .options
1466                .get("allow_builds"),
1467            Some(&ToolConfigValue::Array(vec!["pkg-a".to_string()]))
1468        );
1469        assert_eq!(
1470            tools["npm"]
1471                .structured_config()
1472                .unwrap()
1473                .options
1474                .get("engine"),
1475            Some(&ToolConfigValue::String("node".to_string()))
1476        );
1477        assert_eq!(
1478            tools["npm"]
1479                .structured_config()
1480                .unwrap()
1481                .options
1482                .get("frozen"),
1483            Some(&ToolConfigValue::Bool(true))
1484        );
1485        assert_eq!(
1486            tools["@scope/tool"].to_cli_option_strings(),
1487            vec!["allow_builds=pkg-a".to_string()]
1488        );
1489        assert_eq!(
1490            tools["npm"].to_request_options().get("allow_builds"),
1491            Some(&"esbuild,sharp".to_string())
1492        );
1493    }
1494
1495    #[test]
1496    fn project_tool_configs_override_global_and_tool_versions_only_fill_missing() {
1497        let temporary = tempfile::tempdir().unwrap();
1498        let project = temporary.path().join("project/nested");
1499        std::fs::create_dir_all(&project).unwrap();
1500        let user_config = temporary.path().join("config.toml");
1501        std::fs::write(
1502            &user_config,
1503            r#"
1504[tools]
1505node = "20"
1506npm = { version = "11.5.1", allow_builds = ["esbuild"] }
1507"#,
1508        )
1509        .unwrap();
1510        std::fs::write(
1511            temporary.path().join("project/osdk.toml"),
1512            r#"
1513[tools]
1514npm = { version = "11.5.2", allow_builds = ["sharp"], engine = "node" }
1515pnpm = "9.0.0"
1516"#,
1517        )
1518        .unwrap();
1519        std::fs::write(
1520            temporary.path().join("project/.tool-versions"),
1521            "node 22.0.0\nbun 1.1.0\n",
1522        )
1523        .unwrap();
1524
1525        let config = Config::load(&user_config, &project).unwrap();
1526        let tools = &config.tool_configs;
1527        assert_eq!(tools["node"].version(), "20");
1528        assert_eq!(tools["npm"].version(), "11.5.2");
1529        assert_eq!(tools["pnpm"].version(), "9.0.0");
1530        assert_eq!(tools["bun"].version(), "1.1.0");
1531        assert_eq!(
1532            tools["npm"]
1533                .structured_config()
1534                .unwrap()
1535                .options
1536                .get("allow_builds"),
1537            Some(&ToolConfigValue::Array(vec!["sharp".to_string()]))
1538        );
1539        assert_eq!(
1540            tools["npm"]
1541                .structured_config()
1542                .unwrap()
1543                .options
1544                .get("engine"),
1545            Some(&ToolConfigValue::String("node".to_string()))
1546        );
1547        assert_eq!(config.global_tools["node"], "20");
1548        assert_eq!(config.global_tools["npm"], "11.5.1");
1549        assert_eq!(config.global_tool_configs["npm"].version(), "11.5.1");
1550        assert_eq!(
1551            config.tool_origins["node"],
1552            ToolConfigOrigin::GlobalConfig(user_config.clone())
1553        );
1554        assert_eq!(
1555            config.tool_origins["npm"],
1556            ToolConfigOrigin::ProjectConfig(temporary.path().join("project/osdk.toml"))
1557        );
1558        assert_eq!(
1559            config.tool_origins["pnpm"],
1560            ToolConfigOrigin::ProjectConfig(temporary.path().join("project/osdk.toml"))
1561        );
1562        assert_eq!(
1563            config.tool_origins["bun"],
1564            ToolConfigOrigin::ToolVersions(temporary.path().join("project/.tool-versions"))
1565        );
1566    }
1567
1568    #[test]
1569    fn load_user_preserves_global_tool_provenance_without_project_entries() {
1570        let temporary = tempfile::tempdir().unwrap();
1571        let user_config = temporary.path().join("config.toml");
1572        std::fs::write(
1573            &user_config,
1574            "[tools]\nnode = \"20\"\nnpm = { version = \"11\", installer = \"npm\" }\n",
1575        )
1576        .unwrap();
1577
1578        let config = Config::load_user(&user_config).unwrap();
1579        assert_eq!(config.tools, config.global_tools);
1580        assert_eq!(config.tool_configs, config.global_tool_configs);
1581        assert_eq!(
1582            config.tool_origins["npm"],
1583            ToolConfigOrigin::GlobalConfig(user_config)
1584        );
1585        assert!(config.project_config_path.is_none());
1586    }
1587
1588    #[test]
1589    fn structured_tool_config_requires_version_and_rejects_non_scalar_options() {
1590        let temporary = tempfile::tempdir().unwrap();
1591        let config_file = temporary.path().join("config.toml");
1592
1593        std::fs::write(
1594            &config_file,
1595            r#"
1596[tools.npm]
1597allow_builds = ["esbuild"]
1598"#,
1599        )
1600        .unwrap();
1601        assert!(Config::load_user(&config_file).is_err());
1602
1603        std::fs::write(
1604            &config_file,
1605            r#"
1606[tools.npm]
1607version = "11.5.2"
1608nested = { enabled = true }
1609"#,
1610        )
1611        .unwrap();
1612        assert!(Config::load_user(&config_file).is_err());
1613    }
1614
1615    #[test]
1616    fn project_sources_cannot_override_global_model_env_switches() {
1617        let temporary = tempfile::tempdir().unwrap();
1618        let config_dir = temporary.path().join("config");
1619        let project = temporary.path().join("project");
1620        std::fs::create_dir_all(&config_dir).unwrap();
1621        std::fs::create_dir_all(&project).unwrap();
1622        let user_config = config_dir.join("config.toml");
1623        std::fs::write(
1624            &user_config,
1625            r#"
1626[sources.huggingface]
1627env = true
1628env_force = true
1629pin = "global"
1630"#,
1631        )
1632        .unwrap();
1633        std::fs::write(
1634            project.join("osdk.toml"),
1635            r#"
1636[sources.huggingface]
1637env = false
1638env_force = false
1639pin = "project"
1640"#,
1641        )
1642        .unwrap();
1643
1644        let config = Config::load(&user_config, &project).unwrap();
1645        let huggingface = config.tool_sources("huggingface").unwrap();
1646        assert!(huggingface.env);
1647        assert!(huggingface.env_force);
1648        assert_eq!(huggingface.pin.as_deref(), Some("project"));
1649    }
1650
1651    #[test]
1652    fn project_registry_replaces_user_registry_and_load_user_excludes_it() {
1653        let temporary = tempfile::tempdir().unwrap();
1654        let project = temporary.path().join("project/nested");
1655        std::fs::create_dir_all(&project).unwrap();
1656        let user_config = temporary.path().join("config.toml");
1657        std::fs::write(
1658            &user_config,
1659            r#"
1660[registries.npm]
1661urls = ["https://registry.npmjs.org"]
1662probe_timeout_ms = 900
1663"#,
1664        )
1665        .unwrap();
1666        std::fs::write(
1667            temporary.path().join("project/osdk.toml"),
1668            r#"
1669[registries.npm]
1670urls = ["https://registry.npmmirror.com/path/"]
1671probe_timeout_ms = 125
1672"#,
1673        )
1674        .unwrap();
1675
1676        let config = Config::load(&user_config, &project).unwrap();
1677        assert_eq!(
1678            config.registries().npm.urls,
1679            ["https://registry.npmmirror.com/path/"]
1680        );
1681        assert_eq!(config.registries().npm.probe_timeout_ms, 125);
1682
1683        let user = Config::load_user(&user_config).unwrap();
1684        assert_eq!(user.registries().npm.urls, ["https://registry.npmjs.org/"]);
1685        assert_eq!(user.registries().npm.probe_timeout_ms, 900);
1686    }
1687
1688    #[test]
1689    fn registry_urls_normalize_and_reject_unsafe_values() {
1690        assert_eq!(
1691            normalize_registry_url("https://example.test/team").unwrap(),
1692            "https://example.test/team/"
1693        );
1694        let query_error = normalize_registry_url("https://example.test/team?x=1").unwrap_err();
1695        assert_eq!(
1696            query_error.to_string(),
1697            "config error: registry URL must not contain a query string or fragment"
1698        );
1699        let fragment_error =
1700            normalize_registry_url("https://example.test/team#fragment").unwrap_err();
1701        assert_eq!(
1702            fragment_error.to_string(),
1703            "config error: registry URL must not contain a query string or fragment"
1704        );
1705        assert!(normalize_registry_url("file:///tmp/registry").is_err());
1706        assert!(normalize_registry_url("https://token@example.test/").is_err());
1707        assert!(normalize_registry_url("relative/path").is_err());
1708    }
1709
1710    #[test]
1711    fn registry_config_rejects_query_and_fragment() {
1712        let temporary = tempfile::tempdir().unwrap();
1713        let config_file = temporary.path().join("config.toml");
1714
1715        for url in [
1716            "https://registry.npmjs.org/?write=true",
1717            "https://registry.npmjs.org/#scope",
1718        ] {
1719            std::fs::write(
1720                &config_file,
1721                format!("[registries.npm]\nurls = [{url:?}]\n"),
1722            )
1723            .unwrap();
1724
1725            let error = Config::load_user(&config_file).unwrap_err();
1726            assert_eq!(
1727                error.to_string(),
1728                "config error: registry URL must not contain a query string or fragment"
1729            );
1730        }
1731    }
1732
1733    #[test]
1734    fn tool_versions_parse() {
1735        let m = parse_tool_versions(
1736            "# comment\nnode 20.11.1\npython 3.12.4 # trailing\n\ngo   1.22.5\n",
1737        );
1738        assert_eq!(m.get("node").unwrap(), "20.11.1");
1739        assert_eq!(m.get("python").unwrap(), "3.12.4");
1740        assert_eq!(m.get("go").unwrap(), "1.22.5");
1741    }
1742
1743    #[test]
1744    fn duration_parse() {
1745        assert_eq!(parse_duration_secs("6h"), Some(6 * 3600));
1746        assert_eq!(parse_duration_secs("30m"), Some(1800));
1747        assert_eq!(parse_duration_secs("45"), Some(45));
1748        assert_eq!(parse_duration_secs("1d"), Some(86400));
1749        assert_eq!(parse_duration_secs("bad"), None);
1750    }
1751
1752    #[test]
1753    fn aliases_expand_and_reject_cycles() {
1754        let aliases = BTreeMap::from([
1755            ("default".to_string(), "maintenance".to_string()),
1756            ("maintenance".to_string(), "20".to_string()),
1757        ]);
1758        assert_eq!(expand_alias(&aliases, "default").unwrap(), "20");
1759        assert_eq!(expand_alias(&aliases, "21").unwrap(), "21");
1760
1761        let cycle = BTreeMap::from([
1762            ("a".to_string(), "b".to_string()),
1763            ("b".to_string(), "a".to_string()),
1764        ]);
1765        assert!(expand_alias(&cycle, "a")
1766            .unwrap_err()
1767            .to_string()
1768            .contains("cycle"));
1769        assert!(validate_alias_name("latest").is_err());
1770        assert!(validate_alias_name("default").is_ok());
1771    }
1772
1773    #[test]
1774    fn attestation_policy_parses() {
1775        assert_eq!(
1776            "if-available".parse::<AttestationPolicy>().unwrap(),
1777            AttestationPolicy::IfAvailable
1778        );
1779        assert_eq!(
1780            "required".parse::<AttestationPolicy>().unwrap(),
1781            AttestationPolicy::Required
1782        );
1783        assert!("sometimes".parse::<AttestationPolicy>().is_err());
1784    }
1785}