Etapa G: importador Alpine APKBUILD→receta (carga los parches de musl)
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 <pkg> [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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<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"
|
||||
)
|
||||
};
|
||||
|
||||
// deps: makedepends + depends (nombres; filtramos self-subpaquetes y los negados `!x`).
|
||||
let mut deps: Vec<String> = 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::<Vec<_>>().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::<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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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"]);
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
@@ -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<PathBuf>,
|
||||
},
|
||||
/// [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 <pkg>` 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<PathBuf>,
|
||||
},
|
||||
/// [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)?;
|
||||
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
# alpine-import.sh <pkg> [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 <pkg> [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
|
||||
Reference in New Issue
Block a user