diff --git a/crates/soar-config/src/packages.rs b/crates/soar-config/src/packages.rs index 8109d3e2f..de0b30a1c 100644 --- a/crates/soar-config/src/packages.rs +++ b/crates/soar-config/src/packages.rs @@ -199,6 +199,13 @@ pub struct PackageOptions { /// Direct URL to download the package from (makes it a "local" package). pub url: Option, + /// Expected BLAKE3 checksum (hex) of the downloaded artifact, for `url`/`github`/ + /// `gitlab` packages. When set, soar verifies the download against it and refuses to + /// install on mismatch. + /// Without it, these user-declared sources install on implicit trust. + /// Has no effect on registry packages, which already ship their own checksum. + pub bsum: Option, + /// GitHub repository in "owner/repo" format for installing from releases. /// When set, soar fetches the latest release and downloads the matching asset. pub github: Option, @@ -301,6 +308,7 @@ pub struct ResolvedPackage { pub version: Option, pub repo: Option, pub url: Option, + pub bsum: Option, pub github: Option, pub gitlab: Option, pub asset_pattern: Option, @@ -340,6 +348,7 @@ impl PackageSpec { version, repo: None, url: None, + bsum: None, github: None, gitlab: None, asset_pattern: None, @@ -376,6 +385,7 @@ impl PackageSpec { version, repo: opts.repo.clone(), url: opts.url.clone(), + bsum: opts.bsum.clone(), github: opts.github.clone(), gitlab: opts.gitlab.clone(), asset_pattern: opts.asset_pattern.clone(), @@ -663,6 +673,34 @@ special = { profile = "isolated" } assert_eq!(resolved[0].profile, Some("isolated".to_string())); } + #[test] + fn test_bsum_pin_resolved() { + let toml_str = r#" +[packages] +myapp = { url = "https://example.com/myapp-1.0.AppImage", bsum = "abc123" } +nopin = { url = "https://example.com/nopin-1.0.AppImage" } +"#; + let config: PackagesConfig = toml::from_str(toml_str).unwrap(); + let resolved = config.resolved_packages(); + + let myapp = resolved.iter().find(|p| p.name == "myapp").unwrap(); + let nopin = resolved.iter().find(|p| p.name == "nopin").unwrap(); + + assert_eq!(myapp.bsum, Some("abc123".to_string())); + assert_eq!(nopin.bsum, None); + } + + #[test] + fn test_bsum_none_for_simple_spec() { + let toml_str = r#" +[packages] +curl = "*" +"#; + let config: PackagesConfig = toml::from_str(toml_str).unwrap(); + let resolved = config.resolved_packages(); + assert_eq!(resolved[0].bsum, None); + } + #[test] fn test_portable_config() { let toml_str = r#" diff --git a/crates/soar-operations/src/apply.rs b/crates/soar-operations/src/apply.rs index 5a6d57940..7410f1308 100644 --- a/crates/soar-operations/src/apply.rs +++ b/crates/soar-operations/src/apply.rs @@ -636,8 +636,10 @@ fn create_url_install_target( resolved: &ResolvedPackage, existing: Option, ) -> InstallTarget { + let mut package = url_pkg.to_package(); + package.bsum = resolved.bsum.as_ref().map(|s| s.trim().to_lowercase()); InstallTarget { - package: url_pkg.to_package(), + package, existing_install: existing, pinned: resolved.pinned, profile: resolved.profile.clone(), diff --git a/crates/soar-operations/src/install.rs b/crates/soar-operations/src/install.rs index 4492f97a5..7e64fc4c2 100644 --- a/crates/soar-operations/src/install.rs +++ b/crates/soar-operations/src/install.rs @@ -690,6 +690,14 @@ pub async fn perform_installation( }) } +/// Whether the registry-style "checksum or signature required" integrity gate is +/// inapplicable to this package's source. +/// Exemption only skips the gate; an explicit `bsum` (e.g. a user-provided pin) is still +/// enforced by checksum verification. +fn source_skips_integrity_gate(pkg: &Package) -> bool { + pkg.repo_name == "local" || pkg.ghcr_pkg.is_some() +} + #[allow(clippy::too_many_arguments)] async fn install_single_package( ctx: &SoarContext, @@ -764,7 +772,9 @@ async fn install_single_package( let config = ctx.config(); let bin_dir = config.get_bin_path()?; - if !no_verify && pkg.bsum.is_none() { + let skip_integrity_gate = source_skips_integrity_gate(pkg); + + if !no_verify && !skip_integrity_gate && pkg.bsum.is_none() { let has_signing = config .get_repository(&pkg.repo_name) .map(|repo| repo.signature_verification() && repo.pubkey.is_some()) @@ -919,7 +929,7 @@ async fn install_single_package( cleanup_sig_files(&install_dir); } - if !no_verify && pkg.bsum.is_none() && verified_sig_count == 0 { + if !no_verify && !skip_integrity_gate && 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 @@ -1133,3 +1143,55 @@ fn cleanup_sig_files(install_dir: &Path) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn pkg(repo_name: &str, ghcr: Option<&str>, bsum: Option<&str>) -> Package { + Package { + repo_name: repo_name.to_string(), + ghcr_pkg: ghcr.map(String::from), + bsum: bsum.map(String::from), + ..Default::default() + } + } + + #[test] + fn local_source_skips_integrity_gate() { + assert!(source_skips_integrity_gate(&pkg("local", None, None))); + } + + #[test] + fn ghcr_source_skips_integrity_gate() { + assert!(source_skips_integrity_gate(&pkg( + "local", + Some("ghcr.io/org/repo:tag"), + None + ))); + assert!(source_skips_integrity_gate(&pkg( + "some-repo", + Some("ghcr.io/org/repo:tag"), + None + ))); + } + + #[test] + fn registry_source_is_subject_to_integrity_gate() { + assert!(!source_skips_integrity_gate(&pkg("soarpkgs", None, None))); + } + + #[test] + fn integrity_gate_exemption_is_independent_of_pinned_bsum() { + assert!(source_skips_integrity_gate(&pkg( + "local", + None, + Some("deadbeef") + ))); + assert!(!source_skips_integrity_gate(&pkg( + "soarpkgs", + None, + Some("deadbeef") + ))); + } +}