//! Importador `Alpine APKBUILD → receta takana` (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 takana). 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 takana. pub fn recipe_from_apkbuild(text: &str) -> Result { 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 = 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 `takana 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 { let mut v: Vec = 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::>().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::>().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 { let mut raw: BTreeMap = 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 = 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 { 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 { 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 takana. 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 — `takana-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 ""` 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=" \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 = takana_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 = takana_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()); } }