use crate::{
downloader::{Downloader, ProgressReporter},
symlink::{get_symlink_target, is_symlink, remove_symlink_dir, symlink_dir},
InstallRequest, ListInstalledRequest, RuntimeStatus, StatusRequest, SwitchRequest,
UninstallRequest, VersionInfo, VersionList, VersionManager,
};
use log::{debug, info, warn};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::io::AsyncReadExt;
#[derive(Debug, Clone)]
pub struct GoVersionInfo {
pub version: String,
pub os: String,
pub arch: String,
pub extension: String,
pub filename: String,
pub download_url: String,
pub sha256: Option<String>,
pub size: Option<u64>,
pub is_installed: bool,
pub is_cached: bool,
pub install_path: Option<PathBuf>,
pub cache_path: Option<PathBuf>,
}
pub struct GoManager {}
impl Default for GoManager {
fn default() -> Self {
Self::new()
}
}
impl GoManager {
#[must_use]
pub fn new() -> Self {
Self {}
}
pub fn extract_archive(&self, archive_path: &Path, extract_to: &Path) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
Self::extract_zip(archive_path, extract_to)
}
#[cfg(not(target_os = "windows"))]
{
self.extract_tar_gz(archive_path, extract_to)
}
}
pub fn switch_version(&self, version: &str, base_dir: &Path) -> Result<(), String> {
let version_path = base_dir.join(version);
let current_path = base_dir.join("current");
if !version_path.exists() {
return Err(format!("Go version {version} is not installed"));
}
#[cfg(target_os = "windows")]
let go_binary = version_path.join("bin").join("go.exe");
#[cfg(not(target_os = "windows"))]
let go_binary = version_path.join("bin").join("go");
if !go_binary.exists() {
return Err(format!(
"Invalid Go installation: missing go binary in {}",
version_path.display()
));
}
debug!("Creating link for Go version {version}");
if current_path.exists() {
debug!("Current link exists, removing first");
if is_symlink(¤t_path) {
remove_symlink_dir(¤t_path)
.map_err(|e| format!("Failed to remove existing link: {e}"))?;
} else if current_path.is_dir() {
std::fs::remove_dir_all(¤t_path)
.map_err(|e| format!("Failed to remove existing directory: {e}"))?;
} else {
std::fs::remove_file(¤t_path)
.map_err(|e| format!("Failed to remove existing file: {e}"))?;
}
}
symlink_dir(&version_path, ¤t_path)
.map_err(|e| format!("Failed to create link: {e}"))?;
info!("Successfully created link for Go version {version}");
if !current_path.exists() {
return Err("Link does not exist after creation".to_string());
}
if !go_binary.exists() {
return Err("Link target is invalid: missing go binary".to_string());
}
debug!("Successfully created link for Go version {version}");
Ok(())
}
#[must_use]
pub fn get_current_version(&self, base_dir: &Path) -> Option<String> {
if let Some(target) = self.get_link_target(base_dir) {
return target
.file_name()
.and_then(|name| name.to_str())
.map(std::string::ToString::to_string);
}
if let Ok(goroot) = std::env::var("GOROOT") {
let goroot_path = PathBuf::from(goroot);
if goroot_path.starts_with(base_dir) {
return goroot_path
.file_name()
.and_then(|name| name.to_str())
.map(std::string::ToString::to_string);
}
}
None
}
#[must_use]
pub fn get_link_target(&self, base_dir: &Path) -> Option<PathBuf> {
let link_path = base_dir.join("current");
get_symlink_target(&link_path)
}
#[must_use]
pub fn get_symlink_target(&self, base_dir: &Path) -> Option<PathBuf> {
self.get_link_target(base_dir)
}
#[must_use]
pub fn get_symlink_info(&self, base_dir: &Path) -> String {
let link_path = base_dir.join("current");
if !link_path.exists() {
return "No symlink found".to_string();
}
if let Some(target) = get_symlink_target(&link_path) {
return format!("Symlink: {} -> {}", link_path.display(), target.display());
}
"Symlink exists but target unknown".to_string()
}
#[cfg(target_os = "windows")]
fn extract_zip(zip_path: &Path, extract_to: &Path) -> Result<(), String> {
use std::fs::File;
use std::io::BufReader;
let file = File::open(zip_path).map_err(|e| format!("Failed to open zip file: {e}"))?;
let reader = BufReader::new(file);
let mut archive =
zip::ZipArchive::new(reader).map_err(|e| format!("Failed to read zip archive: {e}"))?;
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| format!("Failed to access file in archive: {e}"))?;
let Some(file_path) = file.enclosed_name() else { continue }; let Ok(relative_path) = file_path.strip_prefix("go") else {
continue; };
if relative_path.as_os_str().is_empty() {
continue;
}
let outpath = extract_to.join(relative_path);
if file.name().ends_with('/') {
std::fs::create_dir_all(&outpath)
.map_err(|e| format!("Failed to create directory: {e}"))?;
} else {
if let Some(p) = outpath.parent() {
if !p.exists() {
std::fs::create_dir_all(p)
.map_err(|e| format!("Failed to create parent directory: {e}"))?;
}
}
let mut outfile = File::create(&outpath)
.map_err(|e| format!("Failed to create output file: {e}"))?;
std::io::copy(&mut file, &mut outfile)
.map_err(|e| format!("Failed to extract file: {e}"))?;
}
}
Ok(())
}
#[cfg(not(target_os = "windows"))]
fn extract_tar_gz(&self, tar_gz_path: &Path, extract_to: &Path) -> Result<(), String> {
use flate2::read::GzDecoder;
use std::fs::File;
use tar::Archive;
let file =
File::open(tar_gz_path).map_err(|e| format!("Failed to open tar.gz file: {}", e))?;
let gz = GzDecoder::new(file);
let mut archive = Archive::new(gz);
for entry in
archive.entries().map_err(|e| format!("Failed to read archive entries: {}", e))?
{
let mut entry = entry.map_err(|e| format!("Failed to read archive entry: {}", e))?;
let entry_path =
entry.path().map_err(|e| format!("Failed to get entry path: {}", e))?;
let relative_path = if let Ok(stripped) = entry_path.strip_prefix("go") {
stripped
} else {
continue; };
if relative_path.as_os_str().is_empty() {
continue;
}
let target_path = extract_to.join(relative_path);
if let Some(parent) = target_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create parent directory: {}", e))?;
}
entry.unpack(&target_path).map_err(|e| format!("Failed to extract entry: {}", e))?;
}
Ok(())
}
pub async fn calculate_file_hash(&self, file_path: &Path) -> Result<String, String> {
let mut file = tokio::fs::File::open(file_path)
.await
.map_err(|e| format!("Failed to open file for hash calculation: {e}"))?;
let mut hasher = Sha256::new();
let mut buffer = vec![0u8; 8192];
loop {
let bytes_read =
file.read(&mut buffer).await.map_err(|e| format!("Error reading file: {e}"))?;
if bytes_read == 0 {
break;
}
hasher.update(&buffer[..bytes_read]);
}
let result = hasher.finalize();
Ok(format!("{result:x}"))
}
async fn get_official_checksum(
&self,
version: &str,
os: &str,
arch: &str,
extension: &str,
) -> Result<String, String> {
let client = reqwest::Client::new();
let checksums_url = "https://go.dev/dl/?mode=json&include=all".to_string();
debug!("Fetching official checksums: {checksums_url}");
let response = client
.get(&checksums_url)
.send()
.await
.map_err(|e| format!("Failed to fetch checksums: {e}"))?;
let versions: serde_json::Value =
response.json().await.map_err(|e| format!("Failed to parse checksum data: {e}"))?;
let filename = format!("go{version}.{os}-{arch}.{extension}");
debug!("Looking for checksum for file: {filename}");
if let Some(releases) = versions.as_array() {
for release in releases {
if let Some(version_str) = release.get("version").and_then(|v| v.as_str()) {
if version_str == format!("go{version}") {
if let Some(files) = release.get("files").and_then(|f| f.as_array()) {
for file in files {
if let Some(file_name) =
file.get("filename").and_then(|f| f.as_str())
{
if file_name == filename {
if let Some(sha256) =
file.get("sha256").and_then(|s| s.as_str())
{
debug!("Found official checksum: {sha256}");
return Ok(sha256.to_string());
}
}
}
}
}
}
}
}
}
Err(format!("Official checksum not found for Go {version} ({filename})"))
}
async fn verify_file_integrity(
&self,
file_path: &Path,
version: &str,
os: &str,
arch: &str,
extension: &str,
) -> Result<(), String> {
debug!("Starting file integrity verification: {}", file_path.display());
let file_hash = self.calculate_file_hash(file_path).await?;
debug!("File hash: {file_hash}");
let official_hash = self.get_official_checksum(version, os, arch, extension).await?;
debug!("Official hash: {official_hash}");
if file_hash.to_lowercase() == official_hash.to_lowercase() {
info!("File integrity verification passed");
Ok(())
} else {
Err(format!(
"File integrity verification failed!\nExpected: {official_hash}\nActual: {file_hash}"
))
}
}
#[must_use]
pub fn validate_cache_file(&self, file_path: &Path) -> bool {
if !file_path.exists() {
return false;
}
match std::fs::metadata(file_path) {
Ok(metadata) => {
if metadata.len() < 1024 {
return false;
}
std::fs::File::open(file_path).is_ok()
}
Err(_) => false,
}
}
pub async fn get_version_info(
&self,
version: &str,
install_dir: &Path,
cache_dir: &Path,
) -> Result<GoVersionInfo, String> {
use crate::downloader::Downloader;
let (os, arch) = if cfg!(target_os = "windows") {
("windows", if cfg!(target_arch = "x86_64") { "amd64" } else { "386" })
} else if cfg!(target_os = "macos") {
("darwin", if cfg!(target_arch = "x86_64") { "amd64" } else { "arm64" })
} else {
("linux", if cfg!(target_arch = "x86_64") { "amd64" } else { "386" })
};
let extension = if cfg!(target_os = "windows") { "zip" } else { "tar.gz" };
let filename = format!("go{version}.{os}-{arch}.{extension}");
let download_url = format!("https://go.dev/dl/{filename}");
let install_path = install_dir.join(version);
let is_installed = install_path.exists() && {
let go_binary = if cfg!(target_os = "windows") {
install_path.join("bin").join("go.exe")
} else {
install_path.join("bin").join("go")
};
go_binary.exists()
};
let cache_path = cache_dir.join(&filename);
let is_cached = cache_path.exists() && self.validate_cache_file(&cache_path);
let sha256 = self.get_official_checksum(version, os, arch, extension).await.ok();
let size = if is_cached {
debug!("Getting size from cached file: {}", cache_path.display());
std::fs::metadata(&cache_path).ok().map(|m| m.len())
} else {
debug!("Getting size from network for: {download_url}");
let downloader = Downloader::new();
match downloader.get_file_size(&download_url).await {
Ok(size) => {
debug!("Successfully got file size from network: {size} bytes");
Some(size)
}
Err(e) => {
debug!("Failed to get file size from network: {e}");
None
}
}
};
Ok(GoVersionInfo {
version: version.to_string(),
os: os.to_string(),
arch: arch.to_string(),
extension: extension.to_string(),
filename,
download_url,
sha256,
size,
is_installed,
is_cached,
install_path: if is_installed { Some(install_path) } else { None },
cache_path: if is_cached { Some(cache_path) } else { None },
})
}
}
#[async_trait::async_trait]
impl VersionManager for GoManager {
#[allow(clippy::too_many_lines)]
async fn install(&self, request: InstallRequest) -> Result<VersionInfo, String> {
let version = &request.version;
let install_dir = &request.install_dir;
let download_dir = &request.download_dir;
let force = request.force;
if !install_dir.exists() {
return Err("Install directory does not exist".to_string());
}
if !install_dir.is_dir() {
return Err("Install path is not a directory".to_string());
}
let version_dir = install_dir.join(version);
if version_dir.exists() && !force {
return Err(format!("Go version {version} is already installed"));
}
if force && version_dir.exists() {
std::fs::remove_dir_all(&version_dir)
.map_err(|e| format!("Failed to remove existing version directory: {e}"))?;
}
std::fs::create_dir_all(&version_dir)
.map_err(|e| format!("Failed to create version directory: {e}"))?;
let (os, arch) = if cfg!(target_os = "windows") {
("windows", if cfg!(target_arch = "x86_64") { "amd64" } else { "386" })
} else if cfg!(target_os = "macos") {
("darwin", if cfg!(target_arch = "x86_64") { "amd64" } else { "arm64" })
} else {
("linux", if cfg!(target_arch = "x86_64") { "amd64" } else { "386" })
};
let extension = if cfg!(target_os = "windows") { "zip" } else { "tar.gz" };
let download_url = format!("https://go.dev/dl/go{version}.{os}-{arch}.{extension}");
let archive_name = format!("go{version}.{os}-{arch}.{extension}");
if !download_dir.exists() {
return Err(format!(
"Download directory does not exist: {}. Please ensure the download directory is created before installation.",
download_dir.display()
));
}
if !download_dir.is_dir() {
return Err(format!("Download path is not a directory: {}", download_dir.display()));
}
let download_path = download_dir.join(&archive_name);
let need_download = if force {
if download_path.exists() {
debug!("Force mode: removing existing cached file");
std::fs::remove_file(&download_path)
.map_err(|e| format!("Failed to remove cached file: {e}"))?;
}
debug!("Force mode: downloading Go {version} from {download_url}");
true
} else if download_path.exists() {
debug!("Found cached file: {}", download_path.display());
if self.validate_cache_file(&download_path) {
let metadata = std::fs::metadata(&download_path).unwrap();
debug!("Using valid cached file (size: {} bytes)", metadata.len());
false
} else {
debug!("Cached file appears to be corrupted or incomplete, will re-download");
true
}
} else {
debug!("Downloading Go {version} from {download_url}");
true
};
if need_download {
let downloader = Downloader::new();
let file_size =
downloader.get_file_size(&download_url).await.map_err(|e| format!("{e}"))?;
let progress_reporter = ProgressReporter::new(file_size);
downloader
.download(&download_url, &download_path, Some(progress_reporter))
.await
.map_err(|e| format!("{e}"))?;
debug!("Verifying downloaded file integrity");
self.verify_file_integrity(&download_path, version, os, arch, extension)
.await
.map_err(|e| {
if download_path.exists() {
let _ = std::fs::remove_file(&download_path);
warn!(
"Verification failed, deleted corrupted file: {}",
download_path.display()
);
}
format!("File integrity verification failed: {e}")
})?;
} else {
debug!("Verifying cached file integrity");
self.verify_file_integrity(&download_path, version, os, arch, extension)
.await
.map_err(|e| {
if download_path.exists() {
let _ = std::fs::remove_file(&download_path);
warn!(
"Cached file verification failed, deleted: {}",
download_path.display()
);
}
format!(
"Cached file integrity verification failed, recommend re-downloading: {e}"
)
})?;
}
debug!("Extracting Go {} to {}", version, version_dir.display());
self.extract_archive(&download_path, &version_dir)?;
info!("Go {} installed successfully", version);
Ok(VersionInfo { version: version.to_string(), install_path: version_dir })
}
fn switch_to(&self, request: SwitchRequest) -> Result<(), String> {
let version = &request.version;
let base_dir = &request.base_dir;
self.switch_version(version, base_dir)
}
fn uninstall(&self, request: UninstallRequest) -> Result<(), String> {
let version = &request.version;
let base_dir = &request.base_dir;
let version_path = base_dir.join(version);
if !version_path.exists() {
return Err(format!("Go version {version} is not installed"));
}
if let Some(current_version) = self.get_current_version(base_dir) {
if current_version == *version {
return Err(format!(
"Cannot uninstall Go {version} as it is currently active. Please switch to another version or clear the current symlink first."
));
}
}
std::fs::remove_dir_all(&version_path)
.map_err(|e| format!("Failed to remove Go {version}: {e}"))?;
Ok(())
}
fn list_installed(&self, request: ListInstalledRequest) -> Result<VersionList, String> {
let base_dir = &request.base_dir;
if !base_dir.exists() {
return Err(format!("Base directory does not exist: {}", base_dir.display()));
}
let mut versions = Vec::new();
match std::fs::read_dir(base_dir) {
Ok(entries) => {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name == "current" {
continue;
}
if entry.path().is_dir() {
let go_binary_name =
if cfg!(target_os = "windows") { "go.exe" } else { "go" };
let go_binary = entry.path().join("bin").join(go_binary_name);
if go_binary.exists() {
versions.push(name);
}
}
}
}
Err(e) => return Err(format!("Failed to read directory: {e}")),
}
versions.sort();
let total_count = versions.len();
Ok(VersionList { versions, total_count })
}
async fn list_available(&self) -> Result<VersionList, String> {
let url = "https://go.dev/dl/?mode=json";
let resp = reqwest::get(url).await.map_err(|e| format!("{e}"))?;
let releases: serde_json::Value = resp.json().await.map_err(|e| format!("{e}"))?;
let mut versions = Vec::new();
if let Some(array) = releases.as_array() {
for release in array {
if let Some(version_str) = release["version"].as_str() {
if let Some(version) = version_str.strip_prefix("go") {
versions.push(version.to_string());
}
}
}
}
versions.retain(|v| !v.contains("beta") && !v.contains("rc"));
versions.sort_by(|a, b| {
let a_parts: Vec<u32> = a.split('.').filter_map(|s| s.parse().ok()).collect();
let b_parts: Vec<u32> = b.split('.').filter_map(|s| s.parse().ok()).collect();
b_parts.cmp(&a_parts)
});
let total_count = versions.len();
Ok(VersionList { versions, total_count })
}
fn status(&self, request: StatusRequest) -> Result<RuntimeStatus, String> {
let base_dir = request.base_dir.as_deref();
let mut environment_vars = HashMap::new();
let goroot = std::env::var("GOROOT").unwrap_or_else(|_| "Not set".to_string());
let gopath = std::env::var("GOPATH").unwrap_or_else(|_| "Not set".to_string());
environment_vars.insert("GOROOT".to_string(), goroot.clone());
environment_vars.insert("GOPATH".to_string(), gopath);
let mut current_version = None;
let mut install_path = None;
#[cfg(target_os = "windows")]
let mut link_info = None;
#[cfg(not(target_os = "windows"))]
let link_info = None;
if let Some(base_dir) = base_dir {
current_version = self.get_current_version(base_dir);
if let Some(ref version) = current_version {
install_path = Some(base_dir.join(version));
}
#[cfg(target_os = "windows")]
{
link_info = Some(self.get_symlink_info(base_dir));
}
}
Ok(RuntimeStatus { current_version, install_path, environment_vars, link_info })
}
}