Files
takana/crates/takana-cli/src/alpine_import.rs
T
Sergio b818f5249f takana etapa 5d: comentarios de crates, CLAUDE.md y el skill de granja
205 lineas en 73 ficheros de crates, mas la prosa de CLAUDE.md y del skill,
que se me habian quedado afuera de los barridos anteriores (no eran ni recetas
ni docs/ ni scripts/).

EL BARRIDO ANCHO ESTUVO A UN COMMIT DE ROMPER EL CORPUS ENTERO.

El primer intento reescribia los .rs completos, no solo los comentarios. Entre
las lineas de codigo que tocaba estaban SIETE etiquetas de separacion de
dominio, que son ENTRADA DE HASH:

  b"hammer-tree-v1"        <- el prefijo de ArtifactHash::of_tree (hash.rs:60)
  b"hammer-seed-v1"           la funcion que hashea TODOS los artefactos:
  b"hammer-stage1-rootfs-v2"  cambiarla mueve los 4750 hashes del store
  b"hammer-product-rootfs-v3"
  b"hammer-product-attested-v2"
  b"hammer-builder-rootfs-v1"
  b"hammer-attest-dev-rootkey-0001!!"  <- clave raiz de atestacion, [u8;32]

Revertido y rehecho solo sobre comentarios, esquivando ademas las cadenas
crudas de Rust (r#"..."#) porque el SYSTEM_PROMPT del traductor tiene lineas
que empiezan como comentario.

Controles: las 7 etiquetas siguen ahi, el diff toca CERO lineas de codigo,
605 tests en verde y el hash de zlib sigue en b3:dc363f26.

La leccion es la misma de toda esta etapa: un literal que parece prosa puede
ser entrada de hash, y la unica forma de saberlo es mirar donde se usa.
2026-09-09 19:38:33 +00:00

392 lines
16 KiB
Rust

//! 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<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 `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<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 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 "<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 = 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());
}
}