use super::error::{DownloadError, FetchChecksumError, FetchError, HttpError, VerifyChecksumError};
use alloc::{
borrow::ToOwned as _,
format,
string::{String, ToString as _},
vec,
};
use asimov_module::ModuleManifest;
use serde::Deserialize;
use std::path::{Path, PathBuf};
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
#[derive(Debug, Deserialize)]
pub struct GitHubRelease {
pub name: String,
}
#[tracing::instrument(skip_all)]
pub async fn fetch_latest_release(
client: &reqwest::Client,
module_name: impl AsRef<str>,
) -> Result<String, FetchError> {
async fn by_api(
client: &reqwest::Client,
module_name: impl AsRef<str>,
) -> Result<String, FetchError> {
let url = format!(
"https://api.github.com/repos/asimov-modules/asimov-{}-module/releases/latest",
module_name.as_ref()
);
let response = client
.get(url)
.send()
.await
.inspect_err(|err| tracing::debug!(?err))?;
if !response.status().is_success() {
Err(HttpError::NotSuccess(response.status()))?;
}
let content = response
.text()
.await
.inspect_err(|err| tracing::debug!(?err))?;
serde_json::from_str::<GitHubRelease>(&content)
.inspect_err(|err| tracing::debug!(?err, ?content))
.map_err(|e| FetchError::Deserialize(e.into()))
.map(|release| release.name)
}
async fn by_redirect(
client: &reqwest::Client,
module_name: impl AsRef<str>,
) -> Result<String, FetchError> {
let url = format!(
"https://github.com/asimov-modules/asimov-{}-module/releases/latest",
module_name.as_ref()
);
let response = client
.head(&url)
.send()
.await
.inspect_err(|err| tracing::debug!(?err))?;
let final_url = response.url().as_str();
if final_url == url {
return by_api(client, &module_name).await;
}
tracing::debug!("got redirected to: {final_url}");
let mut parts = final_url.split('/');
Ok(parts.next_back().unwrap().into())
}
by_redirect(client, &module_name).await
}
#[tracing::instrument(skip_all)]
pub async fn fetch_module_manifest(
client: &reqwest::Client,
module_name: &str,
version: &str,
) -> Result<ModuleManifest, FetchError> {
let url = format!(
"https://raw.githubusercontent.com/asimov-modules/asimov-{module_name}-module/{version}/.asimov/module.yaml",
);
let response = client
.get(&url)
.send()
.await
.inspect_err(|err| tracing::debug!(?err))?;
if !response.status().is_success() {
Err(HttpError::NotSuccess(response.status()))?;
}
let content = response
.text()
.await
.inspect_err(|err| tracing::debug!(?err))?;
serde_yaml_ng::from_str(&content)
.inspect_err(|err| tracing::debug!(?err, ?content))
.map_err(|e| FetchError::Deserialize(e.into()))
}
#[tracing::instrument(skip_all)]
pub async fn fetch_readme(
client: &reqwest::Client,
module_name: &str,
version: &str,
) -> Option<String> {
for revision in [version, "master", "main"] {
let url = format!(
"https://raw.githubusercontent.com/asimov-modules/asimov-{module_name}-module/{revision}/README.md",
);
tracing::debug!("trying README URL {url}...");
let response = match client.get(&url).send().await {
Ok(response) => response,
Err(err) => {
tracing::debug!(?err);
continue;
},
};
if !response.status().is_success() {
continue;
}
match response.text().await {
Ok(content) => return Some(content),
Err(err) => tracing::debug!(?err),
}
}
None
}
#[tracing::instrument(skip_all)]
pub async fn fetch_checksum(
client: &reqwest::Client,
asset_url: &str,
) -> Result<Option<String>, FetchChecksumError> {
let checksum_url = format!("{asset_url}.sha256");
let response = client
.get(&checksum_url)
.send()
.await
.inspect_err(|err| tracing::debug!(?err))?;
if response.status() == 404 {
return Ok(None);
}
if !response.status().is_success() {
Err(HttpError::NotSuccess(response.status()))?;
}
Ok(Some(
response
.text()
.await
.inspect_err(|err| tracing::debug!(?err))?
.trim()
.to_string(),
))
}
pub async fn verify_checksum(
binary_path: &Path,
expected_checksum: &str,
) -> Result<(), VerifyChecksumError> {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
let mut file = tokio::fs::File::open(binary_path).await?;
const READ_BUFFER_SIZE: usize = 10 * 1024;
let mut buf = vec![0u8; READ_BUFFER_SIZE];
loop {
let n = file.read(&mut buf).await?;
if n == 0 {
break; }
hasher.update(&buf[..n]);
}
let actual_checksum = format!("{:x}", hasher.finalize());
let expected_hash = expected_checksum
.split_whitespace()
.next()
.unwrap_or(expected_checksum);
if actual_checksum != expected_hash {
return Err(VerifyChecksumError::InvalidChecksum(
actual_checksum,
expected_checksum.into(),
));
}
Ok(())
}
#[tracing::instrument(skip_all)]
pub async fn download_matching_asset(
client: &reqwest::Client,
module_name: &str,
version: &str,
platform: &super::platform::PlatformInfo,
dst_dir: &Path,
) -> Result<(String, PathBuf), DownloadError> {
let filenames = if let Some(libc) = &platform.libc {
vec![
format!(
"asimov-{}-module-{}-{}-{}.tar.gz",
module_name, platform.os, platform.arch, libc
),
format!(
"asimov-{}-module-{}-{}-{}.zip",
module_name, platform.os, platform.arch, libc
),
format!(
"asimov-{}-module-{}-{}.tar.gz",
module_name, platform.os, platform.arch
),
format!(
"asimov-{}-module-{}-{}.zip",
module_name, platform.os, platform.arch
),
]
} else {
vec![
format!(
"asimov-{}-module-{}-{}.tar.gz",
module_name, platform.os, platform.arch
),
format!(
"asimov-{}-module-{}-{}.zip",
module_name, platform.os, platform.arch
),
]
};
for filename in filenames {
let url = format!(
"https://github.com/asimov-modules/asimov-{module_name}-module/releases/download/{version}/{filename}"
);
tracing::debug!("trying asset URL {url}...");
let mut response = client
.get(&url)
.send()
.await
.inspect_err(|err| tracing::debug!(?err))?;
if response.status() == 404 {
continue;
}
if !response.status().is_success() {
Err(HttpError::NotSuccess(response.status()))?;
}
let asset_path = dst_dir.join(&filename);
let mut dst = tokio::fs::File::create(&asset_path).await?;
while let Some(chunk) = response
.chunk()
.await
.inspect_err(|err| tracing::debug!(?err))?
{
dst.write_all(&chunk).await?;
}
dst.flush().await?;
return Ok((url, asset_path));
}
Err(DownloadError::NoMatch)
}
pub async fn extract_files(
src_archive: impl AsRef<Path>,
dst_dir: impl AsRef<Path>,
) -> Result<(), tokio::io::Error> {
use std::io::{Error, Result};
let src_archive = src_archive.as_ref().to_owned();
let dst = dst_dir.as_ref().to_owned();
let Some(src_name) = src_archive
.file_name()
.and_then(std::ffi::OsStr::to_str)
.map(String::from)
else {
return Err(Error::other("Unsupported format"));
};
tokio::task::spawn_blocking(move || -> Result<()> {
let asset_file = std::fs::File::open(&src_archive)?;
if src_name.ends_with(".tar.gz") {
let gz = flate2::read::GzDecoder::new(asset_file);
let mut archive = tar::Archive::new(gz);
archive.unpack(&dst)?;
} else if src_name.ends_with(".zip") {
let mut archive = zip::ZipArchive::new(asset_file)?;
archive.extract(&dst)?;
} else {
return Err(Error::other("Unsupported format"));
}
Ok(())
})
.await??;
Ok(())
}