From ee0c1d09d37bfde835960e4837a5cd1eb6496ef4 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 28 Jul 2026 16:52:20 +0545 Subject: [PATCH 1/5] fix(meta): keep desktop resources out of binaries --- src/port/meta.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/port/meta.rs b/src/port/meta.rs index 710751f..def9414 100644 --- a/src/port/meta.rs +++ b/src/port/meta.rs @@ -70,6 +70,16 @@ pub struct Binary { pub link_as: Option, } +/// Whether an installed file is a desktop-integration resource rather than an +/// executable. +/// +/// soar treats a non-empty `binaries` as the complete list of things to link, +/// so one icon or desktop entry in there stops the actual binary being found. +fn is_resource(name: &str) -> bool { + let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()); + matches!(ext.as_deref(), Some("desktop" | "png" | "svg" | "xpm" | "ico")) +} + /// Drop a leading archive-root component. /// /// Install paths are written against the archive as published, but soar @@ -178,6 +188,7 @@ pub fn generate(root: &Path, host: &str) -> (Vec, Vec) { let nested = from.trim_start_matches("*/").contains('/'); (base != *to || nested) && !to.eq_ignore_ascii_case("LICENSE") + && !is_resource(to) && *from != "*" }) .map(|(from, to)| Binary { From b2840a18af9c3d0f174d3caa0cc08e5d1bd2c5e5 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 28 Jul 2026 17:03:47 +0545 Subject: [PATCH 2/5] feat(port): pin side files per host, clarify audit output --- src/commands/port.rs | 14 ++++--- src/port/hashfill.rs | 88 +++++++++++++++++++++++++++++++------------- src/port/meta.rs | 3 ++ src/port/model.rs | 5 +++ 4 files changed, 80 insertions(+), 30 deletions(-) diff --git a/src/commands/port.rs b/src/commands/port.rs index 9fa0074..9de2dbc 100644 --- a/src/commands/port.rs +++ b/src/commands/port.rs @@ -207,7 +207,7 @@ pub async fn run(command: PortCommands) -> Result<(), String> { let egaps = hashfill::extra_gaps(&root); if !egaps.is_empty() { println!("hashing {} side files ...", egaps.len()); - let mut per_file: std::collections::BTreeMap> = + let mut per_file: std::collections::BTreeMap, String, String)>> = Default::default(); let mut efailed = 0; let mut estream = futures::stream::iter(egaps.iter().map(|g| { @@ -228,7 +228,7 @@ pub async fn run(command: PortCommands) -> Result<(), String> { Ok((b3, sha, _)) => per_file .entry(g.path.clone()) .or_default() - .push((g.url.clone(), g.to.clone(), b3, sha)), + .push((g.url.clone(), g.to.clone(), g.host.clone(), b3, sha)), Err(e) => { efailed += 1; eprintln!(" {} {}: {e}", "FAIL".red(), g.url); @@ -260,9 +260,13 @@ pub async fn run(command: PortCommands) -> Result<(), String> { } println!("\n{ok}/{} verified against real archive contents", findings.len()); if !unlistable.is_empty() { - // Single-file compression carries no member list, so these - // are unchecked rather than wrong. - println!("\n{} unlistable (single-file compression):", unlistable.len()); + // A bare binary, or one compressed on its own, has no members + // to list. The artifact is the file the package installs, so + // there are no interior paths that could be wrong. + println!( + "\n{} not archives, nothing to verify inside:", + unlistable.len() + ); for f in &unlistable { println!(" {} ({})", f.package, f.host); } diff --git a/src/port/hashfill.rs b/src/port/hashfill.rs index f0d710c..cb5b46c 100644 --- a/src/port/hashfill.rs +++ b/src/port/hashfill.rs @@ -12,6 +12,8 @@ use sha2::{Digest, Sha256}; /// A side file declared in pkg.toml that this version has not pinned yet. pub struct ExtraGap { + /// Which host this gap is for, when the URL is arch-dependent. + pub host: Option, pub path: std::path::PathBuf, /// URL the client will fetch it from. pub url: String, @@ -46,27 +48,59 @@ pub fn extra_gaps(root: &Path) -> Vec { paths.sort(); for (v, path) in p.versions.iter().zip(paths) { for e in &p.pkg.extra { - let pinned = v - .extra - .iter() - .any(|x| x.to == e.to && x.blake3.is_some()); - if pinned { - continue; - } - // A vendored licence resolves to this repository rather than - // upstream, which is the point: some hosts rate-limit and some - // upstreams are gone. - let (url, local) = match (&e.url, &e.license) { - (Some(u), _) => (u.replace("${version}", &v.version), None), - (None, Some(spdx)) => ( - format!( - "https://raw.githubusercontent.com/pkgforge/soarpkgs/main/licenses/{spdx}.txt" - ), - Some(root.join("licenses").join(format!("{spdx}.txt"))), - ), - _ => continue, + // A side file whose URL names an architecture is a different + // file on every host, so it is pinned once per host rather + // than once per package. + let hosts: Vec> = match &e.url { + Some(u) if u.contains("${arch}") => p + .pkg + .host + .supported + .iter() + .map(|h| Some(h.clone())) + .collect(), + _ => vec![None], }; - out.push(ExtraGap { path: path.clone(), url, to: e.to.clone(), local }); + for host in hosts { + let pinned = v + .extra + .iter() + .any(|x| x.to == e.to && x.host == host && x.blake3.is_some()); + if pinned { + continue; + } + // A vendored licence resolves to this repository rather + // than upstream, which is the point: some hosts rate-limit + // and some upstreams are gone. + let (url, local) = match (&e.url, &e.license) { + (Some(u), _) => (u.replace("${version}", &v.version), None), + (None, Some(spdx)) => ( + format!( + "https://raw.githubusercontent.com/pkgforge/soarpkgs/main/licenses/{spdx}.txt" + ), + Some(root.join("licenses").join(format!("{spdx}.txt"))), + ), + _ => continue, + }; + // `${arch}` is whatever upstream calls the architecture, + // which is not always what the host is called. + let url = match &host { + Some(h) => { + let raw = h.split('-').next().unwrap_or(h); + let arch = + p.pkg.arch.get(raw).cloned().unwrap_or_else(|| raw.to_string()); + url.replace("${arch}", &arch) + } + None => url, + }; + out.push(ExtraGap { + host, + path: path.clone(), + url, + to: e.to.clone(), + local, + }); + } } } } @@ -152,14 +186,18 @@ pub async fn digests(client: &reqwest::Client, url: &str) -> Result<(String, Str /// Append resolved side files to a version file. pub fn merge_extras( path: &Path, - new: &[(String, String, String, String)], + new: &[(String, String, Option, String, String)], ) -> Result<(), String> { let raw = fs::read_to_string(path).map_err(|e| e.to_string())?; let mut s = raw.trim_end().to_string(); - for (url, to, b3, sha) in new { - s.push_str(&format!( - "\n\n[[extra]]\nurl = {url:?}\nto = {to:?}\nblake3 = {b3:?}\nsha256 = {sha:?}" - )); + for (url, to, host, b3, sha) in new { + s.push_str("\n\n[[extra]]"); + s.push_str(&format!("\nurl = {url:?}")); + s.push_str(&format!("\nto = {to:?}")); + if let Some(h) = host { + s.push_str(&format!("\nhost = {h:?}")); + } + s.push_str(&format!("\nblake3 = {b3:?}\nsha256 = {sha:?}")); } s.push('\n'); fs::write(path, s).map_err(|e| e.to_string()) diff --git a/src/port/meta.rs b/src/port/meta.rs index def9414..4d579b7 100644 --- a/src/port/meta.rs +++ b/src/port/meta.rs @@ -222,6 +222,9 @@ pub fn generate(root: &Path, host: &str) -> (Vec, Vec) { .extra .iter() .filter(|e| e.blake3.is_some() || e.sha256.is_some()) + // A side file pinned per host belongs only to that host's + // index; one without a host applies to all of them. + .filter(|e| e.host.as_deref().is_none_or(|h| h == host)) .map(|e| ExtraFile { url: e.url.clone(), to: e.to.clone(), diff --git a/src/port/model.rs b/src/port/model.rs index 3d90214..4a54615 100644 --- a/src/port/model.rs +++ b/src/port/model.rs @@ -142,6 +142,11 @@ pub struct Extra { pub struct PinnedExtra { pub url: String, pub to: String, + /// Set when the file differs per host, as an upstream's per-arch binary + /// does. Absent means it applies to every host, which is the case for a + /// licence. + #[serde(default)] + pub host: Option, #[serde(default)] pub blake3: Option, #[serde(default)] From b477e1f19d15b1a622294d9f07d3436f68607499 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 28 Jul 2026 21:59:25 +0545 Subject: [PATCH 3/5] feat(validate): catch one url serving several hosts --- src/port/validate.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/port/validate.rs b/src/port/validate.rs index 9011ffe..8c1a4d7 100644 --- a/src/port/validate.rs +++ b/src/port/validate.rs @@ -73,6 +73,24 @@ pub fn run(root: &Path) -> Report { errors.push(format!("{tag}: hash for {host} with no url")); } } + + // One URL serving several hosts is only right when the artifact + // holds every architecture and the install map picks between them. + // Without that, one architecture is being handed another's binary. + let distinct: std::collections::BTreeSet<&String> = v.url.values().collect(); + if v.url.len() > 1 && distinct.len() == 1 { + let selects_arch = p + .pkg + .source + .as_ref() + .is_some_and(|s| s.install.keys().any(|k| k.contains("${arch}"))); + if !selects_arch { + errors.push(format!( + "{tag}: one url for {} hosts and no ${{arch}} in the install map", + v.url.len() + )); + } + } } } From 629d2446130b808b69542a9e4ce0ea55b5f377d7 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 28 Jul 2026 21:59:25 +0545 Subject: [PATCH 4/5] refactor(meta): stop deriving boilerplate notes --- src/port/meta.rs | 41 +++++++++++------------------------------ 1 file changed, 11 insertions(+), 30 deletions(-) diff --git a/src/port/meta.rs b/src/port/meta.rs index 4d579b7..dc6ae50 100644 --- a/src/port/meta.rs +++ b/src/port/meta.rs @@ -31,6 +31,7 @@ pub struct Entry { pub homepage: Vec, pub license: Vec, pub maintainer: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] pub note: Vec, pub category: Vec, pub provides: Vec, @@ -97,40 +98,21 @@ fn expand_arch(s: &str, version: &str, arch: &str) -> String { s.replace("${version}", version).replace("${arch}", arch) } -/// Rebuild the user-facing note list from the structured fields. +/// Notes a user needs told, and nothing else. /// -/// Notes are presentation, so they are derived here rather than stored once -/// per package in the tree. -fn render_notes(p: &PkgToml, src: &str) -> Vec { - let explicit = &p.pkg.note; - let is_prov = |n: &String| n.starts_with("Official binary from") || n.starts_with("Fetched from"); - - // A package may carry its own provenance wording; it wins over the - // derived line and keeps the leading position. - let mut out: Vec = explicit.iter().filter(|n| is_prov(n)).cloned().collect(); - if out.is_empty() { - out.push(if p.pkg.kind.as_deref() == Some("appimage") { - format!("Fetched from Pre Built Community Created AppImage. Check/Report @ {src}") - } else { - format!("Official binary from {src}") - }); - } - - if p.pkg.portable { - let suffix = if p.pkg.kind.as_deref() == Some("appimage") { - "Works on AnyLinux" - } else { - "Portable Static Binary" - }; - out.push(format!("[PORTABLE] ({suffix})")); - } else { +/// Provenance and portability restate `src_url` and `type`, which the entry +/// already carries, so they are not repeated here as prose. Needing something +/// from the host is the exception: it is a limitation rather than a property, +/// and there is no other field carrying it. +fn render_notes(p: &PkgToml) -> Vec { + let mut out = Vec::new(); + if !p.pkg.portable { out.push(match &p.pkg.portable_reason { Some(why) => format!("[NOT PORTABLE] {why}"), None => "[NOT PORTABLE]".to_string(), }); } - - out.extend(explicit.iter().filter(|n| !is_prov(n)).cloned()); + out.extend(p.pkg.note.iter().cloned()); out } @@ -146,7 +128,6 @@ pub fn generate(root: &Path, host: &str) -> (Vec, Vec) { } let fam = p.pkg.family.clone(); let srcs = p.src_urls(); - let src0 = srcs.first().cloned().unwrap_or_default(); for v in &pkg.versions { let Some(url) = v.url.get(host) else { continue }; @@ -235,7 +216,7 @@ pub fn generate(root: &Path, host: &str) -> (Vec, Vec) { { let prov = provides.clone(); - let mut note = render_notes(p, &src0); + let mut note = render_notes(p); if let Some(n) = ¬e_src { note = n.clone(); } From 7b412ff568d37f25d0f3bf0a8bcacc1ef498179d Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 28 Jul 2026 22:11:32 +0545 Subject: [PATCH 5/5] fix(meta): publish install paths as written --- src/port/meta.rs | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/src/port/meta.rs b/src/port/meta.rs index dc6ae50..3b46a56 100644 --- a/src/port/meta.rs +++ b/src/port/meta.rs @@ -81,18 +81,6 @@ fn is_resource(name: &str) -> bool { matches!(ext.as_deref(), Some("desktop" | "png" | "svg" | "xpm" | "ico")) } -/// Drop a leading archive-root component. -/// -/// Install paths are written against the archive as published, but soar -/// promotes a single top-level directory away before locating binaries. A -/// path with no directory component is already at the root. -fn strip_archive_root(path: &str) -> String { - match path.split_once('/') { - Some((_, rest)) if !rest.is_empty() => rest.to_string(), - _ => path.to_string(), - } -} - /// Expand the two template variables an install path may carry. fn expand_arch(s: &str, version: &str, arch: &str) -> String { s.replace("${version}", version).replace("${arch}", arch) @@ -176,14 +164,16 @@ pub fn generate(root: &Path, host: &str) -> (Vec, Vec) { // The index is generated per host, so templates // are expanded here rather than shipped for the // client to resolve. - // The archive root is promoted away before - // binaries are resolved, so publish the path - // relative to what remains. - source: strip_archive_root(&expand_arch( - from, - &v.version, - &arch_for_host, - )), + // Published as written against the archive. An + // archive with one top-level directory has it + // promoted away before binaries are resolved, so + // the client retries without the leading + // component; stripping it here instead would + // discard the only thing telling two + // architectures apart in a multi-arch archive. + source: expand_arch(from, &v.version, &arch_for_host) + .trim_start_matches("*/") + .to_string(), // Strip soar's provides markers; link_as is a // plain filename. link_as: Some(