1use 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#[derive(Debug, Clone)]
23pub struct Config {
24 pub settings: Settings,
25 pub sources: SourcesConfig,
26 pub tools: BTreeMap<String, String>,
28 pub tool_configs: BTreeMap<String, ToolConfigEntry>,
30 pub global_tools: BTreeMap<String, String>,
32 pub global_tool_configs: BTreeMap<String, ToolConfigEntry>,
34 pub tool_origins: BTreeMap<String, ToolConfigOrigin>,
36 pub aliases: BTreeMap<String, BTreeMap<String, String>>,
38 pub project_config_path: Option<PathBuf>,
40}
41
42#[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 pub link_mode: LinkMode,
55 pub jobs: usize,
57 pub yes: bool,
59 pub verify_signatures: bool,
61 pub require_checksums: bool,
63 pub attestations: AttestationPolicy,
65 pub offline: bool,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub lang: Option<String>,
70 pub node: NodeSettings,
72 pub python: PythonSettings,
74 pub java: JavaSettings,
76 pub prerelease: PrereleasePolicy,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, Default)]
81#[serde(default)]
82pub struct NodeSettings {
83 pub corepack: bool,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88#[serde(default)]
89pub struct PythonSettings {
90 pub catalog_url: Option<String>,
92 pub catalog_sha256: Option<String>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, Default)]
97#[serde(default)]
98pub struct JavaSettings {
99 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 pub cache_ttl: String,
170 #[serde(flatten)]
172 pub per_tool: BTreeMap<String, ToolSources>,
173 #[doc(hidden)]
177 #[serde(skip)]
178 pub registries: RegistriesConfig,
179 #[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 pub fn cache_ttl_secs(&self) -> u64 {
203 parse_duration_secs(&self.cache_ttl).unwrap_or(6 * 3600)
204 }
205}
206
207#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
211#[serde(default)]
212pub struct RegistriesConfig {
213 pub npm: NpmRegistryConfig,
214}
215
216#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
237#[serde(default, deny_unknown_fields)]
238pub struct ContainersConfig {
239 pub runtime: ContainerRuntime,
240 pub builder: crate::container::BuildxBuilderSelector,
242 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#[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#[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#[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#[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
418pub struct ToolSources {
419 #[serde(skip_serializing_if = "Option::is_none")]
421 pub pin: Option<String>,
422 #[serde(default, skip_serializing_if = "Vec::is_empty")]
424 pub disable: Vec<String>,
425 #[serde(default, skip_serializing_if = "Vec::is_empty")]
427 pub custom: Vec<Source>,
428 #[serde(default)]
430 pub env: bool,
431 #[serde(default)]
433 pub env_force: bool,
434}
435
436#[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#[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#[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#[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 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 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 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 self.sources.registries = registries;
593 }
594 if let Some(containers) = file.containers {
595 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 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 pub fn tool_origin(&self, tool: &str) -> Option<&ToolConfigOrigin> {
686 self.tool_origins.get(tool)
687 }
688
689 pub fn registries(&self) -> &RegistriesConfig {
691 &self.sources.registries
692 }
693
694 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(¤t) {
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(®istry)?;
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 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
880pub 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
976pub 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
1000fn 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
1016fn 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
1046pub 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 "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}