tidepool_version_manager/
go.rs1use crate::{
3 downloader::{Downloader, ProgressReporter},
4 symlink::{get_symlink_target, is_symlink, remove_symlink_dir, symlink_dir},
5 InstallRequest, ListInstalledRequest, RuntimeStatus, StatusRequest, SwitchRequest,
6 UninstallRequest, VersionInfo, VersionList, VersionManager,
7};
8use log::{debug, info, warn};
9use sha2::{Digest, Sha256};
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use tokio::io::AsyncReadExt;
13
14#[derive(Debug, Clone)]
16pub struct GoVersionInfo {
17 pub version: String,
19 pub os: String,
21 pub arch: String,
23 pub extension: String,
25 pub filename: String,
27 pub download_url: String,
29 pub sha256: Option<String>,
31 pub size: Option<u64>,
33 pub is_installed: bool,
35 pub is_cached: bool,
37 pub install_path: Option<PathBuf>,
39 pub cache_path: Option<PathBuf>,
41}
42
43pub struct GoManager {}
44
45impl Default for GoManager {
46 fn default() -> Self {
47 Self::new()
48 }
49}
50
51impl GoManager {
52 #[must_use]
53 pub fn new() -> Self {
54 Self {}
55 }
56 pub fn extract_archive(&self, archive_path: &Path, extract_to: &Path) -> Result<(), String> {
66 #[cfg(target_os = "windows")]
67 {
68 Self::extract_zip(archive_path, extract_to)
69 }
70
71 #[cfg(not(target_os = "windows"))]
72 {
73 self.extract_tar_gz(archive_path, extract_to)
74 }
75 }
76 pub fn switch_version(&self, version: &str, base_dir: &Path) -> Result<(), String> {
86 let version_path = base_dir.join(version);
87 let current_path = base_dir.join("current");
88
89 if !version_path.exists() {
90 return Err(format!("Go version {version} is not installed"));
91 }
92
93 #[cfg(target_os = "windows")]
95 let go_binary = version_path.join("bin").join("go.exe");
96 #[cfg(not(target_os = "windows"))]
97 let go_binary = version_path.join("bin").join("go");
98
99 if !go_binary.exists() {
100 return Err(format!(
101 "Invalid Go installation: missing go binary in {}",
102 version_path.display()
103 ));
104 }
105
106 debug!("Creating link for Go version {version}");
107
108 if current_path.exists() {
110 debug!("Current link exists, removing first");
111 if is_symlink(¤t_path) {
112 remove_symlink_dir(¤t_path)
113 .map_err(|e| format!("Failed to remove existing link: {e}"))?;
114 } else if current_path.is_dir() {
115 std::fs::remove_dir_all(¤t_path)
116 .map_err(|e| format!("Failed to remove existing directory: {e}"))?;
117 } else {
118 std::fs::remove_file(¤t_path)
119 .map_err(|e| format!("Failed to remove existing file: {e}"))?;
120 }
121 }
122
123 symlink_dir(&version_path, ¤t_path)
125 .map_err(|e| format!("Failed to create link: {e}"))?;
126
127 info!("Successfully created link for Go version {version}");
128
129 if !current_path.exists() {
131 return Err("Link does not exist after creation".to_string());
132 }
133
134 if !go_binary.exists() {
135 return Err("Link target is invalid: missing go binary".to_string());
136 }
137
138 debug!("Successfully created link for Go version {version}");
139 Ok(())
140 }
141 #[must_use]
143 pub fn get_current_version(&self, base_dir: &Path) -> Option<String> {
144 if let Some(target) = self.get_link_target(base_dir) {
146 return target
147 .file_name()
148 .and_then(|name| name.to_str())
149 .map(std::string::ToString::to_string);
150 }
151
152 if let Ok(goroot) = std::env::var("GOROOT") {
154 let goroot_path = PathBuf::from(goroot);
155 if goroot_path.starts_with(base_dir) {
156 return goroot_path
157 .file_name()
158 .and_then(|name| name.to_str())
159 .map(std::string::ToString::to_string);
160 }
161 }
162
163 None
164 }
165 #[must_use]
167 pub fn get_link_target(&self, base_dir: &Path) -> Option<PathBuf> {
168 let link_path = base_dir.join("current");
169 get_symlink_target(&link_path)
170 }
171
172 #[must_use]
174 pub fn get_symlink_target(&self, base_dir: &Path) -> Option<PathBuf> {
175 self.get_link_target(base_dir)
176 }
177
178 #[must_use]
180 pub fn get_symlink_info(&self, base_dir: &Path) -> String {
181 let link_path = base_dir.join("current");
182
183 if !link_path.exists() {
184 return "No symlink found".to_string();
185 }
186
187 if let Some(target) = get_symlink_target(&link_path) {
188 return format!("Symlink: {} -> {}", link_path.display(), target.display());
189 }
190
191 "Symlink exists but target unknown".to_string()
192 }
193
194 #[cfg(target_os = "windows")]
196 fn extract_zip(zip_path: &Path, extract_to: &Path) -> Result<(), String> {
197 use std::fs::File;
198 use std::io::BufReader;
199
200 let file = File::open(zip_path).map_err(|e| format!("Failed to open zip file: {e}"))?;
201 let reader = BufReader::new(file);
202
203 let mut archive =
204 zip::ZipArchive::new(reader).map_err(|e| format!("Failed to read zip archive: {e}"))?;
205
206 for i in 0..archive.len() {
207 let mut file = archive
208 .by_index(i)
209 .map_err(|e| format!("Failed to access file in archive: {e}"))?;
210 let Some(file_path) = file.enclosed_name() else { continue }; let Ok(relative_path) = file_path.strip_prefix("go") else {
212 continue; };
214
215 if relative_path.as_os_str().is_empty() {
217 continue;
218 }
219
220 let outpath = extract_to.join(relative_path);
221
222 if file.name().ends_with('/') {
223 std::fs::create_dir_all(&outpath)
225 .map_err(|e| format!("Failed to create directory: {e}"))?;
226 } else {
227 if let Some(p) = outpath.parent() {
229 if !p.exists() {
230 std::fs::create_dir_all(p)
231 .map_err(|e| format!("Failed to create parent directory: {e}"))?;
232 }
233 }
234
235 let mut outfile = File::create(&outpath)
236 .map_err(|e| format!("Failed to create output file: {e}"))?;
237
238 std::io::copy(&mut file, &mut outfile)
239 .map_err(|e| format!("Failed to extract file: {e}"))?;
240 }
241 }
242
243 Ok(())
244 }
245
246 #[cfg(not(target_os = "windows"))]
248 fn extract_tar_gz(&self, tar_gz_path: &Path, extract_to: &Path) -> Result<(), String> {
249 use flate2::read::GzDecoder;
250 use std::fs::File;
251 use tar::Archive;
252
253 let file =
254 File::open(tar_gz_path).map_err(|e| format!("Failed to open tar.gz file: {}", e))?;
255
256 let gz = GzDecoder::new(file);
257 let mut archive = Archive::new(gz);
258
259 for entry in
261 archive.entries().map_err(|e| format!("Failed to read archive entries: {}", e))?
262 {
263 let mut entry = entry.map_err(|e| format!("Failed to read archive entry: {}", e))?;
264
265 let entry_path =
266 entry.path().map_err(|e| format!("Failed to get entry path: {}", e))?;
267
268 let relative_path = if let Ok(stripped) = entry_path.strip_prefix("go") {
270 stripped
271 } else {
272 continue; };
274
275 if relative_path.as_os_str().is_empty() {
277 continue;
278 }
279
280 let target_path = extract_to.join(relative_path);
281
282 if let Some(parent) = target_path.parent() {
284 std::fs::create_dir_all(parent)
285 .map_err(|e| format!("Failed to create parent directory: {}", e))?;
286 }
287
288 entry.unpack(&target_path).map_err(|e| format!("Failed to extract entry: {}", e))?;
290 }
291
292 Ok(())
293 }
294 pub async fn calculate_file_hash(&self, file_path: &Path) -> Result<String, String> {
303 let mut file = tokio::fs::File::open(file_path)
304 .await
305 .map_err(|e| format!("Failed to open file for hash calculation: {e}"))?;
306
307 let mut hasher = Sha256::new();
308 let mut buffer = vec![0u8; 8192]; loop {
311 let bytes_read =
312 file.read(&mut buffer).await.map_err(|e| format!("Error reading file: {e}"))?;
313
314 if bytes_read == 0 {
315 break;
316 }
317
318 hasher.update(&buffer[..bytes_read]);
319 }
320
321 let result = hasher.finalize();
322 Ok(format!("{result:x}"))
323 }
324
325 async fn get_official_checksum(
327 &self,
328 version: &str,
329 os: &str,
330 arch: &str,
331 extension: &str,
332 ) -> Result<String, String> {
333 let client = reqwest::Client::new();
334 let checksums_url = "https://go.dev/dl/?mode=json&include=all".to_string();
335
336 debug!("Fetching official checksums: {checksums_url}");
337
338 let response = client
339 .get(&checksums_url)
340 .send()
341 .await
342 .map_err(|e| format!("Failed to fetch checksums: {e}"))?;
343
344 let versions: serde_json::Value =
345 response.json().await.map_err(|e| format!("Failed to parse checksum data: {e}"))?;
346
347 let filename = format!("go{version}.{os}-{arch}.{extension}");
348 debug!("Looking for checksum for file: {filename}");
349
350 if let Some(releases) = versions.as_array() {
352 for release in releases {
353 if let Some(version_str) = release.get("version").and_then(|v| v.as_str()) {
354 if version_str == format!("go{version}") {
355 if let Some(files) = release.get("files").and_then(|f| f.as_array()) {
356 for file in files {
357 if let Some(file_name) =
358 file.get("filename").and_then(|f| f.as_str())
359 {
360 if file_name == filename {
361 if let Some(sha256) =
362 file.get("sha256").and_then(|s| s.as_str())
363 {
364 debug!("Found official checksum: {sha256}");
365 return Ok(sha256.to_string());
366 }
367 }
368 }
369 }
370 }
371 }
372 }
373 }
374 }
375
376 Err(format!("Official checksum not found for Go {version} ({filename})"))
377 }
378
379 async fn verify_file_integrity(
381 &self,
382 file_path: &Path,
383 version: &str,
384 os: &str,
385 arch: &str,
386 extension: &str,
387 ) -> Result<(), String> {
388 debug!("Starting file integrity verification: {}", file_path.display());
389
390 let file_hash = self.calculate_file_hash(file_path).await?;
392 debug!("File hash: {file_hash}");
393
394 let official_hash = self.get_official_checksum(version, os, arch, extension).await?;
396 debug!("Official hash: {official_hash}");
397
398 if file_hash.to_lowercase() == official_hash.to_lowercase() {
400 info!("File integrity verification passed");
401 Ok(())
402 } else {
403 Err(format!(
404 "File integrity verification failed!\nExpected: {official_hash}\nActual: {file_hash}"
405 ))
406 }
407 }
408 #[must_use]
411 pub fn validate_cache_file(&self, file_path: &Path) -> bool {
412 if !file_path.exists() {
413 return false;
414 }
415
416 match std::fs::metadata(file_path) {
417 Ok(metadata) => {
418 if metadata.len() < 1024 {
420 return false;
421 }
422
423 std::fs::File::open(file_path).is_ok()
425 }
426 Err(_) => false,
427 }
428 }
429 pub async fn get_version_info(
439 &self,
440 version: &str,
441 install_dir: &Path,
442 cache_dir: &Path,
443 ) -> Result<GoVersionInfo, String> {
444 use crate::downloader::Downloader;
445
446 let (os, arch) = if cfg!(target_os = "windows") {
448 ("windows", if cfg!(target_arch = "x86_64") { "amd64" } else { "386" })
449 } else if cfg!(target_os = "macos") {
450 ("darwin", if cfg!(target_arch = "x86_64") { "amd64" } else { "arm64" })
451 } else {
452 ("linux", if cfg!(target_arch = "x86_64") { "amd64" } else { "386" })
453 };
454
455 let extension = if cfg!(target_os = "windows") { "zip" } else { "tar.gz" };
456 let filename = format!("go{version}.{os}-{arch}.{extension}");
457 let download_url = format!("https://go.dev/dl/{filename}");
458
459 let install_path = install_dir.join(version);
461 let is_installed = install_path.exists() && {
462 let go_binary = if cfg!(target_os = "windows") {
463 install_path.join("bin").join("go.exe")
464 } else {
465 install_path.join("bin").join("go")
466 };
467 go_binary.exists()
468 };
469
470 let cache_path = cache_dir.join(&filename);
472 let is_cached = cache_path.exists() && self.validate_cache_file(&cache_path);
473
474 let sha256 = self.get_official_checksum(version, os, arch, extension).await.ok();
476
477 let size = if is_cached {
479 debug!("Getting size from cached file: {}", cache_path.display());
481 std::fs::metadata(&cache_path).ok().map(|m| m.len())
482 } else {
483 debug!("Getting size from network for: {download_url}");
485 let downloader = Downloader::new();
486 match downloader.get_file_size(&download_url).await {
487 Ok(size) => {
488 debug!("Successfully got file size from network: {size} bytes");
489 Some(size)
490 }
491 Err(e) => {
492 debug!("Failed to get file size from network: {e}");
493 None
494 }
495 }
496 };
497
498 Ok(GoVersionInfo {
499 version: version.to_string(),
500 os: os.to_string(),
501 arch: arch.to_string(),
502 extension: extension.to_string(),
503 filename,
504 download_url,
505 sha256,
506 size,
507 is_installed,
508 is_cached,
509 install_path: if is_installed { Some(install_path) } else { None },
510 cache_path: if is_cached { Some(cache_path) } else { None },
511 })
512 }
513}
514
515#[async_trait::async_trait]
516impl VersionManager for GoManager {
517 #[allow(clippy::too_many_lines)]
519 async fn install(&self, request: InstallRequest) -> Result<VersionInfo, String> {
520 let version = &request.version;
521 let install_dir = &request.install_dir;
522 let download_dir = &request.download_dir;
523 let force = request.force;
524
525 if !install_dir.exists() {
527 return Err("Install directory does not exist".to_string());
528 }
529
530 if !install_dir.is_dir() {
531 return Err("Install path is not a directory".to_string());
532 }
533
534 let version_dir = install_dir.join(version);
536 if version_dir.exists() && !force {
537 return Err(format!("Go version {version} is already installed"));
538 }
539
540 if force && version_dir.exists() {
542 std::fs::remove_dir_all(&version_dir)
543 .map_err(|e| format!("Failed to remove existing version directory: {e}"))?;
544 }
545
546 std::fs::create_dir_all(&version_dir)
547 .map_err(|e| format!("Failed to create version directory: {e}"))?;
548
549 let (os, arch) = if cfg!(target_os = "windows") {
551 ("windows", if cfg!(target_arch = "x86_64") { "amd64" } else { "386" })
552 } else if cfg!(target_os = "macos") {
553 ("darwin", if cfg!(target_arch = "x86_64") { "amd64" } else { "arm64" })
554 } else {
555 ("linux", if cfg!(target_arch = "x86_64") { "amd64" } else { "386" })
556 };
557
558 let extension = if cfg!(target_os = "windows") { "zip" } else { "tar.gz" };
559 let download_url = format!("https://go.dev/dl/go{version}.{os}-{arch}.{extension}");
560
561 let archive_name = format!("go{version}.{os}-{arch}.{extension}");
563
564 if !download_dir.exists() {
566 return Err(format!(
567 "Download directory does not exist: {}. Please ensure the download directory is created before installation.",
568 download_dir.display()
569 ));
570 }
571
572 if !download_dir.is_dir() {
573 return Err(format!("Download path is not a directory: {}", download_dir.display()));
574 }
575
576 let download_path = download_dir.join(&archive_name);
577
578 let need_download = if force {
580 if download_path.exists() {
582 debug!("Force mode: removing existing cached file");
583 std::fs::remove_file(&download_path)
584 .map_err(|e| format!("Failed to remove cached file: {e}"))?;
585 }
586 debug!("Force mode: downloading Go {version} from {download_url}");
587 true
588 } else if download_path.exists() {
589 debug!("Found cached file: {}", download_path.display());
590
591 if self.validate_cache_file(&download_path) {
593 let metadata = std::fs::metadata(&download_path).unwrap();
594 debug!("Using valid cached file (size: {} bytes)", metadata.len());
595 false
596 } else {
597 debug!("Cached file appears to be corrupted or incomplete, will re-download");
598 true
599 }
600 } else {
601 debug!("Downloading Go {version} from {download_url}");
602 true
603 };
604
605 if need_download {
607 let downloader = Downloader::new();
609
610 let file_size =
612 downloader.get_file_size(&download_url).await.map_err(|e| format!("{e}"))?;
613 let progress_reporter = ProgressReporter::new(file_size);
614
615 downloader
616 .download(&download_url, &download_path, Some(progress_reporter))
617 .await
618 .map_err(|e| format!("{e}"))?;
619
620 debug!("Verifying downloaded file integrity");
622 self.verify_file_integrity(&download_path, version, os, arch, extension)
623 .await
624 .map_err(|e| {
625 if download_path.exists() {
627 let _ = std::fs::remove_file(&download_path);
628 warn!(
629 "Verification failed, deleted corrupted file: {}",
630 download_path.display()
631 );
632 }
633 format!("File integrity verification failed: {e}")
634 })?;
635 } else {
636 debug!("Verifying cached file integrity");
638 self.verify_file_integrity(&download_path, version, os, arch, extension)
639 .await
640 .map_err(|e| {
641 if download_path.exists() {
643 let _ = std::fs::remove_file(&download_path);
644 warn!(
645 "Cached file verification failed, deleted: {}",
646 download_path.display()
647 );
648 }
649 format!(
650 "Cached file integrity verification failed, recommend re-downloading: {e}"
651 )
652 })?;
653 }
654
655 debug!("Extracting Go {} to {}", version, version_dir.display());
656
657 self.extract_archive(&download_path, &version_dir)?;
659
660 info!("Go {} installed successfully", version);
664
665 Ok(VersionInfo { version: version.to_string(), install_path: version_dir })
667 }
668 fn switch_to(&self, request: SwitchRequest) -> Result<(), String> {
670 let version = &request.version;
671 let base_dir = &request.base_dir;
672 self.switch_version(version, base_dir)
673 }
674
675 fn uninstall(&self, request: UninstallRequest) -> Result<(), String> {
677 let version = &request.version;
678 let base_dir = &request.base_dir;
679 let version_path = base_dir.join(version);
680
681 if !version_path.exists() {
682 return Err(format!("Go version {version} is not installed"));
683 }
684
685 if let Some(current_version) = self.get_current_version(base_dir) {
687 if current_version == *version {
688 return Err(format!(
689 "Cannot uninstall Go {version} as it is currently active. Please switch to another version or clear the current symlink first."
690 ));
691 }
692 }
693
694 std::fs::remove_dir_all(&version_path)
695 .map_err(|e| format!("Failed to remove Go {version}: {e}"))?;
696
697 Ok(())
698 }
699
700 fn list_installed(&self, request: ListInstalledRequest) -> Result<VersionList, String> {
710 let base_dir = &request.base_dir;
711 if !base_dir.exists() {
712 return Err(format!("Base directory does not exist: {}", base_dir.display()));
713 }
714
715 let mut versions = Vec::new();
716
717 match std::fs::read_dir(base_dir) {
718 Ok(entries) => {
719 for entry in entries.flatten() {
720 let name = entry.file_name().to_string_lossy().to_string();
721
722 if name == "current" {
724 continue;
725 }
726
727 if entry.path().is_dir() {
729 let go_binary_name =
730 if cfg!(target_os = "windows") { "go.exe" } else { "go" };
731 let go_binary = entry.path().join("bin").join(go_binary_name);
732 if go_binary.exists() {
733 versions.push(name);
734 }
735 }
736 }
737 }
738 Err(e) => return Err(format!("Failed to read directory: {e}")),
739 }
740
741 versions.sort();
742 let total_count = versions.len();
743
744 Ok(VersionList { versions, total_count })
745 }
746
747 async fn list_available(&self) -> Result<VersionList, String> {
749 let url = "https://go.dev/dl/?mode=json";
751
752 let resp = reqwest::get(url).await.map_err(|e| format!("{e}"))?;
753 let releases: serde_json::Value = resp.json().await.map_err(|e| format!("{e}"))?;
754
755 let mut versions = Vec::new();
756
757 if let Some(array) = releases.as_array() {
758 for release in array {
759 if let Some(version_str) = release["version"].as_str() {
760 if let Some(version) = version_str.strip_prefix("go") {
762 versions.push(version.to_string());
763 }
764 }
765 }
766 }
767
768 versions.retain(|v| !v.contains("beta") && !v.contains("rc"));
770
771 versions.sort_by(|a, b| {
773 let a_parts: Vec<u32> = a.split('.').filter_map(|s| s.parse().ok()).collect();
775 let b_parts: Vec<u32> = b.split('.').filter_map(|s| s.parse().ok()).collect();
776 b_parts.cmp(&a_parts)
777 });
778
779 let total_count = versions.len();
780
781 Ok(VersionList { versions, total_count })
782 }
783
784 fn status(&self, request: StatusRequest) -> Result<RuntimeStatus, String> {
786 let base_dir = request.base_dir.as_deref();
787 let mut environment_vars = HashMap::new();
788
789 let goroot = std::env::var("GOROOT").unwrap_or_else(|_| "Not set".to_string());
790 let gopath = std::env::var("GOPATH").unwrap_or_else(|_| "Not set".to_string());
791
792 environment_vars.insert("GOROOT".to_string(), goroot.clone());
793 environment_vars.insert("GOPATH".to_string(), gopath);
794 let mut current_version = None;
795 let mut install_path = None;
796 #[cfg(target_os = "windows")]
797 let mut link_info = None;
798 #[cfg(not(target_os = "windows"))]
799 let link_info = None;
800
801 if let Some(base_dir) = base_dir {
802 current_version = self.get_current_version(base_dir);
803
804 if let Some(ref version) = current_version {
805 install_path = Some(base_dir.join(version));
806 }
807 #[cfg(target_os = "windows")]
808 {
809 link_info = Some(self.get_symlink_info(base_dir));
810 }
811 }
812
813 Ok(RuntimeStatus { current_version, install_path, environment_vars, link_info })
814 }
815}