1use 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
60pub 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 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}