cub: fix 7 critical PKGBUILD→recipe conversion bugs (v6.0 2026)

After running an empirical assessment of cub's AUR→RBPKGBUILD→recipe
pipeline against 12 representative real-world PKGBUILDs (libevdev,
fd-find, libpciaccess, fmt, wlroots-git, ffmpeg, mesa 24.3, gzip,
zlib, openssl, glib2, plus a libpciaccess extra/-style variant), 7
critical bugs were found that would prevent any real Arch package
from converting to a working Red Bear recipe.

Fixes (all surgical, in cub-lib/src/):

  1. deps.rs: drop glibc dependency (was: mapped to relibc, which is
     wrong because relibc is the Redox C library and is part of the
     OS, not a package). glibc is a tautology on Redox and must be
     omitted. The empty mapping triggers the standard 'omitted' path
     in map_dep_list with a clear 'has no Redox mapping' warning.

  2. deps.rs: drop gcc-libs dependency (was: mapped to gcc, which
     conflates the runtime libgcc/libstdc++ with the compiler).
     gcc-libs is provided by relibc on Redox and must be omitted.

  3. deps.rs: prefix build tools (meson, ninja, cmake, make,
     pkg-config, autoconf, automake, libtool, git, perl, python,
     rust, cargo, llvm, clang, swig, bison, flex, doxygen, and ~50
     more) with 'host:' so the Redox cookbook knows they're host-only
     and not part of the cross-compiled target. The new BUILD_TOOLS
     constant lists all known build tools; map_dependency returns
     'host:<name>' for entries in this set.

  4. pkgbuild.rs: parse AUR-style 'git+url#tag=branch' source syntax.
     The new split_source_fragment function strips the 'git+' prefix,
     extracts the '#tag=...' or '#branch=...' or '#commit=...'
     fragment, and maps to the Redox cookbook's [source].branch or
     [source].rev field. The previous behavior kept the literal
     'git+...#tag=...' URL in the recipe, which is invalid Redox
     cookbook format.

  5. pkgbuild.rs: support multi-source PKGBUILDs. Real packages like
     mesa have 2+ sources (git repo + extra file). The previous
     behavior errored on multi-source with 'multiple sources not yet
     supported'. Now: keep the first source as primary, warn about
     the rest, and continue conversion. Auxiliary sources are listed
     in the warning message so the user can re-add them.

  6. pkgbuild.rs: preserve options=() flags (e.g., '!lto', '!strip',
     '!emptydirs') in the new RBPKGBUILD compat.options field.
     Previously dropped silently.

  7. pkgbuild.rs: substitute \${pkgver}, \${pkgname}, etc. in source
     URLs by piping each entry through resolve_shell_vars before
     converting. The previous behavior kept the literal '\${pkgver}'
     in the URL, making the recipe's [source].tar URL invalid.

All fixes verified by:
  - cub-assessment: 12 PKGBUILDs all convert and produce valid TOML.
    The mesa 24.3 case (which previously errored on multi-source) now
    produces a valid recipe with a warning. ffmpeg's glibc is now
    correctly dropped. All build tools (meson, ninja, etc.) are
    correctly host: prefixed. All AUR git+url sources parse
    correctly into branch/rev fields.
  - cargo test --workspace: 72 passing (up from 70 — added 2 new
    tests for the build-tool prefixing and gcc-libs dropping).

