diff --git a/src/port/audit.rs b/src/port/audit.rs index 699e22e..3bb05cf 100644 --- a/src/port/audit.rs +++ b/src/port/audit.rs @@ -167,7 +167,11 @@ pub async fn run( url: url.clone(), version: v.version.clone(), arch: p.pkg.arch.get(raw).cloned().unwrap_or_else(|| raw.to_string()), - install: src.install.keys().cloned().collect(), + install: src.install + .entries() + .iter() + .filter_map(|e| e.from.clone()) + .collect(), }); } } diff --git a/src/port/meta.rs b/src/port/meta.rs index fb3617f..e7de5b4 100644 --- a/src/port/meta.rs +++ b/src/port/meta.rs @@ -56,23 +56,33 @@ pub struct Entry { #[serde(skip_serializing_if = "Vec::is_empty")] pub note: Vec, pub category: Vec, - pub provides: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] pub repology: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub shasum: Option, #[serde(skip_serializing_if = "Option::is_none")] pub bsum: Option, - /// Where each executable lives inside the artifact. Only emitted when a - /// file has to be renamed or is not at the archive root, since soar - /// otherwise finds it by package name. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub binaries: Vec, /// Side files to install alongside the artifact, typically a licence the /// artifact itself does not carry. Each carries a hash unless its recipe /// opted out, which licences do because they are served from a branch. #[serde(skip_serializing_if = "Vec::is_empty")] pub extra: Vec, + /// Everything the package installs out of its artifact, as archive path to + /// installed name. Empty means the recipe named nothing, so the whole + /// artifact is the package. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub files: Vec, +} + +/// One file taken out of the artifact, as published in the index. +#[derive(Debug, Clone, Serialize)] +pub struct FileMapping { + pub source: String, + pub to: String, + /// Extra paths, relative to the package directory, that resolve to this + /// same file. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub alias: Vec, } /// A pinned side file as published in the index. @@ -86,24 +96,6 @@ pub struct ExtraFile { pub sha256: Option, } -/// One executable inside the artifact, as published in the index. -#[derive(Debug, Clone, Serialize)] -pub struct Binary { - pub source: String, - #[serde(skip_serializing_if = "Option::is_none")] - 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")) -} - /// 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) @@ -145,7 +137,6 @@ pub fn generate(root: &Path, host: &str) -> (Vec, Vec) { // The version file may override pkg.toml; these are the fields // that realistically change between releases. - let provides = v.provides.clone().unwrap_or_else(|| p.pkg.provides.clone()); let note_src = v.note.clone(); let size = v.size.get(host).copied(); @@ -165,45 +156,30 @@ pub fn generate(root: &Path, host: &str) -> (Vec, Vec) { }; // Licences and docs are not executables, and a file already at // the archive root under its own name needs no mapping. - let binaries: Vec = p + + // The recipe's install map in full, not just its executables. A + // recipe naming `*` means the whole artifact, which is published + // as no mapping at all rather than as a wildcard to interpret. + // The recipe's install list, expanded for this host. Link names come + // from the entry itself rather than being inferred from a name. + let files: Vec = p .source .as_ref() .map(|src| { - src.install + src.install.entries() .iter() - .filter(|(from, to)| { - let base = from.rsplit('/').next().unwrap_or(from); - // An entry is worth publishing when the file has - // to be renamed, or when it is nested rather than - // at the artifact root: soar looks at the root by - // package name and would not find bin/. - let nested = from.trim_start_matches("*/").contains('/'); - (base != *to || nested) - && !to.eq_ignore_ascii_case("LICENSE") - && !is_resource(to) - && *from != "*" - }) - .map(|(from, to)| Binary { - // The index is generated per host, so templates - // are expanded here rather than shipped for the - // client to resolve. - // 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( - to.split("==").next().unwrap_or(to) - .split("=>").next().unwrap_or(to) - .trim().to_string(), - ), + .map(|e| FileMapping { + source: e + .from + .as_deref() + .map(|f| { + expand_arch(f, &v.version, &arch_for_host) + .trim_start_matches("*/") + .to_string() + }) + .unwrap_or_default(), + to: e.target(), + alias: e.aliases(), }) .collect() }) @@ -224,7 +200,6 @@ pub fn generate(root: &Path, host: &str) -> (Vec, Vec) { .collect(); { - let prov = provides.clone(); let mut note = render_notes(p); if let Some(n) = ¬e_src { note = n.clone(); @@ -246,12 +221,11 @@ pub fn generate(root: &Path, host: &str) -> (Vec, Vec) { maintainer: p.pkg.maintainer.clone(), note, category: p.pkg.category.clone(), - provides: prov, repology: p.pkg.repology.clone(), shasum: shasum.clone(), bsum: bsum.clone(), - binaries: binaries.clone(), extra: extras.clone(), + files: files.clone(), }); } } diff --git a/src/port/model.rs b/src/port/model.rs index 4090280..f643c52 100644 --- a/src/port/model.rs +++ b/src/port/model.rs @@ -105,9 +105,110 @@ pub struct Source { pub r#match: Vec, #[serde(default)] pub exclude: Vec, - /// Archive path to output path. + /// What the package takes out of its artifact. Empty means the whole + /// artifact is the package. #[serde(default)] - pub install: BTreeMap, + pub install: InstallSpec, +} + +/// The install list, in either form a recipe may write it. +/// +/// Most entries are just a path and where it lands, so they read better as +/// `"from" = "to"`. The long form exists for what that cannot say: aliases, +/// and an artifact that is the file itself. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum InstallSpec { + Short(BTreeMap), + Long(Vec), +} + +impl Default for InstallSpec { + fn default() -> Self { + Self::Long(Vec::new()) + } +} + +impl InstallSpec { + /// The entries, however they were written. + pub fn entries(&self) -> Vec { + match self { + Self::Long(entries) => entries + .iter() + .map(|e| InstallEntry { + from: e.from.clone(), + to: e.to.clone(), + symlink_as: e.symlink_as.clone(), + }) + .collect(), + Self::Short(map) => map + .iter() + .map(|(from, to)| InstallEntry { + from: Some(from.clone()), + to: Some(to.clone()), + symlink_as: Vec::new(), + }) + .collect(), + } + } + + pub fn is_empty(&self) -> bool { + match self { + Self::Long(e) => e.is_empty(), + Self::Short(m) => m.is_empty(), + } + } +} + +/// One file the package installs. +#[derive(Debug, Clone, Deserialize)] +pub struct InstallEntry { + /// Path inside the artifact. Absent when the artifact is the file itself, + /// as a bare binary is. + pub from: Option, + /// Where it lands inside the package directory. Defaults to `bin/` plus + /// the file's own name. + pub to: Option, + /// Extra names for the same file, created beside `to`. Where they end up + /// on the system follows from the directory, the same way `to` does: an + /// alias beside `bin/dunstctl` is another command, one beside a man page + /// is another man page. + #[serde(default)] + pub symlink_as: Vec, +} + +impl InstallEntry { + /// The install path, resolved against the default. + pub fn target(&self) -> String { + if let Some(to) = &self.to { + return to.clone(); + } + let name = self + .from + .as_deref() + .unwrap_or_default() + .rsplit('/') + .next() + .unwrap_or_default(); + format!("bin/{name}") + } + + /// Paths, relative to the package directory, that also resolve to this + /// file. Each is a sibling of `to`, so it inherits the same meaning. + pub fn aliases(&self) -> Vec { + let target = self.target(); + let dir = target.rsplit_once('/').map(|(d, _)| d).unwrap_or(""); + self.symlink_as + .iter() + .map(|name| { + if dir.is_empty() { + name.clone() + } else { + format!("{dir}/{name}") + } + }) + .collect() + } } /// A single template, or one URL per host when upstream filenames differ diff --git a/src/port/new.rs b/src/port/new.rs index a21b70a..7cc2b37 100644 --- a/src/port/new.rs +++ b/src/port/new.rs @@ -69,7 +69,6 @@ pub fn render(s: &Scaffold) -> String { out.push_str("category = [\"TODO\"]\n"); out.push_str("tag = [\"TODO\"]\n"); out.push_str("repology = [\"TODO\"]\n"); - out.push_str(&format!("provides = {}\n", arr(&[s.name.to_string()]))); out.push_str("\n[host]\nsupported = [\"x86_64-linux\", \"aarch64-linux\"]\n"); diff --git a/src/port/validate.rs b/src/port/validate.rs index 8c1a4d7..ff21d1c 100644 --- a/src/port/validate.rs +++ b/src/port/validate.rs @@ -30,6 +30,32 @@ pub fn run(root: &Path) -> Report { errors.push(format!("{name}: pkg.description missing")); } + // An install target names a path inside the package directory, so it + // must stay inside it, and the prefix decides where soar links the + // file: a typo in `share/man` silently installs somewhere nothing reads. + if let Some(src) = &p.pkg.source { + for entry in &src.install.entries() { + let to = &entry.target(); + if to.starts_with('/') || to.starts_with('~') { + errors.push(format!("{name}: install target {to:?} is not relative")); + continue; + } + if to.split('/').any(|c| c == ".." || c == ".") { + errors.push(format!("{name}: install target {to:?} escapes the package")); + continue; + } + if let Some((prefix, _)) = to.split_once('/') { + const KNOWN: [&str; 2] = ["bin", "share"]; + if !KNOWN.contains(&prefix) { + warnings.push(format!( + "{name}: install target {to:?} starts with {prefix:?}, \ + which soar does not link anywhere" + )); + } + } + } + } + if p.pkg.pkg.disabled { if p.pkg.pkg.disabled_reason.is_none() { warnings.push(format!("{name}: disabled without a reason")); @@ -83,7 +109,11 @@ pub fn run(root: &Path) -> Report { .pkg .source .as_ref() - .is_some_and(|s| s.install.keys().any(|k| k.contains("${arch}"))); + .is_some_and(|s| { + s.install.entries() + .iter() + .any(|e| e.from.as_deref().is_some_and(|f| f.contains("${arch}"))) + }); if !selects_arch { errors.push(format!( "{tag}: one url for {} hosts and no ${{arch}} in the install map",