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
6 changes: 5 additions & 1 deletion src/port/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
}
}
Expand Down
100 changes: 37 additions & 63 deletions src/port/meta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,23 +56,33 @@ pub struct Entry {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub note: Vec<String>,
pub category: Vec<String>,
pub provides: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub repology: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub shasum: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bsum: Option<String>,
/// 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<Binary>,
/// 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<ExtraFile>,
/// 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<FileMapping>,
}

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

/// A pinned side file as published in the index.
Expand All @@ -86,24 +96,6 @@ pub struct ExtraFile {
pub sha256: Option<String>,
}

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

/// 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)
Expand Down Expand Up @@ -145,7 +137,6 @@ pub fn generate(root: &Path, host: &str) -> (Vec<Entry>, Vec<String>) {

// 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();
Expand All @@ -165,45 +156,30 @@ pub fn generate(root: &Path, host: &str) -> (Vec<Entry>, Vec<String>) {
};
// Licences and docs are not executables, and a file already at
// the archive root under its own name needs no mapping.
let binaries: Vec<Binary> = 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<FileMapping> = 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/<name>.
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()
})
Expand All @@ -224,7 +200,6 @@ pub fn generate(root: &Path, host: &str) -> (Vec<Entry>, Vec<String>) {
.collect();

{
let prov = provides.clone();
let mut note = render_notes(p);
if let Some(n) = &note_src {
note = n.clone();
Expand All @@ -246,12 +221,11 @@ pub fn generate(root: &Path, host: &str) -> (Vec<Entry>, Vec<String>) {
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(),
});
}
}
Expand Down
105 changes: 103 additions & 2 deletions src/port/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,110 @@ pub struct Source {
pub r#match: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
/// 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<String, String>,
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<String, String>),
Long(Vec<InstallEntry>),
}

impl Default for InstallSpec {
fn default() -> Self {
Self::Long(Vec::new())
}
}

impl InstallSpec {
/// The entries, however they were written.
pub fn entries(&self) -> Vec<InstallEntry> {
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<String>,
/// Where it lands inside the package directory. Defaults to `bin/` plus
/// the file's own name.
pub to: Option<String>,
/// 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<String>,
}

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<String> {
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
Expand Down
1 change: 0 additions & 1 deletion src/port/new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
32 changes: 31 additions & 1 deletion src/port/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -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",
Expand Down
Loading