Skip to main content

ant_core/node/
binary.rs

1use std::path::{Path, PathBuf};
2
3use futures_util::StreamExt;
4
5use crate::channel::version_matches_channel;
6use crate::error::{Error, Result};
7use crate::node::types::{BinarySource, UpgradeChannel};
8
9const GITHUB_REPO: &str = "WithAutonomi/ant-node";
10pub const BINARY_NAME: &str = "ant-node";
11pub const BOOTSTRAP_PEERS_FILE: &str = "bootstrap_peers.toml";
12
13/// Result of resolving a node binary, including any companion files found in the archive.
14#[derive(Debug, Clone)]
15pub struct ResolvedBinary {
16    /// Path to the node binary.
17    pub path: PathBuf,
18    /// Version string extracted from the binary.
19    pub version: String,
20    /// Path to `bootstrap_peers.toml` if it was found alongside the binary.
21    pub bootstrap_peers_path: Option<PathBuf>,
22}
23
24/// Trait for reporting progress during long-running operations like binary downloads.
25pub trait ProgressReporter: Send + Sync {
26    fn report_started(&self, message: &str);
27    fn report_progress(&self, bytes: u64, total: u64);
28    fn report_complete(&self, message: &str);
29}
30
31/// A no-op progress reporter for when callers don't need progress updates.
32pub struct NoopProgress;
33
34impl ProgressReporter for NoopProgress {
35    fn report_started(&self, _message: &str) {}
36    fn report_progress(&self, _bytes: u64, _total: u64) {}
37    fn report_complete(&self, _message: &str) {}
38}
39
40/// Resolve a node binary from the given source.
41///
42/// Returns a [`ResolvedBinary`] containing the binary path, version string, and
43/// an optional path to `bootstrap_peers.toml` if one was found alongside the binary.
44///
45/// For `LocalPath`, validates the binary exists and extracts version.
46/// For download variants (`Latest`, `Version`, `Url`), downloads and caches the binary
47/// in `install_dir`.
48///
49/// `channel` is the upgrade channel the resulting node will track. It only affects
50/// `Latest`: a node destined for the beta channel must not be installed from a stable
51/// release it would immediately upgrade away from, nor from a release candidate it would
52/// never accept. `None` is treated as `Stable`, matching the node's own default.
53pub async fn resolve_binary(
54    source: &BinarySource,
55    channel: Option<UpgradeChannel>,
56    install_dir: &Path,
57    progress: &dyn ProgressReporter,
58) -> Result<ResolvedBinary> {
59    match source {
60        BinarySource::LocalPath(path) => resolve_local(path).await,
61        BinarySource::Latest => resolve_latest(channel, install_dir, progress).await,
62        BinarySource::Version(version) => resolve_version(version, install_dir, progress).await,
63        BinarySource::Url(url) => resolve_url(url, install_dir, progress).await,
64    }
65}
66
67/// Resolve a local binary path: validate it exists and extract its version.
68///
69/// Also checks for `bootstrap_peers.toml` in the same directory as the binary.
70async fn resolve_local(path: &Path) -> Result<ResolvedBinary> {
71    if !path.exists() {
72        return Err(Error::BinaryNotFound(path.to_path_buf()));
73    }
74
75    let version = extract_version(path).await?;
76
77    // Check for bootstrap_peers.toml next to the binary
78    let bootstrap_peers_path = path
79        .parent()
80        .map(|dir| dir.join(BOOTSTRAP_PEERS_FILE))
81        .filter(|p| p.exists());
82
83    Ok(ResolvedBinary {
84        path: path.to_path_buf(),
85        version,
86        bootstrap_peers_path,
87    })
88}
89
90/// Download the latest release binary from GitHub for the given channel.
91async fn resolve_latest(
92    channel: Option<UpgradeChannel>,
93    install_dir: &Path,
94    progress: &dyn ProgressReporter,
95) -> Result<ResolvedBinary> {
96    let version = match channel.unwrap_or(UpgradeChannel::Stable) {
97        UpgradeChannel::Stable => fetch_latest_version().await?,
98        UpgradeChannel::Beta => fetch_latest_channel_version(UpgradeChannel::Beta).await?,
99    };
100    resolve_version(&version, install_dir, progress).await
101}
102
103/// Download a specific version of the binary from GitHub.
104async fn resolve_version(
105    version: &str,
106    install_dir: &Path,
107    progress: &dyn ProgressReporter,
108) -> Result<ResolvedBinary> {
109    let version = version.strip_prefix('v').unwrap_or(version);
110
111    // Check cache first
112    let cached_path = install_dir.join(format!("{BINARY_NAME}-{version}"));
113    if cached_path.exists() {
114        progress.report_complete(&format!("Using cached {BINARY_NAME} v{version}"));
115        let bootstrap_peers_path =
116            install_dir.join(format!("{BINARY_NAME}-{version}.{BOOTSTRAP_PEERS_FILE}"));
117        let bootstrap_peers_path = Some(bootstrap_peers_path).filter(|p| p.exists());
118        return Ok(ResolvedBinary {
119            path: cached_path,
120            version: version.to_string(),
121            bootstrap_peers_path,
122        });
123    }
124
125    let asset_name = platform_asset_name()?;
126    let url = format!("https://github.com/{GITHUB_REPO}/releases/download/v{version}/{asset_name}");
127
128    download_and_extract(&url, install_dir, version, progress).await
129}
130
131/// Download a binary from an arbitrary URL.
132async fn resolve_url(
133    url: &str,
134    install_dir: &Path,
135    progress: &dyn ProgressReporter,
136) -> Result<ResolvedBinary> {
137    // Download to a temp location, extract, then get version from binary
138    download_and_extract(url, install_dir, "unknown", progress).await
139}
140
141/// Fetch the latest release version tag from the GitHub API.
142async fn fetch_latest_version() -> Result<String> {
143    let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases/latest");
144    let client = reqwest::Client::new();
145    let resp = client
146        .get(&url)
147        .header("User-Agent", "ant-cli")
148        .header("Accept", "application/vnd.github+json")
149        .send()
150        .await
151        .map_err(|e| Error::BinaryResolution(format!("failed to fetch latest release: {e}")))?;
152
153    if !resp.status().is_success() {
154        return Err(Error::BinaryResolution(format!(
155            "GitHub API returned status {} when fetching latest release",
156            resp.status()
157        )));
158    }
159
160    let body: serde_json::Value = resp
161        .json()
162        .await
163        .map_err(|e| Error::BinaryResolution(format!("failed to parse release JSON: {e}")))?;
164
165    let tag = body["tag_name"]
166        .as_str()
167        .ok_or_else(|| Error::BinaryResolution("no tag_name in release response".to_string()))?;
168
169    Ok(tag.strip_prefix('v').unwrap_or(tag).to_string())
170}
171
172/// Fetch the highest release version eligible for `channel` from the GitHub API.
173///
174/// `/releases/latest` cannot be used here: GitHub never returns a pre-release from that
175/// endpoint, so it can only ever serve the stable channel. This walks the full release
176/// list and applies the same rule and the same asset requirements that the node itself
177/// uses when it picks an upgrade — a release is only a candidate if it carries both the
178/// platform archive and its detached `.sig`, so a half-uploaded release is skipped rather
179/// than downloaded.
180async fn fetch_latest_channel_version(channel: UpgradeChannel) -> Result<String> {
181    // per_page=100 is the GitHub API maximum; covers repos with many release tags.
182    let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=100");
183    let client = reqwest::Client::new();
184    let resp = client
185        .get(&url)
186        .header("User-Agent", "ant-cli")
187        .header("Accept", "application/vnd.github+json")
188        .send()
189        .await
190        .map_err(|e| Error::BinaryResolution(format!("failed to fetch releases: {e}")))?;
191
192    if !resp.status().is_success() {
193        return Err(Error::BinaryResolution(format!(
194            "GitHub API returned status {} when fetching releases",
195            resp.status()
196        )));
197    }
198
199    let releases: Vec<serde_json::Value> = resp
200        .json()
201        .await
202        .map_err(|e| Error::BinaryResolution(format!("failed to parse releases JSON: {e}")))?;
203
204    let asset_name = platform_asset_name()?;
205    select_channel_version(&releases, channel, &asset_name).ok_or_else(|| {
206        Error::BinaryResolution(format!(
207            "no {BINARY_NAME} release with a {asset_name} asset found for the {channel} channel"
208        ))
209    })
210}
211
212/// Pick the highest version from a GitHub releases payload that is eligible for `channel`
213/// and carries both `asset_name` and `{asset_name}.sig`.
214///
215/// Draft releases are skipped. The GitHub `prerelease` flag is deliberately ignored: the
216/// tag's own semver pre-release component is the authority, exactly as on the node side.
217fn select_channel_version(
218    releases: &[serde_json::Value],
219    channel: UpgradeChannel,
220    asset_name: &str,
221) -> Option<String> {
222    let sig_name = format!("{asset_name}.sig");
223    let mut best: Option<semver::Version> = None;
224
225    for release in releases {
226        if release["draft"].as_bool().unwrap_or(false) {
227            continue;
228        }
229
230        let tag = release["tag_name"].as_str().unwrap_or_default();
231        let Ok(version) = semver::Version::parse(tag.strip_prefix('v').unwrap_or(tag)) else {
232            continue;
233        };
234
235        if !version_matches_channel(&version, channel) {
236            continue;
237        }
238
239        let assets = release["assets"]
240            .as_array()
241            .map_or(&[][..], |a| a.as_slice());
242        let has = |name: &str| {
243            assets
244                .iter()
245                .any(|asset| asset["name"].as_str() == Some(name))
246        };
247        if !has(asset_name) || !has(&sig_name) {
248            continue;
249        }
250
251        if best.as_ref().is_none_or(|b| version > *b) {
252            best = Some(version);
253        }
254    }
255
256    best.map(|v| v.to_string())
257}
258
259/// Download an archive from a URL, extract the binary, and cache it.
260///
261/// Streams the download to a temporary file to avoid unbounded memory usage.
262async fn download_and_extract(
263    url: &str,
264    install_dir: &Path,
265    version: &str,
266    progress: &dyn ProgressReporter,
267) -> Result<ResolvedBinary> {
268    progress.report_started(&format!("Downloading {BINARY_NAME} from {url}"));
269
270    let client = reqwest::Client::new();
271    let resp = client
272        .get(url)
273        .header("User-Agent", "ant-cli")
274        .send()
275        .await
276        .map_err(|e| Error::BinaryResolution(format!("download request failed: {e}")))?;
277
278    if !resp.status().is_success() {
279        return Err(Error::BinaryResolution(format!(
280            "download returned status {}",
281            resp.status()
282        )));
283    }
284
285    let total_size = resp.content_length().unwrap_or(0);
286    let mut downloaded: u64 = 0;
287
288    // Stream to a temp file to avoid holding the entire archive in memory
289    std::fs::create_dir_all(install_dir)?;
290    let tmp_path = install_dir.join(".download.tmp");
291    let mut tmp_file = std::fs::File::create(&tmp_path)
292        .map_err(|e| Error::BinaryResolution(format!("failed to create temp file: {e}")))?;
293
294    let mut stream = resp.bytes_stream();
295    while let Some(chunk) = stream.next().await {
296        let chunk =
297            chunk.map_err(|e| Error::BinaryResolution(format!("download stream error: {e}")))?;
298        downloaded += chunk.len() as u64;
299        std::io::Write::write_all(&mut tmp_file, &chunk)
300            .map_err(|e| Error::BinaryResolution(format!("failed to write temp file: {e}")))?;
301        progress.report_progress(downloaded, total_size);
302    }
303    drop(tmp_file);
304
305    progress.report_started("Extracting archive...");
306
307    // Read the temp file for extraction
308    let bytes = std::fs::read(&tmp_path)
309        .map_err(|e| Error::BinaryResolution(format!("failed to read temp file: {e}")))?;
310    let _ = std::fs::remove_file(&tmp_path);
311
312    // Extract based on file extension
313    let extracted = if url.ends_with(".zip") {
314        extract_zip(&bytes, install_dir, BINARY_NAME)?
315    } else {
316        // Assume .tar.gz
317        extract_tar_gz(&bytes, install_dir, BINARY_NAME)?
318    };
319
320    // Determine the actual version from the binary
321    let actual_version = match extract_version(&extracted.binary_path).await {
322        Ok(v) => v,
323        Err(_) => version.to_string(),
324    };
325
326    // Rename to versioned name for caching
327    let cached_path = install_dir.join(format!("{BINARY_NAME}-{actual_version}"));
328    if extracted.binary_path != cached_path {
329        if !cached_path.exists() {
330            std::fs::rename(&extracted.binary_path, &cached_path)?;
331        } else {
332            let _ = std::fs::remove_file(&extracted.binary_path);
333        }
334    }
335
336    // Rename bootstrap_peers.toml to versioned name for caching
337    let bootstrap_peers_path = if let Some(bp_path) = extracted.bootstrap_peers_path {
338        let cached_bp = install_dir.join(format!(
339            "{BINARY_NAME}-{actual_version}.{BOOTSTRAP_PEERS_FILE}"
340        ));
341        if bp_path != cached_bp {
342            if !cached_bp.exists() {
343                std::fs::rename(&bp_path, &cached_bp)?;
344            } else {
345                let _ = std::fs::remove_file(&bp_path);
346            }
347        }
348        Some(cached_bp)
349    } else {
350        None
351    };
352
353    progress.report_complete(&format!(
354        "Downloaded {BINARY_NAME} v{actual_version} to {}",
355        cached_path.display()
356    ));
357
358    Ok(ResolvedBinary {
359        path: cached_path,
360        version: actual_version,
361        bootstrap_peers_path,
362    })
363}
364
365/// Result of extracting an archive, containing the binary and any companion files.
366#[derive(Debug)]
367pub struct ExtractionResult {
368    /// Path to the extracted binary.
369    pub binary_path: PathBuf,
370    /// Path to `bootstrap_peers.toml` if found in the archive.
371    pub bootstrap_peers_path: Option<PathBuf>,
372}
373
374/// Extract a .tar.gz archive and return the path to a named binary.
375///
376/// Searches the archive for an entry whose file name matches `binary_name`
377/// and writes it to `install_dir/<binary_name>`. Also extracts `bootstrap_peers.toml`
378/// if found in the archive.
379pub fn extract_tar_gz(
380    data: &[u8],
381    install_dir: &Path,
382    binary_name: &str,
383) -> Result<ExtractionResult> {
384    let decoder = flate2::read::GzDecoder::new(data);
385    let mut archive = tar::Archive::new(decoder);
386
387    let mut binary_path = None;
388    let mut bootstrap_peers_path = None;
389
390    for entry in archive
391        .entries()
392        .map_err(|e| Error::BinaryResolution(format!("failed to read tar entries: {e}")))?
393    {
394        let mut entry =
395            entry.map_err(|e| Error::BinaryResolution(format!("failed to read tar entry: {e}")))?;
396
397        let path = entry
398            .path()
399            .map_err(|e| Error::BinaryResolution(format!("invalid path in archive: {e}")))?;
400
401        // Reject paths with traversal components (e.g., "../../../etc/passwd")
402        for component in path.components() {
403            if matches!(component, std::path::Component::ParentDir) {
404                return Err(Error::BinaryResolution(format!(
405                    "path traversal detected in archive: {}",
406                    path.display()
407                )));
408            }
409        }
410
411        let file_name = path
412            .file_name()
413            .and_then(|n| n.to_str())
414            .unwrap_or_default();
415
416        if file_name == binary_name {
417            let dest = install_dir.join(binary_name);
418            let mut file = std::fs::File::create(&dest)?;
419            std::io::copy(&mut entry, &mut file)?;
420
421            #[cfg(unix)]
422            {
423                use std::os::unix::fs::PermissionsExt;
424                std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755))?;
425            }
426
427            binary_path = Some(dest);
428        } else if file_name == BOOTSTRAP_PEERS_FILE {
429            let dest = install_dir.join(BOOTSTRAP_PEERS_FILE);
430            let mut file = std::fs::File::create(&dest)?;
431            std::io::copy(&mut entry, &mut file)?;
432
433            bootstrap_peers_path = Some(dest);
434        }
435    }
436
437    let binary_path = binary_path
438        .ok_or_else(|| Error::BinaryResolution(format!("'{binary_name}' not found in archive")))?;
439
440    Ok(ExtractionResult {
441        binary_path,
442        bootstrap_peers_path,
443    })
444}
445
446/// Extract a .zip archive and return the path to a named binary.
447///
448/// Searches the archive for an entry whose file name matches `binary_name`
449/// (or `binary_name.exe` on Windows) and writes it to `install_dir/`. Also
450/// extracts `bootstrap_peers.toml` if found in the archive.
451pub fn extract_zip(data: &[u8], install_dir: &Path, binary_name: &str) -> Result<ExtractionResult> {
452    let cursor = std::io::Cursor::new(data);
453    let mut archive = zip::ZipArchive::new(cursor)
454        .map_err(|e| Error::BinaryResolution(format!("failed to open zip archive: {e}")))?;
455
456    let mut binary_path = None;
457    let mut bootstrap_peers_path = None;
458
459    for i in 0..archive.len() {
460        let mut file = archive
461            .by_index(i)
462            .map_err(|e| Error::BinaryResolution(format!("failed to read zip entry: {e}")))?;
463
464        let file_name = file
465            .enclosed_name()
466            .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
467            .unwrap_or_default();
468
469        if file_name == binary_name || file_name == format!("{binary_name}.exe") {
470            let dest = install_dir.join(&file_name);
471            let mut out = std::fs::File::create(&dest)?;
472            std::io::copy(&mut file, &mut out)?;
473
474            #[cfg(unix)]
475            {
476                use std::os::unix::fs::PermissionsExt;
477                std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755))?;
478            }
479
480            binary_path = Some(dest);
481        } else if file_name == BOOTSTRAP_PEERS_FILE {
482            let dest = install_dir.join(BOOTSTRAP_PEERS_FILE);
483            let mut out = std::fs::File::create(&dest)?;
484            std::io::copy(&mut file, &mut out)?;
485
486            bootstrap_peers_path = Some(dest);
487        }
488    }
489
490    let binary_path = binary_path
491        .ok_or_else(|| Error::BinaryResolution(format!("'{binary_name}' not found in archive")))?;
492
493    Ok(ExtractionResult {
494        binary_path,
495        bootstrap_peers_path,
496    })
497}
498
499/// Extract the version string from a node binary by running `<binary> --version`.
500///
501/// `pub(crate)` so the supervisor can poll the on-disk binary's version to detect
502/// auto-upgrade state without duplicating the parse logic.
503pub(crate) async fn extract_version(binary_path: &Path) -> Result<String> {
504    let mut cmd = tokio::process::Command::new(binary_path);
505    cmd.arg("--version");
506    // CREATE_NO_WINDOW: prevents Windows from allocating a console window for
507    // the console-subsystem child binary. Without this, every version probe
508    // flashes a window — visible as "ghost flashes" in GUI consumers.
509    #[cfg(windows)]
510    {
511        const CREATE_NO_WINDOW: u32 = 0x08000000;
512        cmd.creation_flags(CREATE_NO_WINDOW);
513    }
514    let output = cmd.output().await.map_err(|e| {
515        Error::BinaryResolution(format!(
516            "failed to run {} --version: {e}",
517            binary_path.display()
518        ))
519    })?;
520
521    if !output.status.success() {
522        return Err(Error::BinaryResolution(format!(
523            "{} --version exited with status {}",
524            binary_path.display(),
525            output.status
526        )));
527    }
528
529    let stdout = String::from_utf8_lossy(&output.stdout);
530    // Expect output like "ant-node 0.3.4" — extract the version part.
531    let version = stdout
532        .split_whitespace()
533        .last()
534        .unwrap_or("unknown")
535        .to_string();
536
537    Ok(version)
538}
539
540/// Returns the platform-specific archive asset name.
541fn platform_asset_name() -> Result<String> {
542    let os = if cfg!(target_os = "linux") {
543        "linux"
544    } else if cfg!(target_os = "macos") {
545        "macos"
546    } else if cfg!(target_os = "windows") {
547        "windows"
548    } else {
549        return Err(Error::BinaryResolution(format!(
550            "unsupported platform: {}",
551            std::env::consts::OS
552        )));
553    };
554
555    let arch = if cfg!(target_arch = "aarch64") {
556        "arm64"
557    } else if cfg!(target_arch = "x86_64") {
558        "x64"
559    } else {
560        return Err(Error::BinaryResolution(format!(
561            "unsupported architecture: {}",
562            std::env::consts::ARCH
563        )));
564    };
565
566    let ext = if cfg!(target_os = "windows") {
567        "zip"
568    } else {
569        "tar.gz"
570    };
571
572    Ok(format!("ant-node-cli-{os}-{arch}.{ext}"))
573}
574
575/// Returns the directory where downloaded binaries are cached.
576pub fn binary_install_dir() -> crate::error::Result<PathBuf> {
577    Ok(crate::config::data_dir()?.join("bin"))
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583
584    #[tokio::test]
585    async fn local_path_not_found() {
586        let result = resolve_binary(
587            &BinarySource::LocalPath("/nonexistent/binary".into()),
588            None,
589            Path::new("/tmp"),
590            &NoopProgress,
591        )
592        .await;
593        assert!(result.is_err());
594        let err = result.unwrap_err();
595        assert!(matches!(err, Error::BinaryNotFound(_)));
596    }
597
598    #[test]
599    fn platform_asset_name_has_correct_format() {
600        let name = platform_asset_name().unwrap();
601        assert!(name.starts_with("ant-node-cli-"));
602        assert!(
603            name.ends_with(".tar.gz") || name.ends_with(".zip"),
604            "unexpected extension: {name}"
605        );
606    }
607
608    #[test]
609    fn extract_tar_gz_finds_binary() {
610        // Create a tar.gz with a fake binary inside
611        let tmp = tempfile::tempdir().unwrap();
612        let mut builder = tar::Builder::new(Vec::new());
613
614        let data = b"#!/bin/sh\necho test\n";
615        let mut header = tar::Header::new_gnu();
616        header.set_path(BINARY_NAME).unwrap();
617        header.set_size(data.len() as u64);
618        header.set_mode(0o755);
619        header.set_cksum();
620        builder.append(&header, &data[..]).unwrap();
621        let tar_data = builder.into_inner().unwrap();
622
623        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
624        std::io::Write::write_all(&mut encoder, &tar_data).unwrap();
625        let gz_data = encoder.finish().unwrap();
626
627        let result = extract_tar_gz(&gz_data, tmp.path(), BINARY_NAME);
628        assert!(result.is_ok());
629        let extracted = result.unwrap();
630        assert!(extracted.binary_path.exists());
631        assert_eq!(extracted.binary_path.file_name().unwrap(), BINARY_NAME);
632        assert!(extracted.bootstrap_peers_path.is_none());
633    }
634
635    #[test]
636    fn extract_tar_gz_finds_bootstrap_peers() {
637        let tmp = tempfile::tempdir().unwrap();
638        let mut builder = tar::Builder::new(Vec::new());
639
640        // Add the binary
641        let bin_data = b"#!/bin/sh\necho test\n";
642        let mut header = tar::Header::new_gnu();
643        header.set_path(BINARY_NAME).unwrap();
644        header.set_size(bin_data.len() as u64);
645        header.set_mode(0o755);
646        header.set_cksum();
647        builder.append(&header, &bin_data[..]).unwrap();
648
649        // Add bootstrap_peers.toml
650        let bp_data = b"[peers]\naddrs = [\"1.2.3.4:5000\"]\n";
651        let mut header = tar::Header::new_gnu();
652        header.set_path(BOOTSTRAP_PEERS_FILE).unwrap();
653        header.set_size(bp_data.len() as u64);
654        header.set_mode(0o644);
655        header.set_cksum();
656        builder.append(&header, &bp_data[..]).unwrap();
657
658        let tar_data = builder.into_inner().unwrap();
659
660        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
661        std::io::Write::write_all(&mut encoder, &tar_data).unwrap();
662        let gz_data = encoder.finish().unwrap();
663
664        let result = extract_tar_gz(&gz_data, tmp.path(), BINARY_NAME).unwrap();
665        assert!(result.binary_path.exists());
666        assert!(result.bootstrap_peers_path.is_some());
667        let bp_path = result.bootstrap_peers_path.unwrap();
668        assert!(bp_path.exists());
669        assert_eq!(bp_path.file_name().unwrap(), BOOTSTRAP_PEERS_FILE);
670    }
671
672    #[test]
673    fn extract_tar_gz_missing_binary_errors() {
674        let tmp = tempfile::tempdir().unwrap();
675        let builder = tar::Builder::new(Vec::new());
676        let tar_data = builder.into_inner().unwrap();
677
678        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
679        std::io::Write::write_all(&mut encoder, &tar_data).unwrap();
680        let gz_data = encoder.finish().unwrap();
681
682        let result = extract_tar_gz(&gz_data, tmp.path(), BINARY_NAME);
683        assert!(result.is_err());
684    }
685
686    #[test]
687    fn extract_tar_gz_rejects_path_traversal() {
688        let tmp = tempfile::tempdir().unwrap();
689
690        // Build a tar archive with a path traversal entry using raw bytes.
691        // The tar crate's set_path() rejects ".." so we write the header manually.
692        let data = b"malicious content";
693        let mut header = tar::Header::new_gnu();
694        // Use a safe placeholder first, then overwrite the raw name bytes
695        header.set_path("placeholder").unwrap();
696        header.set_size(data.len() as u64);
697        header.set_mode(0o755);
698
699        // Overwrite the name field (first 100 bytes) with a traversal path
700        let traversal = b"../../../etc/evil";
701        let raw = header.as_mut_bytes();
702        raw[..traversal.len()].copy_from_slice(traversal);
703        raw[traversal.len()] = 0;
704        header.set_cksum();
705
706        let mut builder = tar::Builder::new(Vec::new());
707        builder.append(&header, &data[..]).unwrap();
708        let tar_data = builder.into_inner().unwrap();
709
710        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
711        std::io::Write::write_all(&mut encoder, &tar_data).unwrap();
712        let gz_data = encoder.finish().unwrap();
713
714        let result = extract_tar_gz(&gz_data, tmp.path(), BINARY_NAME);
715        assert!(result.is_err());
716        let err = result.unwrap_err().to_string();
717        assert!(
718            err.contains("path traversal"),
719            "expected path traversal error, got: {err}"
720        );
721    }
722
723    #[tokio::test]
724    async fn resolve_version_uses_cache() {
725        let tmp = tempfile::tempdir().unwrap();
726        let cached = tmp.path().join(format!("{BINARY_NAME}-1.2.3"));
727        std::fs::write(&cached, "fake binary").unwrap();
728
729        let result = resolve_version("1.2.3", tmp.path(), &NoopProgress).await;
730        assert!(result.is_ok());
731        let resolved = result.unwrap();
732        assert_eq!(resolved.path, cached);
733        assert_eq!(resolved.version, "1.2.3");
734        assert!(resolved.bootstrap_peers_path.is_none());
735    }
736
737    #[tokio::test]
738    async fn resolve_version_uses_cached_bootstrap_peers() {
739        let tmp = tempfile::tempdir().unwrap();
740        let cached = tmp.path().join(format!("{BINARY_NAME}-1.2.3"));
741        std::fs::write(&cached, "fake binary").unwrap();
742        let cached_bp = tmp
743            .path()
744            .join(format!("{BINARY_NAME}-1.2.3.{BOOTSTRAP_PEERS_FILE}"));
745        std::fs::write(&cached_bp, "[peers]").unwrap();
746
747        let resolved = resolve_version("1.2.3", tmp.path(), &NoopProgress)
748            .await
749            .unwrap();
750        assert_eq!(resolved.path, cached);
751        assert_eq!(resolved.bootstrap_peers_path, Some(cached_bp));
752    }
753
754    #[tokio::test]
755    async fn resolve_version_strips_v_prefix() {
756        let tmp = tempfile::tempdir().unwrap();
757        let cached = tmp.path().join(format!("{BINARY_NAME}-0.3.4"));
758        std::fs::write(&cached, "fake binary").unwrap();
759
760        let result = resolve_version("v0.3.4", tmp.path(), &NoopProgress).await;
761        assert!(result.is_ok());
762        let resolved = result.unwrap();
763        assert_eq!(resolved.version, "0.3.4");
764    }
765
766    /// Build a minimal GitHub releases payload entry.
767    fn release(tag: &str, assets: &[&str]) -> serde_json::Value {
768        serde_json::json!({
769            "tag_name": tag,
770            "draft": false,
771            "assets": assets
772                .iter()
773                .map(|name| serde_json::json!({ "name": name }))
774                .collect::<Vec<_>>(),
775        })
776    }
777
778    /// A release carrying both the platform archive and its signature.
779    fn full_release(tag: &str) -> serde_json::Value {
780        release(tag, &[ASSET, SIG])
781    }
782
783    const ASSET: &str = "ant-node-cli-linux-x64.tar.gz";
784    const SIG: &str = "ant-node-cli-linux-x64.tar.gz.sig";
785
786    #[test]
787    fn beta_selects_the_beta_over_the_rc() {
788        let releases = [
789            full_release("v0.16.0"),
790            full_release("v0.17.0-beta.1"),
791            full_release("v0.17.0-rc.1"),
792        ];
793
794        assert_eq!(
795            select_channel_version(&releases, UpgradeChannel::Beta, ASSET),
796            Some("0.17.0-beta.1".to_string())
797        );
798        assert_eq!(
799            select_channel_version(&releases, UpgradeChannel::Stable, ASSET),
800            Some("0.16.0".to_string())
801        );
802    }
803
804    #[test]
805    fn releases_missing_the_signature_are_skipped() {
806        let releases = [
807            full_release("v0.17.0-beta.1"),
808            release("v0.18.0-beta.1", &[ASSET]),
809        ];
810
811        assert_eq!(
812            select_channel_version(&releases, UpgradeChannel::Beta, ASSET),
813            Some("0.17.0-beta.1".to_string())
814        );
815    }
816
817    #[test]
818    fn releases_missing_the_platform_asset_are_skipped() {
819        let releases = [
820            full_release("v0.17.0-beta.1"),
821            release(
822                "v0.18.0-beta.1",
823                &[
824                    "ant-node-cli-windows-x64.zip",
825                    "ant-node-cli-windows-x64.zip.sig",
826                ],
827            ),
828        ];
829
830        assert_eq!(
831            select_channel_version(&releases, UpgradeChannel::Beta, ASSET),
832            Some("0.17.0-beta.1".to_string())
833        );
834    }
835
836    #[test]
837    fn drafts_are_skipped() {
838        let mut draft = full_release("v0.18.0-beta.1");
839        draft["draft"] = serde_json::Value::Bool(true);
840        let releases = [full_release("v0.17.0-beta.1"), draft];
841
842        assert_eq!(
843            select_channel_version(&releases, UpgradeChannel::Beta, ASSET),
844            Some("0.17.0-beta.1".to_string())
845        );
846    }
847
848    /// The tag's semver is the authority, not GitHub's `prerelease` flag — a final release
849    /// mistakenly flagged as a prerelease is still a stable candidate.
850    #[test]
851    fn the_github_prerelease_flag_is_ignored() {
852        let mut flagged = full_release("v0.17.0");
853        flagged["prerelease"] = serde_json::Value::Bool(true);
854        let releases = [full_release("v0.16.0"), flagged];
855
856        assert_eq!(
857            select_channel_version(&releases, UpgradeChannel::Stable, ASSET),
858            Some("0.17.0".to_string())
859        );
860    }
861
862    #[test]
863    fn unparseable_tags_are_skipped() {
864        let releases = [full_release("nightly"), full_release("v0.16.0")];
865
866        assert_eq!(
867            select_channel_version(&releases, UpgradeChannel::Stable, ASSET),
868            Some("0.16.0".to_string())
869        );
870    }
871
872    #[test]
873    fn no_eligible_release_yields_none() {
874        let releases = [full_release("v0.17.0-rc.1")];
875
876        assert_eq!(
877            select_channel_version(&releases, UpgradeChannel::Beta, ASSET),
878            None
879        );
880    }
881}