rialoman 0.2.0

Rialo native toolchain manager
Documentation
//! Remote manifest + artifact download helpers.
// Copyright (c) Subzero Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

use std::{
    fs::File,
    io::{BufRead, BufReader, Write},
    path::Path,
    time::Duration,
};

use anyhow::{anyhow, Context, Result};
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
use reqwest::{blocking::Client, Url};
use sha2::{Digest, Sha256};

use crate::{
    manifest::{Artifact, Manifest},
    spec::InstallSpec,
};

const DEFAULT_BASE_URL: &str = "https://rialo-artifacts.s3.us-east-2.amazonaws.com/binaries/";
const BASE_URL_ENV: &str = "RIALO_BINARIES_DIST_BASE";

#[derive(Debug, Clone)]
pub struct RemoteClient {
    http: Client,
    base_url: Url,
}

impl RemoteClient {
    pub fn new() -> Result<Self> {
        let base = std::env::var(BASE_URL_ENV).unwrap_or_else(|_| DEFAULT_BASE_URL.to_owned());
        let base_url = Url::parse(&base).context("invalid base distribution URL")?;
        let http = Client::builder().timeout(Duration::from_secs(30)).build()?;

        Ok(Self { http, base_url })
    }

    fn manifest_url(&self, spec: &InstallSpec) -> Result<Url> {
        let path = match spec.version.as_ref() {
            None => format!("{}/latest.json", spec.channel.as_str()),
            Some(version) => format!("{}/{}/manifest.json", spec.channel.as_str(), version),
        };
        self.base_url
            .join(&path)
            .context("failed to construct manifest URL")
    }

    pub fn fetch_manifest(&self, spec: &InstallSpec) -> Result<Manifest> {
        let url = self.manifest_url(spec)?;
        let res = self
            .http
            .get(url.clone())
            .send()
            .with_context(|| format!("failed to fetch manifest from {url}"))?;

        if !res.status().is_success() {
            return Err(anyhow!(
                "manifest fetch failed with status {}",
                res.status()
            ));
        }
        let manifest: Manifest = res.json().context("failed to parse manifest JSON")?;
        Ok(manifest)
    }

    /// Download the selected artifact to the provided destination and verify its SHA.
    pub fn download_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<()> {
        let url = self
            .base_url
            .join(&artifact.path)
            .context("failed to build artifact URL")?;
        let resp = self
            .http
            .get(url.clone())
            .send()
            .with_context(|| format!("failed to download artifact from {url}"))?;
        if !resp.status().is_success() {
            return Err(anyhow!(
                "artifact download failed with status {}",
                resp.status()
            ));
        }

        let total = resp.content_length();
        let pb = ProgressBar::with_draw_target(total, ProgressDrawTarget::stderr());
        pb.set_style(
            ProgressStyle::with_template("{spinner:.green} {msg} {bytes}/{total_bytes} ({eta})")
                .unwrap()
                .progress_chars("=>-"),
        );
        pb.set_message(format!("Downloading {}", artifact.archive));

        let mut file = File::create(dest).with_context(|| {
            format!("failed to create temp artifact file at {}", dest.display())
        })?;
        let mut hasher = Sha256::new();
        let mut reader = BufReader::with_capacity(1024 * 1024, resp);
        loop {
            let chunk = reader.fill_buf()?;
            if chunk.is_empty() {
                break;
            }

            file.write_all(chunk)?;
            hasher.write_all(chunk)?;

            let len = chunk.len();
            reader.consume(len);

            pb.inc(len as u64);
        }
        pb.finish_with_message(format!("Downloaded {}", artifact.archive));

        let calculated = hex::encode(hasher.finalize());
        if !calculated.eq_ignore_ascii_case(&artifact.sha256) {
            return Err(anyhow!(
                "sha256 mismatch for artifact {} (expected {}, got {})",
                artifact.archive,
                artifact.sha256,
                calculated
            ));
        }

        Ok(())
    }
}

pub fn extract_archive(archive_path: &Path, destination: &Path) -> Result<()> {
    let file = File::open(archive_path)
        .with_context(|| format!("failed to open archive at {}", archive_path.display()))?;

    let decoder = flate2::read::GzDecoder::new(file);
    let mut archive = tar::Archive::new(decoder);

    archive.unpack(destination).with_context(|| {
        format!(
            "failed to extract archive {} into {}",
            archive_path.display(),
            destination.display()
        )
    })
}