Bug que la granja destapó: el import de Alpine de crates Rust emitía cargo/cargo- auditable como [deps] build (el path nix ya los filtra, el Alpine no) -> la receta abortaba buscando cargo.toml. Fix raíz en alpine_import.rs: normalize() filtra el TOOLCHAIN (cargo/cargo-auditable/rust/rustc/go/make/cmake/meson/ninja) — lo provee el lab (BuildSys::Cargo + detectores), no es un paquete a materializar. xsv 0.13.0 reescrito como receta Cargo LIMPIA (sin las fases 'cargo auditable build' del abuild que pelean con el flujo del lab, sin deps de toolchain): el lab autodetecta Cargo. Construye+corre estático (computa stats CSV). Publicado al repo (85).
392 lines
16 KiB
Rust
392 lines
16 KiB
Rust
//! Importador `Alpine APKBUILD → receta hammer` (Etapa G, fuente #2).
|
|
//!
|
|
//! **Por qué Alpine y no sólo nix:** Alpine ya porta miles de paquetes a **musl** (y a menudo
|
|
//! estático), CON los parches de portabilidad. Eso es justo lo que un import de nixpkgs PIERDE (los
|
|
//! parches de nix viven aparte y asumen glibc). Por eso Alpine es la mejor respuesta a "¿qué si el
|
|
//! build falla en musl?": su APKBUILD trae el `source=` con los `.patch` que lo hacen compilar.
|
|
//!
|
|
//! Este importador PARSEA el APKBUILD (no lo ejecuta — es shell; sourcearlo correría código ajeno)
|
|
//! y extrae: pkgname/pkgver, la URL de la fuente, **los `.patch` (los carga a `source.patches`)**,
|
|
//! makedepends → deps, y `build()/package()` → fases (best-effort: son shell de abuild con CHOST/
|
|
//! --shared/etc. ⇒ necesitan adaptación al lab estático de hammer). Es un PUNTO DE PARTIDA, pero
|
|
//! uno que YA incluye el trabajo de musl — mucho más cerca de compilar que un import crudo.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
/// Convierte el texto de un APKBUILD en el TOML de una receta hammer.
|
|
pub fn recipe_from_apkbuild(text: &str) -> Result<String, String> {
|
|
let vars = parse_vars(text);
|
|
let get = |k: &str| vars.get(k).cloned().unwrap_or_default();
|
|
|
|
let pkgname = get("pkgname");
|
|
if pkgname.is_empty() {
|
|
return Err("APKBUILD sin pkgname".into());
|
|
}
|
|
let pkgver = get("pkgver");
|
|
let version = if pkgver.is_empty() { "0".into() } else { pkgver };
|
|
|
|
// source=: tokens separados por espacios/saltos. Cada uno: `[name::]url-o-fichero`.
|
|
// El primero con esquema (://) es el tarball; los `.patch` se cargan como parches.
|
|
let mut tarball = String::new();
|
|
let mut patches: Vec<String> = Vec::new();
|
|
for tok in get("source").split_whitespace() {
|
|
let (name, url) = match tok.split_once("::") {
|
|
Some((n, u)) => (n.to_string(), Some(u.to_string())),
|
|
None => {
|
|
let base = tok.rsplit('/').next().unwrap_or(tok).to_string();
|
|
let url = if tok.contains("://") { Some(tok.to_string()) } else { None };
|
|
(base, url)
|
|
}
|
|
};
|
|
if name.ends_with(".patch") || name.ends_with(".diff") {
|
|
patches.push(name);
|
|
} else if tarball.is_empty() {
|
|
if let Some(u) = url {
|
|
tarball = u;
|
|
}
|
|
}
|
|
}
|
|
|
|
let source_block = if tarball.is_empty() {
|
|
// Sin tarball (paquetes git-based usan $_commit + snapshot); dejamos un FIXME claro.
|
|
"# FIXME: no detecté tarball; fijá `tarball+sha256` o `repo+commit` a mano\ntarball = \"\"\nsha256 = \"\"\n".to_string()
|
|
} else {
|
|
let sha512 = first_sha(&get("sha512sums"));
|
|
format!(
|
|
"tarball = \"{tarball}\"\n\
|
|
# FIXME sha256: el wrapper lo calcula (Alpine publica sha512). sha512 de Alpine:\n\
|
|
# sha512 = \"{sha512}\"\n\
|
|
sha256 = \"FIXME-sha256\"\n"
|
|
)
|
|
};
|
|
|
|
// build-deps: SÓLO `makedepends`. El `depends` de abuild es RUNTIME (p.ej. gzip depends="less"
|
|
// para `zless`) — no hace falta para compilar y rompería `hammer build` (busca recipes/less.toml).
|
|
// Lo emitimos como comentario de provenance, no como `[deps] build`. (checkdepends ni se lee: no
|
|
// corremos la suite de tests del paquete.)
|
|
let self_prefix = format!("{pkgname}-");
|
|
let normalize = |src: &str| -> Vec<String> {
|
|
let mut v: Vec<String> = Vec::new();
|
|
for d in src.split_whitespace() {
|
|
let d = d.trim();
|
|
// Saltamos: negados (!x), variables sin expandir ($x), pins versionados a subpaquetes
|
|
// (`foo=1.2-r3`), y auto-referencias al propio paquete/subpaquetes (`pkgname-...`).
|
|
if d.is_empty()
|
|
|| d.starts_with('!')
|
|
|| d.starts_with('$')
|
|
|| d.contains('=')
|
|
|| d.starts_with(&self_prefix)
|
|
|| d == pkgname
|
|
{
|
|
continue;
|
|
}
|
|
// Alpine usa `foo-dev`, `cmd:make`, `pc:libfoo`, `so:libbar.so` — normalizamos lo común.
|
|
let name = d
|
|
.strip_prefix("cmd:")
|
|
.or_else(|| d.strip_prefix("pc:"))
|
|
.unwrap_or(d)
|
|
.trim_end_matches("-dev")
|
|
.to_string();
|
|
// El TOOLCHAIN no es un paquete a materializar: lo provee el lab (cargo/rustc = el flujo
|
|
// BuildSys::Cargo; make/cmake/meson = los detectores). Una receta que los pusiera en
|
|
// `[deps] build` abortaría buscando `cargo.toml`. El path nix ya los filtra; acá igual.
|
|
const TOOLCHAIN: &[&str] = &[
|
|
"cargo", "cargo-auditable", "rust", "rustc", "go", "make", "cmake", "meson", "ninja",
|
|
];
|
|
if !name.is_empty()
|
|
&& !name.starts_with("so:")
|
|
&& !TOOLCHAIN.contains(&name.as_str())
|
|
&& !v.contains(&name)
|
|
{
|
|
v.push(name);
|
|
}
|
|
}
|
|
v
|
|
};
|
|
let deps = normalize(&get("makedepends"));
|
|
let runtime = normalize(&get("depends"));
|
|
let mut deps_block = String::new();
|
|
if !runtime.is_empty() {
|
|
deps_block.push_str(&format!("\n# depends de runtime de Alpine (NO build-deps): {}\n", runtime.join(", ")));
|
|
}
|
|
if !deps.is_empty() {
|
|
let list = deps.iter().map(|d| format!("\"{d}\"")).collect::<Vec<_>>().join(", ");
|
|
deps_block.push_str(&format!("\n[deps]\nbuild = [{list}]\n"));
|
|
}
|
|
|
|
// build()/package() → fases (best-effort, comentadas como abuild-shell a adaptar).
|
|
let phases_block = {
|
|
let compile = func_body(text, "build");
|
|
let install = func_body(text, "package");
|
|
if compile.is_none() && install.is_none() {
|
|
String::new()
|
|
} else {
|
|
let mut s = String::from("\n[build.phases]\n");
|
|
if let Some(b) = compile {
|
|
let t = translate_abuild(&b, &pkgname, &version);
|
|
s.push_str(&format!("# de build() de Alpine (traducido; el lab provee $CBUILD/$CHOST — Etapa G Fase 3; revisá --shared para estático):\ncompile = {}\n", toml_multiline(&t)));
|
|
}
|
|
if let Some(p) = install {
|
|
let t = translate_abuild(&p, &pkgname, &version);
|
|
s.push_str(&format!("# de package() de Alpine (traducido $pkgdir→/out):\ninstall = {}\n", toml_multiline(&t)));
|
|
}
|
|
s
|
|
}
|
|
};
|
|
|
|
let patches_line = if patches.is_empty() {
|
|
String::new()
|
|
} else {
|
|
let list = patches.iter().map(|p| format!("\"{p}\"")).collect::<Vec<_>>().join(", ");
|
|
format!("patches = [{list}]\n")
|
|
};
|
|
|
|
let toml = format!(
|
|
"# Importada de Alpine aports por `hammer import-alpine` (Etapa G). PUNTO DE PARTIDA — pero\n\
|
|
# YA trae los parches de musl de Alpine (lo que un import de nix pierde). Pendiente: el\n\
|
|
# sha256 del tarball (el wrapper lo calcula), y adaptar build/install del shell de abuild.\n\
|
|
name = \"{pkgname}\"\n\
|
|
version = \"{version}\"\n\
|
|
\n\
|
|
[source]\n\
|
|
{source_block}{patches_line}\
|
|
\n\
|
|
[build]\n\
|
|
compiler = \"zig-cc\"\n\
|
|
target = \"x86_64-linux-musl\"\n\
|
|
link = \"static\"\n\
|
|
flags = []\n\
|
|
{phases_block}{deps_block}"
|
|
);
|
|
Ok(toml)
|
|
}
|
|
|
|
/// Recoge asignaciones `clave=valor` y `clave="...multilínea..."` del APKBUILD (ignora funciones y
|
|
/// comentarios). Suficiente para los campos de metadatos; expande `$var`/`${var}` conocidas.
|
|
fn parse_vars(text: &str) -> BTreeMap<String, String> {
|
|
let mut raw: BTreeMap<String, String> = BTreeMap::new();
|
|
let bytes: Vec<&str> = text.lines().collect();
|
|
let mut i = 0;
|
|
while i < bytes.len() {
|
|
let line = bytes[i];
|
|
let trimmed = line.trim_start();
|
|
// Asignación simple `key=...` al inicio de línea (no dentro de función: heurística por '=').
|
|
if let Some(eq) = simple_assign(trimmed) {
|
|
let key = trimmed[..eq].to_string();
|
|
let mut val = trimmed[eq + 1..].to_string();
|
|
// Valor multilínea entre comillas dobles: acumular hasta cerrar.
|
|
if let Some(rest) = val.strip_prefix('"') {
|
|
if let Some(end) = rest.find('"') {
|
|
val = rest[..end].to_string();
|
|
} else {
|
|
let mut acc = rest.to_string();
|
|
i += 1;
|
|
while i < bytes.len() {
|
|
if let Some(end) = bytes[i].find('"') {
|
|
acc.push('\n');
|
|
acc.push_str(&bytes[i][..end]);
|
|
break;
|
|
} else {
|
|
acc.push('\n');
|
|
acc.push_str(bytes[i]);
|
|
}
|
|
i += 1;
|
|
}
|
|
val = acc;
|
|
}
|
|
} else {
|
|
// valor sin comillas: hasta fin de línea, sin comentario.
|
|
val = val.split('#').next().unwrap_or("").trim().to_string();
|
|
val = val.trim_matches('\'').to_string();
|
|
}
|
|
raw.insert(key, val);
|
|
}
|
|
i += 1;
|
|
}
|
|
// Expansión de variables (varias pasadas para anidamiento simple).
|
|
let keys: Vec<String> = raw.keys().cloned().collect();
|
|
for _ in 0..3 {
|
|
for k in &keys {
|
|
let mut v = raw[k].clone();
|
|
for k2 in &keys {
|
|
let val2 = raw[k2].clone();
|
|
v = v.replace(&format!("${{{k2}}}"), &val2).replace(&format!("${k2}"), &val2);
|
|
}
|
|
raw.insert(k.clone(), v);
|
|
}
|
|
}
|
|
raw
|
|
}
|
|
|
|
/// `Some(pos_del_=)` si la línea es una asignación `nombre=...` válida (nombre shell). Evita
|
|
/// confundir `foo() {`, comparaciones, o `export X=Y`.
|
|
fn simple_assign(line: &str) -> Option<usize> {
|
|
let eq = line.find('=')?;
|
|
if eq == 0 {
|
|
return None;
|
|
}
|
|
let name = &line[..eq];
|
|
if name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && name.chars().next()?.is_ascii_alphabetic() || name.starts_with('_') {
|
|
// siguiente char no debe ser '=' (==) ni la asignación es vacía rara.
|
|
if line.as_bytes().get(eq + 1) == Some(&b'=') {
|
|
return None;
|
|
}
|
|
return Some(eq);
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Extrae el cuerpo de una función shell `name() { ... }`: el texto entre la primera `{` y la `}`
|
|
/// que la cierra a nivel 0. Best-effort (no parsea shell de verdad; cuenta llaves).
|
|
fn func_body(text: &str, name: &str) -> Option<String> {
|
|
let pat_a = format!("{name}()");
|
|
let start = text.find(&pat_a)?;
|
|
let brace = text[start..].find('{')? + start;
|
|
let mut depth = 0i32;
|
|
let mut end = None;
|
|
for (idx, ch) in text[brace..].char_indices() {
|
|
match ch {
|
|
'{' => depth += 1,
|
|
'}' => {
|
|
depth -= 1;
|
|
if depth == 0 {
|
|
end = Some(brace + idx);
|
|
break;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
let body = text[brace + 1..end?].trim().to_string();
|
|
if body.is_empty() {
|
|
None
|
|
} else {
|
|
Some(body)
|
|
}
|
|
}
|
|
|
|
/// Traduce las variables de abuild a su equivalente en el lab de hammer. Substituciones SEGURAS
|
|
/// (inequívocas): `$pkgdir`→`/out` (el DESTDIR del lab), `$pkgname`→nombre, `$pkgver`→versión.
|
|
/// NO toca `$CBUILD/$CHOST/$srcdir/$builddir` ni `--shared`. `$CBUILD/$CHOST` los RESUELVE el lab en
|
|
/// runtime (env con el triple nativo saneado, Etapa G Fase 3 — `hammer-build`); `$srcdir/$builddir` y
|
|
/// `--shared` quedan para el humano. `make DESTDIR="$pkgdir"` → `make DESTDIR="/out"`.
|
|
///
|
|
/// Además ENVUELVE el cuerpo en una función shell: abuild ejecuta `build()/package()` COMO funciones,
|
|
/// donde `local i; …` es válido; el lab corre la fase bajo `sh -c "<cuerpo>"` plano, donde `local`
|
|
/// fuera de función es error (rompía gzip al instalar). Envolver restaura el contexto de abuild sin
|
|
/// tocar el sandbox ni las fases planas escritas a mano del corpus (sólo se envuelve lo importado).
|
|
fn translate_abuild(body: &str, name: &str, version: &str) -> String {
|
|
let subst = body
|
|
.replace("${pkgdir}", "/out")
|
|
.replace("$pkgdir", "/out")
|
|
.replace("${pkgname}", name)
|
|
.replace("$pkgname", name)
|
|
.replace("${pkgver}", version)
|
|
.replace("$pkgver", version);
|
|
format!("_abuild_phase() {{\n{subst}\n}}\n_abuild_phase")
|
|
}
|
|
|
|
/// Primer hash de un bloque `sha512sums="<hash> <fichero>\n..."`.
|
|
fn first_sha(block: &str) -> String {
|
|
block
|
|
.split_whitespace()
|
|
.next()
|
|
.unwrap_or("")
|
|
.to_string()
|
|
}
|
|
|
|
/// Serializa un cuerpo multilínea como string TOML (literal multi-línea `'''…'''` si tiene saltos).
|
|
fn toml_multiline(s: &str) -> String {
|
|
if s.contains('\n') {
|
|
format!("'''\n{s}\n'''")
|
|
} else {
|
|
format!("\"{}\"", s.replace('"', "\\\""))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const APKBUILD_PATCHED: &str = r#"
|
|
pkgname=foo
|
|
pkgver=2.5
|
|
pkgrel=1
|
|
url="https://example.com/foo"
|
|
makedepends="zlib-dev openssl-dev linux-headers"
|
|
depends="bar"
|
|
source="https://example.com/foo-$pkgver.tar.gz
|
|
foo-musl-fix.patch
|
|
foo-static.patch
|
|
"
|
|
sha512sums="abc123 foo-2.5.tar.gz"
|
|
|
|
build() {
|
|
./configure --prefix=/usr
|
|
make
|
|
}
|
|
|
|
package() {
|
|
make DESTDIR="$pkgdir" install
|
|
}
|
|
"#;
|
|
|
|
#[test]
|
|
fn extracts_source_patches_deps_phases() {
|
|
let toml = recipe_from_apkbuild(APKBUILD_PATCHED).unwrap();
|
|
let recipe = hammer_core::Recipe::from_toml(&toml).expect("recipe válida");
|
|
assert_eq!(recipe.name, "foo");
|
|
assert_eq!(recipe.version, "2.5");
|
|
// tarball con $pkgver expandido:
|
|
assert_eq!(recipe.source.tarball.as_deref(), Some("https://example.com/foo-2.5.tar.gz"));
|
|
// LOS PARCHES DE MUSL cargados (el punto central):
|
|
assert_eq!(recipe.source.patches, vec!["foo-musl-fix.patch", "foo-static.patch"]);
|
|
// build-deps: SÓLO makedepends, sin `-dev`. El runtime `depends="bar"` NO entra como
|
|
// build-dep (rompería `hammer build`); queda como comentario de provenance.
|
|
assert!(recipe.deps.build.contains(&"zlib".to_string()));
|
|
assert!(recipe.deps.build.contains(&"openssl".to_string()));
|
|
assert!(recipe.deps.build.contains(&"linux-headers".to_string()));
|
|
assert!(!recipe.deps.build.contains(&"bar".to_string()), "depends de runtime no es build-dep");
|
|
assert!(toml.contains("# depends de runtime de Alpine (NO build-deps): bar"), "provenance: {toml}");
|
|
// fases capturadas de build()/package() y TRADUCIDAS: $pkgdir → /out.
|
|
assert!(recipe.build.phases.compile.as_deref().unwrap().contains("./configure"));
|
|
let install = recipe.build.phases.install.as_deref().unwrap();
|
|
assert!(install.contains("DESTDIR=\"/out\""), "instala a /out: {install}");
|
|
assert!(!install.contains("$pkgdir"), "no debe quedar $pkgdir: {install}");
|
|
}
|
|
|
|
#[test]
|
|
fn translate_abuild_substitutes_vars() {
|
|
let body = "make DESTDIR=\"$pkgdir\" install\ncp x ${pkgdir}/usr/share/$pkgname/\necho $pkgver";
|
|
let t = translate_abuild(body, "foo", "2.5");
|
|
assert!(t.contains("DESTDIR=\"/out\""));
|
|
assert!(t.contains("/out/usr/share/foo/"));
|
|
assert!(t.contains("echo 2.5"));
|
|
assert!(!t.contains("$pkg"));
|
|
}
|
|
|
|
#[test]
|
|
fn translate_wraps_body_in_function_for_local() {
|
|
// abuild package() usa `local i;` (válido en función); el lab corre la fase plana ⇒ hay que
|
|
// envolverla o `local` rompe (gzip). El cuerpo queda DENTRO de la función, invocada al final.
|
|
let t = translate_abuild("local i; for i in a b; do mv $i /out/; done", "p", "1");
|
|
assert!(t.starts_with("_abuild_phase() {"), "{t}");
|
|
assert!(t.trim_end().ends_with("_abuild_phase"), "se invoca al final: {t}");
|
|
assert!(t.contains("local i;"), "el `local` se preserva dentro de la función: {t}");
|
|
}
|
|
|
|
#[test]
|
|
fn no_patches_is_empty() {
|
|
let apk = "pkgname=zlib\npkgver=1.3\nsource=\"https://zlib.net/zlib-$pkgver.tar.gz\"\nsha512sums=\"x z\"\n";
|
|
let recipe = hammer_core::Recipe::from_toml(&recipe_from_apkbuild(apk).unwrap()).unwrap();
|
|
assert_eq!(recipe.name, "zlib");
|
|
assert!(recipe.source.patches.is_empty());
|
|
assert_eq!(recipe.source.tarball.as_deref(), Some("https://zlib.net/zlib-1.3.tar.gz"));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_no_pkgname() {
|
|
assert!(recipe_from_apkbuild("pkgver=1\n").is_err());
|
|
}
|
|
}
|