From 630cde65510c2d1e57f33ecd0a5432c2e380c8d1 Mon Sep 17 00:00:00 2001 From: sergio Date: Sun, 21 Jun 2026 08:00:56 -0400 Subject: [PATCH] =?UTF-8?q?Etapa=20G:=20importador=20Alpine=20APKBUILD?= =?UTF-8?q?=E2=86=92receta=20(carga=20los=20parches=20de=20musl)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Segunda fuente del catálogo, y la RESPUESTA a "¿qué si el build falla en musl?": Alpine ya porta miles de paquetes a musl CON los parches; su APKBUILD los trae. Un import de nix los pierde. - crates/hammer-cli/alpine_import.rs: PARSEA el APKBUILD (no lo ejecuta) → receta hammer. Extrae pkgname/pkgver (expande $var), la URL del tarball, LOS .patch (→ source.patches, lo central), makedepends+depends → deps (filtra -dev, !negados, pins versionados, auto-refs), build()/package() → fases. sha256 queda FIXME (Alpine publica sha512; el wrapper lo calcula). 3 tests. - `hammer import-alpine [FILE|-]`; scripts/alpine-import.sh [main|community] baja el APKBUILD + sus .patch de aports. - VALIDADO contra aports REAL: import coreutils 9.11 → patches renameat2-fakeroot.patch + coreutils-9.10-dash-tests.patch BAJADOS a disco; deps limpias (acl/attr/bash/openssl/perl/utmps); fases build/package capturadas. 31 suites verde. Co-Authored-By: Claude Opus 4.8 --- crates/hammer-cli/src/alpine_import.rs | 326 +++++++++++++++++++++++++ crates/hammer-cli/src/main.rs | 36 +++ scripts/alpine-import.sh | 29 +++ 3 files changed, 391 insertions(+) create mode 100644 crates/hammer-cli/src/alpine_import.rs create mode 100755 scripts/alpine-import.sh diff --git a/crates/hammer-cli/src/alpine_import.rs b/crates/hammer-cli/src/alpine_import.rs new file mode 100644 index 00000000..f370897d --- /dev/null +++ b/crates/hammer-cli/src/alpine_import.rs @@ -0,0 +1,326 @@ +//! 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 { + 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" + ) + }; + + // deps: makedepends + depends (nombres; filtramos self-subpaquetes y los negados `!x`). + let mut deps: Vec = Vec::new(); + let self_prefix = format!("{pkgname}-"); + for src in [get("makedepends"), get("depends")] { + 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(); + if !name.is_empty() && !name.starts_with("so:") && !deps.contains(&name) { + deps.push(name); + } + } + } + let deps_block = if deps.is_empty() { + String::new() + } else { + let list = deps.iter().map(|d| format!("\"{d}\"")).collect::>().join(", "); + 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 { + s.push_str(&format!("# de build() de Alpine (abuild: CHOST/--shared/etc; adaptá a estático):\ncompile = {}\n", toml_multiline(&b))); + } + if let Some(p) = install { + s.push_str(&format!("# de package() de Alpine (usa $pkgdir → ajustá a /out):\ninstall = {}\n", toml_multiline(&p))); + } + 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) + } +} + +/// 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 = 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"]); + // deps: makedepends + depends, sin `-dev`: + 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())); + // fases capturadas de build()/package(): + assert!(recipe.build.phases.compile.as_deref().unwrap().contains("./configure")); + assert!(recipe.build.phases.install.as_deref().unwrap().contains("DESTDIR")); + } + + #[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()); + } +} diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index df0998d8..2b76c1e7 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -8,6 +8,7 @@ use std::path::PathBuf; use clap::{Parser, Subcommand}; +mod alpine_import; mod nix_import; const DEFAULT_STORE: &str = "/store"; @@ -263,6 +264,17 @@ enum Cmd { #[arg(long, short)] out: Option, }, + /// [Etapa G] Importa una receta DESDE un APKBUILD de Alpine (aports). Crucial: carga los + /// `.patch` de musl de Alpine — lo que un import de nix pierde — así está MUCHO más cerca de + /// compilar en el lab estático de hammer. `scripts/alpine-import.sh ` baja el APKBUILD. + ImportAlpine { + /// El APKBUILD (texto). `-` o ausente ⇒ stdin. + #[arg(default_value = "-")] + file: String, + /// Receta de salida (`.toml`). Sin esto, imprime a stdout. + #[arg(long, short)] + out: Option, + }, /// [Etapa F] Inspecciona un repositorio de paquetes. Repo { #[command(subcommand)] @@ -827,6 +839,7 @@ fn main() -> anyhow::Result<()> { Cmd::Uninstall { name, db } => run_uninstall(&name, &db)?, Cmd::Installed { db } => run_installed_list(&db)?, Cmd::ImportNix { file, out } => run_import_nix(&file, out.as_deref())?, + Cmd::ImportAlpine { file, out } => run_import_alpine(&file, out.as_deref())?, Cmd::Repo { sub } => match sub { RepoCmd::List { repo } => run_repo_list(&repo)?, RepoCmd::Sign { repo, key, by } => run_repo_sign(&repo, &key, by.as_deref())?, @@ -1620,6 +1633,29 @@ fn run_import_nix(file: &str, out: Option<&std::path::Path>) -> anyhow::Result<( Ok(()) } +/// Importa una receta desde un APKBUILD de Alpine (Etapa G). +fn run_import_alpine(file: &str, out: Option<&std::path::Path>) -> anyhow::Result<()> { + let text = if file == "-" { + use std::io::Read; + let mut s = String::new(); + std::io::stdin().read_to_string(&mut s)?; + s + } else { + std::fs::read_to_string(file).map_err(|e| anyhow::anyhow!("leyendo {file}: {e}"))? + }; + let toml = alpine_import::recipe_from_apkbuild(&text).map_err(|e| anyhow::anyhow!(e))?; + hammer_core::Recipe::from_toml(&toml) + .map_err(|e| anyhow::anyhow!("la receta importada no parsea (bug del importador): {e}"))?; + match out { + Some(dest) => { + std::fs::write(dest, &toml)?; + eprintln!("importada de Alpine → {}", dest.display()); + } + None => print!("{toml}"), + } + Ok(()) +} + /// Lista los paquetes instalados de la DB. fn run_installed_list(db_path: &std::path::Path) -> anyhow::Result<()> { let idb = hammer_core::InstalledDb::load(db_path)?; diff --git a/scripts/alpine-import.sh b/scripts/alpine-import.sh new file mode 100755 index 00000000..900a3a50 --- /dev/null +++ b/scripts/alpine-import.sh @@ -0,0 +1,29 @@ +#!/bin/sh +# alpine-import.sh [main|community] — Etapa G, fuente #2: baja el APKBUILD de Alpine aports +# (y SUS PARCHES de musl), y emite la receta hammer vía `hammer import-alpine`. Alpine es la mejor +# semilla cuando importar de nix falla en musl: su receta YA incluye el trabajo de portabilidad. +# +# Ej: scripts/alpine-import.sh coreutils main > recipes/coreutils-alpine.toml +# (los .patch se bajan a $OUTDIR, default el dir actual) +# Env: ALPINE_BRANCH (default master), OUTDIR (default .), HAMMER (cmd del cli) +set -eu + +PKG="${1:?uso: alpine-import.sh [main|community]}" +REPO="${2:-main}" +BRANCH="${ALPINE_BRANCH:-master}" +OUTDIR="${OUTDIR:-.}" +HAMMER="${HAMMER:-cargo run -q -p hammer-cli --}" +BASE="https://gitlab.alpinelinux.org/alpine/aports/-/raw/${BRANCH}/${REPO}/${PKG}" + +apk=$(curl -fsSL "${BASE}/APKBUILD") || { echo "no encontré ${REPO}/${PKG} en aports@${BRANCH}" >&2; exit 1; } +toml=$(printf '%s' "$apk" | $HAMMER import-alpine -) +printf '%s\n' "$toml" + +# Bajar los .patch referenciados (los parches de musl que hacen al paquete compilar). +printf '%s\n' "$toml" | grep -oE '"[^"]+\.(patch|diff)"' | tr -d '"' | while read -r p; do + if curl -fsSL "${BASE}/${p}" -o "${OUTDIR}/${p}" 2>/dev/null; then + echo " ✓ parche bajado: ${OUTDIR}/${p}" >&2 + else + echo " ✗ no pude bajar ${p} (¿está en otra ruta del aport?)" >&2 + fi +done