//! Importador `nix → receta takana` (Etapa G: poblar el catálogo desde nixpkgs). //! //! El problema: a mano, 34 recetas = userland desierto; un distro usable necesita miles de //! paquetes. nixpkgs es el mayor set de recetas DESDE FUENTE (sources pineados+hash, deps //! explícitas), así que es la semilla natural del catálogo. Este importador toma la *receta* de //! nix (qué bajar, flags, deps) y la reexpresa como una `Recipe` de takana — **nunca el binario** //! del cache de nix (eso rompería "verificar, no confiar"; hammer reconstruye desde fuente igual). //! //! Consume un JSON NORMALIZADO (lo produce `scripts/nix-import.sh` vía `nix eval --apply`), no el //! `.drv` crudo: así el importador es puro y testeable sin nix, y la fricción de evaluar nixpkgs //! queda en el wrapper. Lo que el importador NO resuelve (y se documenta en la receta emitida): el //! build real en el lab de hammer (zig/musl estático) difiere del stdenv de nix (glibc/gcc) ⇒ cada //! paquete necesita adaptación de toolchain. El importador da el punto de partida, no el binario. use serde::Deserialize; /// Paquete nix normalizado (salida de `nix eval --apply` del wrapper). Campos opcionales con /// defaults para tolerar paquetes que no los declaran. #[derive(Debug, Deserialize)] pub struct NixPkg { pub pname: String, #[serde(default)] pub version: String, pub source: RawSource, #[serde(default)] pub configure_flags: Vec, #[serde(default)] pub build_inputs: Vec, #[serde(default)] pub native_build_inputs: Vec, /// `true` si nixpkgs lo construye con `rustPlatform.buildRustPackage` (el wrapper lo detecta /// por `cargoDeps`). Activa la plantilla Cargo de hammer (`--bin` + cp target/release). #[serde(default)] pub is_rust: bool, /// `true` si nixpkgs lo construye con `buildGoModule` (lo detecta por `vendorHash`/`goModules`). /// Emite `deps.build = ["go"]` y deja las phases a `BuildSys::Go` (go install del main auto). #[serde(default)] pub is_go: bool, /// El binario principal (`meta.mainProgram` de nix; p.ej. ripgrep→"rg"). Para el `--bin`. #[serde(default)] pub main_program: String, } /// El origen TAL CUAL lo da `nix eval` del `p.src`: la URL fetcheada + el `outputHash`/mode de la /// derivación fija. `outputHashMode = "flat"` ⇒ el hash es del FICHERO (sha256 de tarball, usable); /// `"recursive"` ⇒ hash NAR del árbol (no casa con un sha256 de tarball ⇒ usamos git+commit). #[derive(Debug, Deserialize)] pub struct RawSource { pub url: String, #[serde(default)] pub output_hash: String, #[serde(default)] pub output_hash_mode: String, } /// El origen ya clasificado para takana. `fetchurl` plano → `tarball+sha256`; GitHub (codeload) → /// `repo+commit` (takana pinea por commit y reproduce clonando, sin depender del hash NAR de nix). #[derive(Debug, PartialEq, Eq)] pub enum NixSource { Tarball { url: String, sha256: String }, Github { owner: String, repo: String, rev: String }, } /// Clasifica el origen RAW de nix. Detecta GitHub por la URL de codeload/archive (de /// `fetchFromGitHub`), si no usa el modo del hash: `flat` ⇒ tarball con su sha256; `recursive` /// no-github ⇒ no podemos reusar el hash, error pidiendo pin manual. pub fn classify_source(raw: &RawSource) -> Result { if let Some((owner, repo, rev)) = parse_github_url(&raw.url) { return Ok(NixSource::Github { owner, repo, rev }); } match raw.output_hash_mode.as_str() { "flat" | "" => { if raw.output_hash.is_empty() { return Err(format!("source flat sin hash: {}", raw.url)); } let hex = nix_hash_to_hex(&raw.output_hash)?; Ok(NixSource::Tarball { url: expand_nix_mirror(&raw.url), sha256: hex }) } "recursive" => Err(format!( "source recursivo no-github (hash NAR no reusable): {} — fijá repo+commit a mano", raw.url )), other => Err(format!("outputHashMode desconocido '{other}': {}", raw.url)), } } /// Traduce el esquema `mirror:///...` de nix a una URL concreta. nix resuelve estos /// mirrors en tiempo de eval; un import los deja literales y el fetch de takana (curl) no los /// entiende. Mapeamos los más comunes a un mirror real; lo no mapeado se deja igual + el usuario /// elige espejo. (nixpkgs tiene la lista completa en `pkgs/build-support/fetchurl/mirrors.nix`.) fn expand_nix_mirror(url: &str) -> String { let Some(rest) = url.strip_prefix("mirror://") else { return url.to_string(); }; let Some((site, path)) = rest.split_once('/') else { return url.to_string(); }; let base = match site { "gnu" => "https://ftp.gnu.org/gnu/", "savannah" => "https://download.savannah.gnu.org/releases/", "kernel" => "https://www.kernel.org/pub/", "sourceforge" => "https://downloads.sourceforge.net/", "gnome" => "https://download.gnome.org/", "apache" => "https://dlcdn.apache.org/", "xorg" => "https://www.x.org/releases/", "pypi" => "https://files.pythonhosted.org/packages/", "cpan" => "https://cpan.metacpan.org/", "debian" => "https://deb.debian.org/debian/", _ => return url.to_string(), // desconocido: dejamos el mirror:// (el humano resuelve) }; format!("{base}{path}") } /// Extrae `(owner, repo, rev)` de una URL de descarga de GitHub (las que arma `fetchFromGitHub`): /// `https://github.com///archive/.tar.gz` o /// `https://codeload.github.com///tar.gz/`. fn parse_github_url(url: &str) -> Option<(String, String, String)> { if let Some(rest) = url.strip_prefix("https://github.com/") { // //archive/.tar.gz (rev puede traer 'refs/tags/..' → tomamos el final) let parts: Vec<&str> = rest.splitn(4, '/').collect(); if parts.len() == 4 && parts[2] == "archive" { let rev = parts[3] .trim_end_matches(".tar.gz") .trim_end_matches(".zip") .rsplit('/') .next()? .to_string(); return Some((parts[0].to_string(), parts[1].to_string(), rev)); } } if let Some(rest) = url.strip_prefix("https://codeload.github.com/") { // //tar.gz/ let parts: Vec<&str> = rest.splitn(4, '/').collect(); if parts.len() == 4 { return Some((parts[0].to_string(), parts[1].to_string(), parts[3].to_string())); } } None } /// Convierte una receta nix normalizada en el TOML de una `Recipe` de takana. El resultado es un /// PUNTO DE PARTIDA: lleva comentarios marcando lo que falta adaptar (toolchain, nombres de deps). pub fn to_recipe_toml(pkg: &NixPkg) -> Result { let name = sanitize_name(&pkg.pname); let version = if pkg.version.is_empty() { "0".to_string() } else { pkg.version.clone() }; let source = classify_source(&pkg.source) .map_err(|e| format!("source de '{}': {e}", pkg.pname))?; let source_block = match &source { NixSource::Tarball { url, sha256 } => { format!("tarball = \"{url}\"\nsha256 = \"{sha256}\"\n") } NixSource::Github { owner, repo, rev } => { format!("repo = \"https://github.com/{owner}/{repo}\"\ncommit = \"{rev}\"\n") } }; // deps: build = nativeBuildInputs ++ buildInputs (por nombre nix; el humano remapea al corpus // de takana si difieren — p.ej. nix `pkg-config` ≈ takana `pkgconf`). let mut deps: Vec = Vec::new(); for d in pkg.native_build_inputs.iter().chain(pkg.build_inputs.iter()) { let n = sanitize_name(d); if !n.is_empty() && !is_nix_noise(&n) && !deps.contains(&n) { deps.push(n); } } // Rust (buildRustPackage): NO emitir los buildInputs como `[deps]` activas. nix lista el closure // MAXIMAL (todos los backends C opcionales: zlib, pcre2, openssl, jemalloc…), pero el build Rust // del lab usa las features DEFAULT de cargo, que suelen traer backend Rust puro (miniz_oxide vs // zlib, rustls vs openssl) o lo dejan opt-in (pcre2). Cargo resuelve el grafo Rust por vendoring; // un sys-lib C que SÍ haga falta es adaptación per-paquete (patch/feature, p.ej. // ripgrep-no-jemalloc), no una dep de corpus. Las recetas Rust validadas (ripgrep/uutils) NO // declaran `[deps]`; emitirlas rompe `takana build` (busca recipes/.toml). Quedan como // COMENTARIO de provenance (no se pierden: señalan qué C podría necesitarse). let deps_block = if pkg.is_go { // Go: la única build-dep es el toolchain `go` (recipes/go.toml). Los buildInputs C de nix // NO aplican (binario Go estático puro, CGO_ENABLED=0). Quedan como comentario de provenance. let prov = if deps.is_empty() { String::new() } else { format!("# buildInputs de nix (no usados con CGO off): {}\n", deps.join(", ")) }; format!("\n{prov}[deps]\nbuild = [\"go\"]\n") } else if deps.is_empty() { String::new() } else if pkg.is_rust { let list = deps.join(", "); format!("\n# buildInputs de nix (NO deps de hammer — cargo vendorea; backend Rust por\n# defecto). Si algún sys-crate C falla, adaptá per-paquete (patch/feature): {list}\n") } else { let list = deps.iter().map(|d| format!("\"{d}\"")).collect::>().join(", "); format!("\n[deps]\nbuild = [{list}]\n") }; // Rust (buildRustPackage): emite la plantilla Cargo de takana — `--bin ` (cargo exige UN // target) + install copiando `target/release/` a /out (mismo patrón que la receta ripgrep). // El bin = meta.mainProgram de nix (ripgrep→rg), o el pname. Esto deja la receta build-ready. let (flags_line, rust_install) = if pkg.is_rust { let bin = if pkg.main_program.is_empty() { name.clone() } else { sanitize_name(&pkg.main_program) }; // `--bin `: default para el caso común (crate suelto, pname=paquete cargo). Los repos // con WORKSPACE VIRTUAL en el root (p.ej. sd: el bin vive en el paquete `sd-cli`≠pname) // necesitan que el humano agregue `-p ` — el import no puede adivinar el nombre. ( format!("flags = [\"--bin\", \"{bin}\"]\n"), format!( "\n[build.phases]\ninstall = \"mkdir -p /out/usr/bin && cp target/release/{bin} /out/usr/bin/{bin}\"\n" ), ) } else { ("flags = []\n".to_string(), String::new()) }; // configureFlags → una fase configure explícita (si las hay). Si no, se deja a la heurística // del lab. NOTA: muchos paquetes nix no son autotools (cmake/meson); esto es best-effort. let phases_block = if pkg.is_go { // Go: sin phases. `BuildSys::Go` deriva el compile (go install del main auto-detectado). String::new() } else if !rust_install.is_empty() { rust_install } else if pkg.configure_flags.is_empty() { String::new() } else { let flags = pkg .configure_flags .iter() .map(|f| f.replace('"', "\\\"")) .collect::>() .join(" "); format!("\n[build.phases]\nconfigure = \"./configure --prefix=/usr {flags}\"\n") }; let toml = format!( "# Importada de nixpkgs por `hammer import-nix` (Etapa G). PUNTO DE PARTIDA, no final:\n\ # - el build usa el lab de takana (zig-cc / musl estático), NO el stdenv de nix ⇒ revisá\n\ # compiler/link/phases y adaptá hasta que compile.\n\ # - las deps van con su nombre NIX; remapealas a las recetas del corpus si difieren.\n\ name = \"{name}\"\n\ version = \"{version}\"\n\ \n\ [source]\n\ {source_block}\ \n\ [build]\n\ compiler = \"zig-cc\"\n\ target = \"x86_64-linux-musl\"\n\ link = \"static\"\n\ {flags_line}\ {phases_block}{deps_block}" ); Ok(toml) } /// Filtra el RUIDO de stdenv de nix que no son paquetes reales: setup-hooks, wrappers de /// compilador, e infraestructura de lenguaje (cargo/rustc-hooks). El humano añade lo que falte; /// preferimos un punto de partida limpio a arrastrar 10 hooks por receta. Conservador: sólo /// patrones inequívocamente internos de nix. fn is_nix_noise(name: &str) -> bool { name.contains("hook") || name.ends_with("-wrapper") || name == "install-shell-files" || name == "auditable-cargo" || name.starts_with("auditable-cargo-") || name == "version-check" // El toolchain Rust ES el lab de takana (cargo vendorea las crates), NO un paquete dep. || name == "rustc" || name == "cargo" || name == "rust" } /// Nombre de receta válido: minúsculas, sólo `[a-z0-9._-]`, sin espacios. nix usa nombres limpios /// pero por las dudas saneamos (también recorta sufijos de output tipo `-dev`). fn sanitize_name(s: &str) -> String { let s = s.trim(); let s = s.strip_suffix("-dev").unwrap_or(s); s.chars() .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '+') { c.to_ascii_lowercase() } else { '-' }) .collect::() .trim_matches('-') .to_string() } /// Convierte un hash de nix a sha256 HEX (lo que pide `Recipe.source.sha256`). Acepta: /// - SRI `sha256-` (formato moderno de nixpkgs), /// - hex de 64 chars (ya en el formato deseado), /// - nix-base32 de 52 chars (formato clásico de `outputHash`). pub fn nix_hash_to_hex(h: &str) -> Result { let h = h.trim(); // SRI: sha256- if let Some(b64) = h.strip_prefix("sha256-") { use base64::Engine; let bytes = base64::engine::general_purpose::STANDARD .decode(b64) .map_err(|e| format!("SRI base64 inválido: {e}"))?; if bytes.len() != 32 { return Err(format!("SRI sha256: esperaba 32 bytes, hay {}", bytes.len())); } return Ok(hex_encode(&bytes)); } // hex puro (64 chars, todos hex) if h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit()) { return Ok(h.to_ascii_lowercase()); } // nix-base32 (52 chars para sha256) if h.len() == 52 { let bytes = nix_base32_decode(h)?; return Ok(hex_encode(&bytes)); } Err(format!("formato de hash no reconocido ({} chars): {h}", h.len())) } fn hex_encode(bytes: &[u8]) -> String { let mut s = String::with_capacity(bytes.len() * 2); for b in bytes { s.push_str(&format!("{b:02x}")); } s } /// Decodifica el base32 PROPIO de nix (alfabeto `0123456789abcdfghijklmnpqrsvwxyz`, sin e/o/u/t; /// orden LSB-first). Devuelve los 32 bytes del sha256. Port directo de `nixbase32::decode`. fn nix_base32_decode(s: &str) -> Result, String> { const ALPHABET: &[u8; 32] = b"0123456789abcdfghijklmnpqrsvwxyz"; let chars = s.as_bytes(); let out_len = 32usize; // sha256 let mut out = vec![0u8; out_len]; for (n, &c) in chars.iter().rev().enumerate() { let digit = ALPHABET .iter() .position(|&a| a == c) .ok_or_else(|| format!("carácter no base32-nix: '{}'", c as char))? as u32; let b = n * 5; let i = b / 8; let j = (b % 8) as u32; out[i] |= ((digit << j) & 0xff) as u8; let carry = digit >> (8 - j); if carry != 0 { if i + 1 >= out_len { return Err("base32-nix: desbordamiento (hash mal formado)".into()); } out[i + 1] |= carry as u8; } } Ok(out) } #[cfg(test)] mod tests { use super::*; #[test] fn sri_hash_to_hex() { // sha256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 // SRI base64 de esos 32 bytes: let sri = "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="; let hex = nix_hash_to_hex(sri).unwrap(); assert_eq!(hex, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); } #[test] fn hex_passthrough() { let h = "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"; assert_eq!(nix_hash_to_hex(h).unwrap(), h.to_ascii_lowercase()); } #[test] fn nixbase32_zeros_decode() { // 52 dígitos '0' (el primer símbolo del alfabeto) ⇒ 32 bytes cero. Caso correcto por // construcción que ejercita el lookup del alfabeto y el sin-desbordamiento. (El formato // moderno de nixpkgs es SRI, cubierto arriba; base32 es fallback para drvs clásicos.) let b32 = "0".repeat(52); let hex = nix_hash_to_hex(&b32).unwrap(); assert_eq!(hex, "0".repeat(64)); } #[test] fn nixbase32_rejects_bad_char() { // 'e' no está en el alfabeto de nix (omite e/o/u/t). let b32 = format!("e{}", "0".repeat(51)); assert!(nix_hash_to_hex(&b32).is_err()); } #[test] fn classify_github_archive_url() { let raw = RawSource { url: "https://github.com/BurntSushi/ripgrep/archive/14.1.1.tar.gz".into(), output_hash: "sha256-xxx".into(), output_hash_mode: "recursive".into(), }; assert_eq!( classify_source(&raw).unwrap(), NixSource::Github { owner: "BurntSushi".into(), repo: "ripgrep".into(), rev: "14.1.1".into() } ); } #[test] fn classify_recursive_non_github_errors() { let raw = RawSource { url: "https://example.com/foo.tar.gz".into(), output_hash: "sha256-xxx".into(), output_hash_mode: "recursive".into(), }; assert!(classify_source(&raw).is_err()); } #[test] fn import_tarball_autotools() { let json = r#"{ "pname": "hello", "version": "2.12.1", "source": { "url": "mirror://gnu/hello/hello-2.12.1.tar.gz", "output_hash": "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", "output_hash_mode": "flat" }, "configure_flags": ["--disable-nls"], "build_inputs": ["zlib"], "native_build_inputs": ["pkg-config"] }"#; let pkg: NixPkg = serde_json::from_str(json).unwrap(); let toml = to_recipe_toml(&pkg).unwrap(); // Parsea como Recipe válida de takana. let recipe = takana_core::Recipe::from_toml(&toml).expect("recipe válida"); assert_eq!(recipe.name, "hello"); assert_eq!(recipe.version, "2.12.1"); // mirror://gnu/ se traduce a una URL concreta que el fetch de takana entiende. assert_eq!(recipe.source.tarball.as_deref(), Some("https://ftp.gnu.org/gnu/hello/hello-2.12.1.tar.gz")); assert_eq!( recipe.source.sha256.as_deref(), Some("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") ); assert_eq!(recipe.deps.build, vec!["pkg-config", "zlib"]); assert!(recipe.build.phases.configure.as_deref().unwrap().contains("--disable-nls")); } #[test] fn rust_package_gets_bin_and_install_template() { let json = r#"{ "pname": "ripgrep", "version": "14.1.1", "source": { "url": "https://github.com/BurntSushi/ripgrep/archive/14.1.1.tar.gz", "output_hash_mode": "recursive" }, "is_rust": true, "main_program": "rg" }"#; let pkg: NixPkg = serde_json::from_str(json).unwrap(); let recipe = takana_core::Recipe::from_toml(&to_recipe_toml(&pkg).unwrap()).unwrap(); assert_eq!(recipe.build.flags, vec!["--bin", "rg"]); let inst = recipe.build.phases.install.as_deref().unwrap(); assert!(inst.contains("cp target/release/rg /out/usr/bin/rg"), "{inst}"); } #[test] fn rust_buildinputs_are_not_active_deps() { // nix lista el closure maximal (pcre2/openssl…); para Rust NO deben volverse `[deps]` (cargo // vendorea, backend Rust por defecto) — romperían `takana build`. Quedan como comentario. let json = r#"{ "pname": "rg", "version": "14", "source": { "url": "https://github.com/o/r/archive/abc.tar.gz", "output_hash_mode": "recursive" }, "is_rust": true, "main_program": "rg", "build_inputs": ["pcre2", "openssl"] }"#; let pkg: NixPkg = serde_json::from_str(json).unwrap(); let toml = to_recipe_toml(&pkg).unwrap(); let recipe = takana_core::Recipe::from_toml(&toml).unwrap(); assert!(recipe.deps.build.is_empty(), "Rust no emite deps activas: {:?}", recipe.deps.build); assert!(toml.contains("# buildInputs de nix"), "deja provenance comentada: {toml}"); assert!(toml.contains("pcre2"), "lista los nombres en el comentario: {toml}"); } #[test] fn filters_nix_stdenv_noise() { let json = r#"{ "pname": "x", "version": "1", "source": { "url": "https://github.com/o/r/archive/abc.tar.gz", "output_hash_mode": "recursive" }, "build_inputs": ["pcre2", "cargo-build-hook.sh", "rustc", "pkg-config-wrapper"], "native_build_inputs": ["install-shell-files", "version-check-hook"] }"#; let pkg: NixPkg = serde_json::from_str(json).unwrap(); let recipe = takana_core::Recipe::from_toml(&to_recipe_toml(&pkg).unwrap()).unwrap(); // pcre2 sobrevive (paquete real); hooks/wrappers/shell-files y el toolchain rustc/cargo // se filtran (rustc/cargo son el lab, no deps). assert_eq!(recipe.deps.build, vec!["pcre2"]); } #[test] fn expands_common_nix_mirrors() { assert_eq!( expand_nix_mirror("mirror://gnu/hello/hello-2.12.1.tar.gz"), "https://ftp.gnu.org/gnu/hello/hello-2.12.1.tar.gz" ); assert_eq!( expand_nix_mirror("mirror://savannah/foo/foo-1.tar.gz"), "https://download.savannah.gnu.org/releases/foo/foo-1.tar.gz" ); // desconocido se deja igual (no rompe, el humano resuelve). assert_eq!(expand_nix_mirror("mirror://raro/x"), "mirror://raro/x"); // url normal pasa sin tocar. assert_eq!(expand_nix_mirror("https://x/y.tar.gz"), "https://x/y.tar.gz"); } #[test] fn import_github_uses_git() { let json = r#"{ "pname": "ripgrep", "version": "14.1.1", "source": { "url": "https://github.com/BurntSushi/ripgrep/archive/4649aa9700619f94cf9c66876e9549d83420e16c.tar.gz", "output_hash": "sha256-zzz", "output_hash_mode": "recursive" } }"#; let pkg: NixPkg = serde_json::from_str(json).unwrap(); let toml = to_recipe_toml(&pkg).unwrap(); let recipe = takana_core::Recipe::from_toml(&toml).unwrap(); assert_eq!(recipe.source.repo.as_deref(), Some("https://github.com/BurntSushi/ripgrep")); assert_eq!( recipe.source.commit.as_deref(), Some("4649aa9700619f94cf9c66876e9549d83420e16c") ); assert!(recipe.source.sha256.is_none(), "github ⇒ pin por commit, sin sha256"); } }