use std::fs;
use std::path::{Path, PathBuf};
use std::thread;
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::dependency::types::DependencySourceType;
use crate::download::github::{GithubReleaseApiResponse, rewrite_github_download_url};
use crate::runtime_logging::info as log_info;
use crate::skill::dependencies::GithubReleaseSourceSpec;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolvedGithubReleaseAsset {
pub tag_name: String,
pub version: String,
pub asset_name: String,
pub download_url: String,
pub sha256: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadManagerConfig {
pub cache_root: PathBuf,
pub allow_network_download: bool,
pub github_base_url: Option<String>,
pub github_api_base_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadRequest {
pub source_type: DependencySourceType,
pub source_locator: String,
pub cache_key: String,
}
pub struct DownloadManager {
config: DownloadManagerConfig,
}
impl DownloadManager {
pub fn new(config: DownloadManagerConfig) -> Self {
Self { config }
}
pub fn download(&self, request: &DownloadRequest) -> Result<PathBuf, String> {
self.ensure_network_allowed()?;
fs::create_dir_all(&self.config.cache_root).map_err(|error| {
format!(
"Failed to create download cache root {}: {}",
self.config.cache_root.display(),
error
)
})?;
let file_extension = infer_download_extension(&request.source_locator);
let target_path = self
.config
.cache_root
.join(format!("{}{}", request.cache_key, file_extension));
if target_path.exists() {
return Ok(target_path);
}
log_info(format!(
"[LuaSkills:download] Fetching {} from {}",
request.cache_key, request.source_locator
));
let source_locator = request.source_locator.clone();
let bytes = self.run_http_task(move |client| {
let response = client
.get(&source_locator)
.send()
.map_err(|error| format!("Failed to download {}: {}", source_locator, error))?
.error_for_status()
.map_err(|error| format!("Failed to download {}: {}", source_locator, error))?;
response
.bytes()
.map(|value| value.to_vec())
.map_err(|error| format!("Failed to read {}: {}", source_locator, error))
})?;
fs::write(&target_path, &bytes)
.map_err(|error| format!("Failed to write {}: {}", target_path.display(), error))?;
Ok(target_path)
}
pub fn download_with_sha256(
&self,
request: &DownloadRequest,
expected_sha256: &str,
) -> Result<PathBuf, String> {
let target_path = self.download(request)?;
if let Err(error) = verify_file_sha256(&target_path, expected_sha256) {
let _ = fs::remove_file(&target_path);
let redownloaded_path = self.download(request)?;
if let Err(redownload_error) = verify_file_sha256(&redownloaded_path, expected_sha256) {
let _ = fs::remove_file(&redownloaded_path);
return Err(format!(
"{}. Automatic redownload also failed checksum verification: {}",
error, redownload_error
));
}
return Ok(redownloaded_path);
}
Ok(target_path)
}
pub fn fetch_text(&self, url: &str, cache_key: &str) -> Result<String, String> {
let cached_path = self.download(&DownloadRequest {
source_type: DependencySourceType::Url,
source_locator: url.to_string(),
cache_key: cache_key.to_string(),
})?;
fs::read_to_string(&cached_path)
.map_err(|error| format!("Failed to read {}: {}", cached_path.display(), error))
}
pub fn resolve_github_release_asset_url(
&self,
source: &GithubReleaseSourceSpec,
asset_name_template: &str,
expected_version: Option<&str>,
) -> Result<String, String> {
Ok(self
.resolve_github_release_asset(source, asset_name_template, expected_version)?
.download_url)
}
pub fn resolve_github_release_asset(
&self,
source: &GithubReleaseSourceSpec,
asset_name_template: &str,
expected_version: Option<&str>,
) -> Result<ResolvedGithubReleaseAsset, String> {
self.ensure_network_allowed()?;
let release = self.fetch_github_release(source, expected_version)?;
let normalized_version = normalize_release_version(
expected_version.unwrap_or(release.tag_name.as_str()),
release.tag_name.as_str(),
);
let expected_asset_name = asset_name_template
.replace("{version}", normalized_version.as_str())
.replace("{tag}", release.tag_name.as_str());
let asset = release
.assets
.iter()
.find(|asset| asset.name == expected_asset_name)
.ok_or_else(|| {
format!(
"GitHub release {} does not contain asset '{}'",
release.tag_name, expected_asset_name
)
})?;
Ok(ResolvedGithubReleaseAsset {
tag_name: release.tag_name.clone(),
version: normalized_version,
asset_name: asset.name.clone(),
download_url: rewrite_github_download_url(
asset.browser_download_url.as_str(),
self.config.github_base_url.as_deref(),
),
sha256: None,
})
}
pub fn resolve_github_managed_skill_release_asset(
&self,
source: &GithubReleaseSourceSpec,
skill_id: &str,
expected_version: Option<&str>,
) -> Result<ResolvedGithubReleaseAsset, String> {
let mut resolved = self.resolve_github_release_asset(
source,
&format!("{}-v{{version}}-skill.zip", skill_id),
expected_version,
)?;
let release = self.fetch_github_release(source, Some(resolved.version.as_str()))?;
let checksum_asset_name = format!("{}-v{}-checksums.txt", skill_id, resolved.version);
let checksum_asset = release
.assets
.iter()
.find(|asset| asset.name == checksum_asset_name)
.ok_or_else(|| {
format!(
"GitHub release {} does not contain checksum asset '{}'",
release.tag_name, checksum_asset_name
)
})?;
let checksum_url = rewrite_github_download_url(
checksum_asset.browser_download_url.as_str(),
self.config.github_base_url.as_deref(),
);
let checksum_text = self.fetch_text(
checksum_url.as_str(),
&format!(
"github-checksums-{}-{}",
sanitize_cache_key_fragment(source.repo.as_str()),
sanitize_cache_key_fragment(release.tag_name.as_str())
),
)?;
resolved.sha256 = Some(parse_checksum_manifest_for_asset(
checksum_text.as_str(),
resolved.asset_name.as_str(),
)?);
Ok(resolved)
}
fn ensure_network_allowed(&self) -> Result<(), String> {
if self.config.allow_network_download {
Ok(())
} else {
Err("network download is disabled by host policy".to_string())
}
}
fn fetch_github_release(
&self,
source: &GithubReleaseSourceSpec,
expected_version: Option<&str>,
) -> Result<GithubReleaseApiResponse, String> {
if let Some(tag_api) = source.tag_api.as_ref() {
return self.fetch_github_release_from_url(tag_api);
}
if let Some(expected_version) = expected_version {
let trimmed_version = expected_version.trim().trim_start_matches('v');
if !trimmed_version.is_empty() {
let candidate_tags = [trimmed_version.to_string(), format!("v{}", trimmed_version)];
let mut last_not_found = None;
for candidate_tag in candidate_tags {
let api_url = build_github_release_tag_api_url(
&self.config,
source.repo.as_str(),
&candidate_tag,
);
match self.try_fetch_github_release_from_url(&api_url)? {
Some(release) => return Ok(release),
None => last_not_found = Some(api_url),
}
}
return Err(format!(
"Failed to resolve GitHub release for repo '{}' and version '{}'; attempted tag endpoints ending with '{}'",
source.repo,
trimmed_version,
last_not_found.unwrap_or_default()
));
}
}
let api_url = build_github_release_api_url(&self.config, source.repo.as_str());
self.fetch_github_release_from_url(&api_url)
}
fn fetch_github_release_from_url(
&self,
api_url: &str,
) -> Result<GithubReleaseApiResponse, String> {
let api_url = api_url.to_string();
let request_url = api_url.clone();
let response_text = self.run_http_task(move |client| {
client
.get(&request_url)
.send()
.map_err(|error| format!("Failed to query {}: {}", request_url, error))?
.error_for_status()
.map_err(|error| format!("Failed to query {}: {}", request_url, error))?
.text()
.map_err(|error| format!("Failed to read {}: {}", request_url, error))
})?;
serde_json::from_str(&response_text)
.map_err(|error| format!("Failed to parse {}: {}", api_url, error))
}
fn try_fetch_github_release_from_url(
&self,
api_url: &str,
) -> Result<Option<GithubReleaseApiResponse>, String> {
let api_url = api_url.to_string();
self.run_http_task(move |client| {
let response = client
.get(&api_url)
.send()
.map_err(|error| format!("Failed to query {}: {}", api_url, error))?;
if response.status() == StatusCode::NOT_FOUND {
return Ok(None);
}
let response = response
.error_for_status()
.map_err(|error| format!("Failed to query {}: {}", api_url, error))?;
let response_text = response
.text()
.map_err(|error| format!("Failed to read {}: {}", api_url, error))?;
let release = serde_json::from_str(&response_text)
.map_err(|error| format!("Failed to parse {}: {}", api_url, error))?;
Ok(Some(release))
})
}
fn run_http_task<T, F>(&self, operation: F) -> Result<T, String>
where
T: Send + 'static,
F: FnOnce(reqwest::blocking::Client) -> Result<T, String> + Send + 'static,
{
thread::spawn(move || {
let client = Self::build_http_client()?;
operation(client)
})
.join()
.map_err(|_| "Blocking HTTP worker thread panicked".to_string())?
}
fn build_http_client() -> Result<reqwest::blocking::Client, String> {
reqwest::blocking::Client::builder()
.user_agent("luaskills/0.1.0")
.build()
.map_err(|error| format!("Failed to build HTTP client: {}", error))
}
}
fn build_github_release_api_url(config: &DownloadManagerConfig, repo: &str) -> String {
let normalized_repo = repo
.trim()
.trim_start_matches("https://github.com/")
.trim_start_matches("http://github.com/")
.trim_matches('/');
let api_base = config
.github_api_base_url
.as_deref()
.unwrap_or("https://api.github.com")
.trim_end_matches('/');
format!("{}/repos/{}/releases/latest", api_base, normalized_repo)
}
fn build_github_release_tag_api_url(
config: &DownloadManagerConfig,
repo: &str,
tag: &str,
) -> String {
let normalized_repo = repo
.trim()
.trim_start_matches("https://github.com/")
.trim_start_matches("http://github.com/")
.trim_matches('/');
let api_base = config
.github_api_base_url
.as_deref()
.unwrap_or("https://api.github.com")
.trim_end_matches('/');
format!(
"{}/repos/{}/releases/tags/{}",
api_base,
normalized_repo,
tag.trim()
)
}
fn normalize_release_version(expected_version: &str, tag_name: &str) -> String {
let trimmed_expected = expected_version.trim();
if !trimmed_expected.is_empty() {
return trimmed_expected.trim_start_matches('v').to_string();
}
tag_name.trim().trim_start_matches('v').to_string()
}
fn infer_download_extension(url: &str) -> &'static str {
let lower = url.to_ascii_lowercase();
if lower.ends_with(".tar.gz") {
".tar.gz"
} else if lower.ends_with(".zip") {
".zip"
} else if let Some(extension) = Path::new(url)
.extension()
.and_then(|value| value.to_str())
.filter(|value| !value.is_empty())
{
if extension.eq_ignore_ascii_case("gz") && lower.ends_with(".tar.gz") {
".tar.gz"
} else {
match extension {
"txt" => ".txt",
"yaml" => ".yaml",
"yml" => ".yml",
"json" => ".json",
"dll" => ".dll",
"so" => ".so",
"dylib" => ".dylib",
"lua" => ".lua",
_ => "",
}
}
} else {
""
}
}
fn parse_checksum_manifest_for_asset(content: &str, asset_name: &str) -> Result<String, String> {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let mut parts = trimmed.split_whitespace();
let checksum = parts.next().unwrap_or_default().trim();
let file_name = parts
.next()
.unwrap_or_default()
.trim_start_matches('*')
.trim();
if file_name == asset_name {
if checksum.len() == 64 && checksum.chars().all(|value| value.is_ascii_hexdigit()) {
return Ok(checksum.to_ascii_lowercase());
}
return Err(format!(
"Checksum entry for '{}' is not one valid SHA-256 value",
asset_name
));
}
}
Err(format!(
"Checksum manifest does not contain an entry for '{}'",
asset_name
))
}
fn verify_file_sha256(path: &Path, expected_sha256: &str) -> Result<(), String> {
let expected = expected_sha256.trim().to_ascii_lowercase();
if expected.len() != 64 || !expected.chars().all(|value| value.is_ascii_hexdigit()) {
return Err(format!(
"Expected checksum for {} is not one valid SHA-256 value",
path.display()
));
}
let bytes =
fs::read(path).map_err(|error| format!("Failed to read {}: {}", path.display(), error))?;
let actual = format!("{:x}", Sha256::digest(&bytes));
if actual != expected {
return Err(format!(
"Checksum mismatch for {}: expected {}, got {}",
path.display(),
expected,
actual
));
}
Ok(())
}
fn sanitize_cache_key_fragment(value: &str) -> String {
value
.chars()
.map(|ch| match ch {
'a'..='z' | 'A'..='Z' | '0'..='9' => ch,
_ => '-',
})
.collect()
}
#[cfg(test)]
mod tests {
use super::{parse_checksum_manifest_for_asset, verify_file_sha256};
#[test]
fn checksum_manifest_parser_returns_matching_sha256() {
let checksum = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let manifest = format!(
"{} demo-v0.1.0-skill.zip\n{} other-file.zip\n",
checksum, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
);
let parsed = parse_checksum_manifest_for_asset(&manifest, "demo-v0.1.0-skill.zip")
.expect("checksum should be parsed");
assert_eq!(parsed, checksum);
}
#[test]
fn file_sha256_verification_succeeds_for_matching_payload() {
let temp_root = std::env::temp_dir().join(format!(
"luaskills_download_checksum_test_{}",
std::process::id()
));
if temp_root.exists() {
let _ = std::fs::remove_dir_all(&temp_root);
}
std::fs::create_dir_all(&temp_root).expect("temp root should be created");
let file_path = temp_root.join("payload.txt");
std::fs::write(&file_path, b"hello world").expect("payload should be written");
let checksum = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
verify_file_sha256(&file_path, checksum).expect("checksum should match");
let _ = std::fs::remove_dir_all(&temp_root);
}
}