Skip to main content

osdk_core/backend/
go_package.rs

1//! Go command tools installed with an exact osdk-managed Go runtime.
2//!
3//! The dynamic `go:` namespace addresses a module or nested command path.
4//! Version discovery uses bounded Go proxy metadata, while installation invokes
5//! the selected managed `go` binary once in a cleared, osdk-controlled
6//! environment and publishes only lifecycle-validated staged binaries.
7
8use std::collections::BTreeMap;
9use std::ffi::{OsStr, OsString};
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::time::Duration;
13
14use async_trait::async_trait;
15use futures_util::StreamExt;
16use serde::{Deserialize, Serialize};
17
18use crate::backend::native_tool::{
19    self, NativeToolFamily, NativeToolLifecycle, NativeToolPreparation, NativeToolProvider,
20    LOCKED_NATIVE_REPLAY_OPTION, LOCKED_NATIVE_RUNTIME_OPTION,
21    LOCKED_NATIVE_RUNTIME_VERSION_OPTION,
22};
23use crate::backend::{Backend, Ctx, InstallCtx};
24use crate::error::{Error, Result};
25use crate::process::{
26    CaptureLimits, CommandOutcome, CommandRunner, CommandSpec, SystemCommandRunner,
27};
28use crate::source::Source;
29use crate::tool::{InstallDependency, InstallDependencyKind, InstallIdentity, ToolId};
30use crate::version::{ToolRequest, ToolVersion, VersionInfo, VersionSpec};
31
32const GO_PROXY_METADATA_LIMIT: usize = 4 * 1024 * 1024;
33const GO_PROXY_TIMEOUT: Duration = Duration::from_secs(30);
34const GO_RESOLUTION_FILE: &str = "go-resolution.json";
35const GO_RESOLUTION_SCHEMA: u32 = 1;
36const PROVIDER_OUTPUT_LIMIT: usize = 1024 * 1024;
37const PROVIDER_TIMEOUT: Duration = Duration::from_secs(60 * 60);
38pub const LOCKED_GO_PROXY_OPTION: &str = "__osdk_go_proxy";
39pub const LOCKED_GO_MODULE_OPTION: &str = "__osdk_go_module";
40static NEXT_METADATA_TEMPORARY: AtomicU64 = AtomicU64::new(0);
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44struct GoResolution {
45    schema: u32,
46    backend: String,
47    version: String,
48    proxy: String,
49    module_root: String,
50    replay: String,
51}
52
53#[derive(Debug)]
54struct GoProxySelection {
55    source: Source,
56    module_root: String,
57    versions: Vec<VersionInfo>,
58}
59
60/// Backend bound to one canonical Go module or nested command path.
61pub struct GoPackageBackend {
62    id: String,
63    command_path: String,
64}
65
66impl GoPackageBackend {
67    pub fn from_id(id: &str) -> Option<Self> {
68        let id = ToolId::parse(id).ok()?;
69        if id.namespace() != Some("go") {
70            return None;
71        }
72        Some(Self {
73            command_path: id.subject().to_string(),
74            id: id.to_string(),
75        })
76    }
77
78    fn runtime_version<'a>(&self, options: &'a BTreeMap<String, String>) -> Result<&'a str> {
79        match (
80            options.get(LOCKED_NATIVE_RUNTIME_OPTION),
81            options.get(LOCKED_NATIVE_RUNTIME_VERSION_OPTION),
82        ) {
83            (Some(runtime), Some(version))
84                if runtime == "go"
85                    && matches!(VersionSpec::parse(version), VersionSpec::Exact(exact) if exact == *version) =>
86            {
87                Ok(version)
88            }
89            (Some(runtime), _) if runtime != "go" => Err(Error::config(format!(
90                "Go tool `{}` requires managed runtime `go`, got `{runtime}`",
91                self.id
92            ))),
93            _ => Err(Error::config(format!(
94                "Go tool `{}` requires an exact managed Go selection; add an exact `go@<version>` request or configuration",
95                self.id
96            ))),
97        }
98    }
99
100    fn canonical_options(
101        &self,
102        options: &BTreeMap<String, String>,
103    ) -> Result<BTreeMap<String, String>> {
104        let id = ToolId::parse(&self.id)?;
105        let mut canonical = options
106            .iter()
107            .filter(|(name, _)| name.starts_with("__osdk_"))
108            .map(|(name, value)| (name.clone(), value.clone()))
109            .collect::<BTreeMap<_, _>>();
110        canonical.extend(crate::tool::canonicalize_dynamic_options(&id, options)?.into_map());
111        Ok(canonical)
112    }
113
114    fn runtime_dependency(
115        &self,
116        ctx: &Ctx,
117        options: &BTreeMap<String, String>,
118    ) -> Result<InstallDependency> {
119        let version = self.runtime_version(options)?;
120        let root = ctx.dirs.install_path("go", version);
121        if !regular_file(&root.join(".osdk-complete")) {
122            return Err(Error::NotInstalled {
123                tool: "go".into(),
124                version: version.into(),
125            });
126        }
127        Ok(InstallDependency {
128            kind: InstallDependencyKind::Runtime,
129            id: "go".into(),
130            version: version.into(),
131            identity: Some(native_tool::go_runtime_identity(
132                &ctx.dirs,
133                ctx.platform,
134                version,
135            )?),
136        })
137    }
138
139    fn proxy<'a>(&self, options: &'a BTreeMap<String, String>) -> Result<&'a str> {
140        let proxy = options
141            .get(LOCKED_GO_PROXY_OPTION)
142            .map(String::as_str)
143            .unwrap_or("https://proxy.golang.org");
144        validate_go_proxy(proxy)?;
145        Ok(proxy)
146    }
147
148    fn module_root<'a>(&'a self, options: &'a BTreeMap<String, String>) -> Result<&'a str> {
149        let module = options
150            .get(LOCKED_GO_MODULE_OPTION)
151            .map(String::as_str)
152            .unwrap_or(&self.command_path);
153        let id = ToolId::parse(&format!("go:{module}"))?;
154        let suffix = self.command_path.strip_prefix(module).unwrap_or("!");
155        if id.subject() != module || (!suffix.is_empty() && !suffix.starts_with('/')) {
156            return Err(Error::config("locked Go module root is invalid"));
157        }
158        Ok(module)
159    }
160
161    fn has_locked_resolution(options: &BTreeMap<String, String>) -> bool {
162        options.get(LOCKED_NATIVE_REPLAY_OPTION).map(String::as_str) == Some("version-only")
163            && options.contains_key(LOCKED_GO_PROXY_OPTION)
164            && options.contains_key(LOCKED_GO_MODULE_OPTION)
165    }
166
167    fn materials(&self, tv: &ToolVersion) -> Result<BTreeMap<String, String>> {
168        Ok(BTreeMap::from([
169            ("source-kind".into(), "go-proxy".into()),
170            ("command-path".into(), self.command_path.clone()),
171            ("proxy".into(), self.proxy(&tv.options)?.into()),
172            ("module-root".into(), self.module_root(&tv.options)?.into()),
173        ]))
174    }
175
176    fn materials_match(&self, tv: &ToolVersion, actual: &BTreeMap<String, String>) -> bool {
177        if actual.get("source-kind").map(String::as_str) != Some("go-proxy")
178            || actual.get("command-path").map(String::as_str) != Some(self.command_path.as_str())
179            || actual.len() != 4
180        {
181            return false;
182        }
183        let Some(proxy) = actual.get("proxy") else {
184            return false;
185        };
186        let Some(module_root) = actual.get("module-root") else {
187            return false;
188        };
189        validate_go_proxy(proxy).is_ok()
190            && ToolId::parse(&format!("go:{module_root}"))
191                .is_ok_and(|id| id.subject() == module_root)
192            && self
193                .command_path
194                .strip_prefix(module_root)
195                .is_some_and(|suffix| suffix.is_empty() || suffix.starts_with('/'))
196            && tv
197                .options
198                .get(LOCKED_GO_PROXY_OPTION)
199                .is_none_or(|expected| expected == proxy)
200            && tv
201                .options
202                .get(LOCKED_GO_MODULE_OPTION)
203                .is_none_or(|expected| expected == module_root)
204    }
205
206    fn lifecycle(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<NativeToolLifecycle> {
207        NativeToolLifecycle::new(
208            &ctx.dirs,
209            ctx.platform,
210            &self.id,
211            &tv.version,
212            &tv.options,
213            NativeToolFamily::Go,
214            self.runtime_dependency(ctx, &tv.options)?,
215            self.materials(tv)?,
216        )
217    }
218
219    fn selected_lifecycle(
220        &self,
221        ctx: &Ctx,
222        tv: &ToolVersion,
223    ) -> Result<Option<NativeToolLifecycle>> {
224        if tv
225            .options
226            .contains_key(LOCKED_NATIVE_RUNTIME_VERSION_OPTION)
227            && tv.options.contains_key(LOCKED_GO_PROXY_OPTION)
228        {
229            return self.lifecycle(ctx, tv).map(Some);
230        }
231        let expected_options = crate::backend::dynamic::identity_options(&self.id, &tv.options)?;
232        let report = crate::inventory::scan_installs(
233            &ctx.dirs.installs,
234            &crate::inventory::ScanOptions::default(),
235        )?;
236        let mut matching = report.installs.into_iter().filter(|install| {
237            let identity = &install.manifest.identity;
238            identity.tool == self.id
239                && identity.version == tv.version
240                && identity.platform == ctx.platform.to_string()
241                && identity.scope == crate::tool::InstallScope::Isolated
242                && identity.material_options == expected_options
243                && self.materials_match(tv, &identity.materials)
244                && native_tool::validate_install_candidate(
245                    &ctx.dirs,
246                    NativeToolFamily::Go,
247                    &install.install_root,
248                    identity,
249                )
250                .unwrap_or(false)
251        });
252        let first = matching.next();
253        if matching.next().is_some() {
254            return Err(Error::other(format!(
255                "Go tool `{}@{}` has multiple matching managed Go identities; select it through a lockfile",
256                self.id, tv.version
257            )));
258        }
259        first
260            .map(|install| {
261                NativeToolLifecycle::from_identity(
262                    &ctx.dirs,
263                    NativeToolFamily::Go,
264                    install.manifest.identity,
265                )
266            })
267            .transpose()
268    }
269
270    async fn proxy_selection(&self, ctx: &Ctx) -> Result<GoProxySelection> {
271        let sources = self.ranked_proxy_sources(ctx).await?;
272        let tried = sources.len();
273        let candidates = module_path_candidates(&self.command_path);
274        let mut last_error = None;
275        if !ctx.config.settings.offline {
276            for module in &candidates {
277                for source in &sources {
278                    let url = proxy_list_url(source, module);
279                    match fetch_live_proxy_versions(ctx, source, &url).await {
280                        Ok(versions) if !versions.is_empty() => {
281                            return Ok(GoProxySelection {
282                                source: source.clone(),
283                                module_root: module.clone(),
284                                versions,
285                            });
286                        }
287                        Ok(_) => {}
288                        Err(error) => last_error = Some(error),
289                    }
290                }
291            }
292        }
293        for module in &candidates {
294            for source in &sources {
295                let url = proxy_list_url(source, module);
296                match read_cached_proxy_versions(ctx, source, &url) {
297                    Ok(versions) if !versions.is_empty() => {
298                        tracing::warn!(
299                            source = %source.id,
300                            url,
301                            "using stale cached Go proxy metadata after all live sources failed"
302                        );
303                        return Ok(GoProxySelection {
304                            source: source.clone(),
305                            module_root: module.clone(),
306                            versions,
307                        });
308                    }
309                    Ok(_) => {}
310                    Err(_) if !ctx.config.settings.offline => {}
311                    Err(_) => {
312                        last_error = Some(Error::other(format!(
313                            "offline Go proxy metadata cache miss for {url}"
314                        )));
315                    }
316                }
317            }
318        }
319        Err(last_error.unwrap_or_else(|| Error::NoUsableSource {
320            tool: self.id.clone(),
321            tried,
322        }))
323    }
324
325    async fn exact_proxy_selection(&self, ctx: &Ctx, version: &str) -> Result<GoProxySelection> {
326        let sources = self.ranked_proxy_sources(ctx).await?;
327        let tried = sources.len();
328        let candidates = module_path_candidates(&self.command_path);
329        let mut last_error = None;
330        if !ctx.config.settings.offline {
331            for module in &candidates {
332                for source in &sources {
333                    let url = proxy_info_url(source, module, version);
334                    match fetch_live_proxy_info(ctx, source, &url, version).await {
335                        Ok(()) => {
336                            return Ok(GoProxySelection {
337                                source: source.clone(),
338                                module_root: module.clone(),
339                                versions: vec![version_info(version)],
340                            });
341                        }
342                        Err(error) => last_error = Some(error),
343                    }
344                }
345            }
346        }
347        for module in &candidates {
348            for source in &sources {
349                let url = proxy_info_url(source, module, version);
350                match read_cached_proxy_info(ctx, source, &url, version) {
351                    Ok(()) => {
352                        tracing::warn!(
353                            source = %source.id,
354                            url,
355                            "using stale cached Go proxy version evidence after all live sources failed"
356                        );
357                        return Ok(GoProxySelection {
358                            source: source.clone(),
359                            module_root: module.clone(),
360                            versions: vec![version_info(version)],
361                        });
362                    }
363                    Err(_) if !ctx.config.settings.offline => {}
364                    Err(_) => {
365                        last_error = Some(Error::other(format!(
366                            "offline Go proxy metadata cache miss for {url}"
367                        )));
368                    }
369                }
370            }
371        }
372        Err(last_error.unwrap_or_else(|| Error::NoUsableSource {
373            tool: self.id.clone(),
374            tried,
375        }))
376    }
377
378    async fn latest_proxy_selection(&self, ctx: &Ctx) -> Result<GoProxySelection> {
379        let sources = self.ranked_proxy_sources(ctx).await?;
380        let tried = sources.len();
381        let candidates = module_path_candidates(&self.command_path);
382        let mut last_error = None;
383        if !ctx.config.settings.offline {
384            for module in &candidates {
385                for source in &sources {
386                    let url = proxy_latest_url(source, module);
387                    match fetch_live_proxy_latest(ctx, source, &url).await {
388                        Ok(version) => {
389                            return Ok(GoProxySelection {
390                                source: source.clone(),
391                                module_root: module.clone(),
392                                versions: vec![version_info(&version)],
393                            });
394                        }
395                        Err(error) => last_error = Some(error),
396                    }
397                }
398            }
399        }
400        for module in &candidates {
401            for source in &sources {
402                let url = proxy_latest_url(source, module);
403                match read_cached_proxy_latest(ctx, source, &url) {
404                    Ok(version) => {
405                        tracing::warn!(
406                            source = %source.id,
407                            url,
408                            "using stale cached Go proxy latest metadata after all live sources failed"
409                        );
410                        return Ok(GoProxySelection {
411                            source: source.clone(),
412                            module_root: module.clone(),
413                            versions: vec![version_info(&version)],
414                        });
415                    }
416                    Err(_) if !ctx.config.settings.offline => {}
417                    Err(_) => {
418                        last_error = Some(Error::other(format!(
419                            "offline Go proxy metadata cache miss for {url}"
420                        )));
421                    }
422                }
423            }
424        }
425        Err(last_error.unwrap_or_else(|| Error::NoUsableSource {
426            tool: self.id.clone(),
427            tried,
428        }))
429    }
430
431    async fn ranked_proxy_sources(&self, ctx: &Ctx) -> Result<Vec<Source>> {
432        let mut sources = crate::source::select::effective_sources(ctx, self);
433        if let Some(config) = ctx.config.tool_sources(self.id()) {
434            if !config.custom.is_empty() {
435                let mut allowed_ids = config
436                    .custom
437                    .iter()
438                    .map(|source| source.id.as_str())
439                    .collect::<std::collections::BTreeSet<_>>();
440                if let Some(pin) = config.pin.as_deref() {
441                    allowed_ids.insert(pin);
442                }
443                sources.retain(|source| allowed_ids.contains(source.id.as_str()));
444            }
445        }
446        for source in &sources {
447            validate_go_source(source)?;
448        }
449        crate::source::select::ranked_source_candidates(ctx, self, sources).await
450    }
451
452    fn managed_go(
453        &self,
454        ctx: &Ctx,
455        options: &BTreeMap<String, String>,
456    ) -> Result<(PathBuf, PathBuf)> {
457        let version = self.runtime_version(options)?;
458        let root = ctx.dirs.install_path("go", version);
459        let go = root
460            .join("bin")
461            .join(format!("go{}", ctx.platform.os.exe_suffix()));
462        let root_metadata =
463            std::fs::symlink_metadata(&root).map_err(|error| Error::io(&root, error))?;
464        let go_metadata = std::fs::symlink_metadata(&go).map_err(|error| Error::io(&go, error))?;
465        let canonical_root = dunce::canonicalize(&root).map_err(|error| Error::io(&root, error))?;
466        let canonical_go = dunce::canonicalize(&go).map_err(|error| Error::io(&go, error))?;
467        let canonical_store = dunce::canonicalize(&ctx.dirs.store).ok();
468        if root_metadata.file_type().is_symlink()
469            || !root_metadata.is_dir()
470            || (!go_metadata.is_file() && !go_metadata.file_type().is_symlink())
471            || (!canonical_go.starts_with(&canonical_root)
472                && canonical_store
473                    .as_ref()
474                    .is_none_or(|store| !canonical_go.starts_with(store)))
475        {
476            return Err(Error::other(format!(
477                "managed Go runtime `{version}` has an unsafe or missing go executable"
478            )));
479        }
480        Ok((root, canonical_go))
481    }
482
483    fn command_env(
484        &self,
485        ctx: &Ctx,
486        tv: &ToolVersion,
487        stage: &Path,
488    ) -> Result<BTreeMap<OsString, OsString>> {
489        let (go_root, _) = self.managed_go(ctx, &tv.options)?;
490        let home = stage.join("home");
491        let go_path = stage.join("gopath");
492        let tmp = stage.join("tmp");
493        let module_cache = crate::cache::downstream_root(&ctx.dirs.cache).join("go-mod");
494        let build_cache = crate::cache::downstream_root(&ctx.dirs.cache).join("go-build");
495        for path in [&home, &go_path, &tmp, &module_cache, &build_cache] {
496            std::fs::create_dir_all(path).map_err(|error| Error::io(path, error))?;
497        }
498        let mut env = BTreeMap::from([
499            (OsString::from("HOME"), home.clone().into_os_string()),
500            (OsString::from("USERPROFILE"), home.into_os_string()),
501            (OsString::from("GOROOT"), go_root.clone().into_os_string()),
502            (OsString::from("GOBIN"), stage.join("bin").into_os_string()),
503            (OsString::from("GOPATH"), go_path.into_os_string()),
504            (OsString::from("GOMODCACHE"), module_cache.into_os_string()),
505            (OsString::from("GOCACHE"), build_cache.into_os_string()),
506            (OsString::from("GOENV"), OsString::from("off")),
507            (OsString::from("GOTOOLCHAIN"), OsString::from("local")),
508            (OsString::from("CGO_ENABLED"), OsString::from("0")),
509            (OsString::from("GOSUMDB"), OsString::from("off")),
510            (
511                OsString::from("GONOSUMDB"),
512                OsString::from(self.module_root(&tv.options)?),
513            ),
514            (OsString::from("GONOPROXY"), OsString::from("none")),
515            (
516                OsString::from("GOPROXY"),
517                OsString::from(self.proxy(&tv.options)?),
518            ),
519            (
520                OsString::from("PATH"),
521                sanitized_provider_path(ctx, &go_root.join("bin"), std::env::var_os("PATH"))?,
522            ),
523            (OsString::from("TMPDIR"), tmp.clone().into_os_string()),
524            (OsString::from("TEMP"), tmp.clone().into_os_string()),
525            (OsString::from("TMP"), tmp.into_os_string()),
526            (OsString::from("GIT_TERMINAL_PROMPT"), OsString::from("0")),
527        ]);
528        if let Some(values) = tv.options.get("env") {
529            for assignment in values.split(';') {
530                let (name, value) = assignment
531                    .split_once('=')
532                    .ok_or_else(|| Error::config("invalid canonical Go install env"))?;
533                env.insert(OsString::from(name), OsString::from(value));
534            }
535        }
536        if ctx.platform.os == crate::platform::Os::Windows {
537            for name in ["SystemRoot", "WINDIR", "ComSpec", "PATHEXT"] {
538                if let Some(value) = std::env::var_os(name) {
539                    env.insert(OsString::from(name), value);
540                }
541            }
542        }
543        Ok(env)
544    }
545
546    fn install_args(&self, tv: &ToolVersion) -> Vec<OsString> {
547        let mut args = vec![OsString::from("install")];
548        if let Some(tags) = tv.options.get("tags") {
549            args.push(OsString::from("-tags"));
550            args.push(OsString::from(tags));
551        }
552        args.push(OsString::from(format!(
553            "{}@{}",
554            self.command_path,
555            go_provider_version(&tv.version)
556        )));
557        args
558    }
559
560    async fn install_with_runner(
561        &self,
562        ctx: &Ctx,
563        tv: &ToolVersion,
564        runner: &dyn CommandRunner,
565    ) -> Result<()> {
566        let canonical = self.canonical_options(&tv.options)?;
567        if canonical != tv.options {
568            return Err(Error::config(format!(
569                "Go tool `{}` contains non-canonical install options",
570                self.id
571            )));
572        }
573        if ctx.config.settings.offline {
574            if let Some(lifecycle) = self.selected_lifecycle(ctx, tv)? {
575                if lifecycle.validate_complete(&ctx.dirs)? {
576                    return Ok(());
577                }
578            }
579            return Err(Error::other(format!(
580                "offline Go install requires an already complete matching install for `{}`; Go native locks do not contain a complete module graph",
581                self.id
582            )));
583        }
584        let lifecycle = self.lifecycle(ctx, tv)?;
585        let NativeToolPreparation::Staged(stage) = lifecycle.prepare(&ctx.dirs).await? else {
586            return Ok(());
587        };
588        let (_, go) = self.managed_go(ctx, &tv.options)?;
589        let env = self.command_env(ctx, tv, stage.path())?;
590        let command = CommandSpec::new(go.as_os_str().to_owned())
591            .args(self.install_args(tv))
592            .envs(env)
593            .current_dir(stage.path())
594            .clear_env();
595        match runner.run_captured(
596            &command,
597            CaptureLimits::new(
598                PROVIDER_TIMEOUT,
599                PROVIDER_OUTPUT_LIMIT,
600                PROVIDER_OUTPUT_LIMIT,
601            ),
602        ) {
603            CommandOutcome::Exited { status, output: _ } if status.success() => {}
604            CommandOutcome::Exited { status, output } => {
605                return Err(provider_error(status.to_string(), &output.stderr));
606            }
607            outcome => {
608                return Err(Error::other(format!(
609                    "go install could not run: {outcome:?}"
610                )))
611            }
612        }
613        clean_provider_workspace(stage.path())?;
614        write_resolution(stage.path(), &self.resolution(tv)?)?;
615        stage.publish(NativeToolProvider::GoInstall)?;
616        Ok(())
617    }
618
619    fn resolution(&self, tv: &ToolVersion) -> Result<GoResolution> {
620        Ok(GoResolution {
621            schema: GO_RESOLUTION_SCHEMA,
622            backend: self.id.clone(),
623            version: tv.version.clone(),
624            proxy: self.proxy(&tv.options)?.into(),
625            module_root: self.module_root(&tv.options)?.into(),
626            replay: "version-only".into(),
627        })
628    }
629
630    fn resolution_matches(&self, tv: &ToolVersion, actual: &GoResolution) -> bool {
631        let valid_module = ToolId::parse(&format!("go:{}", actual.module_root))
632            .is_ok_and(|id| id.subject() == actual.module_root)
633            && self
634                .command_path
635                .strip_prefix(&actual.module_root)
636                .is_some_and(|suffix| suffix.is_empty() || suffix.starts_with('/'));
637        actual.schema == GO_RESOLUTION_SCHEMA
638            && actual.backend == self.id
639            && actual.version == tv.version
640            && actual.replay == "version-only"
641            && validate_go_proxy(&actual.proxy).is_ok()
642            && valid_module
643            && tv
644                .options
645                .get(LOCKED_GO_PROXY_OPTION)
646                .is_none_or(|proxy| proxy == &actual.proxy)
647            && tv
648                .options
649                .get(LOCKED_GO_MODULE_OPTION)
650                .is_none_or(|module| module == &actual.module_root)
651    }
652}
653
654fn go_provider_version(version: &str) -> String {
655    if let Some(base) = version.strip_suffix("+incompatible") {
656        format!("v{base}+incompatible")
657    } else {
658        format!("v{version}")
659    }
660}
661
662#[async_trait]
663impl Backend for GoPackageBackend {
664    fn id(&self) -> &str {
665        &self.id
666    }
667
668    fn default_sources(&self) -> Vec<Source> {
669        vec![
670            Source::official("proxy.golang.org", "https://proxy.golang.org"),
671            Source::mirror("goproxy.cn", "https://goproxy.cn", 10),
672        ]
673    }
674
675    fn probe_url(&self, _ctx: &Ctx, source: &Source) -> Option<String> {
676        validate_go_source(source)
677            .ok()
678            .map(|()| format!("{}/", source.download_url.trim_end_matches('/')))
679    }
680
681    async fn list_remote_versions(&self, ctx: &Ctx) -> Result<Vec<VersionInfo>> {
682        Ok(self.proxy_selection(ctx).await?.versions)
683    }
684
685    async fn resolve_version(&self, ctx: &Ctx, req: &ToolRequest) -> Result<ToolVersion> {
686        let id = ToolId::parse(&req.backend)?;
687        crate::tool::validate_dynamic_selector(&id, Some(&req.spec.to_string()))?;
688        let mut options = req
689            .options
690            .iter()
691            .filter(|(name, _)| name.starts_with("__osdk_"))
692            .map(|(name, value)| (name.clone(), value.clone()))
693            .collect::<BTreeMap<_, _>>();
694        options.extend(crate::tool::canonicalize_dynamic_options(&id, &req.options)?.into_map());
695        if ctx.config.settings.offline
696            && matches!(&req.spec, VersionSpec::Exact(version) if crate::tool::is_canonical_go_module_version(version))
697            && !options.contains_key(LOCKED_GO_PROXY_OPTION)
698            && !options.contains_key(LOCKED_GO_MODULE_OPTION)
699        {
700            let mut resolved = ToolVersion::new(&self.id, req.spec.to_string());
701            resolved.options = options;
702            resolved
703                .options
704                .insert(LOCKED_NATIVE_REPLAY_OPTION.into(), "version-only".into());
705            return Ok(resolved);
706        }
707        let (version, source, module_root) = match &req.spec {
708            VersionSpec::Exact(version) if crate::tool::is_canonical_go_module_version(version) => {
709                let selection = if Self::has_locked_resolution(&options) {
710                    let proxy = options
711                        .get(LOCKED_GO_PROXY_OPTION)
712                        .expect("locked resolution checked proxy");
713                    let module = options
714                        .get(LOCKED_GO_MODULE_OPTION)
715                        .expect("locked resolution checked module");
716                    validate_go_proxy(proxy)?;
717                    self.module_root(&options)?;
718                    GoProxySelection {
719                        source: Source::official("locked", proxy),
720                        module_root: module.clone(),
721                        versions: vec![version_info(version)],
722                    }
723                } else {
724                    self.exact_proxy_selection(ctx, version).await?
725                };
726                (
727                    version.clone(),
728                    selection.source.download_url,
729                    selection.module_root,
730                )
731            }
732            VersionSpec::Latest => {
733                let selection = self.latest_proxy_selection(ctx).await?;
734                let selected = selection
735                    .versions
736                    .first()
737                    .ok_or_else(|| Error::VersionResolve {
738                        tool: self.id.clone(),
739                        spec: req.spec.to_string(),
740                        hint: Some("Go proxy returned no latest module version".into()),
741                    })?;
742                (
743                    selected.version.clone(),
744                    selection.source.download_url,
745                    selection.module_root,
746                )
747            }
748            VersionSpec::Prefix(_) => {
749                let selection = self.proxy_selection(ctx).await?;
750                let stable = selection
751                    .versions
752                    .iter()
753                    .filter(|version| version.stable)
754                    .cloned()
755                    .collect::<Vec<_>>();
756                let selected =
757                    crate::version::select_version(&req.spec, &stable).ok_or_else(|| {
758                        Error::VersionResolve {
759                        tool: self.id.clone(),
760                        spec: req.spec.to_string(),
761                        hint: Some(
762                            "no matching Go module release found through the configured proxies"
763                                .into(),
764                        ),
765                    }
766                    })?;
767                (
768                    selected.version.clone(),
769                    selection.source.download_url,
770                    selection.module_root,
771                )
772            }
773            _ => {
774                return Err(Error::VersionResolve {
775                    tool: self.id.clone(),
776                    spec: req.spec.to_string(),
777                    hint: Some("Go tools require latest, an exact semantic or pseudo-version, or a numeric prefix".into()),
778                });
779            }
780        };
781        validate_go_proxy(&source)?;
782        let mut resolved = ToolVersion::new(&self.id, version);
783        resolved.options = options;
784        resolved
785            .options
786            .insert(LOCKED_GO_PROXY_OPTION.into(), source);
787        resolved
788            .options
789            .insert(LOCKED_GO_MODULE_OPTION.into(), module_root);
790        resolved
791            .options
792            .insert(LOCKED_NATIVE_REPLAY_OPTION.into(), "version-only".into());
793        Ok(resolved)
794    }
795
796    async fn install(&self, ctx: &InstallCtx<'_>, tv: &ToolVersion) -> Result<()> {
797        self.install_with_runner(ctx.ctx, tv, &SystemCommandRunner)
798            .await
799    }
800
801    async fn uninstall(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<()> {
802        if let Some(lifecycle) = self.selected_lifecycle(ctx, tv)? {
803            lifecycle.uninstall().await?;
804        }
805        Ok(())
806    }
807
808    fn list_installed(&self, ctx: &Ctx) -> Result<Vec<String>> {
809        native_tool::list_installed(&ctx.dirs, ctx.platform, NativeToolFamily::Go, &self.id)
810    }
811
812    fn bin_paths(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<PathBuf>> {
813        let Some(lifecycle) = self.selected_lifecycle(ctx, tv)? else {
814            return Ok(Vec::new());
815        };
816        Ok(lifecycle
817            .validate_complete(&ctx.dirs)?
818            .then(|| lifecycle.install_root().join("bin"))
819            .into_iter()
820            .collect())
821    }
822
823    fn bin_names(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<String>> {
824        let Some(lifecycle) = self.selected_lifecycle(ctx, tv)? else {
825            return Err(Error::NotInstalled {
826                tool: self.id.clone(),
827                version: tv.version.clone(),
828            });
829        };
830        if !lifecycle.validate_complete(&ctx.dirs)? {
831            return Err(Error::NotInstalled {
832                tool: self.id.clone(),
833                version: tv.version.clone(),
834            });
835        }
836        Ok(native_tool::load_receipt(lifecycle.install_root())?
837            .bins
838            .into_iter()
839            .filter_map(|bin| {
840                Path::new(&bin.path)
841                    .file_stem()
842                    .and_then(OsStr::to_str)
843                    .map(str::to_string)
844            })
845            .collect())
846    }
847
848    fn dynamic_install_identity(
849        &self,
850        ctx: &Ctx,
851        tv: &ToolVersion,
852    ) -> Result<Option<InstallIdentity>> {
853        if !tv
854            .options
855            .contains_key(LOCKED_NATIVE_RUNTIME_VERSION_OPTION)
856        {
857            return Ok(None);
858        }
859        self.lifecycle(ctx, tv)
860            .map(|lifecycle| Some(lifecycle.identity().clone()))
861    }
862
863    fn validate_dynamic_install(
864        &self,
865        ctx: &Ctx,
866        tv: &ToolVersion,
867        install_root: &Path,
868        identity: &InstallIdentity,
869    ) -> Result<bool> {
870        if identity.tool != self.id
871            || identity.version != tv.version
872            || !self.materials_match(tv, &identity.materials)
873            || identity.material_options
874                != crate::backend::dynamic::identity_options(&self.id, &tv.options)?
875            || !self.resolution_matches(tv, &load_resolution(install_root)?)
876        {
877            return Ok(false);
878        }
879        NativeToolLifecycle::from_identity(&ctx.dirs, NativeToolFamily::Go, identity.clone())?
880            .validate_dynamic_install(&ctx.dirs, install_root, identity)
881    }
882}
883
884fn module_path_candidates(path: &str) -> Vec<String> {
885    let components = path.split('/').collect::<Vec<_>>();
886    (2..=components.len())
887        .rev()
888        .map(|length| components[..length].join("/"))
889        .collect()
890}
891
892fn escape_module_path(path: &str) -> String {
893    let mut escaped = String::with_capacity(path.len());
894    for character in path.chars() {
895        if character.is_ascii_uppercase() {
896            escaped.push('!');
897            escaped.push(character.to_ascii_lowercase());
898        } else {
899            escaped.push(character);
900        }
901    }
902    escaped
903}
904
905fn proxy_list_url(source: &Source, module: &str) -> String {
906    format!(
907        "{}/{}/@v/list",
908        source.download_url.trim_end_matches('/'),
909        escape_module_path(module)
910    )
911}
912
913fn proxy_info_url(source: &Source, module: &str, version: &str) -> String {
914    format!(
915        "{}/{}/@v/{}.info",
916        source.download_url.trim_end_matches('/'),
917        escape_module_path(module),
918        escape_go_token(&format!("v{version}"))
919    )
920}
921
922fn proxy_latest_url(source: &Source, module: &str) -> String {
923    format!(
924        "{}/{}/@latest",
925        source.download_url.trim_end_matches('/'),
926        escape_module_path(module)
927    )
928}
929
930fn escape_go_token(value: &str) -> String {
931    let mut escaped = String::with_capacity(value.len());
932    for character in value.chars() {
933        if character.is_ascii_uppercase() {
934            escaped.push('!');
935            escaped.push(character.to_ascii_lowercase());
936        } else {
937            escaped.push(character);
938        }
939    }
940    escaped
941}
942
943fn validate_go_source(source: &Source) -> Result<()> {
944    validate_go_proxy(&source.download_url)?;
945    if !source.headers.is_empty() {
946        return Err(Error::config(format!(
947            "Go proxy source `{}` cannot use custom HTTP headers because the go command cannot enforce their forwarding boundary",
948            source.id
949        )));
950    }
951    Ok(())
952}
953
954pub fn validate_go_proxy(value: &str) -> Result<()> {
955    let parsed =
956        reqwest::Url::parse(value).map_err(|_| Error::config("Go proxy URL is invalid"))?;
957    let loopback_http = parsed.scheme() == "http"
958        && parsed
959            .host_str()
960            .and_then(|host| host.parse::<std::net::IpAddr>().ok())
961            .is_some_and(|address| address.is_loopback());
962    let canonical = parsed.as_str().trim_end_matches('/');
963    if (parsed.scheme() != "https" && !loopback_http)
964        || parsed.host_str().is_none()
965        || !parsed.username().is_empty()
966        || parsed.password().is_some()
967        || parsed.query().is_some()
968        || parsed.fragment().is_some()
969        || (parsed.path() != "/" && parsed.path().ends_with('/'))
970        || canonical != value
971    {
972        return Err(Error::config(
973            "Go proxy must be a canonical HTTPS URL without credentials, query, fragment, or trailing slash",
974        ));
975    }
976    Ok(())
977}
978
979fn parse_proxy_versions(bytes: &[u8]) -> Result<Vec<VersionInfo>> {
980    let text = std::str::from_utf8(bytes)
981        .map_err(|_| Error::config("Go proxy version list is not valid UTF-8"))?;
982    let mut versions = text
983        .lines()
984        .map(str::trim)
985        .filter(|line| !line.is_empty())
986        .filter_map(|line| {
987            let canonical = line.strip_prefix('v')?;
988            let parsed = semver::Version::parse(canonical).ok()?;
989            if !crate::tool::is_canonical_go_module_version(canonical) {
990                return None;
991            }
992            Some(VersionInfo {
993                version: canonical.to_string(),
994                stable: parsed.pre.is_empty(),
995                lts: None,
996            })
997        })
998        .collect::<Vec<_>>();
999    versions.sort_by(|left, right| {
1000        semver::Version::parse(&left.version)
1001            .expect("filtered canonical Go version")
1002            .cmp(&semver::Version::parse(&right.version).expect("filtered canonical Go version"))
1003    });
1004    versions.dedup_by(|left, right| left.version == right.version);
1005    Ok(versions)
1006}
1007
1008fn version_info(version: &str) -> VersionInfo {
1009    VersionInfo {
1010        version: version.into(),
1011        stable: semver::Version::parse(version).is_ok_and(|version| version.pre.is_empty()),
1012        lts: None,
1013    }
1014}
1015
1016#[derive(Deserialize)]
1017struct GoProxyInfo {
1018    #[serde(rename = "Version")]
1019    version: String,
1020    #[serde(rename = "Time")]
1021    _time: String,
1022}
1023
1024fn parse_proxy_info(bytes: &[u8], expected: &str) -> Result<()> {
1025    let info: GoProxyInfo = serde_json::from_slice(bytes)?;
1026    if info.version != format!("v{expected}") {
1027        return Err(Error::config(format!(
1028            "Go proxy returned version `{}` while resolving `v{expected}`",
1029            info.version
1030        )));
1031    }
1032    Ok(())
1033}
1034
1035fn parse_proxy_latest(bytes: &[u8]) -> Result<String> {
1036    let info: GoProxyInfo = serde_json::from_slice(bytes)?;
1037    let version = info
1038        .version
1039        .strip_prefix('v')
1040        .ok_or_else(|| Error::config("Go proxy latest version must start with `v`"))?;
1041    if !crate::tool::is_canonical_go_module_version(version) {
1042        return Err(Error::config(
1043            "Go proxy latest metadata has an invalid version",
1044        ));
1045    }
1046    Ok(version.to_string())
1047}
1048
1049async fn fetch_live_proxy_info(
1050    ctx: &Ctx,
1051    source: &Source,
1052    url: &str,
1053    expected: &str,
1054) -> Result<()> {
1055    let cache = crate::http::source_metadata_cache_path(ctx, source, url)?;
1056    let bytes = fetch_live_proxy_bytes(
1057        ctx,
1058        source,
1059        url,
1060        64 * 1024,
1061        "Go proxy version metadata exceeds 64 KiB",
1062    )
1063    .await?;
1064    parse_proxy_info(&bytes, expected)?;
1065    if let Some(parent) = cache.parent() {
1066        std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
1067    }
1068    let serial = NEXT_METADATA_TEMPORARY.fetch_add(1, Ordering::Relaxed);
1069    let temporary = cache.with_extension(format!("tmp-{}-{serial}", std::process::id()));
1070    if std::fs::write(&temporary, &bytes).is_ok() {
1071        let _ = std::fs::rename(&temporary, &cache);
1072        let _ = std::fs::remove_file(&temporary);
1073    }
1074    Ok(())
1075}
1076
1077fn read_cached_proxy_info(ctx: &Ctx, source: &Source, url: &str, expected: &str) -> Result<()> {
1078    let cache = crate::http::source_metadata_cache_path(ctx, source, url)?;
1079    let bytes = crate::inventory::read_stable_regular_file(&cache, 64 * 1024)
1080        .map_err(|error| Error::io(&cache, error))?;
1081    parse_proxy_info(&bytes, expected)
1082}
1083
1084async fn fetch_live_proxy_latest(ctx: &Ctx, source: &Source, url: &str) -> Result<String> {
1085    let cache = crate::http::source_metadata_cache_path(ctx, source, url)?;
1086    let bytes = fetch_live_proxy_bytes(
1087        ctx,
1088        source,
1089        url,
1090        64 * 1024,
1091        "Go proxy latest metadata exceeds 64 KiB",
1092    )
1093    .await?;
1094    let version = parse_proxy_latest(&bytes)?;
1095    if let Some(parent) = cache.parent() {
1096        std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
1097    }
1098    let serial = NEXT_METADATA_TEMPORARY.fetch_add(1, Ordering::Relaxed);
1099    let temporary = cache.with_extension(format!("tmp-{}-{serial}", std::process::id()));
1100    if std::fs::write(&temporary, &bytes).is_ok() {
1101        let _ = std::fs::rename(&temporary, &cache);
1102        let _ = std::fs::remove_file(&temporary);
1103    }
1104    Ok(version)
1105}
1106
1107fn read_cached_proxy_latest(ctx: &Ctx, source: &Source, url: &str) -> Result<String> {
1108    let cache = crate::http::source_metadata_cache_path(ctx, source, url)?;
1109    let bytes = crate::inventory::read_stable_regular_file(&cache, 64 * 1024)
1110        .map_err(|error| Error::io(&cache, error))?;
1111    parse_proxy_latest(&bytes)
1112}
1113
1114async fn fetch_live_proxy_versions(
1115    ctx: &Ctx,
1116    source: &Source,
1117    url: &str,
1118) -> Result<Vec<VersionInfo>> {
1119    let cache = crate::http::source_metadata_cache_path(ctx, source, url)?;
1120    let bytes = fetch_live_proxy_bytes(
1121        ctx,
1122        source,
1123        url,
1124        GO_PROXY_METADATA_LIMIT,
1125        "Go proxy metadata exceeds the 4 MiB limit",
1126    )
1127    .await?;
1128    let versions = parse_proxy_versions(&bytes)?;
1129    if let Some(parent) = cache.parent() {
1130        std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
1131    }
1132    let serial = NEXT_METADATA_TEMPORARY.fetch_add(1, Ordering::Relaxed);
1133    let temporary = cache.with_extension(format!("tmp-{}-{serial}", std::process::id()));
1134    if std::fs::write(&temporary, &bytes).is_ok() {
1135        let _ = std::fs::rename(&temporary, &cache);
1136        let _ = std::fs::remove_file(&temporary);
1137    }
1138    Ok(versions)
1139}
1140
1141async fn fetch_live_proxy_bytes(
1142    ctx: &Ctx,
1143    source: &Source,
1144    url: &str,
1145    limit: usize,
1146    limit_error: &'static str,
1147) -> Result<Vec<u8>> {
1148    let fetch = async {
1149        let response = crate::http::get_source_response(&ctx.client, source, url)
1150            .await?
1151            .error_for_status()
1152            .map_err(|error| Error::network(url, error))?;
1153        if response
1154            .content_length()
1155            .is_some_and(|size| size > limit as u64)
1156        {
1157            return Err(Error::other(limit_error));
1158        }
1159        let mut bytes = Vec::new();
1160        let mut stream = response.bytes_stream();
1161        while let Some(chunk) = stream.next().await {
1162            let chunk = chunk.map_err(|error| Error::network(url, error))?;
1163            if bytes.len().saturating_add(chunk.len()) > limit {
1164                return Err(Error::other(limit_error));
1165            }
1166            bytes.extend_from_slice(&chunk);
1167        }
1168        Ok::<_, Error>(bytes)
1169    };
1170    tokio::time::timeout(GO_PROXY_TIMEOUT, fetch)
1171        .await
1172        .map_err(|_| Error::other("Go proxy metadata exceeded the 30 second timeout"))?
1173}
1174
1175fn read_cached_proxy_versions(ctx: &Ctx, source: &Source, url: &str) -> Result<Vec<VersionInfo>> {
1176    let cache = crate::http::source_metadata_cache_path(ctx, source, url)?;
1177    let bytes = crate::inventory::read_stable_regular_file(&cache, GO_PROXY_METADATA_LIMIT as u64)
1178        .map_err(|error| Error::io(&cache, error))?;
1179    parse_proxy_versions(&bytes)
1180}
1181
1182fn sanitized_provider_path(
1183    ctx: &Ctx,
1184    managed_bin: &Path,
1185    inherited: Option<OsString>,
1186) -> Result<OsString> {
1187    let shims = ctx.dirs.shims();
1188    let managed_go_base = ctx.dirs.installs.join("go");
1189    let mut paths = vec![managed_bin.to_path_buf()];
1190    if let Some(inherited) = inherited {
1191        for path in std::env::split_paths(&inherited) {
1192            if path.as_os_str().is_empty()
1193                || path == managed_bin
1194                || path == shims
1195                || path.starts_with(&managed_go_base)
1196                || paths.iter().any(|existing| existing == &path)
1197            {
1198                continue;
1199            }
1200            paths.push(path);
1201        }
1202    }
1203    std::env::join_paths(paths)
1204        .map_err(|error| Error::config(format!("invalid sanitized Go provider PATH: {error}")))
1205}
1206
1207fn clean_provider_workspace(stage: &Path) -> Result<()> {
1208    for name in ["home", "gopath", "tmp"] {
1209        let path = stage.join(name);
1210        match std::fs::symlink_metadata(&path) {
1211            Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
1212                return Err(Error::other(format!(
1213                    "Go provider workspace is unsafe: {}",
1214                    path.display()
1215                )));
1216            }
1217            Ok(_) => std::fs::remove_dir_all(&path).map_err(|error| Error::io(&path, error))?,
1218            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1219            Err(error) => return Err(Error::io(&path, error)),
1220        }
1221    }
1222    Ok(())
1223}
1224
1225fn write_resolution(root: &Path, resolution: &GoResolution) -> Result<()> {
1226    let path = root.join(GO_RESOLUTION_FILE);
1227    let bytes = serde_json::to_vec_pretty(resolution)?;
1228    let mut options = std::fs::OpenOptions::new();
1229    options.write(true).create_new(true);
1230    #[cfg(unix)]
1231    {
1232        use std::os::unix::fs::OpenOptionsExt;
1233        options.mode(0o600);
1234    }
1235    use std::io::Write as _;
1236    let mut file = options.open(&path).map_err(|error| {
1237        if error.kind() == std::io::ErrorKind::AlreadyExists {
1238            Error::other(format!(
1239                "Go provider wrote reserved metadata path {}",
1240                path.display()
1241            ))
1242        } else {
1243            Error::io(&path, error)
1244        }
1245    })?;
1246    file.write_all(&bytes)
1247        .map_err(|error| Error::io(&path, error))
1248}
1249
1250fn load_resolution(root: &Path) -> Result<GoResolution> {
1251    let path = root.join(GO_RESOLUTION_FILE);
1252    let bytes = crate::inventory::read_stable_regular_file(&path, 64 * 1024)
1253        .map_err(|error| Error::io(&path, error))?;
1254    let resolution: GoResolution = serde_json::from_slice(&bytes)?;
1255    if resolution.schema != GO_RESOLUTION_SCHEMA {
1256        return Err(Error::config("unsupported Go resolution schema"));
1257    }
1258    Ok(resolution)
1259}
1260
1261fn provider_error(status: String, stderr: &[u8]) -> Error {
1262    let stderr = String::from_utf8_lossy(stderr);
1263    let stderr = stderr.trim();
1264    Error::Command {
1265        cmd: "go install".into(),
1266        status,
1267        stderr: (!stderr.is_empty()).then(|| stderr.to_string()),
1268    }
1269}
1270
1271fn regular_file(path: &Path) -> bool {
1272    std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file())
1273}
1274
1275#[cfg(test)]
1276mod tests {
1277    use super::*;
1278    use std::io::{Read, Write};
1279    use std::net::TcpListener;
1280    use std::sync::mpsc;
1281    use std::sync::{Arc, Mutex};
1282    use std::thread;
1283
1284    use crate::config::{Config, Settings};
1285    use crate::dirs::Dirs;
1286    use crate::platform::Platform;
1287    use crate::process::CapturedOutput;
1288    use crate::store::Cas;
1289
1290    fn context(root: &Path, offline: bool) -> Ctx {
1291        let dirs = Dirs::resolve_from(|key| match key {
1292            "OSDK_DATA_DIR" => Some(root.join("data").display().to_string()),
1293            "OSDK_CACHE_DIR" => Some(root.join("cache").display().to_string()),
1294            "OSDK_CONFIG_DIR" => Some(root.join("config").display().to_string()),
1295            "OSDK_STORE_DIR" => Some(root.join("store").display().to_string()),
1296            "OSDK_INSTALL_DIR" => Some(root.join("installs").display().to_string()),
1297            _ => None,
1298        })
1299        .unwrap();
1300        dirs.ensure().unwrap();
1301        Ctx {
1302            cas: Arc::new(Cas::new(dirs.store.clone())),
1303            dirs,
1304            platform: Platform::current(),
1305            config: Config {
1306                settings: Settings {
1307                    offline,
1308                    ..Default::default()
1309                },
1310                sources: Default::default(),
1311                tools: Default::default(),
1312                tool_configs: Default::default(),
1313                global_tools: Default::default(),
1314                global_tool_configs: Default::default(),
1315                tool_origins: Default::default(),
1316                aliases: Default::default(),
1317                project_config_path: None,
1318            },
1319            client: reqwest::Client::new(),
1320            show_progress: false,
1321        }
1322    }
1323
1324    fn write_executable(path: &Path, bytes: &[u8]) {
1325        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1326        std::fs::write(path, bytes).unwrap();
1327        #[cfg(unix)]
1328        {
1329            use std::os::unix::fs::PermissionsExt;
1330            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
1331        }
1332    }
1333
1334    fn managed_go(ctx: &Ctx, version: &str) {
1335        let root = ctx.dirs.install_path("go", version);
1336        for directory in ["bin", "pkg/tool", "src/runtime"] {
1337            std::fs::create_dir_all(root.join(directory)).unwrap();
1338        }
1339        for name in ["go", "gofmt"] {
1340            write_executable(
1341                &root
1342                    .join("bin")
1343                    .join(format!("{name}{}", ctx.platform.os.exe_suffix())),
1344                name.as_bytes(),
1345            );
1346        }
1347        write_executable(
1348            &root
1349                .join("pkg/tool")
1350                .join(format!("compile{}", ctx.platform.os.exe_suffix())),
1351            b"compile",
1352        );
1353        std::fs::write(root.join("src/runtime/runtime.go"), b"package runtime").unwrap();
1354        std::fs::write(root.join("VERSION"), format!("go{version}\n")).unwrap();
1355        std::fs::write(root.join("go.env"), b"GOTOOLCHAIN=local\n").unwrap();
1356        std::fs::write(root.join(".osdk-complete"), b"").unwrap();
1357    }
1358
1359    fn selected(backend: &GoPackageBackend, runtime: &str) -> ToolVersion {
1360        let mut version = ToolVersion::new(backend.id(), "1.2.3");
1361        version.options.extend(BTreeMap::from([
1362            (LOCKED_NATIVE_RUNTIME_OPTION.into(), "go".into()),
1363            (LOCKED_NATIVE_RUNTIME_VERSION_OPTION.into(), runtime.into()),
1364            (LOCKED_NATIVE_REPLAY_OPTION.into(), "version-only".into()),
1365            (
1366                LOCKED_GO_PROXY_OPTION.into(),
1367                "https://proxy.golang.org".into(),
1368            ),
1369            (LOCKED_GO_MODULE_OPTION.into(), backend.command_path.clone()),
1370        ]));
1371        version
1372    }
1373
1374    #[derive(Clone)]
1375    struct FixtureRunner {
1376        calls: Arc<Mutex<Vec<CommandSpec>>>,
1377        statuses: Arc<Mutex<Vec<i32>>>,
1378        forge_resolution: bool,
1379    }
1380
1381    impl FixtureRunner {
1382        fn new(statuses: impl IntoIterator<Item = i32>) -> Self {
1383            Self {
1384                calls: Arc::new(Mutex::new(Vec::new())),
1385                statuses: Arc::new(Mutex::new(statuses.into_iter().collect())),
1386                forge_resolution: false,
1387            }
1388        }
1389    }
1390
1391    impl CommandRunner for FixtureRunner {
1392        fn run_captured(&self, command: &CommandSpec, _limits: CaptureLimits) -> CommandOutcome {
1393            self.calls.lock().unwrap().push(command.clone());
1394            let stage = command.working_directory().unwrap();
1395            std::fs::create_dir_all(stage.join("bin")).unwrap();
1396            let code = self.statuses.lock().unwrap().remove(0);
1397            if code == 0 {
1398                write_executable(
1399                    &stage
1400                        .join("bin")
1401                        .join(if cfg!(windows) { "tool.exe" } else { "tool" }),
1402                    b"fixture binary",
1403                );
1404                if self.forge_resolution {
1405                    std::fs::write(stage.join(GO_RESOLUTION_FILE), b"{}").unwrap();
1406                }
1407            }
1408            exited(code)
1409        }
1410
1411        fn run_foreground(
1412            &self,
1413            _command: &CommandSpec,
1414        ) -> std::io::Result<std::process::ExitStatus> {
1415            unreachable!()
1416        }
1417    }
1418
1419    #[cfg(unix)]
1420    fn exited(code: i32) -> CommandOutcome {
1421        use std::os::unix::process::ExitStatusExt;
1422        CommandOutcome::Exited {
1423            status: std::process::ExitStatus::from_raw(code << 8),
1424            output: CapturedOutput::default(),
1425        }
1426    }
1427
1428    #[cfg(windows)]
1429    fn exited(code: i32) -> CommandOutcome {
1430        use std::os::windows::process::ExitStatusExt;
1431        CommandOutcome::Exited {
1432            status: std::process::ExitStatus::from_raw(code as u32),
1433            output: CapturedOutput::default(),
1434        }
1435    }
1436
1437    struct ProxyServer {
1438        base_url: String,
1439        requests: Arc<Mutex<Vec<String>>>,
1440        shutdown: Option<mpsc::Sender<()>>,
1441        handle: Option<thread::JoinHandle<()>>,
1442    }
1443
1444    impl ProxyServer {
1445        fn start(responses: Vec<(String, String, String)>) -> Self {
1446            let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1447            listener.set_nonblocking(true).unwrap();
1448            let base_url = format!("http://{}", listener.local_addr().unwrap());
1449            let requests = Arc::new(Mutex::new(Vec::new()));
1450            let server_requests = Arc::clone(&requests);
1451            let (shutdown, shutdown_rx) = mpsc::channel();
1452            let handle = thread::spawn(move || loop {
1453                if shutdown_rx.try_recv().is_ok() {
1454                    break;
1455                }
1456                match listener.accept() {
1457                    Ok((mut stream, _)) => {
1458                        // Accepted sockets inherit the listener's nonblocking mode on
1459                        // Windows. Switch back to blocking I/O before reading the request
1460                        // so the fixture behaves consistently across platforms.
1461                        stream.set_nonblocking(false).unwrap();
1462                        stream
1463                            .set_read_timeout(Some(Duration::from_secs(2)))
1464                            .unwrap();
1465                        let mut request = Vec::new();
1466                        let mut buffer = [0u8; 1024];
1467                        while !request.ends_with(b"\r\n\r\n") {
1468                            let read = stream.read(&mut buffer).unwrap();
1469                            if read == 0 {
1470                                break;
1471                            }
1472                            request.extend_from_slice(&buffer[..read]);
1473                        }
1474                        let path = String::from_utf8(request)
1475                            .unwrap()
1476                            .lines()
1477                            .next()
1478                            .and_then(|line| line.split_whitespace().nth(1))
1479                            .unwrap_or("/")
1480                            .to_string();
1481                        server_requests.lock().unwrap().push(path.clone());
1482                        let (status, body) = responses
1483                            .iter()
1484                            .find(|(expected, _, _)| expected == &path)
1485                            .map(|(_, status, body)| (status.as_str(), body.as_str()))
1486                            .unwrap_or(("404 Not Found", ""));
1487                        write!(
1488                            stream,
1489                            "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1490                            body.len()
1491                        )
1492                        .unwrap();
1493                    }
1494                    Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
1495                        thread::sleep(Duration::from_millis(1));
1496                    }
1497                    Err(error) => panic!("proxy server failed: {error}"),
1498                }
1499            });
1500            Self {
1501                base_url,
1502                requests,
1503                shutdown: Some(shutdown),
1504                handle: Some(handle),
1505            }
1506        }
1507    }
1508
1509    impl Drop for ProxyServer {
1510        fn drop(&mut self) {
1511            if let Some(shutdown) = self.shutdown.take() {
1512                let _ = shutdown.send(());
1513            }
1514            if let Some(handle) = self.handle.take() {
1515                handle.join().unwrap();
1516            }
1517        }
1518    }
1519
1520    fn configure_proxy(ctx: &mut Ctx, backend: &GoPackageBackend, server: &ProxyServer) {
1521        ctx.config.sources.selection = crate::source::Selection::Ordered;
1522        ctx.config.sources.per_tool.insert(
1523            backend.id().into(),
1524            crate::config::ToolSources {
1525                disable: vec!["proxy.golang.org".into(), "goproxy.cn".into()],
1526                custom: vec![Source::mirror("fixture", &server.base_url, 0)],
1527                ..Default::default()
1528            },
1529        );
1530    }
1531
1532    #[test]
1533    fn factory_and_proxy_helpers_are_strict() {
1534        assert!(GoPackageBackend::from_id("go:example.com/acme/tool/cmd/tool").is_some());
1535        assert!(GoPackageBackend::from_id("go:Example.com/acme/tool").is_none());
1536        assert_eq!(
1537            module_path_candidates("example.com/acme/tool/cmd/tool"),
1538            vec![
1539                "example.com/acme/tool/cmd/tool",
1540                "example.com/acme/tool/cmd",
1541                "example.com/acme/tool",
1542                "example.com/acme",
1543            ]
1544        );
1545        assert!(validate_go_proxy("https://user:secret@example.com").is_err());
1546        assert!(validate_go_proxy("http://example.com").is_err());
1547        assert!(crate::tool::is_canonical_go_module_version("1.2.3"));
1548        assert!(crate::tool::is_canonical_go_module_version("1.2.3-beta.1"));
1549        assert!(crate::tool::is_canonical_go_module_version(
1550            "1.2.3+incompatible"
1551        ));
1552        assert!(crate::tool::is_canonical_go_module_version(
1553            "0.0.0-20240801123456-0123456789ab"
1554        ));
1555        assert!(crate::tool::is_canonical_go_module_version(
1556            "1.2.4-0.20240801123456-0123456789ab"
1557        ));
1558        assert!(crate::tool::is_canonical_go_module_version(
1559            "1.2.3-beta.0.20240801123456-0123456789ab"
1560        ));
1561        assert!(crate::tool::is_canonical_go_module_version(
1562            "2.0.0-20240801123456-0123456789ab+incompatible"
1563        ));
1564        assert!(crate::tool::is_canonical_go_module_version(
1565            "1.2.3-arbitrary"
1566        ));
1567        assert!(!crate::tool::is_canonical_go_module_version(
1568            "1.2.3+metadata"
1569        ));
1570        assert!(!crate::tool::is_canonical_go_module_version(
1571            "0.0.0-2024080112345-0123456789ab"
1572        ));
1573        assert_eq!(escape_go_token("v1.2.3-RC1"), "v1.2.3-!r!c1");
1574        assert_eq!(go_provider_version("1.2.3"), "v1.2.3");
1575        assert_eq!(
1576            go_provider_version("1.2.3+incompatible"),
1577            "v1.2.3+incompatible"
1578        );
1579        assert_eq!(
1580            proxy_info_url(
1581                &Source::official("fixture", "https://proxy.example.test"),
1582                "example.com/Acme/Tool",
1583                "1.2.3+incompatible",
1584            ),
1585            "https://proxy.example.test/example.com/!acme/!tool/@v/v1.2.3+incompatible.info"
1586        );
1587    }
1588
1589    #[tokio::test]
1590    async fn exact_resolution_requires_proxy_evidence_and_discovers_nested_module_root() {
1591        let server = ProxyServer::start(vec![
1592            (
1593                "/example.com/acme/tool/cmd/tool/@v/v1.2.3.info".into(),
1594                "404 Not Found".into(),
1595                String::new(),
1596            ),
1597            (
1598                "/example.com/acme/tool/cmd/@v/v1.2.3.info".into(),
1599                "404 Not Found".into(),
1600                String::new(),
1601            ),
1602            (
1603                "/example.com/acme/tool/@v/v1.2.3.info".into(),
1604                "200 OK".into(),
1605                r#"{"Version":"v1.2.3","Time":"2026-01-01T00:00:00Z"}"#.into(),
1606            ),
1607        ]);
1608        let temporary = tempfile::tempdir().unwrap();
1609        let mut ctx = context(temporary.path(), false);
1610        let backend = GoPackageBackend::from_id("go:example.com/acme/tool/cmd/tool").unwrap();
1611        configure_proxy(&mut ctx, &backend, &server);
1612
1613        let resolved = backend
1614            .resolve_version(
1615                &ctx,
1616                &ToolRequest::parse("go:example.com/acme/tool/cmd/tool@1.2.3").unwrap(),
1617            )
1618            .await
1619            .unwrap();
1620        assert_eq!(resolved.options[LOCKED_GO_PROXY_OPTION], server.base_url);
1621        assert_eq!(
1622            resolved.options[LOCKED_GO_MODULE_OPTION],
1623            "example.com/acme/tool"
1624        );
1625        assert_eq!(server.requests.lock().unwrap().len(), 3);
1626
1627        let missing = backend
1628            .resolve_version(
1629                &ctx,
1630                &ToolRequest::parse("go:example.com/acme/tool/cmd/tool@9.9.9").unwrap(),
1631            )
1632            .await;
1633        assert!(missing.is_err());
1634    }
1635
1636    #[tokio::test]
1637    async fn exact_resolution_falls_back_to_later_live_proxy_before_stale_cache() {
1638        let server = ProxyServer::start(vec![
1639            (
1640                "/first/example.com/acme/tool/@v/v1.2.3.info".into(),
1641                "503 Service Unavailable".into(),
1642                String::new(),
1643            ),
1644            (
1645                "/second/example.com/acme/tool/@v/v1.2.3.info".into(),
1646                "200 OK".into(),
1647                r#"{"Version":"v1.2.3","Time":"2026-01-01T00:00:00Z"}"#.into(),
1648            ),
1649        ]);
1650        let temporary = tempfile::tempdir().unwrap();
1651        let mut ctx = context(temporary.path(), false);
1652        let backend = GoPackageBackend::from_id("go:example.com/acme/tool").unwrap();
1653        ctx.config.sources.selection = crate::source::Selection::Ordered;
1654        let first = Source::mirror("first", &format!("{}/first", server.base_url), 0);
1655        let second = Source::mirror("second", &format!("{}/second", server.base_url), 10);
1656        ctx.config.sources.per_tool.insert(
1657            backend.id().into(),
1658            crate::config::ToolSources {
1659                disable: vec!["proxy.golang.org".into(), "goproxy.cn".into()],
1660                custom: vec![first.clone(), second.clone()],
1661                ..Default::default()
1662            },
1663        );
1664        let stale_url = proxy_info_url(&first, "example.com/acme/tool", "1.2.3");
1665        let stale_cache =
1666            crate::http::source_metadata_cache_path(&ctx, &first, &stale_url).unwrap();
1667        std::fs::create_dir_all(stale_cache.parent().unwrap()).unwrap();
1668        std::fs::write(
1669            stale_cache,
1670            br#"{"Version":"v1.2.3","Time":"2025-01-01T00:00:00Z"}"#,
1671        )
1672        .unwrap();
1673
1674        let resolved = backend
1675            .resolve_version(
1676                &ctx,
1677                &ToolRequest::parse("go:example.com/acme/tool@1.2.3").unwrap(),
1678            )
1679            .await
1680            .unwrap();
1681        assert_eq!(
1682            resolved.options[LOCKED_GO_PROXY_OPTION],
1683            format!("{}/second", server.base_url)
1684        );
1685        assert_eq!(
1686            server.requests.lock().unwrap().last().unwrap(),
1687            "/second/example.com/acme/tool/@v/v1.2.3.info"
1688        );
1689    }
1690
1691    #[tokio::test]
1692    async fn longest_module_candidate_wins_across_source_priorities() {
1693        let server = ProxyServer::start(vec![
1694            (
1695                "/first/example.com/acme/tool/cmd/x/@v/v1.2.3.info".into(),
1696                "404 Not Found".into(),
1697                String::new(),
1698            ),
1699            (
1700                "/second/example.com/acme/tool/cmd/x/@v/v1.2.3.info".into(),
1701                "404 Not Found".into(),
1702                String::new(),
1703            ),
1704            (
1705                "/first/example.com/acme/tool/cmd/@v/v1.2.3.info".into(),
1706                "404 Not Found".into(),
1707                String::new(),
1708            ),
1709            (
1710                "/second/example.com/acme/tool/cmd/@v/v1.2.3.info".into(),
1711                "404 Not Found".into(),
1712                String::new(),
1713            ),
1714            (
1715                "/first/example.com/acme/tool/@v/v1.2.3.info".into(),
1716                "404 Not Found".into(),
1717                String::new(),
1718            ),
1719            (
1720                "/second/example.com/acme/tool/@v/v1.2.3.info".into(),
1721                "200 OK".into(),
1722                r#"{"Version":"v1.2.3","Time":"2026-01-01T00:00:00Z"}"#.into(),
1723            ),
1724            (
1725                "/first/example.com/acme/@v/v1.2.3.info".into(),
1726                "200 OK".into(),
1727                r#"{"Version":"v1.2.3","Time":"2026-01-01T00:00:00Z"}"#.into(),
1728            ),
1729        ]);
1730        let temporary = tempfile::tempdir().unwrap();
1731        let mut ctx = context(temporary.path(), false);
1732        let backend = GoPackageBackend::from_id("go:example.com/acme/tool/cmd/x").unwrap();
1733        ctx.config.sources.selection = crate::source::Selection::Ordered;
1734        ctx.config.sources.per_tool.insert(
1735            backend.id().into(),
1736            crate::config::ToolSources {
1737                disable: vec!["proxy.golang.org".into(), "goproxy.cn".into()],
1738                custom: vec![
1739                    Source::mirror("first", &format!("{}/first", server.base_url), 0),
1740                    Source::mirror("second", &format!("{}/second", server.base_url), 10),
1741                ],
1742                ..Default::default()
1743            },
1744        );
1745        let resolved = backend
1746            .resolve_version(
1747                &ctx,
1748                &ToolRequest::parse("go:example.com/acme/tool/cmd/x@1.2.3").unwrap(),
1749            )
1750            .await
1751            .unwrap();
1752        assert_eq!(
1753            resolved.options[LOCKED_GO_MODULE_OPTION],
1754            "example.com/acme/tool"
1755        );
1756        assert_eq!(
1757            resolved.options[LOCKED_GO_PROXY_OPTION],
1758            format!("{}/second", server.base_url)
1759        );
1760    }
1761
1762    #[tokio::test]
1763    async fn proxy_sources_with_custom_headers_are_rejected() {
1764        let temporary = tempfile::tempdir().unwrap();
1765        let mut ctx = context(temporary.path(), false);
1766        let backend = GoPackageBackend::from_id("go:example.com/acme/tool").unwrap();
1767        let mut source = Source::official("private", "https://proxy.example.test");
1768        source
1769            .headers
1770            .push(("Authorization".into(), "secret".into()));
1771        ctx.config.sources.selection = crate::source::Selection::Ordered;
1772        ctx.config.sources.per_tool.insert(
1773            backend.id().into(),
1774            crate::config::ToolSources {
1775                disable: vec!["proxy.golang.org".into(), "goproxy.cn".into()],
1776                custom: vec![source],
1777                ..Default::default()
1778            },
1779        );
1780        let error = backend
1781            .resolve_version(
1782                &ctx,
1783                &ToolRequest::parse("go:example.com/acme/tool@1.2.3").unwrap(),
1784            )
1785            .await
1786            .unwrap_err();
1787        assert!(error.to_string().contains("custom HTTP headers"));
1788        assert!(!error.to_string().contains("secret"));
1789    }
1790
1791    #[tokio::test]
1792    async fn unpinned_custom_proxies_exclude_public_defaults_before_probing() {
1793        let temporary = tempfile::tempdir().unwrap();
1794        let mut ctx = context(temporary.path(), true);
1795        let backend = GoPackageBackend::from_id("go:private.example/acme/tool").unwrap();
1796        ctx.config.sources.selection = crate::source::Selection::Auto;
1797        ctx.config.sources.per_tool.insert(
1798            backend.id().into(),
1799            crate::config::ToolSources {
1800                custom: vec![Source::mirror(
1801                    "private",
1802                    "https://proxy.private.example",
1803                    0,
1804                )],
1805                ..Default::default()
1806            },
1807        );
1808
1809        let sources = backend.ranked_proxy_sources(&ctx).await.unwrap();
1810        assert_eq!(sources.len(), 1);
1811        assert_eq!(sources[0].id, "private");
1812    }
1813
1814    #[tokio::test]
1815    async fn latest_and_prefix_resolve_through_proxy_list() {
1816        let server = ProxyServer::start(vec![
1817            (
1818                "/example.com/acme/tool/@latest".into(),
1819                "200 OK".into(),
1820                r#"{"Version":"v2.0.0","Time":"2026-01-01T00:00:00Z"}"#.into(),
1821            ),
1822            (
1823                "/example.com/acme/tool/@v/list".into(),
1824                "200 OK".into(),
1825                "v1.2.1\nv1.2.4-beta.1\nv2.0.0\nv1.2.3\n".into(),
1826            ),
1827        ]);
1828        let temporary = tempfile::tempdir().unwrap();
1829        let mut ctx = context(temporary.path(), false);
1830        let backend = GoPackageBackend::from_id("go:example.com/acme/tool").unwrap();
1831        configure_proxy(&mut ctx, &backend, &server);
1832
1833        let latest = backend
1834            .resolve_version(
1835                &ctx,
1836                &ToolRequest::parse("go:example.com/acme/tool@latest").unwrap(),
1837            )
1838            .await
1839            .unwrap();
1840        assert_eq!(latest.version, "2.0.0");
1841        let prefix = backend
1842            .resolve_version(
1843                &ctx,
1844                &ToolRequest::parse("go:example.com/acme/tool@1.2").unwrap(),
1845            )
1846            .await
1847            .unwrap();
1848        assert_eq!(prefix.version, "1.2.3");
1849    }
1850
1851    #[test]
1852    fn proxy_version_lists_use_semver_order_and_mark_prereleases() {
1853        let versions = parse_proxy_versions(
1854            b"v1.9.0\nv1.10.0\nv1.11.0-beta.1\nv0.0.0-20240801123456-0123456789ab\n",
1855        )
1856        .unwrap();
1857
1858        assert_eq!(
1859            versions
1860                .iter()
1861                .map(|version| version.version.as_str())
1862                .collect::<Vec<_>>(),
1863            [
1864                "0.0.0-20240801123456-0123456789ab",
1865                "1.9.0",
1866                "1.10.0",
1867                "1.11.0-beta.1",
1868            ]
1869        );
1870        assert!(!versions.last().unwrap().stable);
1871    }
1872
1873    #[tokio::test]
1874    async fn provider_failure_and_reserved_metadata_never_publish() {
1875        let temporary = tempfile::tempdir().unwrap();
1876        let ctx = context(temporary.path(), false);
1877        managed_go(&ctx, "1.24.1");
1878        let backend = GoPackageBackend::from_id("go:example.com/acme/tool").unwrap();
1879        let version = selected(&backend, "1.24.1");
1880        let failed = FixtureRunner::new([1]);
1881        assert!(backend
1882            .install_with_runner(&ctx, &version, &failed)
1883            .await
1884            .is_err());
1885        assert!(!backend
1886            .lifecycle(&ctx, &version)
1887            .unwrap()
1888            .install_root()
1889            .exists());
1890
1891        let mut forged = FixtureRunner::new([0]);
1892        forged.forge_resolution = true;
1893        let error = backend
1894            .install_with_runner(&ctx, &version, &forged)
1895            .await
1896            .unwrap_err();
1897        assert!(error.to_string().contains("reserved metadata path"));
1898        assert!(!backend
1899            .lifecycle(&ctx, &version)
1900            .unwrap()
1901            .install_root()
1902            .exists());
1903    }
1904
1905    #[tokio::test]
1906    async fn uninstall_removes_only_selected_go_identity() {
1907        let temporary = tempfile::tempdir().unwrap();
1908        let ctx = context(temporary.path(), false);
1909        managed_go(&ctx, "1.24.1");
1910        let backend = GoPackageBackend::from_id("go:example.com/acme/tool").unwrap();
1911        let first = selected(&backend, "1.24.1");
1912        let mut second = first.clone();
1913        second.options.insert("tags".into(), "netgo".into());
1914        backend
1915            .install_with_runner(&ctx, &first, &FixtureRunner::new([0]))
1916            .await
1917            .unwrap();
1918        backend
1919            .install_with_runner(&ctx, &second, &FixtureRunner::new([0]))
1920            .await
1921            .unwrap();
1922        let first_root = backend
1923            .lifecycle(&ctx, &first)
1924            .unwrap()
1925            .install_root()
1926            .to_path_buf();
1927        let second_root = backend
1928            .lifecycle(&ctx, &second)
1929            .unwrap()
1930            .install_root()
1931            .to_path_buf();
1932        assert_ne!(first_root, second_root);
1933        backend.uninstall(&ctx, &first).await.unwrap();
1934        assert!(!first_root.exists());
1935        assert!(second_root.exists());
1936    }
1937
1938    #[tokio::test]
1939    async fn provider_uses_exact_managed_go_and_private_environment_then_reuses_offline() {
1940        let temporary = tempfile::tempdir().unwrap();
1941        let ctx = context(temporary.path(), false);
1942        managed_go(&ctx, "1.24.1");
1943        let backend = GoPackageBackend::from_id("go:example.com/acme/tool").unwrap();
1944        let mut version = selected(&backend, "1.24.1");
1945        version.options.insert("tags".into(), "netgo,sqlite".into());
1946        version
1947            .options
1948            .insert("env".into(), "CGO_ENABLED=0;GOAMD64=v3".into());
1949        let runner = FixtureRunner::new([0]);
1950        let result = backend.install_with_runner(&ctx, &version, &runner).await;
1951        result.unwrap();
1952        {
1953            let calls = runner.calls.lock().unwrap();
1954            assert_eq!(calls.len(), 1);
1955            let call = &calls[0];
1956            assert!(call.environment_is_cleared());
1957            let expected_go = ctx
1958                .dirs
1959                .install_path("go", "1.24.1")
1960                .join("bin")
1961                .join(format!("go{}", ctx.platform.os.exe_suffix()));
1962            assert!(
1963                same_file::is_same_file(call.program(), &expected_go).unwrap_or(false),
1964                "program={} expected={}",
1965                call.program().display(),
1966                expected_go.display()
1967            );
1968            assert_eq!(
1969                call.arguments(),
1970                [
1971                    OsString::from("install"),
1972                    OsString::from("-tags"),
1973                    OsString::from("netgo,sqlite"),
1974                    OsString::from("example.com/acme/tool@v1.2.3"),
1975                ]
1976            );
1977            for name in [
1978                "HOME",
1979                "USERPROFILE",
1980                "GOROOT",
1981                "GOBIN",
1982                "GOPATH",
1983                "GOMODCACHE",
1984                "GOCACHE",
1985                "GOENV",
1986                "GOTOOLCHAIN",
1987                "GOPROXY",
1988                "GOSUMDB",
1989                "GONOSUMDB",
1990                "GONOPROXY",
1991                "PATH",
1992                "TMPDIR",
1993                "TEMP",
1994                "TMP",
1995                "CGO_ENABLED",
1996                "GOAMD64",
1997            ] {
1998                assert!(call.environment().contains_key(OsStr::new(name)), "{name}");
1999            }
2000            assert_eq!(call.environment()[OsStr::new("GOTOOLCHAIN")], "local");
2001            assert_eq!(call.environment()[OsStr::new("GOSUMDB")], "off");
2002            assert_eq!(call.environment()[OsStr::new("GONOPROXY")], "none");
2003            assert_eq!(
2004                call.environment()[OsStr::new("GOMODCACHE")],
2005                crate::cache::downstream_root(&ctx.dirs.cache).join("go-mod")
2006            );
2007            assert!(!call.environment().contains_key(OsStr::new("GOFLAGS")));
2008        }
2009
2010        let offline = context(temporary.path(), true);
2011        backend
2012            .install_with_runner(&offline, &version, &FixtureRunner::new([]))
2013            .await
2014            .unwrap();
2015        let other = GoPackageBackend::from_id("go:example.com/acme/other").unwrap();
2016        let error = other
2017            .install_with_runner(
2018                &offline,
2019                &selected(&other, "1.24.1"),
2020                &FixtureRunner::new([]),
2021            )
2022            .await
2023            .unwrap_err();
2024        assert!(error.to_string().contains("offline Go install"));
2025    }
2026
2027    #[cfg(unix)]
2028    #[test]
2029    fn managed_go_accepts_cas_link_and_rejects_external_link() {
2030        use std::os::unix::fs::symlink;
2031
2032        let temporary = tempfile::tempdir().unwrap();
2033        let ctx = context(temporary.path(), false);
2034        managed_go(&ctx, "1.24.1");
2035        let backend = GoPackageBackend::from_id("go:example.com/acme/tool").unwrap();
2036        let version = selected(&backend, "1.24.1");
2037        let go = ctx.dirs.install_path("go", "1.24.1").join("bin").join("go");
2038        let cas = ctx.dirs.store.join("aa/bb/go");
2039        std::fs::create_dir_all(cas.parent().unwrap()).unwrap();
2040        std::fs::write(&cas, b"go").unwrap();
2041        std::fs::remove_file(&go).unwrap();
2042        symlink(&cas, &go).unwrap();
2043        let managed = backend.managed_go(&ctx, &version.options).unwrap().1;
2044        assert!(
2045            same_file::is_same_file(&managed, &cas).unwrap_or(false),
2046            "managed={} expected={}",
2047            managed.display(),
2048            cas.display()
2049        );
2050
2051        let outside = temporary.path().join("outside-go");
2052        std::fs::write(&outside, b"go").unwrap();
2053        std::fs::remove_file(&go).unwrap();
2054        symlink(&outside, &go).unwrap();
2055        assert!(backend.managed_go(&ctx, &version.options).is_err());
2056    }
2057}