ant_releases/
lib.rs

1// Copyright (C) 2025 MaidSafe.net limited.
2//
3// This SAFE Network Software is licensed to you under The General Public License (GPL), version 3.
4// Unless required by applicable law or agreed to in writing, the SAFE Network Software distributed
5// under the GPL Licence is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
6// KIND, either express or implied. Please review the Licences for the specific language governing
7// permissions and limitations relating to use of the SAFE Network Software.
8
9pub use crate::error::{Error, Result};
10
11pub mod error;
12
13use async_trait::async_trait;
14use lazy_static::lazy_static;
15use reqwest::Client;
16use semver::Version;
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19use std::collections::HashMap;
20use std::env::consts::{ARCH, OS};
21use std::fmt;
22use std::path::{Path, PathBuf};
23use tar::Archive;
24use tokio::fs::File;
25use tokio::io::AsyncWriteExt;
26use zip::ZipArchive;
27
28const ANTCTL_S3_BASE_URL: &str = "https://antctl.s3.eu-west-2.amazonaws.com";
29const ANTNODE_S3_BASE_URL: &str = "https://antnode.s3.eu-west-2.amazonaws.com";
30const ANTNODE_RPC_CLIENT_S3_BASE_URL: &str =
31    "https://antnode-rpc-client.s3.eu-west-2.amazonaws.com";
32const ANT_S3_BASE_URL: &str = "https://autonomi-cli.s3.eu-west-2.amazonaws.com";
33const GITHUB_API_URL: &str = "https://api.github.com";
34const NAT_DETECTION_S3_BASE_URL: &str = "https://nat-detection.s3.eu-west-2.amazonaws.com";
35const NODE_LAUNCHPAD_S3_BASE_URL: &str = "https://node-launchpad.s3.eu-west-2.amazonaws.com";
36const WINSW_URL: &str = "https://sn-node-manager.s3.eu-west-2.amazonaws.com/WinSW-x64.exe";
37
38#[derive(Clone, Debug, Eq, Hash, PartialEq)]
39pub enum ReleaseType {
40    Ant,
41    AntCtl,
42    AntCtlDaemon,
43    AntNode,
44    AntNodeRpcClient,
45    NatDetection,
46    NodeLaunchpad,
47}
48
49impl fmt::Display for ReleaseType {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        write!(
52            f,
53            "{}",
54            match self {
55                ReleaseType::Ant => "ant",
56                ReleaseType::AntCtl => "antctl",
57                ReleaseType::AntCtlDaemon => "antctld",
58                ReleaseType::AntNode => "antnode",
59                ReleaseType::AntNodeRpcClient => "antnode_rpc_client",
60                ReleaseType::NatDetection => "nat-detection",
61                ReleaseType::NodeLaunchpad => "node-launchpad",
62            }
63        )
64    }
65}
66
67lazy_static! {
68    static ref RELEASE_TYPE_CRATE_NAME_MAP: HashMap<ReleaseType, &'static str> = {
69        let mut m = HashMap::new();
70        m.insert(ReleaseType::Ant, "ant-cli");
71        m.insert(ReleaseType::AntCtl, "ant-node-manager");
72        m.insert(ReleaseType::AntCtlDaemon, "ant-node-manager");
73        m.insert(ReleaseType::AntNode, "ant-node");
74        m.insert(ReleaseType::AntNodeRpcClient, "ant-node-rpc-client");
75        m.insert(ReleaseType::NatDetection, "nat-detection");
76        m.insert(ReleaseType::NodeLaunchpad, "node-launchpad");
77        m
78    };
79}
80
81#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
82pub enum Platform {
83    LinuxMusl,
84    LinuxMuslAarch64,
85    LinuxMuslArm,
86    LinuxMuslArmV7,
87    MacOs,
88    MacOsAarch64,
89    Windows,
90}
91
92impl fmt::Display for Platform {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        match self {
95            Platform::LinuxMusl => write!(f, "x86_64-unknown-linux-musl"),
96            Platform::LinuxMuslAarch64 => write!(f, "aarch64-unknown-linux-musl"),
97            Platform::LinuxMuslArm => write!(f, "arm-unknown-linux-musleabi"),
98            Platform::LinuxMuslArmV7 => write!(f, "armv7-unknown-linux-musleabihf"),
99            Platform::MacOs => write!(f, "x86_64-apple-darwin"),
100            Platform::MacOsAarch64 => write!(f, "aarch64-apple-darwin"),
101            Platform::Windows => write!(f, "x86_64-pc-windows-msvc"), // This appears to be the same as the above, so I'm using the same string.
102        }
103    }
104}
105
106impl Platform {
107    /// Parses a platform string from a release into a Platform enum variant.
108    pub fn from_release_string(s: &str) -> Result<Self> {
109        match s {
110            "x86_64-unknown-linux-musl" => Ok(Platform::LinuxMusl),
111            "aarch64-unknown-linux-musl" => Ok(Platform::LinuxMuslAarch64),
112            "arm-unknown-linux-musleabi" => Ok(Platform::LinuxMuslArm),
113            "armv7-unknown-linux-musleabihf" => Ok(Platform::LinuxMuslArmV7),
114            "x86_64-apple-darwin" => Ok(Platform::MacOs),
115            "aarch64-apple-darwin" => Ok(Platform::MacOsAarch64),
116            "x86_64-pc-windows-msvc" => Ok(Platform::Windows),
117            _ => Err(Error::UnknownPlatform(s.to_string())),
118        }
119    }
120}
121
122#[derive(Clone, Debug, Eq, Hash, PartialEq)]
123pub enum ArchiveType {
124    TarGz,
125    Zip,
126}
127
128impl fmt::Display for ArchiveType {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        match self {
131            ArchiveType::TarGz => write!(f, "tar.gz"),
132            ArchiveType::Zip => write!(f, "zip"),
133        }
134    }
135}
136
137pub type ProgressCallback = dyn Fn(u64, u64) + Send + Sync;
138
139/// Information about a specific binary in a release, including its version and SHA256 hash.
140#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
141pub struct BinaryInfo {
142    pub name: String,
143    pub version: String,
144    pub sha256: String,
145}
146
147/// Collection of binaries for a specific platform.
148#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
149pub struct PlatformBinaries {
150    pub platform: Platform,
151    pub binaries: Vec<BinaryInfo>,
152}
153
154/// Release information from the maidsafe/autonomi GitHub repository.
155#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
156pub struct AutonomiReleaseInfo {
157    pub commit_hash: String,
158    pub name: String,
159    pub platform_binaries: Vec<PlatformBinaries>,
160}
161
162#[async_trait]
163pub trait AntReleaseRepoActions: Send + Sync {
164    async fn get_latest_version(&self, release_type: &ReleaseType) -> Result<Version>;
165    async fn download_release_from_s3(
166        &self,
167        release_type: &ReleaseType,
168        version: &Version,
169        platform: &Platform,
170        archive_type: &ArchiveType,
171        dest_path: &Path,
172        callback: &ProgressCallback,
173    ) -> Result<PathBuf>;
174    async fn download_release(
175        &self,
176        url: &str,
177        dest_dir_path: &Path,
178        callback: &ProgressCallback,
179    ) -> Result<PathBuf>;
180    async fn download_winsw(&self, dest_path: &Path, callback: &ProgressCallback) -> Result<()>;
181    fn extract_release_archive(&self, archive_path: &Path, dest_dir_path: &Path)
182        -> Result<PathBuf>;
183    async fn get_latest_autonomi_release_info(&self) -> Result<AutonomiReleaseInfo>;
184    async fn get_autonomi_release_info(&self, tag_name: &str) -> Result<AutonomiReleaseInfo>;
185}
186
187impl dyn AntReleaseRepoActions {
188    pub fn default_config() -> Box<dyn AntReleaseRepoActions> {
189        Box::new(AntReleaseRepository {
190            github_api_base_url: GITHUB_API_URL.to_string(),
191            nat_detection_base_url: NAT_DETECTION_S3_BASE_URL.to_string(),
192            node_launchpad_base_url: NODE_LAUNCHPAD_S3_BASE_URL.to_string(),
193            ant_base_url: ANT_S3_BASE_URL.to_string(),
194            antnode_base_url: ANTNODE_S3_BASE_URL.to_string(),
195            antctl_base_url: ANTCTL_S3_BASE_URL.to_string(),
196            antnode_rpc_client_base_url: ANTNODE_RPC_CLIENT_S3_BASE_URL.to_string(),
197        })
198    }
199}
200
201pub struct AntReleaseRepository {
202    pub ant_base_url: String,
203    pub antctl_base_url: String,
204    pub antnode_base_url: String,
205    pub antnode_rpc_client_base_url: String,
206    pub github_api_base_url: String,
207    pub nat_detection_base_url: String,
208    pub node_launchpad_base_url: String,
209}
210
211impl AntReleaseRepository {
212    fn get_base_url(&self, release_type: &ReleaseType) -> String {
213        match release_type {
214            ReleaseType::Ant => self.ant_base_url.clone(),
215            ReleaseType::AntCtl => self.antctl_base_url.clone(),
216            ReleaseType::AntCtlDaemon => self.antctl_base_url.clone(),
217            ReleaseType::AntNode => self.antnode_base_url.clone(),
218            ReleaseType::AntNodeRpcClient => self.antnode_rpc_client_base_url.clone(),
219            ReleaseType::NatDetection => self.nat_detection_base_url.clone(),
220            ReleaseType::NodeLaunchpad => self.node_launchpad_base_url.clone(),
221        }
222    }
223
224    /// Parses the markdown body of a release to extract binary versions and hashes.
225    fn parse_release_body(&self, body: &str) -> Result<Vec<PlatformBinaries>> {
226        use regex::Regex;
227
228        // Parse binary versions from "## Binary Versions" section
229        let version_regex = Regex::new(r"\* `([^`]+)`: v?([0-9.]+(?:-[a-zA-Z0-9.]+)?)")
230            .map_err(|_| Error::RegexError)?;
231        let mut binary_versions = HashMap::new();
232        for cap in version_regex.captures_iter(body) {
233            let name = cap[1].to_string();
234            let version = cap[2].to_string();
235            binary_versions.insert(name, version);
236        }
237
238        // Split body into lines and process line by line
239        let lines: Vec<&str> = body.lines().collect();
240        let mut platform_binaries = Vec::new();
241        let mut current_platform: Option<Platform> = None;
242        let mut current_binaries: Vec<BinaryInfo> = Vec::new();
243        let mut in_hash_table = false;
244
245        let hash_row_regex =
246            Regex::new(r"^\| ([^ ]+) \| `([a-f0-9]{64})` \|$").map_err(|_| Error::RegexError)?;
247
248        for line in lines {
249            let trimmed = line.trim();
250
251            // Check for platform header (### platform-name)
252            if trimmed.starts_with("### ") {
253                // Save previous platform if any
254                if let Some(platform) = current_platform.take() {
255                    if !current_binaries.is_empty() {
256                        platform_binaries.push(PlatformBinaries {
257                            platform,
258                            binaries: current_binaries.clone(),
259                        });
260                        current_binaries.clear();
261                    }
262                }
263
264                let platform_str = trimmed.trim_start_matches("### ");
265                if let Ok(platform) = Platform::from_release_string(platform_str) {
266                    current_platform = Some(platform);
267                    in_hash_table = false;
268                }
269            } else if trimmed == "| Binary | SHA256 Hash |" {
270                in_hash_table = true;
271            } else if trimmed.starts_with("|--------") {
272                // Continue in table
273            } else if in_hash_table && current_platform.is_some() {
274                if let Some(cap) = hash_row_regex.captures(trimmed) {
275                    let name = cap[1].to_string();
276                    let sha256 = cap[2].to_string();
277                    let version = binary_versions
278                        .get(&name)
279                        .cloned()
280                        .unwrap_or_else(|| "unknown".to_string());
281
282                    current_binaries.push(BinaryInfo {
283                        name,
284                        version,
285                        sha256,
286                    });
287                } else if !trimmed.is_empty() && !trimmed.starts_with("|") {
288                    // End of table
289                    in_hash_table = false;
290                }
291            }
292        }
293
294        // Save last platform if any
295        if let Some(platform) = current_platform {
296            if !current_binaries.is_empty() {
297                platform_binaries.push(PlatformBinaries {
298                    platform,
299                    binaries: current_binaries,
300                });
301            }
302        }
303
304        Ok(platform_binaries)
305    }
306
307    async fn download_url(
308        &self,
309        url: &str,
310        dest_path: &Path,
311        callback: &ProgressCallback,
312    ) -> Result<()> {
313        let client = Client::new();
314        let mut response = client.get(url).send().await?;
315        if !response.status().is_success() {
316            return Err(Error::ReleaseBinaryNotFound(url.to_string()));
317        }
318
319        let total_size = response
320            .headers()
321            .get("content-length")
322            .and_then(|ct_len| ct_len.to_str().ok())
323            .and_then(|ct_len| ct_len.parse::<u64>().ok())
324            .unwrap_or(0);
325
326        let mut downloaded: u64 = 0;
327        let mut out_file = File::create(&dest_path).await?;
328
329        while let Some(chunk) = response.chunk().await.unwrap() {
330            downloaded += chunk.len() as u64;
331            out_file.write_all(&chunk).await?;
332            callback(downloaded, total_size);
333        }
334
335        Ok(())
336    }
337
338    /// Fetches release information from the maidsafe/autonomi repository.
339    async fn fetch_autonomi_release(&self, url: &str) -> Result<AutonomiReleaseInfo> {
340        let client = Client::new();
341        let response = client
342            .get(url)
343            .header("Accept", "application/vnd.github+json")
344            .header("X-GitHub-Api-Version", "2022-11-28")
345            .header("User-Agent", "ant-releases")
346            .send()
347            .await?;
348        if !response.status().is_success() {
349            return Err(Error::LatestReleaseNotFound("autonomi".to_string()));
350        }
351
352        let json: Value = response.json().await?;
353        let commit_hash = json["target_commitish"]
354            .as_str()
355            .ok_or_else(|| Error::LatestReleaseNotFound("commit hash not found".to_string()))?
356            .to_string();
357        let name = json["name"]
358            .as_str()
359            .ok_or_else(|| Error::LatestReleaseNotFound("release name not found".to_string()))?
360            .to_string();
361        let body = json["body"]
362            .as_str()
363            .ok_or_else(|| Error::LatestReleaseNotFound("release body not found".to_string()))?;
364
365        let platform_binaries = self.parse_release_body(body)?;
366
367        Ok(AutonomiReleaseInfo {
368            commit_hash,
369            name,
370            platform_binaries,
371        })
372    }
373}
374
375#[async_trait]
376impl AntReleaseRepoActions for AntReleaseRepository {
377    /// Uses the crates.io API to obtain the latest version of a crate.
378    ///
379    /// # Arguments
380    ///
381    /// * `release_type` - A reference to a `ReleaseType` enum specifying the type of release to look for.
382    ///
383    /// # Returns
384    ///
385    /// Returns a `Result` containing a `String` with the latest version number in the semantic format.
386    /// Otherwise, returns an `Error`.
387    ///
388    /// # Errors
389    ///
390    /// This function will return an error if:
391    /// - The HTTP request to crates.io API fails
392    /// - The received JSON data does not have a `crate.newest_version` value
393    async fn get_latest_version(&self, release_type: &ReleaseType) -> Result<Version> {
394        let crate_name = *RELEASE_TYPE_CRATE_NAME_MAP.get(release_type).unwrap();
395        let url = format!("https://crates.io/api/v1/crates/{crate_name}");
396
397        let client = reqwest::Client::new();
398        let response = client
399            .get(url)
400            .header("User-Agent", "reqwest")
401            .send()
402            .await?;
403        if !response.status().is_success() {
404            return Err(Error::CratesIoResponseError(response.status().as_u16()));
405        }
406
407        let body = response.text().await?;
408        let json: Value = serde_json::from_str(&body)?;
409
410        if let Some(version) = json["crate"]["newest_version"].as_str() {
411            return Ok(Version::parse(version)?);
412        }
413
414        Err(Error::LatestReleaseNotFound(release_type.to_string()))
415    }
416
417    /// Downloads a release binary archive from S3.
418    ///
419    /// # Arguments
420    ///
421    /// - `release_type`: The type of release.
422    /// - `version`: The version of the release.
423    /// - `platform`: The target platform.
424    /// - `archive_type`: The type of archive (e.g., tar.gz, zip).
425    /// - `dest_path`: The directory where the downloaded archive will be stored.
426    /// - `callback`: A callback function that can be used for download progress.
427    ///
428    /// # Returns
429    ///
430    /// A `Result` with `PathBuf` indicating the full path of the downloaded archive, or an error if
431    /// the download or file write operation fails.
432    async fn download_release_from_s3(
433        &self,
434        release_type: &ReleaseType,
435        version: &Version,
436        platform: &Platform,
437        archive_type: &ArchiveType,
438        dest_path: &Path,
439        callback: &ProgressCallback,
440    ) -> Result<PathBuf> {
441        let archive_ext = archive_type.to_string();
442        let url = format!(
443            "{}/{}-{}-{}.{}",
444            self.get_base_url(release_type),
445            release_type.to_string().to_lowercase(),
446            version,
447            platform,
448            archive_type
449        );
450
451        let archive_name = format!(
452            "{}-{}-{}.{}",
453            release_type.to_string().to_lowercase(),
454            version,
455            platform,
456            archive_ext
457        );
458        let archive_path = dest_path.join(archive_name);
459
460        self.download_url(&url, &archive_path, callback).await?;
461
462        Ok(archive_path)
463    }
464
465    async fn download_release(
466        &self,
467        url: &str,
468        dest_dir_path: &Path,
469        callback: &ProgressCallback,
470    ) -> Result<PathBuf> {
471        if !url.ends_with(".tar.gz") && !url.ends_with(".zip") {
472            return Err(Error::UrlIsNotArchive);
473        }
474
475        let file_name = url
476            .split('/')
477            .next_back()
478            .ok_or_else(|| Error::CannotParseFilenameFromUrl)?;
479        let dest_path = dest_dir_path.join(file_name);
480
481        self.download_url(url, &dest_path, callback).await?;
482
483        Ok(dest_path)
484    }
485
486    async fn download_winsw(&self, dest_path: &Path, callback: &ProgressCallback) -> Result<()> {
487        self.download_url(WINSW_URL, dest_path, callback).await?;
488        Ok(())
489    }
490
491    /// Extracts a release binary archive.
492    ///
493    /// The archive will include a single binary file.
494    ///
495    /// # Arguments
496    ///
497    /// - `archive_path`: The path of the archive file to extract.
498    /// - `dest_dir`: The directory where the archive should be extracted.
499    ///
500    /// # Returns
501    ///
502    /// A `Result` with `PathBuf` indicating the full path of the extracted binary.
503    fn extract_release_archive(
504        &self,
505        archive_path: &Path,
506        dest_dir_path: &Path,
507    ) -> Result<PathBuf> {
508        if !archive_path.exists() {
509            return Err(Error::Io(std::io::Error::new(
510                std::io::ErrorKind::NotFound,
511                format!("Archive not found at: {:?}", archive_path),
512            )));
513        }
514
515        if archive_path.extension() == Some(std::ffi::OsStr::new("gz")) {
516            let archive_file = std::fs::File::open(archive_path)?;
517            let tarball = flate2::read::GzDecoder::new(archive_file);
518            let mut archive = Archive::new(tarball);
519            if let Some(file) = (archive.entries()?).next() {
520                let mut file = file?;
521                let out_path = dest_dir_path.join(file.path()?);
522                file.unpack(&out_path)?;
523                return Ok(out_path);
524            }
525        } else if archive_path.extension() == Some(std::ffi::OsStr::new("zip")) {
526            let archive_file = std::fs::File::open(archive_path)?;
527            let mut archive = ZipArchive::new(archive_file)?;
528            if let Some(i) = (0..archive.len()).next() {
529                let mut file = archive.by_index(i)?;
530                let out_path = dest_dir_path.join(file.name());
531                if file.name().ends_with('/') {
532                    std::fs::create_dir_all(&out_path)?;
533                } else {
534                    let mut outfile = std::fs::File::create(&out_path)?;
535                    std::io::copy(&mut file, &mut outfile)?;
536                }
537                return Ok(out_path);
538            }
539        } else {
540            return Err(Error::Io(std::io::Error::new(
541                std::io::ErrorKind::InvalidInput,
542                "Unsupported archive format",
543            )));
544        }
545
546        Err(Error::Io(std::io::Error::other(
547            "Failed to extract archive",
548        )))
549    }
550
551    async fn get_latest_autonomi_release_info(&self) -> Result<AutonomiReleaseInfo> {
552        let url = format!(
553            "{}/repos/maidsafe/autonomi/releases/latest",
554            self.github_api_base_url
555        );
556        self.fetch_autonomi_release(&url).await
557    }
558
559    async fn get_autonomi_release_info(&self, tag_name: &str) -> Result<AutonomiReleaseInfo> {
560        let url = format!(
561            "{}/repos/maidsafe/autonomi/releases/tags/{}",
562            self.github_api_base_url, tag_name
563        );
564        self.fetch_autonomi_release(&url).await
565    }
566}
567
568pub fn get_running_platform() -> Result<Platform> {
569    match OS {
570        "linux" => match ARCH {
571            "x86_64" => Ok(Platform::LinuxMusl),
572            "armv7" => Ok(Platform::LinuxMuslArmV7),
573            "arm" => Ok(Platform::LinuxMuslArm),
574            "aarch64" => Ok(Platform::LinuxMuslAarch64),
575            &_ => Err(Error::PlatformNotSupported(format!(
576                "We currently do not have binaries for the {OS}/{ARCH} combination"
577            ))),
578        },
579        "windows" => {
580            if ARCH != "x86_64" {
581                return Err(Error::PlatformNotSupported(
582                    "We currently only have x86_64 binaries available for Windows".to_string(),
583                ));
584            }
585            Ok(Platform::Windows)
586        }
587        "macos" => match ARCH {
588            "x86_64" => Ok(Platform::MacOs),
589            "aarch64" => Ok(Platform::MacOsAarch64),
590            &_ => Err(Error::PlatformNotSupported(format!(
591                "We currently do not have binaries for the {OS}/{ARCH} combination"
592            ))),
593        },
594        &_ => Err(Error::PlatformNotSupported(format!(
595            "{OS} is not currently supported"
596        ))),
597    }
598}