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#[derive(Debug, Clone)]
15pub struct ResolvedBinary {
16 pub path: PathBuf,
18 pub version: String,
20 pub bootstrap_peers_path: Option<PathBuf>,
22}
23
24pub 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
31pub 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
40pub 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
67async 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 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
90async 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
103async 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 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
131async fn resolve_url(
133 url: &str,
134 install_dir: &Path,
135 progress: &dyn ProgressReporter,
136) -> Result<ResolvedBinary> {
137 download_and_extract(url, install_dir, "unknown", progress).await
139}
140
141async 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
172async fn fetch_latest_channel_version(channel: UpgradeChannel) -> Result<String> {
181 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
212fn 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
259async 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 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 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 let extracted = if url.ends_with(".zip") {
314 extract_zip(&bytes, install_dir, BINARY_NAME)?
315 } else {
316 extract_tar_gz(&bytes, install_dir, BINARY_NAME)?
318 };
319
320 let actual_version = match extract_version(&extracted.binary_path).await {
322 Ok(v) => v,
323 Err(_) => version.to_string(),
324 };
325
326 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 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#[derive(Debug)]
367pub struct ExtractionResult {
368 pub binary_path: PathBuf,
370 pub bootstrap_peers_path: Option<PathBuf>,
372}
373
374pub 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 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
446pub 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
499pub(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 #[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 let version = stdout
532 .split_whitespace()
533 .last()
534 .unwrap_or("unknown")
535 .to_string();
536
537 Ok(version)
538}
539
540fn 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
575pub 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 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 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 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 let data = b"malicious content";
693 let mut header = tar::Header::new_gnu();
694 header.set_path("placeholder").unwrap();
696 header.set_size(data.len() as u64);
697 header.set_mode(0o755);
698
699 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 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 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 #[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}