Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions crates/soar-config/src/packages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,13 @@ pub struct PackageOptions {
/// Direct URL to download the package from (makes it a "local" package).
pub url: Option<String>,

/// 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<String>,

/// 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<String>,
Expand Down Expand Up @@ -301,6 +308,7 @@ pub struct ResolvedPackage {
pub version: Option<String>,
pub repo: Option<String>,
pub url: Option<String>,
pub bsum: Option<String>,
pub github: Option<String>,
pub gitlab: Option<String>,
pub asset_pattern: Option<String>,
Expand Down Expand Up @@ -340,6 +348,7 @@ impl PackageSpec {
version,
repo: None,
url: None,
bsum: None,
github: None,
gitlab: None,
asset_pattern: None,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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#"
Expand Down
4 changes: 3 additions & 1 deletion crates/soar-operations/src/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -636,8 +636,10 @@ fn create_url_install_target(
resolved: &ResolvedPackage,
existing: Option<InstalledPackage>,
) -> 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(),
Expand Down
66 changes: 64 additions & 2 deletions crates/soar-operations/src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
)));
}
}
Loading