Skip to main content

ant_core/node/
binary.rs

1use std::path::{Path, PathBuf};
2
3use futures_util::StreamExt;
4
5use crate::error::{Error, Result};
6use crate::node::types::BinarySource;
7
8const GITHUB_REPO: &str = "WithAutonomi/ant-node";
9pub const BINARY_NAME: &str = "ant-node";
10pub const BOOTSTRAP_PEERS_FILE: &str = "bootstrap_peers.toml";
11
12/// Result of resolving a node binary, including any companion files found in the archive.
13#[derive(Debug, Clone)]
14pub struct ResolvedBinary {
15    /// Path to the node binary.
16    pub path: PathBuf,
17    /// Version string extracted from the binary.
18    pub version: String,
19    /// Path to `bootstrap_peers.toml` if it was found alongside the binary.
20    pub bootstrap_peers_path: Option<PathBuf>,
21}
22
23/// Trait for reporting progress during long-running operations like binary downloads.
24pub trait ProgressReporter: Send + Sync {
25    fn report_started(&self, message: &str);
26    fn report_progress(&self, bytes: u64, total: u64);
27    fn report_complete(&self, message: &str);
28}
29
30/// A no-op progress reporter for when callers don't need progress updates.
31pub struct NoopProgress;
32
33impl ProgressReporter for NoopProgress {
34    fn report_started(&self, _message: &str) {}
35    fn report_progress(&self, _bytes: u64, _total: u64) {}
36    fn report_complete(&self, _message: &str) {}
37}
38
39/// Resolve a node binary from the given source.
40///
41/// Returns a [`ResolvedBinary`] containing the binary path, version string, and
42/// an optional path to `bootstrap_peers.toml` if one was found alongside the binary.
43///
44/// For `LocalPath`, validates the binary exists and extracts version.
45/// For download variants (`Latest`, `Version`, `Url`), downloads and caches the binary
46/// in `install_dir`.
47pub async fn resolve_binary(
48    source: &BinarySource,
49    install_dir: &Path,
50    progress: &dyn ProgressReporter,
51) -> Result<ResolvedBinary> {
52    match source {
53        BinarySource::LocalPath(path) => resolve_local(path).await,
54        BinarySource::Latest => resolve_latest(install_dir, progress).await,
55        BinarySource::Version(version) => resolve_version(version, install_dir, progress).await,
56        BinarySource::Url(url) => resolve_url(url, install_dir, progress).await,
57    }
58}
59
60/// Resolve a local binary path: validate it exists and extract its version.
61///
62/// Also checks for `bootstrap_peers.toml` in the same directory as the binary.
63async fn resolve_local(path: &Path) -> Result<ResolvedBinary> {
64    if !path.exists() {
65        return Err(Error::BinaryNotFound(path.to_path_buf()));
66    }
67
68    let version = extract_version(path).await?;
69
70    // Check for bootstrap_peers.toml next to the binary
71    let bootstrap_peers_path = path
72        .parent()
73        .map(|dir| dir.join(BOOTSTRAP_PEERS_FILE))
74        .filter(|p| p.exists());
75
76    Ok(ResolvedBinary {
77        path: path.to_path_buf(),
78        version,
79        bootstrap_peers_path,
80    })
81}
82
83/// Download the latest release binary from GitHub.
84async fn resolve_latest(
85    install_dir: &Path,
86    progress: &dyn ProgressReporter,
87) -> Result<ResolvedBinary> {
88    let version = fetch_latest_version().await?;
89    resolve_version(&version, install_dir, progress).await
90}
91
92/// Download a specific version of the binary from GitHub.
93async fn resolve_version(
94    version: &str,
95    install_dir: &Path,
96    progress: &dyn ProgressReporter,
97) -> Result<ResolvedBinary> {
98    let version = version.strip_prefix('v').unwrap_or(version);
99
100    // Check cache first
101    let cached_path = install_dir.join(format!("{BINARY_NAME}-{version}"));
102    if cached_path.exists() {
103        progress.report_complete(&format!("Using cached {BINARY_NAME} v{version}"));
104        let bootstrap_peers_path =
105            install_dir.join(format!("{BINARY_NAME}-{version}.{BOOTSTRAP_PEERS_FILE}"));
106        let bootstrap_peers_path = Some(bootstrap_peers_path).filter(|p| p.exists());
107        return Ok(ResolvedBinary {
108            path: cached_path,
109            version: version.to_string(),
110            bootstrap_peers_path,
111        });
112    }
113
114    let asset_name = platform_asset_name()?;
115    let url = format!("https://github.com/{GITHUB_REPO}/releases/download/v{version}/{asset_name}");
116
117    download_and_extract(&url, install_dir, version, progress).await
118}
119
120/// Download a binary from an arbitrary URL.
121async fn resolve_url(
122    url: &str,
123    install_dir: &Path,
124    progress: &dyn ProgressReporter,
125) -> Result<ResolvedBinary> {
126    // Download to a temp location, extract, then get version from binary
127    download_and_extract(url, install_dir, "unknown", progress).await
128}
129
130/// Fetch the latest release version tag from the GitHub API.
131async fn fetch_latest_version() -> Result<String> {
132    let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases/latest");
133    let client = reqwest::Client::new();
134    let resp = client
135        .get(&url)
136        .header("User-Agent", "ant-cli")
137        .header("Accept", "application/vnd.github+json")
138        .send()
139        .await
140        .map_err(|e| Error::BinaryResolution(format!("failed to fetch latest release: {e}")))?;
141
142    if !resp.status().is_success() {
143        return Err(Error::BinaryResolution(format!(
144            "GitHub API returned status {} when fetching latest release",
145            resp.status()
146        )));
147    }
148
149    let body: serde_json::Value = resp
150        .json()
151        .await
152        .map_err(|e| Error::BinaryResolution(format!("failed to parse release JSON: {e}")))?;
153
154    let tag = body["tag_name"]
155        .as_str()
156        .ok_or_else(|| Error::BinaryResolution("no tag_name in release response".to_string()))?;
157
158    Ok(tag.strip_prefix('v').unwrap_or(tag).to_string())
159}
160
161/// Download an archive from a URL, extract the binary, and cache it.
162///
163/// Streams the download to a temporary file to avoid unbounded memory usage.
164async fn download_and_extract(
165    url: &str,
166    install_dir: &Path,
167    version: &str,
168    progress: &dyn ProgressReporter,
169) -> Result<ResolvedBinary> {
170    progress.report_started(&format!("Downloading {BINARY_NAME} from {url}"));
171
172    let client = reqwest::Client::new();
173    let resp = client
174        .get(url)
175        .header("User-Agent", "ant-cli")
176        .send()
177        .await
178        .map_err(|e| Error::BinaryResolution(format!("download request failed: {e}")))?;
179
180    if !resp.status().is_success() {
181        return Err(Error::BinaryResolution(format!(
182            "download returned status {}",
183            resp.status()
184        )));
185    }
186
187    let total_size = resp.content_length().unwrap_or(0);
188    let mut downloaded: u64 = 0;
189
190    // Stream to a temp file to avoid holding the entire archive in memory
191    std::fs::create_dir_all(install_dir)?;
192    let tmp_path = install_dir.join(".download.tmp");
193    let mut tmp_file = std::fs::File::create(&tmp_path)
194        .map_err(|e| Error::BinaryResolution(format!("failed to create temp file: {e}")))?;
195
196    let mut stream = resp.bytes_stream();
197    while let Some(chunk) = stream.next().await {
198        let chunk =
199            chunk.map_err(|e| Error::BinaryResolution(format!("download stream error: {e}")))?;
200        downloaded += chunk.len() as u64;
201        std::io::Write::write_all(&mut tmp_file, &chunk)
202            .map_err(|e| Error::BinaryResolution(format!("failed to write temp file: {e}")))?;
203        progress.report_progress(downloaded, total_size);
204    }
205    drop(tmp_file);
206
207    progress.report_started("Extracting archive...");
208
209    // Read the temp file for extraction
210    let bytes = std::fs::read(&tmp_path)
211        .map_err(|e| Error::BinaryResolution(format!("failed to read temp file: {e}")))?;
212    let _ = std::fs::remove_file(&tmp_path);
213
214    // Extract based on file extension
215    let extracted = if url.ends_with(".zip") {
216        extract_zip(&bytes, install_dir, BINARY_NAME)?
217    } else {
218        // Assume .tar.gz
219        extract_tar_gz(&bytes, install_dir, BINARY_NAME)?
220    };
221
222    // Determine the actual version from the binary
223    let actual_version = match extract_version(&extracted.binary_path).await {
224        Ok(v) => v,
225        Err(_) => version.to_string(),
226    };
227
228    // Rename to versioned name for caching
229    let cached_path = install_dir.join(format!("{BINARY_NAME}-{actual_version}"));
230    if extracted.binary_path != cached_path {
231        if !cached_path.exists() {
232            std::fs::rename(&extracted.binary_path, &cached_path)?;
233        } else {
234            let _ = std::fs::remove_file(&extracted.binary_path);
235        }
236    }
237
238    // Rename bootstrap_peers.toml to versioned name for caching
239    let bootstrap_peers_path = if let Some(bp_path) = extracted.bootstrap_peers_path {
240        let cached_bp = install_dir.join(format!(
241            "{BINARY_NAME}-{actual_version}.{BOOTSTRAP_PEERS_FILE}"
242        ));
243        if bp_path != cached_bp {
244            if !cached_bp.exists() {
245                std::fs::rename(&bp_path, &cached_bp)?;
246            } else {
247                let _ = std::fs::remove_file(&bp_path);
248            }
249        }
250        Some(cached_bp)
251    } else {
252        None
253    };
254
255    progress.report_complete(&format!(
256        "Downloaded {BINARY_NAME} v{actual_version} to {}",
257        cached_path.display()
258    ));
259
260    Ok(ResolvedBinary {
261        path: cached_path,
262        version: actual_version,
263        bootstrap_peers_path,
264    })
265}
266
267/// Result of extracting an archive, containing the binary and any companion files.
268#[derive(Debug)]
269pub struct ExtractionResult {
270    /// Path to the extracted binary.
271    pub binary_path: PathBuf,
272    /// Path to `bootstrap_peers.toml` if found in the archive.
273    pub bootstrap_peers_path: Option<PathBuf>,
274}
275
276/// Extract a .tar.gz archive and return the path to a named binary.
277///
278/// Searches the archive for an entry whose file name matches `binary_name`
279/// and writes it to `install_dir/<binary_name>`. Also extracts `bootstrap_peers.toml`
280/// if found in the archive.
281pub fn extract_tar_gz(
282    data: &[u8],
283    install_dir: &Path,
284    binary_name: &str,
285) -> Result<ExtractionResult> {
286    let decoder = flate2::read::GzDecoder::new(data);
287    let mut archive = tar::Archive::new(decoder);
288
289    let mut binary_path = None;
290    let mut bootstrap_peers_path = None;
291
292    for entry in archive
293        .entries()
294        .map_err(|e| Error::BinaryResolution(format!("failed to read tar entries: {e}")))?
295    {
296        let mut entry =
297            entry.map_err(|e| Error::BinaryResolution(format!("failed to read tar entry: {e}")))?;
298
299        let path = entry
300            .path()
301            .map_err(|e| Error::BinaryResolution(format!("invalid path in archive: {e}")))?;
302
303        // Reject paths with traversal components (e.g., "../../../etc/passwd")
304        for component in path.components() {
305            if matches!(component, std::path::Component::ParentDir) {
306                return Err(Error::BinaryResolution(format!(
307                    "path traversal detected in archive: {}",
308                    path.display()
309                )));
310            }
311        }
312
313        let file_name = path
314            .file_name()
315            .and_then(|n| n.to_str())
316            .unwrap_or_default();
317
318        if file_name == binary_name {
319            let dest = install_dir.join(binary_name);
320            let mut file = std::fs::File::create(&dest)?;
321            std::io::copy(&mut entry, &mut file)?;
322
323            #[cfg(unix)]
324            {
325                use std::os::unix::fs::PermissionsExt;
326                std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755))?;
327            }
328
329            binary_path = Some(dest);
330        } else if file_name == BOOTSTRAP_PEERS_FILE {
331            let dest = install_dir.join(BOOTSTRAP_PEERS_FILE);
332            let mut file = std::fs::File::create(&dest)?;
333            std::io::copy(&mut entry, &mut file)?;
334
335            bootstrap_peers_path = Some(dest);
336        }
337    }
338
339    let binary_path = binary_path
340        .ok_or_else(|| Error::BinaryResolution(format!("'{binary_name}' not found in archive")))?;
341
342    Ok(ExtractionResult {
343        binary_path,
344        bootstrap_peers_path,
345    })
346}
347
348/// Extract a .zip archive and return the path to a named binary.
349///
350/// Searches the archive for an entry whose file name matches `binary_name`
351/// (or `binary_name.exe` on Windows) and writes it to `install_dir/`. Also
352/// extracts `bootstrap_peers.toml` if found in the archive.
353pub fn extract_zip(data: &[u8], install_dir: &Path, binary_name: &str) -> Result<ExtractionResult> {
354    let cursor = std::io::Cursor::new(data);
355    let mut archive = zip::ZipArchive::new(cursor)
356        .map_err(|e| Error::BinaryResolution(format!("failed to open zip archive: {e}")))?;
357
358    let mut binary_path = None;
359    let mut bootstrap_peers_path = None;
360
361    for i in 0..archive.len() {
362        let mut file = archive
363            .by_index(i)
364            .map_err(|e| Error::BinaryResolution(format!("failed to read zip entry: {e}")))?;
365
366        let file_name = file
367            .enclosed_name()
368            .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
369            .unwrap_or_default();
370
371        if file_name == binary_name || file_name == format!("{binary_name}.exe") {
372            let dest = install_dir.join(&file_name);
373            let mut out = std::fs::File::create(&dest)?;
374            std::io::copy(&mut file, &mut out)?;
375
376            #[cfg(unix)]
377            {
378                use std::os::unix::fs::PermissionsExt;
379                std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755))?;
380            }
381
382            binary_path = Some(dest);
383        } else if file_name == BOOTSTRAP_PEERS_FILE {
384            let dest = install_dir.join(BOOTSTRAP_PEERS_FILE);
385            let mut out = std::fs::File::create(&dest)?;
386            std::io::copy(&mut file, &mut out)?;
387
388            bootstrap_peers_path = Some(dest);
389        }
390    }
391
392    let binary_path = binary_path
393        .ok_or_else(|| Error::BinaryResolution(format!("'{binary_name}' not found in archive")))?;
394
395    Ok(ExtractionResult {
396        binary_path,
397        bootstrap_peers_path,
398    })
399}
400
401/// Extract the version string from a node binary by running `<binary> --version`.
402///
403/// `pub(crate)` so the supervisor can poll the on-disk binary's version to detect
404/// auto-upgrade state without duplicating the parse logic.
405pub(crate) async fn extract_version(binary_path: &Path) -> Result<String> {
406    let mut cmd = tokio::process::Command::new(binary_path);
407    cmd.arg("--version");
408    // CREATE_NO_WINDOW: prevents Windows from allocating a console window for
409    // the console-subsystem child binary. Without this, every version probe
410    // flashes a window — visible as "ghost flashes" in GUI consumers.
411    #[cfg(windows)]
412    {
413        const CREATE_NO_WINDOW: u32 = 0x08000000;
414        cmd.creation_flags(CREATE_NO_WINDOW);
415    }
416    let output = cmd.output().await.map_err(|e| {
417        Error::BinaryResolution(format!(
418            "failed to run {} --version: {e}",
419            binary_path.display()
420        ))
421    })?;
422
423    if !output.status.success() {
424        return Err(Error::BinaryResolution(format!(
425            "{} --version exited with status {}",
426            binary_path.display(),
427            output.status
428        )));
429    }
430
431    let stdout = String::from_utf8_lossy(&output.stdout);
432    // Expect output like "ant-node 0.3.4" — extract the version part.
433    let version = stdout
434        .split_whitespace()
435        .last()
436        .unwrap_or("unknown")
437        .to_string();
438
439    Ok(version)
440}
441
442/// Returns the platform-specific archive asset name.
443fn platform_asset_name() -> Result<String> {
444    let os = if cfg!(target_os = "linux") {
445        "linux"
446    } else if cfg!(target_os = "macos") {
447        "macos"
448    } else if cfg!(target_os = "windows") {
449        "windows"
450    } else {
451        return Err(Error::BinaryResolution(format!(
452            "unsupported platform: {}",
453            std::env::consts::OS
454        )));
455    };
456
457    let arch = if cfg!(target_arch = "aarch64") {
458        "arm64"
459    } else if cfg!(target_arch = "x86_64") {
460        "x64"
461    } else {
462        return Err(Error::BinaryResolution(format!(
463            "unsupported architecture: {}",
464            std::env::consts::ARCH
465        )));
466    };
467
468    let ext = if cfg!(target_os = "windows") {
469        "zip"
470    } else {
471        "tar.gz"
472    };
473
474    Ok(format!("ant-node-cli-{os}-{arch}.{ext}"))
475}
476
477/// Returns the directory where downloaded binaries are cached.
478pub fn binary_install_dir() -> crate::error::Result<PathBuf> {
479    Ok(crate::config::data_dir()?.join("bin"))
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    #[tokio::test]
487    async fn local_path_not_found() {
488        let result = resolve_binary(
489            &BinarySource::LocalPath("/nonexistent/binary".into()),
490            Path::new("/tmp"),
491            &NoopProgress,
492        )
493        .await;
494        assert!(result.is_err());
495        let err = result.unwrap_err();
496        assert!(matches!(err, Error::BinaryNotFound(_)));
497    }
498
499    #[test]
500    fn platform_asset_name_has_correct_format() {
501        let name = platform_asset_name().unwrap();
502        assert!(name.starts_with("ant-node-cli-"));
503        assert!(
504            name.ends_with(".tar.gz") || name.ends_with(".zip"),
505            "unexpected extension: {name}"
506        );
507    }
508
509    #[test]
510    fn extract_tar_gz_finds_binary() {
511        // Create a tar.gz with a fake binary inside
512        let tmp = tempfile::tempdir().unwrap();
513        let mut builder = tar::Builder::new(Vec::new());
514
515        let data = b"#!/bin/sh\necho test\n";
516        let mut header = tar::Header::new_gnu();
517        header.set_path(BINARY_NAME).unwrap();
518        header.set_size(data.len() as u64);
519        header.set_mode(0o755);
520        header.set_cksum();
521        builder.append(&header, &data[..]).unwrap();
522        let tar_data = builder.into_inner().unwrap();
523
524        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
525        std::io::Write::write_all(&mut encoder, &tar_data).unwrap();
526        let gz_data = encoder.finish().unwrap();
527
528        let result = extract_tar_gz(&gz_data, tmp.path(), BINARY_NAME);
529        assert!(result.is_ok());
530        let extracted = result.unwrap();
531        assert!(extracted.binary_path.exists());
532        assert_eq!(extracted.binary_path.file_name().unwrap(), BINARY_NAME);
533        assert!(extracted.bootstrap_peers_path.is_none());
534    }
535
536    #[test]
537    fn extract_tar_gz_finds_bootstrap_peers() {
538        let tmp = tempfile::tempdir().unwrap();
539        let mut builder = tar::Builder::new(Vec::new());
540
541        // Add the binary
542        let bin_data = b"#!/bin/sh\necho test\n";
543        let mut header = tar::Header::new_gnu();
544        header.set_path(BINARY_NAME).unwrap();
545        header.set_size(bin_data.len() as u64);
546        header.set_mode(0o755);
547        header.set_cksum();
548        builder.append(&header, &bin_data[..]).unwrap();
549
550        // Add bootstrap_peers.toml
551        let bp_data = b"[peers]\naddrs = [\"1.2.3.4:5000\"]\n";
552        let mut header = tar::Header::new_gnu();
553        header.set_path(BOOTSTRAP_PEERS_FILE).unwrap();
554        header.set_size(bp_data.len() as u64);
555        header.set_mode(0o644);
556        header.set_cksum();
557        builder.append(&header, &bp_data[..]).unwrap();
558
559        let tar_data = builder.into_inner().unwrap();
560
561        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
562        std::io::Write::write_all(&mut encoder, &tar_data).unwrap();
563        let gz_data = encoder.finish().unwrap();
564
565        let result = extract_tar_gz(&gz_data, tmp.path(), BINARY_NAME).unwrap();
566        assert!(result.binary_path.exists());
567        assert!(result.bootstrap_peers_path.is_some());
568        let bp_path = result.bootstrap_peers_path.unwrap();
569        assert!(bp_path.exists());
570        assert_eq!(bp_path.file_name().unwrap(), BOOTSTRAP_PEERS_FILE);
571    }
572
573    #[test]
574    fn extract_tar_gz_missing_binary_errors() {
575        let tmp = tempfile::tempdir().unwrap();
576        let builder = tar::Builder::new(Vec::new());
577        let tar_data = builder.into_inner().unwrap();
578
579        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
580        std::io::Write::write_all(&mut encoder, &tar_data).unwrap();
581        let gz_data = encoder.finish().unwrap();
582
583        let result = extract_tar_gz(&gz_data, tmp.path(), BINARY_NAME);
584        assert!(result.is_err());
585    }
586
587    #[test]
588    fn extract_tar_gz_rejects_path_traversal() {
589        let tmp = tempfile::tempdir().unwrap();
590
591        // Build a tar archive with a path traversal entry using raw bytes.
592        // The tar crate's set_path() rejects ".." so we write the header manually.
593        let data = b"malicious content";
594        let mut header = tar::Header::new_gnu();
595        // Use a safe placeholder first, then overwrite the raw name bytes
596        header.set_path("placeholder").unwrap();
597        header.set_size(data.len() as u64);
598        header.set_mode(0o755);
599
600        // Overwrite the name field (first 100 bytes) with a traversal path
601        let traversal = b"../../../etc/evil";
602        let raw = header.as_mut_bytes();
603        raw[..traversal.len()].copy_from_slice(traversal);
604        raw[traversal.len()] = 0;
605        header.set_cksum();
606
607        let mut builder = tar::Builder::new(Vec::new());
608        builder.append(&header, &data[..]).unwrap();
609        let tar_data = builder.into_inner().unwrap();
610
611        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
612        std::io::Write::write_all(&mut encoder, &tar_data).unwrap();
613        let gz_data = encoder.finish().unwrap();
614
615        let result = extract_tar_gz(&gz_data, tmp.path(), BINARY_NAME);
616        assert!(result.is_err());
617        let err = result.unwrap_err().to_string();
618        assert!(
619            err.contains("path traversal"),
620            "expected path traversal error, got: {err}"
621        );
622    }
623
624    #[tokio::test]
625    async fn resolve_version_uses_cache() {
626        let tmp = tempfile::tempdir().unwrap();
627        let cached = tmp.path().join(format!("{BINARY_NAME}-1.2.3"));
628        std::fs::write(&cached, "fake binary").unwrap();
629
630        let result = resolve_version("1.2.3", tmp.path(), &NoopProgress).await;
631        assert!(result.is_ok());
632        let resolved = result.unwrap();
633        assert_eq!(resolved.path, cached);
634        assert_eq!(resolved.version, "1.2.3");
635        assert!(resolved.bootstrap_peers_path.is_none());
636    }
637
638    #[tokio::test]
639    async fn resolve_version_uses_cached_bootstrap_peers() {
640        let tmp = tempfile::tempdir().unwrap();
641        let cached = tmp.path().join(format!("{BINARY_NAME}-1.2.3"));
642        std::fs::write(&cached, "fake binary").unwrap();
643        let cached_bp = tmp
644            .path()
645            .join(format!("{BINARY_NAME}-1.2.3.{BOOTSTRAP_PEERS_FILE}"));
646        std::fs::write(&cached_bp, "[peers]").unwrap();
647
648        let resolved = resolve_version("1.2.3", tmp.path(), &NoopProgress)
649            .await
650            .unwrap();
651        assert_eq!(resolved.path, cached);
652        assert_eq!(resolved.bootstrap_peers_path, Some(cached_bp));
653    }
654
655    #[tokio::test]
656    async fn resolve_version_strips_v_prefix() {
657        let tmp = tempfile::tempdir().unwrap();
658        let cached = tmp.path().join(format!("{BINARY_NAME}-0.3.4"));
659        std::fs::write(&cached, "fake binary").unwrap();
660
661        let result = resolve_version("v0.3.4", tmp.path(), &NoopProgress).await;
662        assert!(result.is_ok());
663        let resolved = result.unwrap();
664        assert_eq!(resolved.version, "0.3.4");
665    }
666}