use std::env::consts::EXE_EXTENSION;
use std::fmt::Display;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::LazyLock;
use anyhow::{Context, Result};
use itertools::Itertools;
use prek_consts::env_vars::{EnvVars, EnvVarsRead};
use serde::Deserialize;
use target_lexicon::{Architecture, HOST, OperatingSystem};
use tracing::{debug, trace, warn};
use crate::archive;
use crate::checksum::Sha256Digest;
use crate::fs::LockedFile;
use crate::git;
use crate::http::{REQWEST_CLIENT, download_artifact};
use crate::languages::golang::GoRequest;
use crate::languages::golang::golang::bin_dir;
use crate::languages::golang::version::GoVersion;
use crate::languages::version::{ToolchainPolicy, ToolchainSource, find_system_executables};
use crate::process::Cmd;
use crate::store::Store;
pub(crate) struct GoResult {
path: PathBuf,
version: GoVersion,
from_system: bool,
}
impl Display for GoResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}@{}", self.path.display(), self.version)?;
Ok(())
}
}
static GO_BINARY_NAME: LazyLock<String> = LazyLock::new(|| {
if let Ok(name) = EnvVars.var(EnvVars::PREK_INTERNAL__GO_BINARY_NAME) {
name
} else {
"go".to_string()
}
});
#[derive(Deserialize)]
struct GoRelease {
version: String,
files: Vec<GoFile>,
}
#[derive(Deserialize)]
struct GoFile {
filename: String,
sha256: String,
}
impl GoResult {
pub(crate) fn from_dir(dir: &Path, version: GoVersion) -> Self {
let go = bin_dir(dir).join("go").with_extension(EXE_EXTENSION);
Self {
path: go,
version,
from_system: false,
}
}
pub(crate) fn bin(&self) -> &Path {
&self.path
}
pub(crate) fn version(&self) -> &GoVersion {
&self.version
}
pub(crate) fn is_from_system(&self) -> bool {
self.from_system
}
pub(crate) fn cmd(&self) -> Cmd {
Cmd::new(&self.path)
}
async fn from_system_executable(path: PathBuf) -> Result<Self> {
let output = Cmd::new(&path)
.arg("version")
.env(EnvVars::GOTOOLCHAIN, "local")
.check(true)
.output()
.await?;
let version_str = String::from_utf8(output.stdout)?;
let version_str = version_str
.split_ascii_whitespace()
.nth(2)
.with_context(|| format!("Failed to parse Go version from output: {version_str}"))?;
let version = GoVersion::from_str(version_str)?;
Ok(Self {
path,
version,
from_system: true,
})
}
}
pub(crate) struct GoInstaller {
root: PathBuf,
}
impl GoInstaller {
pub(crate) fn new(root: PathBuf) -> Self {
Self { root }
}
pub(crate) async fn install(
&self,
store: &Store,
request: &GoRequest,
policy: ToolchainPolicy,
) -> Result<GoResult> {
fs_err::tokio::create_dir_all(&self.root).await?;
let _lock = LockedFile::acquire(self.root.join(".lock"), "go").await?;
for &source in policy.search_order() {
let result = match source {
ToolchainSource::Managed => self.find_installed(request).ok(),
ToolchainSource::System => self.find_system_go(request).await?,
};
if let Some(result) = result {
trace!(%result, ?source, "Found go");
return Ok(result);
}
}
if !policy.allows_download() {
anyhow::bail!("No suitable Go version found for toolchain policy: {policy}");
}
let resolved_version = self
.resolve_version(request)
.await
.with_context(|| format!("Failed to resolve go version `{request}`"))?;
trace!(version = %resolved_version, "Installing go");
self.download(store, &resolved_version).await
}
fn find_installed(&self, request: &GoRequest) -> Result<GoResult> {
let mut installed = fs_err::read_dir(&self.root)
.ok()
.into_iter()
.flatten()
.filter_map(|entry| match entry {
Ok(entry) => Some(entry),
Err(e) => {
warn!(?e, "Failed to read entry");
None
}
})
.filter(|entry| entry.file_type().is_ok_and(|f| f.is_dir()))
.filter_map(|entry| {
let dir_name = entry.file_name();
let version = GoVersion::from_str(&dir_name.to_string_lossy()).ok()?;
Some((version, entry.path()))
})
.sorted_unstable_by(|(a, _), (b, _)| a.cmp(b))
.rev();
installed
.find_map(|(version, path)| {
if request.matches(&version) {
trace!(%version, "Found matching installed go");
Some(GoResult::from_dir(&path, version))
} else {
trace!(%version, "Installed go does not match request");
None
}
})
.context("No installed go version matches the request")
}
async fn resolve_version(&self, req: &GoRequest) -> Result<GoVersion> {
let output = git::git_cmd()?
.arg("ls-remote")
.arg("--tags")
.arg("https://github.com/golang/go")
.output()
.await?
.stdout;
let output_str = str::from_utf8(&output)?;
let version = output_str
.lines()
.filter_map(|line| {
let tag = line.split('\t').nth(1)?;
let tag = tag.strip_prefix("refs/tags/go")?;
GoVersion::from_str(tag).ok()
})
.sorted_unstable_by(|a, b| b.cmp(a))
.find(|version| req.matches(version))
.with_context(|| format!("Version `{req}` not found on remote"))?;
Ok(version)
}
async fn download(&self, store: &Store, version: &GoVersion) -> Result<GoResult> {
let arch = match HOST.architecture {
Architecture::X86_32(_) => "386",
Architecture::X86_64 => "amd64",
Architecture::Aarch64(_) => "arm64",
Architecture::S390x => "s390x",
Architecture::Powerpc => "ppc64",
Architecture::Powerpc64le => "ppc64le",
_ => anyhow::bail!("Unsupported architecture"),
};
let os = match HOST.operating_system {
OperatingSystem::Darwin(_) => "darwin",
OperatingSystem::Linux => "linux",
OperatingSystem::Windows => "windows",
OperatingSystem::Aix => "aix",
OperatingSystem::Netbsd => "netbsd",
OperatingSystem::Openbsd => "openbsd",
OperatingSystem::Solaris => "solaris",
OperatingSystem::Dragonfly => "dragonfly",
OperatingSystem::Illumos => "illumos",
_ => anyhow::bail!("Unsupported OS"),
};
let ext = if cfg!(windows) { "zip" } else { "tar.gz" };
let filename = format!("go{version}.{os}-{arch}.{ext}");
let url = format!("https://go.dev/dl/{filename}");
let checksum_version = version.to_string();
let target = self.root.join(version.to_string());
let download = download_artifact(&url, &filename, store, async || {
Self::fetch_checksum(&checksum_version, &filename).await
})
.await
.context("Failed to download go")?;
let extracted = archive::extract_archive(download.path())
.await
.context("Failed to extract go")?;
if target.exists() {
debug!(target = %target.display(), "Removing existing go");
fs_err::tokio::remove_dir_all(&target).await?;
}
debug!(?extracted, target = %target.display(), "Moving go to target");
fs_err::tokio::rename(&extracted, &target).await?;
Ok(GoResult::from_dir(&target, version.clone()))
}
async fn fetch_checksum(version: &str, filename: &str) -> Result<Option<Sha256Digest>> {
let url = "https://go.dev/dl/?mode=json&include=all";
let response = REQWEST_CLIENT
.get(url)
.send()
.await
.context("Failed to fetch Go release metadata")?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
let releases: Vec<GoRelease> = response
.error_for_status()
.context("Failed to fetch Go release metadata")?
.json()
.await
.context("Failed to parse Go release metadata")?;
digest_from_go_releases(&releases, version, filename)
}
async fn find_system_go(&self, go_request: &GoRequest) -> Result<Option<GoResult>> {
let go_paths = match find_system_executables(&*GO_BINARY_NAME, &self.root) {
Ok(paths) => paths,
Err(e) => {
debug!("No go executables found in PATH: {}", e);
return Ok(None);
}
};
for go_path in go_paths {
match GoResult::from_system_executable(go_path).await {
Ok(go) => {
if go_request.matches(&go.version) {
trace!(
%go,
"Found matching system go"
);
return Ok(Some(go));
}
trace!(
%go,
"System go does not match requested version"
);
}
Err(e) => {
warn!(?e, "Failed to get version for system go");
}
}
}
debug!(?go_request, "No system go matches the requested version");
Ok(None)
}
}
fn digest_from_go_releases(
releases: &[GoRelease],
version: &str,
filename: &str,
) -> Result<Option<Sha256Digest>> {
let release_name = format!("go{version}");
let Some(file) = releases
.iter()
.find(|release| release.version == release_name)
.and_then(|release| release.files.iter().find(|file| file.filename == filename))
else {
return Ok(None);
};
file.sha256.parse().map(Some)
}
#[cfg(test)]
mod tests {
use super::*;
const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
#[test]
fn finds_go_checksum_for_release_file() -> Result<()> {
let releases = vec![
go_release(
"go1.23.0",
vec![go_file(
"go1.23.0.linux-amd64.tar.gz",
"0000000000000000000000000000000000000000000000000000000000000000",
)],
),
go_release(
"go1.24.1",
vec![
go_file("go1.24.1.darwin-arm64.tar.gz", EMPTY_SHA256),
go_file(
"go1.24.1.linux-amd64.tar.gz",
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
),
],
),
];
let digest = digest_from_go_releases(&releases, "1.24.1", "go1.24.1.darwin-arm64.tar.gz")?
.expect("expected checksum");
assert_eq!(digest.to_string(), EMPTY_SHA256);
Ok(())
}
#[test]
fn returns_none_when_go_release_file_is_missing() -> Result<()> {
let releases = vec![go_release(
"go1.24.1",
vec![go_file("go1.24.1.linux-amd64.tar.gz", EMPTY_SHA256)],
)];
let digest = digest_from_go_releases(&releases, "1.24.1", "go1.24.1.darwin-arm64.tar.gz")?;
assert!(digest.is_none());
Ok(())
}
#[test]
fn returns_none_when_go_release_is_missing() -> Result<()> {
let releases = vec![go_release(
"go1.23.0",
vec![go_file("go1.23.0.linux-amd64.tar.gz", EMPTY_SHA256)],
)];
let digest = digest_from_go_releases(&releases, "1.24.1", "go1.24.1.linux-amd64.tar.gz")?;
assert!(digest.is_none());
Ok(())
}
#[tokio::test]
#[cfg(unix)]
async fn from_system_executable_uses_local_gotoolchain() -> anyhow::Result<()> {
let temp_dir = tempfile::tempdir()?;
let fake_go = temp_dir.path().join("go");
fs_err::write(
&fake_go,
indoc::indoc! {r#"#!/bin/sh
if [ "$1" = "version" ]; then
if [ "${GOTOOLCHAIN:-}" = "local" ]; then
printf 'go version go1.24.13 linux/amd64\n'
else
printf 'go version go1.26.0 linux/amd64\n'
fi
exit 0
fi
printf 'unexpected args: %s\n' "$*" >&2
exit 1
"#},
)?;
crate::fs::make_executable(&fake_go)?;
let go = GoResult::from_system_executable(fake_go).await?;
assert_eq!(go.version().to_string(), "1.24.13");
Ok(())
}
fn go_release(version: &str, files: Vec<GoFile>) -> GoRelease {
GoRelease {
version: version.to_string(),
files,
}
}
fn go_file(filename: &str, sha256: &str) -> GoFile {
GoFile {
filename: filename.to_string(),
sha256: sha256.to_string(),
}
}
}