use std::path::Path;
use std::time::Duration;
use futures_util::StreamExt;
use sha2::{Digest, Sha256};
use tokio::io::AsyncWriteExt;
use crate::error::CoreError;
use crate::module::{
ModuleSpec, cached_entry, module_cache_dir, validate_module_id, validate_version,
};
const DIAGNOSTIC_BODY_CAP_BYTES: usize = 8 * 1024;
const DIAGNOSTIC_BODY_CHARS: usize = 200;
const DIAGNOSTIC_TRUNCATION_MARKER: &str = "… (truncated)";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FetchPolicy {
#[default]
CacheFirst,
Refresh,
AcceptUpstreamChange,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ArtifactSource {
Cache,
Download,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub struct FetchedModule {
pub module_id: String,
pub version: String,
pub digest_sha256: String,
pub path: std::path::PathBuf,
pub bytes: u64,
pub source: ArtifactSource,
}
#[derive(Debug, serde::Deserialize)]
struct Release {
assets: Vec<Asset>,
}
#[derive(Debug, serde::Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
digest: Option<String>,
size: u64,
}
pub struct ModuleFeed {
api_base: url::Url,
client: reqwest::Client,
}
impl ModuleFeed {
pub fn github() -> Result<Self, CoreError> {
Self::for_base(
url::Url::parse("https://api.github.com").expect("literal GitHub API URL parses"),
)
}
pub fn for_base(api_base: url::Url) -> Result<Self, CoreError> {
crate::client::install_crypto_provider();
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30))
.build()
.map_err(|err| {
CoreError::Internal(format!("cannot build module feed HTTP client: {err}"))
})?;
Ok(Self { api_base, client })
}
pub async fn fetch_and_verify(
&self,
spec: &ModuleSpec,
version: &str,
cache_root: &Path,
policy: FetchPolicy,
) -> Result<FetchedModule, CoreError> {
validate_module_id(spec.id)?;
validate_version(version)?;
let cached = cached_entry(cache_root, spec.id, version);
if let Some(cached) = &cached
&& policy == FetchPolicy::CacheFirst
{
ensure_readable(&cached.path)?;
return Ok(FetchedModule {
module_id: spec.id.to_string(),
version: version.to_string(),
digest_sha256: cached.digest_sha256.clone(),
path: cached.path.clone(),
bytes: cached.bytes,
source: ArtifactSource::Cache,
});
}
let tag = spec.tag(version);
let release_url = self
.api_base
.join(&format!("/repos/{}/releases/tags/{tag}", spec.repo))
.map_err(|err| CoreError::Internal(format!("cannot build release URL: {err}")))?;
let response = self
.client
.get(release_url.clone())
.header(reqwest::header::USER_AGENT, "ignition-cli")
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
.send()
.await
.map_err(|err| CoreError::ModuleFeedUnreachable {
url: release_url.to_string(),
source: Some(err),
})?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(CoreError::ModuleReleaseNotFound {
module: spec.id.to_string(),
version: version.to_string(),
url: release_url.to_string(),
detail: format!("no release exists for tag {tag:?}"),
});
}
if !response.status().is_success() {
let status = response.status();
let rate_limit_remaining = response
.headers()
.get("x-ratelimit-remaining")
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let rate_limit_reset = response
.headers()
.get("x-ratelimit-reset")
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let capped_body = bounded_diagnostic_body(response).await;
let detail = if rate_limit_remaining.as_deref() == Some("0") {
match rate_limit_reset {
Some(reset) => format!(
"feed rate limit exhausted (x-ratelimit-remaining: 0, resets at {reset})"
),
None => "feed rate limit exhausted (x-ratelimit-remaining: 0)".to_string(),
}
} else {
format!("release lookup answered HTTP {status}: {capped_body}")
};
return Err(CoreError::ModuleFeedUnusable {
url: release_url.to_string(),
detail,
});
}
let release: Release =
response
.json()
.await
.map_err(|err| CoreError::ModuleFeedUnusable {
url: release_url.to_string(),
detail: format!("release body did not match the expected shape: {err}"),
})?;
let expected_asset_name = spec.asset_name(version);
let Some(asset) = release
.assets
.iter()
.find(|asset| asset.name == expected_asset_name)
else {
let present: Vec<&str> = release.assets.iter().map(|a| a.name.as_str()).collect();
return Err(CoreError::ModuleReleaseNotFound {
module: spec.id.to_string(),
version: version.to_string(),
url: release_url.to_string(),
detail: format!(
"release has no asset named {expected_asset_name:?} (present: [{}])",
present.join(", ")
),
});
};
let expected_digest = match asset
.digest
.as_deref()
.and_then(|digest| digest.strip_prefix("sha256:"))
{
Some(hex) if hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit()) => {
hex.to_ascii_lowercase()
}
_ => {
return Err(CoreError::ModuleFeedUnusable {
url: release_url.to_string(),
detail: format!(
"asset {:?} has no usable sha256 digest (published: {:?})",
asset.name, asset.digest
),
});
}
};
if let Some(cached) = cached {
if expected_digest == cached.digest_sha256 {
ensure_readable(&cached.path)?;
return Ok(FetchedModule {
module_id: spec.id.to_string(),
version: version.to_string(),
digest_sha256: cached.digest_sha256,
path: cached.path,
bytes: cached.bytes,
source: ArtifactSource::Cache,
});
}
if policy == FetchPolicy::Refresh {
return Err(CoreError::ModuleDigestChanged(Box::new(
crate::error::ModuleDigestChangedDetails {
module: spec.id.to_string(),
version: version.to_string(),
cached_digest: cached.digest_sha256,
upstream_digest: expected_digest.clone(),
cached_path: cached.path.display().to_string(),
},
)));
}
}
let download_url = url::Url::parse(&asset.browser_download_url).map_err(|err| {
CoreError::ModuleFeedUnusable {
url: release_url.to_string(),
detail: format!(
"asset download URL {:?} did not parse: {err}",
asset.browser_download_url
),
}
})?;
if is_insecure_downgrade(self.api_base.scheme(), download_url.scheme()) {
return Err(CoreError::ModuleFeedUnusable {
url: download_url.to_string(),
detail: format!(
"asset download URL uses {} while the feed itself is https — \
refusing the insecure downgrade",
download_url.scheme()
),
});
}
let response = self
.client
.get(download_url.clone())
.send()
.await
.map_err(|err| CoreError::ModuleFeedUnreachable {
url: download_url.to_string(),
source: Some(err),
})?;
if !response.status().is_success() {
return Err(CoreError::ModuleFeedUnusable {
url: download_url.to_string(),
detail: format!("asset download answered HTTP {}", response.status()),
});
}
let cache_dir = module_cache_dir(cache_root, spec.id);
tokio::fs::create_dir_all(&cache_dir).await.map_err(|err| {
CoreError::Internal(format!("cannot create {}: {err}", cache_dir.display()))
})?;
let temp = tempfile::NamedTempFile::new_in(&cache_dir).map_err(|err| {
CoreError::Internal(format!(
"cannot create temp file in {}: {err}",
cache_dir.display()
))
})?;
let std_file = temp
.reopen()
.map_err(|err| CoreError::Internal(format!("cannot reopen temp file: {err}")))?;
let mut async_file = tokio::fs::File::from_std(std_file);
let mut hasher = Sha256::new();
let mut stream = response.bytes_stream();
let mut bytes: u64 = 0;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|err| CoreError::ModuleFeedUnreachable {
url: download_url.to_string(),
source: Some(err),
})?;
bytes += chunk.len() as u64;
if bytes > asset.size {
return Err(CoreError::ModuleFeedUnusable {
url: download_url.to_string(),
detail: format!(
"asset body exceeded the published size ({} bytes) — aborted mid-stream",
asset.size
),
});
}
hasher.update(&chunk);
async_file
.write_all(&chunk)
.await
.map_err(|err| CoreError::Internal(format!("cannot write temp file: {err}")))?;
}
async_file
.flush()
.await
.map_err(|err| CoreError::Internal(format!("cannot flush temp file: {err}")))?;
let actual_digest = format!("{:x}", hasher.finalize());
if actual_digest != expected_digest {
return Err(CoreError::ModuleDigestMismatch(Box::new(
crate::error::ModuleDigestMismatchDetails {
module: spec.id.to_string(),
version: version.to_string(),
url: download_url.to_string(),
expected: expected_digest,
actual: actual_digest,
},
)));
}
ensure_readable(temp.path())?;
let final_path = cache_dir.join(format!("{version}-{actual_digest}.modl"));
temp.persist(&final_path).map_err(|err| {
CoreError::Internal(format!(
"cannot persist cache entry at {}: {err}",
final_path.display()
))
})?;
Ok(FetchedModule {
module_id: spec.id.to_string(),
version: version.to_string(),
digest_sha256: actual_digest,
path: final_path,
bytes,
source: ArtifactSource::Download,
})
}
}
fn is_insecure_downgrade(feed_scheme: &str, asset_scheme: &str) -> bool {
feed_scheme == "https" && asset_scheme != "https"
}
async fn bounded_diagnostic_body(response: reqwest::Response) -> String {
let mut buf: Vec<u8> = Vec::new();
let mut truncated = false;
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let Ok(chunk) = chunk else { break };
let remaining = DIAGNOSTIC_BODY_CAP_BYTES.saturating_sub(buf.len());
if chunk.len() >= remaining {
buf.extend_from_slice(&chunk[..remaining]);
truncated = true;
break;
}
buf.extend_from_slice(&chunk);
}
let text = String::from_utf8_lossy(&buf);
let mut capped: String = text.chars().take(DIAGNOSTIC_BODY_CHARS).collect();
if truncated || capped.chars().count() < text.chars().count() {
capped.push_str(DIAGNOSTIC_TRUNCATION_MARKER);
}
capped
}
fn ensure_readable(path: &Path) -> Result<(), CoreError> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let current = std::fs::metadata(path)
.map_err(|err| {
CoreError::Internal(format!(
"cannot read permissions of cache entry at {}: {err}",
path.display()
))
})?
.permissions()
.mode();
if current & 0o044 == 0o044 {
return Ok(());
}
std::fs::set_permissions(path, std::fs::Permissions::from_mode(current | 0o044)).map_err(
|err| {
CoreError::Internal(format!(
"cannot make cache entry readable at {}: {err}",
path.display()
))
},
)?;
}
#[cfg(not(unix))]
let _ = path;
Ok(())
}
#[cfg(test)]
mod tests {
use super::is_insecure_downgrade;
#[test]
fn https_feed_refuses_non_https_asset() {
assert!(is_insecure_downgrade("https", "http"));
}
#[test]
fn https_feed_allows_https_asset() {
assert!(!is_insecure_downgrade("https", "https"));
}
#[test]
fn http_feed_allows_any_asset_scheme() {
assert!(!is_insecure_downgrade("http", "http"));
assert!(!is_insecure_downgrade("http", "https"));
}
}