//! Release catalog and verified downloads (port of `updates/hashUrlProvider.js`): //! `api/releases.php`, HTTPS allowlist, private cache (0700/0600→0400), streamed SHA-512 + //! size checks, `.asc` must be exactly `.asc`, mandatory OpenPGP verification //! and re-verification immediately before an installer is launched. use crate::core::error::{CoreError, CoreResult}; use crate::core::net::https::HttpsClient; use crate::core::storage::clean_download_cache; use crate::core::updates::contract::{assert_supported_target, normalize_architecture, normalize_platform, parse_date, server_channel, validate_release_list, Expected, Release}; use crate::core::updates::openpgp::{OpenPgpVerifier, Verification}; use crate::core::version::compare_versions; use crate::security::url_policy::{assert_allowed_url, normalize_base_url, AllowOptions}; use serde_json::{json, Value}; use sha2::{Digest, Sha512}; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; struct VerificationGuard(tokio_util::sync::CancellationToken); impl Drop for VerificationGuard { fn drop(&mut self) { self.0.cancel(); } } async fn bounded_verification(limit: Duration, work: impl FnOnce(tokio_util::sync::CancellationToken) -> CoreResult + Send + 'static) -> CoreResult { let guard = VerificationGuard(tokio_util::sync::CancellationToken::new()); let cancel = guard.0.clone(); tokio::time::timeout(limit, tokio::task::spawn_blocking(move || work(cancel))) .await .map_err(|_| CoreError::new("UPDATE_SIGNATURE_TIMEOUT", "OpenPGP verification timed out."))? .map_err(|_| CoreError::new("UPDATE_SIGNATURE_ERROR", "OpenPGP verification failed unexpectedly."))? } pub const MAX_MANIFEST_BYTES: usize = 256 * 1024; pub const MAX_ARTIFACT_BYTES: u64 = 2 * 1024 * 1024 * 1024; pub const MAX_SIGNATURE_BYTES: u64 = 128 * 1024; pub fn safe_filename(value: &str) -> String { let base = Path::new(value).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "update.bin".into()); let mut safe = String::new(); let mut dash = false; for c in base.chars() { if c.is_ascii_alphanumeric() || "._-".contains(c) { safe.push(c); dash = false; } else if !dash { safe.push('-'); dash = true; } } let trimmed = safe.trim_start_matches(['-', '.']).to_string(); if trimmed.is_empty() { "update.bin".into() } else { trimmed } } #[derive(Debug, Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct VerifiedDownload { pub path: PathBuf, pub signature_path: PathBuf, pub filename: String, pub size: u64, pub sha512: String, pub reused: bool, pub signature_verified: bool, pub primary_fingerprint: String, pub signing_fingerprints: Vec, pub signature_created_at: Option, pub release_id: Option, } pub struct ReleaseProvider { base_urls: Vec, client: Arc, download_root: PathBuf, verifier: Arc, logger: Arc, } fn private_dir(path: &Path, recursive: bool) -> CoreResult<()> { if !path.exists() { if recursive { std::fs::create_dir_all(path)? } else { std::fs::create_dir(path)? } } let meta = std::fs::symlink_metadata(path)?; if !meta.is_dir() || meta.file_type().is_symlink() { return Err(CoreError::plain("Update download directory is unsafe.")); } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)); } Ok(()) } fn link_count(meta: &std::fs::Metadata) -> u64 { #[cfg(unix)] { use std::os::unix::fs::MetadataExt; meta.nlink() } #[cfg(not(unix))] { let _ = meta; 1 } } fn set_read_only(path: &Path) { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o400)); } #[cfg(not(unix))] { if let Ok(meta) = std::fs::metadata(path) { let mut permissions = meta.permissions(); permissions.set_readonly(true); let _ = std::fs::set_permissions(path, permissions); } } } fn random_hex() -> String { let mut bytes = [0u8; 6]; rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut bytes); hex::encode(bytes) } impl ReleaseProvider { pub fn new(base_urls: &[String], client: Arc, download_root: PathBuf, verifier: Arc, logger: Arc) -> CoreResult { let base_urls = base_urls.iter().map(|u| normalize_base_url(u).map(|u| u.to_string())).collect::>>()?; if base_urls.is_empty() { return Err(CoreError::plain("At least one update base URL is required.")); } Ok(Self { base_urls, client, download_root, verifier, logger }) } pub fn download_root(&self) -> &Path { &self.download_root } fn resolve_artifact(&self, value: &str, base: &str) -> CoreResult { let joined = url::Url::parse(base).and_then(|b| b.join(value.trim().trim_start_matches('/'))).map_err(|_| CoreError::plain("Invalid update URL."))?; assert_allowed_url(joined.as_str(), &self.base_urls, &AllowOptions { path_segment: Some("files/"), ..Default::default() }) } fn resolve_signature(&self, value: &str, artifact: &url::Url, base: &str) -> CoreResult { let resolved = self.resolve_artifact(value, base)?; if resolved.origin() != artifact.origin() || resolved.path() != format!("{}.asc", artifact.path()) || resolved.query().is_some() || resolved.fragment().is_some() { return Err(CoreError::plain("Detached signature URL must be the artifact URL followed by .asc.")); } Ok(resolved) } pub async fn list_releases(&self, current_version: &str, platform: &str, arch: &str, channel: &str) -> CoreResult<(Vec, String)> { let (platform, arch) = (normalize_platform(platform), normalize_architecture(arch)); assert_supported_target(&platform, &arch)?; let channels: Vec = if channel == "all" { vec!["alpha".into(), "beta".into(), "stable".into()] } else { vec![server_channel(channel)?] }; let mut errors = Vec::new(); for base in &self.base_urls { let attempt = async { let mut by_id: Vec = Vec::new(); for selected in &channels { let mut url = url::Url::parse(base).and_then(|b| b.join("api/releases.php")).map_err(|_| CoreError::plain("Invalid update URL."))?; url.query_pairs_mut().append_pair("platform", &platform).append_pair("arch", &arch).append_pair("channel", selected); assert_allowed_url(url.as_str(), &self.base_urls, &AllowOptions { path_segment: Some("api/"), ..Default::default() })?; let (status, text) = self.client.get_text(&url, "application/json", Duration::from_secs(5), MAX_MANIFEST_BYTES).await?; if status != 200 { return Err(CoreError::plain(format!("Update server returned HTTP {status}."))); } let payload: Value = serde_json::from_str(&text).map_err(|_| CoreError::plain("Update server returned invalid JSON."))?; let expected = Expected { platform: &platform, arch: &arch, channel: selected, current_version }; for mut release in validate_release_list(&payload, &expected)? { let artifact = self.resolve_artifact(&release.download_url, base)?; release.signature_url = self.resolve_signature(&release.signature_url, &artifact, base)?.to_string(); release.download_url = artifact.to_string(); by_id.retain(|r| r.id != release.id); by_id.push(release); } } by_id.sort_by(|a, b| { compare_versions(&b.version, &a.version) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| parse_date(b.published_at.as_deref().unwrap_or("")).cmp(&parse_date(a.published_at.as_deref().unwrap_or("")))) }); Ok::<_, CoreError>(by_id) } .await; match attempt { Ok(list) => return Ok((list, base.clone())), Err(error) => { self.logger.warn("Update catalog endpoint failed.", json!({ "baseUrl": base, "message": error.message })); errors.push(json!({ "baseUrl": base, "message": error.message })); } } } Err(CoreError::new("UPDATE_ENDPOINT_UNREACHABLE", "No configured update catalog endpoint is reachable.").with("details", Value::Array(errors))) } fn paths_for(&self, release: &Release) -> (PathBuf, PathBuf, PathBuf, String) { let filename = safe_filename(&release.filename); let version_dir = self.download_root.join(safe_filename(&format!("{}-{}", release.version, if release.id.is_empty() { "release" } else { &release.id }))); let destination = version_dir.join(&filename); let signature = PathBuf::from(format!("{}.asc", destination.to_string_lossy())); (version_dir, destination, signature, filename) } fn assert_safe_file(&self, path: &Path, expected_size: Option, label: &str) -> CoreResult { let meta = std::fs::symlink_metadata(path)?; if !meta.is_file() || meta.file_type().is_symlink() || link_count(&meta) != 1 { return Err(CoreError::plain(format!("{label} is not a safe regular file."))); } if expected_size.map(|s| s != meta.len()).unwrap_or(false) { return Err(CoreError::plain(format!("{label} size changed after verification."))); } let (root, file) = (std::fs::canonicalize(&self.download_root)?, std::fs::canonicalize(path)?); if file == root || !file.starts_with(&root) { return Err(CoreError::plain(format!("{label} resolves outside the private update cache."))); } Ok(meta) } async fn fetch_to(&self, url: &url::Url, temp: &Path, accept: &str, timeout: Duration, max: u64, expected: Option<(u64, &str)>) -> CoreResult { let mut response = self.client.get(url, accept, timeout).await?; let status = response.status().as_u16(); if status != 200 { return Err(CoreError::plain(format!("{} server returned HTTP {status}.", if expected.is_some() { "Download" } else { "Signature" }))); } if let (Some(declared), Some((size, _))) = (response.content_length(), expected) { if declared > 0 && declared != size { return Err(CoreError::plain("Download Content-Length does not match the catalog.")); } } if expected.is_none() && response.content_length().map(|l| l > max).unwrap_or(false) { return Err(CoreError::plain("Detached signature is too large.")); } let mut options = std::fs::OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } let mut file = options.open(temp)?; let mut hasher = Sha512::new(); let mut received = 0u64; while let Some(chunk) = response.chunk().await.map_err(|e| CoreError::plain(e.to_string()))? { received += chunk.len() as u64; if received > max || expected.map(|(s, _)| received > s).unwrap_or(false) { return Err(CoreError::plain(if expected.is_some() { "Downloaded artifact exceeds the expected size." } else { "Detached signature is too large." })); } hasher.update(&chunk); file.write_all(&chunk)?; } file.sync_all()?; if let Some((size, sha)) = expected { if received != size { return Err(CoreError::plain(format!("Downloaded size mismatch: expected {size}, received {received}."))); } if hex::encode(hasher.finalize()) != sha { return Err(CoreError::plain("Downloaded SHA-512 does not match the catalog.")); } } else if received == 0 { return Err(CoreError::plain("Detached signature is empty.")); } Ok(received) } pub async fn download_and_verify(&self, release: &Release) -> CoreResult { if release.size == 0 || release.size > MAX_ARTIFACT_BYTES { return Err(CoreError::plain("Update artifact size is outside the allowed range.")); } let artifact_url = self.resolve_artifact(&release.download_url, &self.base_urls[0])?; let signature_url = self.resolve_signature(&release.signature_url, &artifact_url, &self.base_urls[0])?; let (version_dir, destination, signature_path, filename) = self.paths_for(release); private_dir(&self.download_root, true)?; private_dir(&version_dir, false)?; let mut reused = false; if destination.exists() { let meta = std::fs::symlink_metadata(&destination)?; if meta.is_file() && !meta.file_type().is_symlink() && link_count(&meta) == 1 && meta.len() == release.size && crate::core::hashing_util::sha512_file(&destination)? == release.sha512 { reused = true; } else { set_writable(&destination); std::fs::remove_file(&destination)?; } } let result = async { if !reused { let temp = version_dir.join(format!(".{filename}.{}.part", random_hex())); let outcome = self.fetch_to(&artifact_url, &temp, "application/octet-stream", Duration::from_secs(15 * 60), MAX_ARTIFACT_BYTES, Some((release.size, &release.sha512))).await; match outcome { Ok(_) => std::fs::rename(&temp, &destination)?, Err(error) => { let _ = std::fs::remove_file(&temp); return Err(error); } } } if signature_path.exists() { set_writable(&signature_path); let _ = std::fs::remove_file(&signature_path); } let temp_sig = version_dir.join(format!(".{filename}.{}.asc.part", random_hex())); match self.fetch_to(&signature_url, &temp_sig, "application/pgp-signature, application/octet-stream, text/plain", Duration::from_secs(5), MAX_SIGNATURE_BYTES, None).await { Ok(_) => std::fs::rename(&temp_sig, &signature_path)?, Err(error) => { let _ = std::fs::remove_file(&temp_sig); return Err(error); } } self.assert_safe_file(&destination, Some(release.size), "Update artifact")?; self.assert_safe_file(&signature_path, None, "Detached OpenPGP signature")?; let (artifact, sig) = (destination.clone(), signature_path.clone()); let verifier = self.verifier.clone(); let verification: Verification = bounded_verification(Duration::from_secs(120), move |cancel| verifier.verify_file_cancellable(&artifact, &sig, cancel)).await?; if !verification.valid { return Err(CoreError::plain("Mandatory OpenPGP verification did not return a valid result.")); } set_read_only(&destination); set_read_only(&signature_path); Ok(VerifiedDownload { size: std::fs::symlink_metadata(&destination)?.len(), path: destination.clone(), signature_path: signature_path.clone(), filename: filename.clone(), sha512: release.sha512.clone(), reused, signature_verified: true, primary_fingerprint: verification.primary_fingerprint, signing_fingerprints: verification.signing_fingerprints, signature_created_at: verification.signature_created_at, release_id: None, }) } .await; if let Err(error) = &result { self.logger.warn("OpenPGP update verification failed.", json!({ "code": error.code, "reason": error.message })); for path in [&signature_path, &destination] { set_writable(path); let _ = std::fs::remove_file(path); } } result } pub async fn reverify_for_install(&self, download: &VerifiedDownload, release: &Release) -> CoreResult { if !download.signature_verified { return Err(CoreError::plain("No OpenPGP-verified update is ready to install.")); } let (version_dir, expected, expected_sig, _) = self.paths_for(release); if download.path != expected || download.signature_path != expected_sig { return Err(CoreError::plain("Verified update paths do not match the selected release.")); } private_dir(&self.download_root, true)?; private_dir(&version_dir, false)?; let meta = self.assert_safe_file(&expected, Some(release.size), "Update artifact")?; self.assert_safe_file(&expected_sig, None, "Detached OpenPGP signature")?; let (artifact, sig, verifier) = (expected.clone(), expected_sig.clone(), self.verifier.clone()); let (sha512, size) = (release.sha512.clone(), release.size); let verification = bounded_verification(Duration::from_secs(120), move |cancel| { let hash = crate::core::hashing_util::sha512_file(&artifact)?; if hash != sha512 || meta.len() != size { return Err(CoreError::plain("The downloaded update changed after verification.")); } verifier.verify_file_cancellable(&artifact, &sig, cancel) }).await?; if !verification.valid { return Err(CoreError::plain("The downloaded update signature is no longer valid.")); } Ok(expected) } pub fn cleanup_downloads(&self) -> CoreResult { if self.download_root.exists() { make_tree_writable(&self.download_root); } Ok(clean_download_cache(&self.download_root)?.0) } } fn set_writable(path: &Path) { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); } #[cfg(not(unix))] { if let Ok(meta) = std::fs::metadata(path) { let mut permissions = meta.permissions(); #[allow(clippy::permissions_set_readonly_false)] permissions.set_readonly(false); let _ = std::fs::set_permissions(path, permissions); } } } /// Read-only (0400) cached files must become writable before Windows can delete them. fn make_tree_writable(root: &Path) { if let Ok(entries) = std::fs::read_dir(root) { for entry in entries.flatten() { let path = entry.path(); if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { make_tree_writable(&path); } else { set_writable(&path); } } } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn verification_deadline_cancels_worker() { let (tx, rx) = tokio::sync::oneshot::channel(); let result = bounded_verification(Duration::from_millis(30), move |cancel| { while !cancel.is_cancelled() { std::thread::sleep(Duration::from_millis(1)); } let _ = tx.send(()); Ok(()) }).await; assert_eq!(result.unwrap_err().code, "UPDATE_SIGNATURE_TIMEOUT"); tokio::time::timeout(Duration::from_secs(1), rx).await.unwrap().unwrap(); assert_eq!(bounded_verification(Duration::from_secs(1), |_| Ok(42)).await.unwrap(), 42); } #[test] fn filenames() { assert_eq!(safe_filename("../../evil name.exe"), "evil-name.exe"); assert_eq!(safe_filename(".hidden"), "hidden"); assert_eq!(safe_filename(""), "update.bin"); } }