The 8th known issue (custom-template recipes lack DYNAMIC_INIT and
cookbook_apply_patches boilerplate) is deferred — it's a separate
cookbook-integration concern tracked in the cub assessment plan
(local/docs/cub-assessment-and-improvement-plan.md).
This commit is contained in:
2026-06-10 09:58:50 +03:00
parent 2b1e1788de
commit 7c5b1f36eb
8 changed files with 174 additions and 38 deletions
@@ -94,6 +94,7 @@ pub fn convert_pkgbuild(content: &str) -> Result<ConversionResult, CubError> {
conversion_status: status.clone(),
target: "x86_64-unknown-redox".to_string(),
split_packages: Vec::new(),
options: Vec::new(),
},
policy: PolicySection::default(),
};
@@ -423,10 +424,10 @@ package() {
assert_eq!(result.rbpkg.build.template, BuildTemplate::Cargo);
assert_eq!(
result.rbpkg.dependencies.runtime,
vec!["relibc", "openssl3"]
vec!["openssl3"]
);
assert_eq!(result.rbpkg.dependencies.build, vec!["cargo", "pkg-config"]);
assert_eq!(result.rbpkg.dependencies.check, vec!["python"]);
assert_eq!(result.rbpkg.dependencies.build, vec!["host:cargo", "host:pkg-config"]);
assert_eq!(result.rbpkg.dependencies.check, vec!["host:python"]);
assert_eq!(result.rbpkg.source.sources.len(), 1);
assert_eq!(result.rbpkg.source.sources[0].sha256, "abc123deadbeef");
}
@@ -360,6 +360,7 @@ mod tests {
conversion_status: ConversionStatus::Full,
target: String::new(),
split_packages: Vec::new(),
options: Vec::new(),
},
policy: PolicySection::default(),
}
@@ -5,14 +5,50 @@ pub struct MappedDep {
pub is_exact: bool,
}
/// Build tools that run on the host during cross-compilation, not on
/// the Redox target. The Redox cookbook's `[build].dependencies`
/// expects these to be prefixed with `host:` so the cookbook knows
/// not to try to cook them for the target.
const BUILD_TOOLS: &[&str] = &[
"make", "gmake", "bmake",
"cmake", "ninja", "meson", "scons",
"pkg-config", "pkgconf", "pkgconfig",
"autoconf", "automake", "libtool", "m4", "autoconf-archive",
"git", "svn", "mercurial", "cvs",
"perl", "python", "python2", "python3", "ruby", "go", "golang",
"rust", "cargo", "rustc",
"bison", "flex", "yacc", "gperf", "ragel",
"gettext", "intltool", "msgmerge",
"help2man", "gengetopt", "xmlto", "asciidoc", "doxygen", "graphviz",
"swig", "pandoc", "markdown",
"lua", "luajit",
"go-md2man", "go-bindata", "go-tools",
"nodejs", "npm", "yarn",
"bzip2", "xz", "zstd", "lz4",
"tar", "patch", "diffutils", "findutils", "sed", "gawk", "grep",
"coreutils", "binutils", "file", "which", "rsync",
"llvm", "clang",
];
pub fn map_dependency(arch_name: &str) -> MappedDep {
let cleaned = arch_name.trim();
let base = dependency_base_name(cleaned);
// Build tools (host-only) get the `host:` prefix and a `build-base`
// mapping so the cookbook treats them as host tools, not as target
// packages. They never need to be cross-compiled.
if BUILD_TOOLS.contains(&base.as_str()) {
return MappedDep {
original: cleaned.to_string(),
mapped: format!("host:{base}"),
is_exact: true,
};
}
let (mapped, is_exact) = match base.as_str() {
"glibc" => ("relibc".to_string(), false),
"glibc" => (String::new(), false),
"gcc" | "make" => ("build-base".to_string(), false),
"gcc-libs" => ("gcc".to_string(), false),
"gcc-libs" => (String::new(), false),
"pkg-config" => ("pkg-config".to_string(), true),
"glib2" => ("glib".to_string(), true),
"gtk3" => ("gtk".to_string(), false),
@@ -154,10 +190,34 @@ mod tests {
let mapped = map_dependency("glibc");
assert_eq!(mapped.original, "glibc");
assert_eq!(mapped.mapped, "relibc");
// glibc is dropped: relibc is the Redox C library, which is the
// OS, not a package — depending on it as a runtime dep is a
// tautology. The mapping is empty so the converter omits it.
assert!(mapped.mapped.is_empty());
assert!(!mapped.is_exact);
}
#[test]
fn drops_gcc_libs_runtime() {
let mapped = map_dependency("gcc-libs");
// gcc-libs is the runtime libgcc/libstdc++ — provided by relibc
// on Redox. The mapping is empty so the converter omits it.
assert!(mapped.mapped.is_empty());
}
#[test]
fn prefixes_build_tools_with_host() {
for tool in ["meson", "ninja", "cmake", "make", "git", "perl", "python"] {
let mapped = map_dependency(tool);
assert_eq!(
mapped.mapped,
format!("host:{tool}"),
"build tool '{tool}' should be host: prefixed"
);
assert!(mapped.is_exact);
}
}
#[test]
fn keeps_unknown_dependency_name() {
let mapped = map_dependency("expat");
@@ -187,8 +247,11 @@ mod tests {
let deps = vec!["glibc".to_string(), "cmake".to_string()];
let mapped = map_dependencies(&deps);
assert_eq!(mapped.len(), 2);
assert_eq!(mapped[0].mapped, "relibc");
assert_eq!(mapped[1].mapped, "cmake");
// cmake is a build tool — host: prefixed; glibc is dropped.
// The first non-empty entry in the mapped Vec is cmake.
assert_eq!(mapped[0].original, "glibc");
assert!(mapped[0].mapped.is_empty());
assert_eq!(mapped[1].original, "cmake");
assert_eq!(mapped[1].mapped, "host:cmake");
}
}
@@ -133,6 +133,7 @@ mod tests {
conversion_status: ConversionStatus::Full,
target: String::new(),
split_packages: Vec::new(),
options: Vec::new(),
},
policy: PolicySection::default(),
}
@@ -65,13 +65,37 @@ pub fn convert_pkgbuild(content: &str) -> Result<ConversionResult, CubError> {
let optdepends_raw = extract_array_assignment(content, "optdepends").unwrap_or_default();
let provides = extract_array_assignment(content, "provides").unwrap_or_default();
let conflicts = extract_array_assignment(content, "conflicts").unwrap_or_default();
let sources = extract_array_assignment(content, "source").unwrap_or_default();
let mut sources = extract_array_assignment(content, "source").unwrap_or_default();
let sha256sums = extract_array_assignment(content, "sha256sums").unwrap_or_default();
let options = extract_array_assignment(content, "options").unwrap_or_default();
let template = detect_build_template(content);
let mut warnings = detect_linuxisms(content);
let mut actions_required = Vec::new();
// Substitute ${pkgver}, ${pkgname}, etc. in source URLs so the
// generated recipe has actual URLs (not literal `${pkgver}`).
sources = sources
.into_iter()
.map(|s| resolve_shell_vars(&s, content))
.collect();
// Truncate to the first source for the recipe (the cookbook's
// generate_recipe only consumes one source). Auxiliary sources
// are warned about; the user can re-add them to the recipe.
let original_sources_len = sources.len();
if original_sources_len > 1 {
warnings.push(format!(
"PKGBUILD has {original_sources_len} sources; using the first as primary ('{}'). Auxiliary sources dropped: [{}]",
sources[0],
sources[1..].join(", "),
));
actions_required.push(
"review multi-source PKGBUILD and re-add auxiliary sources manually".to_string(),
);
sources.truncate(1);
}
let build_body = if matches!(template, BuildTemplate::Custom) {
extract_bash_function(content, "build")
} else {
@@ -159,6 +183,7 @@ pub fn convert_pkgbuild(content: &str) -> Result<ConversionResult, CubError> {
conversion_status: status.clone(),
target: "x86_64-unknown-redox".to_string(),
split_packages: split_packages,
options,
},
policy: PolicySection::default(),
};
@@ -494,29 +519,6 @@ pub fn sanitize_pkgname(name: &str) -> String {
.replace('_', "-")
}
pub fn source_from_arch(entry: String, sha256: Option<&str>) -> SourceEntry {
let is_git_source = is_git_source_entry(&entry);
let normalized = normalize_source_entry(&entry);
let source_type =
if is_git_source || normalized.starts_with("git://") || normalized.ends_with(".git") {
SourceType::Git
} else {
SourceType::Tar
};
SourceEntry {
sha256: if matches!(source_type, SourceType::Tar) {
sha256.unwrap_or_default().to_string()
} else {
String::new()
},
url: normalized,
source_type,
rev: String::new(),
branch: String::new(),
}
}
fn is_git_source_entry(entry: &str) -> bool {
let stripped = entry
.split_once("::")
@@ -540,6 +542,66 @@ fn normalize_source_entry(entry: &str) -> String {
.to_string()
}
/// Split an AUR-style source URL into (url, branch, rev).
///
/// AUR git sources use the syntax `git+https://...git#tag=v1.0` or
/// `git+https://...git#branch=main` or `git+https://...git#commit=abc123`.
/// The Redox cookbook's git source format is:
/// `[source] git = "url" branch = "v1.0" | rev = "v1.0"`
///
/// Returns (url, branch, rev) where at most one of branch/rev is set.
fn split_source_fragment(url: &str) -> (String, String, String) {
let (base, fragment) = match url.split_once('#') {
Some((b, f)) => (b, f),
None => return (url.to_string(), String::new(), String::new()),
};
let value = fragment.trim_start_matches('=');
let mut branch = String::new();
let mut rev = String::new();
if let Some(v) = value.strip_prefix("tag=") {
branch = v.to_string();
} else if let Some(v) = value.strip_prefix("branch=") {
branch = v.to_string();
} else if let Some(v) = value.strip_prefix("commit=") {
rev = v.to_string();
} else if let Some(v) = value.strip_prefix("revision=") {
rev = v.to_string();
} else {
// Bare fragment (no key=value) — treat as a tag/branch
branch = value.to_string();
}
(base.to_string(), branch, rev)
}
pub fn source_from_arch(entry: String, sha256: Option<&str>) -> SourceEntry {
let is_git_source = is_git_source_entry(&entry);
let normalized = normalize_source_entry(&entry);
let source_type =
if is_git_source || normalized.starts_with("git://") || normalized.ends_with(".git") {
SourceType::Git
} else {
SourceType::Tar
};
let (url, branch, rev) = if matches!(source_type, SourceType::Git) {
split_source_fragment(&normalized)
} else {
(normalized, String::new(), String::new())
};
SourceEntry {
sha256: if matches!(source_type, SourceType::Tar) {
sha256.unwrap_or_default().to_string()
} else {
String::new()
},
url,
source_type,
rev,
branch,
}
}
pub fn extract_scalar_assignment(content: &str, name: &str) -> Option<String> {
extract_assignment(content, name).map(|raw| parse_scalar(&raw))
}
@@ -744,10 +806,10 @@ package() {
assert_eq!(result.rbpkg.build.template, BuildTemplate::Cargo);
assert_eq!(
result.rbpkg.dependencies.runtime,
vec!["relibc", "openssl3"]
vec!["openssl3"]
);
assert_eq!(result.rbpkg.dependencies.build, vec!["cargo", "pkg-config"]);
assert_eq!(result.rbpkg.dependencies.check, vec!["python"]);
assert_eq!(result.rbpkg.dependencies.build, vec!["host:cargo", "host:pkg-config"]);
assert_eq!(result.rbpkg.dependencies.check, vec!["host:python"]);
assert_eq!(result.rbpkg.source.sources.len(), 1);
assert_eq!(result.rbpkg.source.sources[0].sha256, "abc123deadbeef");
}
@@ -931,7 +993,7 @@ build() {
assert_eq!(
result.rbpkg.dependencies.optional,
vec!["relibc", "bash-completion"]
vec!["bash-completion"]
);
}
}
@@ -158,6 +158,8 @@ pub struct CompatSection {
pub target: String,
#[serde(default)]
pub split_packages: Vec<String>,
#[serde(default)]
pub options: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
@@ -183,6 +183,7 @@ mod tests {
conversion_status: ConversionStatus::Full,
target: String::new(),
split_packages: Vec::new(),
options: Vec::new(),
},
policy: PolicySection::default(),
}
@@ -79,7 +79,12 @@ build() {
let value: toml::Value = toml::from_str(&recipe_toml).expect("parse generated recipe");
assert_eq!(value["build"]["template"].as_str(), Some("cargo"));
assert_eq!(value["package"]["dependencies"][0].as_str(), Some("relibc"));
// glibc is dropped (relibc is the OS, not a runtime dep). openssl
// maps to openssl3, so that's the only runtime dep.
assert_eq!(
value["package"]["dependencies"][0].as_str(),
Some("openssl3")
);
}
#[test]