From 51c2bb3896f97b8fa190df495217e8646733fa64 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 2 Jun 2026 21:35:35 +0545 Subject: [PATCH 1/4] fix(dl): verify download integrity before exec/extract and fail closed on missing checksum --- Cargo.lock | 1 + Cargo.toml | 1 + crates/soar-core/src/package/install.rs | 4 ++ crates/soar-dl/Cargo.toml | 1 + crates/soar-dl/src/download.rs | 88 ++++++++++++++++++++++++- crates/soar-dl/src/error.rs | 8 +++ crates/soar-dl/src/oci.rs | 56 ++++++++++++++++ crates/soar-operations/src/install.rs | 25 +++++++ 8 files changed, 181 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d28db3f0d..033f6aab0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2302,6 +2302,7 @@ dependencies = [ "regex", "serde", "serde_json", + "sha2", "soar-utils", "tempfile", "thiserror", diff --git a/Cargo.toml b/Cargo.toml index 5d7a21156..8c843af2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,7 @@ semver = "1.0.27" serde = { version = "1.0.228", features = ["derive"] } serde_json = { version = "1.0.149", features = ["indexmap"] } serial_test = "3.3.1" +sha2 = "0.10.9" soar-config = { version = "0.7.0", path = "crates/soar-config" } soar-core = { version = "0.15.0", path = "crates/soar-core" } soar-db = { version = "0.5.1", path = "crates/soar-db" } diff --git a/crates/soar-core/src/package/install.rs b/crates/soar-core/src/package/install.rs index 19f5f4167..da964a6cb 100644 --- a/crates/soar-core/src/package/install.rs +++ b/crates/soar-core/src/package/install.rs @@ -634,6 +634,10 @@ impl PackageInstaller { .extract(should_extract) .extract_to(&extract_dir); + if let Some(ref bsum) = self.package.bsum { + dl = dl.checksum(bsum); + } + if let Some(ref cb) = self.progress_callback { let cb = cb.clone(); dl = dl.progress(move |p| { diff --git a/crates/soar-dl/Cargo.toml b/crates/soar-dl/Cargo.toml index 689fa9fac..3973e000e 100644 --- a/crates/soar-dl/Cargo.toml +++ b/crates/soar-dl/Cargo.toml @@ -17,6 +17,7 @@ percent-encoding = { workspace = true } regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } soar-utils = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } diff --git a/crates/soar-dl/src/download.rs b/crates/soar-dl/src/download.rs index 11ba3eb55..3b79ecb8a 100644 --- a/crates/soar-dl/src/download.rs +++ b/crates/soar-dl/src/download.rs @@ -33,6 +33,7 @@ pub struct Download { pub extract_to: Option, pub on_progress: Option>, pub ghcr_blob: bool, + pub expected_checksum: Option, } impl Download { @@ -63,9 +64,25 @@ impl Download { extract_to: None, on_progress: None, ghcr_blob: false, + expected_checksum: None, } } + /// Sets the expected blake3 checksum (hex) to verify the downloaded file + /// against before it is made executable or extracted. + /// + /// # Examples + /// + /// ``` + /// use soar_dl::download::Download; + /// + /// let _ = Download::new("https://example.com/file").checksum("abcdef123456"); + /// ``` + pub fn checksum(mut self, checksum: impl Into) -> Self { + self.expected_checksum = Some(checksum.into()); + self + } + /// Turns on GHCR blob support. /// /// When enabled, the `Authorization` header is set to `Bearer QQ==` @@ -257,10 +274,16 @@ impl Download { // Only skip if there's no resume info (complete download) // If resume info exists, it's a partial download that should continue if resume_info.is_none() { - debug!(path = %output_path.display(), "file exists, skipping download"); - return Ok(output_path); + if self.verify_checksum(&output_path).is_ok() { + debug!(path = %output_path.display(), "file exists, skipping download"); + return Ok(output_path); + } + warn!(path = %output_path.display(), "cached file failed checksum, re-downloading"); + fs::remove_file(&output_path)?; + resume_info = None; + } else { + debug!(path = %output_path.display(), "file exists but is partial, resuming download"); } - debug!(path = %output_path.display(), "file exists but is partial, resuming download"); } OverwriteMode::Force => { debug!(path = %output_path.display(), "file exists, forcing overwrite"); @@ -287,6 +310,11 @@ impl Download { self.download_to_file(&output_path, resume_info)?; + if let Err(e) = self.verify_checksum(&output_path) { + fs::remove_file(&output_path).ok(); + return Err(e); + } + if is_elf(&output_path) { trace!(path = %output_path.display(), "detected ELF binary, setting executable permissions"); std::fs::set_permissions(&output_path, Permissions::from_mode(0o755))?; @@ -310,6 +338,22 @@ impl Download { Ok(output_path) } + fn verify_checksum(&self, path: &Path) -> Result<(), DownloadError> { + let Some(ref expected) = self.expected_checksum else { + return Ok(()); + }; + let actual = soar_utils::hash::calculate_checksum(path) + .map_err(|e| DownloadError::Io(std::io::Error::other(e.to_string())))?; + if actual.eq_ignore_ascii_case(expected) { + Ok(()) + } else { + Err(DownloadError::ChecksumMismatch { + expected: expected.clone(), + got: actual, + }) + } + } + /// Streams the HTTP response body for this download's URL to standard output. /// /// # Examples @@ -502,3 +546,41 @@ fn prompt_overwrite(path: &Path) -> std::io::Result { Ok(matches!(line.trim().to_lowercase().as_str(), "y" | "yes")) } + +#[cfg(test)] +mod tests { + use std::io::Write; + + use super::*; + + fn temp_with(contents: &[u8]) -> tempfile::NamedTempFile { + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(contents).unwrap(); + f.flush().unwrap(); + f + } + + #[test] + fn verify_checksum_ok_when_absent_or_matching() { + let f = temp_with(b"hello soar"); + let expected = soar_utils::hash::calculate_checksum(f.path()).unwrap(); + + let dl = Download::new("https://example.com/x"); + assert!(dl.verify_checksum(f.path()).is_ok()); + + let dl = Download::new("https://example.com/x").checksum(expected.to_uppercase()); + assert!(dl.verify_checksum(f.path()).is_ok()); + } + + #[test] + fn verify_checksum_rejects_mismatch() { + let f = temp_with(b"hello soar"); + let dl = Download::new("https://example.com/x").checksum("deadbeef"); + match dl.verify_checksum(f.path()) { + Err(DownloadError::ChecksumMismatch { expected, .. }) => { + assert_eq!(expected, "deadbeef"); + } + other => panic!("expected ChecksumMismatch, got {other:?}"), + } + } +} diff --git a/crates/soar-dl/src/error.rs b/crates/soar-dl/src/error.rs index 539a9599f..35489dfcd 100644 --- a/crates/soar-dl/src/error.rs +++ b/crates/soar-dl/src/error.rs @@ -45,6 +45,14 @@ pub enum DownloadError { #[diagnostic(code(soar_dl::unsafe_layer_path))] UnsafeLayerPath { title: String }, + #[error("Checksum mismatch: expected {expected}, got {got}")] + #[diagnostic(code(soar_dl::checksum_mismatch))] + ChecksumMismatch { expected: String, got: String }, + + #[error("Digest mismatch: expected {expected}, got {got}")] + #[diagnostic(code(soar_dl::digest_mismatch))] + DigestMismatch { expected: String, got: String }, + #[error("Invalid response from server")] #[diagnostic(code(soar_dl::invalid_response))] InvalidResponse, diff --git a/crates/soar-dl/src/oci.rs b/crates/soar-dl/src/oci.rs index a77a68dd1..d46b40228 100644 --- a/crates/soar-dl/src/oci.rs +++ b/crates/soar-dl/src/oci.rs @@ -11,6 +11,7 @@ use std::{ }; use serde::Deserialize; +use sha2::{Digest, Sha256}; use soar_utils::fs::is_elf; use tracing::{debug, trace}; use ureq::http::header::{ACCEPT, AUTHORIZATION, ETAG, IF_RANGE, RANGE}; @@ -161,6 +162,34 @@ fn safe_layer_path(output_dir: &Path, title: &str) -> Result`). On mismatch the file is removed and an error returned. +fn verify_layer_digest(path: &Path, digest: &str) -> Result<(), DownloadError> { + let expected = digest.strip_prefix("sha256:").unwrap_or(digest); + + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 8192]; + loop { + let n = file.read(&mut buffer)?; + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } + let got: String = hasher.finalize().iter().map(|b| format!("{b:02x}")).collect(); + + if got.eq_ignore_ascii_case(expected) { + Ok(()) + } else { + std::fs::remove_file(path).ok(); + Err(DownloadError::DigestMismatch { + expected: expected.to_string(), + got, + }) + } +} + #[derive(Clone)] pub struct OciDownload { reference: OciReference, @@ -957,6 +986,8 @@ fn download_layer_impl( } } + verify_layer_digest(path, &layer.digest)?; + if is_elf(path) { trace!(path = %path.display(), "setting executable permissions on ELF binary"); std::fs::set_permissions(path, Permissions::from_mode(0o755))?; @@ -975,6 +1006,31 @@ fn download_layer_impl( mod tests { use super::*; + #[test] + fn verify_layer_digest_accepts_correct_and_rejects_wrong() { + use std::io::Write; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layer.bin"); + { + let mut f = File::create(&path).unwrap(); + f.write_all(b"layer-bytes").unwrap(); + } + + let mut hasher = Sha256::new(); + hasher.update(b"layer-bytes"); + let hex: String = hasher.finalize().iter().map(|b| format!("{b:02x}")).collect(); + + assert!(verify_layer_digest(&path, &format!("sha256:{hex}")).is_ok()); + assert!(path.exists()); + + match verify_layer_digest(&path, "sha256:00ff") { + Err(DownloadError::DigestMismatch { .. }) => {} + other => panic!("expected DigestMismatch, got {other:?}"), + } + assert!(!path.exists(), "file should be removed on digest mismatch"); + } + #[test] fn safe_layer_path_keeps_plain_name_inside_output_dir() { let out = Path::new("/tmp/soar-out"); diff --git a/crates/soar-operations/src/install.rs b/crates/soar-operations/src/install.rs index 4aa5c77e1..de8bf043e 100644 --- a/crates/soar-operations/src/install.rs +++ b/crates/soar-operations/src/install.rs @@ -764,6 +764,19 @@ async fn install_single_package( let config = ctx.config(); let bin_dir = config.get_bin_path()?; + if !no_verify && pkg.bsum.is_none() { + let has_signing = config + .get_repository(&pkg.repo_name) + .map(|repo| repo.signature_verification() && repo.pubkey.is_some()) + .unwrap_or(false); + if !has_signing { + return Err(SoarError::Custom(format!( + "Refusing to install {}#{}: no checksum or signature available to verify integrity (use --no-verify to override)", + pkg.pkg_name, pkg.pkg_id + ))); + } + } + let dir_suffix: String = pkg .bsum .as_ref() @@ -944,6 +957,18 @@ async fn install_single_package( stage: VerifyStage::Passed, }); } + (None, Some(_)) => { + events.emit(SoarEvent::Verifying { + op_id, + pkg_name: pkg.pkg_name.clone(), + pkg_id: pkg.pkg_id.clone(), + stage: VerifyStage::Failed("checksum unavailable".into()), + }); + return Err(SoarError::Custom(format!( + "Could not verify {}#{}: expected a checksum but none could be computed", + pkg.pkg_name, pkg.pkg_id + ))); + } _ => {} } } From f5557abc635f5fa838618fd61750857c7bd319c9 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 2 Jun 2026 21:39:19 +0545 Subject: [PATCH 2/4] fmt --- crates/soar-dl/src/download.rs | 4 +++- crates/soar-dl/src/oci.rs | 24 +++++++++++++++++------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/crates/soar-dl/src/download.rs b/crates/soar-dl/src/download.rs index 3b79ecb8a..4f3e37893 100644 --- a/crates/soar-dl/src/download.rs +++ b/crates/soar-dl/src/download.rs @@ -577,7 +577,9 @@ mod tests { let f = temp_with(b"hello soar"); let dl = Download::new("https://example.com/x").checksum("deadbeef"); match dl.verify_checksum(f.path()) { - Err(DownloadError::ChecksumMismatch { expected, .. }) => { + Err(DownloadError::ChecksumMismatch { + expected, .. + }) => { assert_eq!(expected, "deadbeef"); } other => panic!("expected ChecksumMismatch, got {other:?}"), diff --git a/crates/soar-dl/src/oci.rs b/crates/soar-dl/src/oci.rs index d46b40228..94bd92c4d 100644 --- a/crates/soar-dl/src/oci.rs +++ b/crates/soar-dl/src/oci.rs @@ -154,11 +154,11 @@ impl OciLayer { /// Only the final path component of the title is used, so titles such as /// `../../etc/passwd`, `/etc/passwd`, or `..` cannot escape `output_dir`. fn safe_layer_path(output_dir: &Path, title: &str) -> Result { - let name = Path::new(title) - .file_name() - .ok_or_else(|| DownloadError::UnsafeLayerPath { + let name = Path::new(title).file_name().ok_or_else(|| { + DownloadError::UnsafeLayerPath { title: title.to_string(), - })?; + } + })?; Ok(output_dir.join(name)) } @@ -177,7 +177,11 @@ fn verify_layer_digest(path: &Path, digest: &str) -> Result<(), DownloadError> { } hasher.update(&buffer[..n]); } - let got: String = hasher.finalize().iter().map(|b| format!("{b:02x}")).collect(); + let got: String = hasher + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); if got.eq_ignore_ascii_case(expected) { Ok(()) @@ -1019,13 +1023,19 @@ mod tests { let mut hasher = Sha256::new(); hasher.update(b"layer-bytes"); - let hex: String = hasher.finalize().iter().map(|b| format!("{b:02x}")).collect(); + let hex: String = hasher + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); assert!(verify_layer_digest(&path, &format!("sha256:{hex}")).is_ok()); assert!(path.exists()); match verify_layer_digest(&path, "sha256:00ff") { - Err(DownloadError::DigestMismatch { .. }) => {} + Err(DownloadError::DigestMismatch { + .. + }) => {} other => panic!("expected DigestMismatch, got {other:?}"), } assert!(!path.exists(), "file should be removed on digest mismatch"); From 87b4287b3cf92566304a087437209cb664fd074a Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 2 Jun 2026 21:51:09 +0545 Subject: [PATCH 3/4] fix --- crates/soar-dl/src/oci.rs | 7 +++++++ crates/soar-operations/src/install.rs | 16 +++++++++++++--- crates/soar-utils/src/system.rs | 2 +- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/soar-dl/src/oci.rs b/crates/soar-dl/src/oci.rs index 94bd92c4d..ab97ba2a3 100644 --- a/crates/soar-dl/src/oci.rs +++ b/crates/soar-dl/src/oci.rs @@ -516,6 +516,7 @@ impl OciDownload { if path.is_file() { if let Ok(metadata) = path.metadata() { if metadata.len() == layer.size { + verify_layer_digest(&path, &layer.digest)?; downloaded += layer.size; if let Some(ref cb) = self.on_progress { cb(Progress::Chunk { @@ -623,6 +624,10 @@ impl OciDownload { if path.is_file() { if let Ok(metadata) = path.metadata() { if metadata.len() == layer.size { + if let Err(e) = verify_layer_digest(&path, &layer.digest) { + errors.lock().unwrap().push(format!("{e}")); + continue; + } let current = downloaded .fetch_add(layer.size, Ordering::Relaxed) + layer.size; @@ -799,6 +804,8 @@ impl OciDownload { let path = dl.execute()?; + verify_layer_digest(&path, &self.reference.tag)?; + Ok(vec![path]) } diff --git a/crates/soar-operations/src/install.rs b/crates/soar-operations/src/install.rs index de8bf043e..4492f97a5 100644 --- a/crates/soar-operations/src/install.rs +++ b/crates/soar-operations/src/install.rs @@ -895,6 +895,7 @@ async fn install_single_package( let downloaded_checksum = installer.download_package().await?; // Signature verification + let mut verified_sig_count = 0usize; if let Some(repository) = config.get_repository(&pkg.repo_name) { if repository.signature_verification() { events.emit(SoarEvent::Verifying { @@ -905,7 +906,7 @@ async fn install_single_package( }); if let Some(ref pubkey) = repository.pubkey { - verify_signatures(pubkey, &install_dir)?; + verified_sig_count = verify_signatures(pubkey, &install_dir)?; } else { warn!( "{}#{} - Signature verification skipped as no pubkey was found.", @@ -918,6 +919,13 @@ async fn install_single_package( cleanup_sig_files(&install_dir); } + if !no_verify && pkg.bsum.is_none() && verified_sig_count == 0 { + return Err(SoarError::Custom(format!( + "Refusing to install {}#{}: no checksum and no valid signature found to verify integrity (use --no-verify to override)", + pkg.pkg_name, pkg.pkg_id + ))); + } + // Checksum verification if !no_verify { events.emit(SoarEvent::Verifying { @@ -1053,13 +1061,14 @@ async fn install_single_package( Ok((install_dir, symlinks)) } -fn verify_signatures(pubkey_str: &str, install_dir: &Path) -> SoarResult<()> { +fn verify_signatures(pubkey_str: &str, install_dir: &Path) -> SoarResult { let pubkey = PublicKey::from_base64(pubkey_str.trim()) .map_err(|err| SoarError::Custom(format!("Failed to parse public key: {}", err)))?; let entries = fs::read_dir(install_dir) .with_context(|| format!("reading package directory {}", install_dir.display()))?; + let mut verified = 0usize; for entry in entries { let path = entry .with_context(|| format!("reading entry from directory {}", install_dir.display()))? @@ -1107,10 +1116,11 @@ fn verify_signatures(pubkey_str: &str, install_dir: &Path) -> SoarResult<()> { fs::remove_file(&path) .with_context(|| format!("removing minisign file {}", path.display()))?; + verified += 1; } } - Ok(()) + Ok(verified) } fn cleanup_sig_files(install_dir: &Path) { diff --git a/crates/soar-utils/src/system.rs b/crates/soar-utils/src/system.rs index 834d3cd7f..fe9f70606 100644 --- a/crates/soar-utils/src/system.rs +++ b/crates/soar-utils/src/system.rs @@ -7,7 +7,7 @@ use nix::unistd::{geteuid, User}; /// This function combines the architecture (e.g., `x86_64`) and the operating /// system (e.g., `linux`) into a single string to identify the platform. pub fn platform() -> String { - format!("{}-{}", std::env::consts::ARCH, &std::env::consts::OS) + format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS) } trait UsernameSource { From 455c5f42f98432bb994f42116a5c17dd0b34c1a5 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 2 Jun 2026 22:02:48 +0545 Subject: [PATCH 4/4] clippy fix --- crates/soar-core/src/package/install.rs | 14 ++++++++------ crates/soar-package/src/formats/common.rs | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/soar-core/src/package/install.rs b/crates/soar-core/src/package/install.rs index da964a6cb..58040331c 100644 --- a/crates/soar-core/src/package/install.rs +++ b/crates/soar-core/src/package/install.rs @@ -576,11 +576,13 @@ impl PackageInstaller { callback(Progress::Aborted); } // Return error after max retries - return Err(last_error.unwrap_or_else(|| { - DownloadError::Multiple { - errors: vec!["Download failed after 5 retries".into()], - } - }))?; + return Err(last_error + .unwrap_or_else(|| { + DownloadError::Multiple { + errors: vec!["Download failed after 5 retries".into()], + } + }) + .into()); } match dl.clone().execute() { Ok(_) => { @@ -605,7 +607,7 @@ impl PackageInstaller { } last_error = Some(err); } else { - return Err(err)?; + return Err(err.into()); } } } diff --git a/crates/soar-package/src/formats/common.rs b/crates/soar-package/src/formats/common.rs index f1a1e3d13..ad9420d4d 100644 --- a/crates/soar-package/src/formats/common.rs +++ b/crates/soar-package/src/formats/common.rs @@ -183,7 +183,7 @@ pub fn symlink_desktop_with_config, T: PackageExt>( "Exec" | "TryExec" => { let old_cmd = &caps[2]; let parts: Vec<&str> = old_cmd.split_whitespace().collect(); - let new_cmd = format!("{}/{}", &bin_path.display(), pkg_name); + let new_cmd = format!("{}/{}", bin_path.display(), pkg_name); if old_cmd.contains("{{pkg_path}}") { caps[0].replace("{{pkg_path}}", &new_cmd)