sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
⎇ c3f890c7d1fdfaf2a15382c3bd295d78a3ec4c99
crates/hammer-cli/src/nix_import.rs
History
Who touched what
raw
Etapa G capa-de-adaptación #4: plantilla Cargo para imports Rust de nix
1//! Importador `nix → receta hammer` (Etapa G: poblar el catálogo desde nixpkgs).
2//!
3//! El problema: a mano, 34 recetas = userland desierto; un distro usable necesita miles de
4//! paquetes. nixpkgs es el mayor set de recetas DESDE FUENTE (sources pineados+hash, deps
5//! explícitas), así que es la semilla natural del catálogo. Este importador toma la *receta* de
6//! nix (qué bajar, flags, deps) y la reexpresa como una `Recipe` de hammer — **nunca el binario**
7//! del cache de nix (eso rompería "verificar, no confiar"; hammer reconstruye desde fuente igual).
8//!
9//! Consume un JSON NORMALIZADO (lo produce `scripts/nix-import.sh` vía `nix eval --apply`), no el
10//! `.drv` crudo: así el importador es puro y testeable sin nix, y la fricción de evaluar nixpkgs
11//! queda en el wrapper. Lo que el importador NO resuelve (y se documenta en la receta emitida): el
12//! build real en el lab de hammer (zig/musl estático) difiere del stdenv de nix (glibc/gcc) ⇒ cada
13//! paquete necesita adaptación de toolchain. El importador da el punto de partida, no el binario.
14
15use serde::Deserialize;
16
17/// Paquete nix normalizado (salida de `nix eval --apply` del wrapper). Campos opcionales con
18/// defaults para tolerar paquetes que no los declaran.
19#[derive(Debug, Deserialize)]
20pub struct NixPkg {
21 pub pname: String,
22 #[serde(default)]
23 pub version: String,
24 pub source: RawSource,
25 #[serde(default)]
26 pub configure_flags: Vec<String>,
27 #[serde(default)]
28 pub build_inputs: Vec<String>,
29 #[serde(default)]
30 pub native_build_inputs: Vec<String>,
31 /// `true` si nixpkgs lo construye con `rustPlatform.buildRustPackage` (el wrapper lo detecta
32 /// por `cargoDeps`). Activa la plantilla Cargo de hammer (`--bin` + cp target/release).
33 #[serde(default)]
34 pub is_rust: bool,
35 /// El binario principal (`meta.mainProgram` de nix; p.ej. ripgrep→"rg"). Para el `--bin`.
36 #[serde(default)]
37 pub main_program: String,
38}
39
40/// El origen TAL CUAL lo da `nix eval` del `p.src`: la URL fetcheada + el `outputHash`/mode de la
41/// derivación fija. `outputHashMode = "flat"` ⇒ el hash es del FICHERO (sha256 de tarball, usable);
42/// `"recursive"` ⇒ hash NAR del árbol (no casa con un sha256 de tarball ⇒ usamos git+commit).
43#[derive(Debug, Deserialize)]
44pub struct RawSource {
45 pub url: String,
46 #[serde(default)]
47 pub output_hash: String,
48 #[serde(default)]
49 pub output_hash_mode: String,
50}
51
52/// El origen ya clasificado para hammer. `fetchurl` plano → `tarball+sha256`; GitHub (codeload) →
53/// `repo+commit` (hammer pinea por commit y reproduce clonando, sin depender del hash NAR de nix).
54#[derive(Debug, PartialEq, Eq)]
55pub enum NixSource {
56 Tarball { url: String, sha256: String },
57 Github { owner: String, repo: String, rev: String },
58}
59
60/// Clasifica el origen RAW de nix. Detecta GitHub por la URL de codeload/archive (de
61/// `fetchFromGitHub`), si no usa el modo del hash: `flat` ⇒ tarball con su sha256; `recursive`
62/// no-github ⇒ no podemos reusar el hash, error pidiendo pin manual.
63pub fn classify_source(raw: &RawSource) -> Result<NixSource, String> {
64 if let Some((owner, repo, rev)) = parse_github_url(&raw.url) {
65 return Ok(NixSource::Github { owner, repo, rev });
66 }
67 match raw.output_hash_mode.as_str() {
68 "flat" | "" => {
69 if raw.output_hash.is_empty() {
70 return Err(format!("source flat sin hash: {}", raw.url));
71 }
72 let hex = nix_hash_to_hex(&raw.output_hash)?;
73 Ok(NixSource::Tarball { url: expand_nix_mirror(&raw.url), sha256: hex })
74 }
75 "recursive" => Err(format!(
76 "source recursivo no-github (hash NAR no reusable): {} — fijá repo+commit a mano",
77 raw.url
78 )),
79 other => Err(format!("outputHashMode desconocido '{other}': {}", raw.url)),
80 }
81}
82
83/// Traduce el esquema `mirror://<sitio>/...` de nix a una URL concreta. nix resuelve estos
84/// mirrors en tiempo de eval; un import los deja literales y el fetch de hammer (curl) no los
85/// entiende. Mapeamos los más comunes a un mirror real; lo no mapeado se deja igual + el usuario
86/// elige espejo. (nixpkgs tiene la lista completa en `pkgs/build-support/fetchurl/mirrors.nix`.)
87fn expand_nix_mirror(url: &str) -> String {
88 let Some(rest) = url.strip_prefix("mirror://") else {
89 return url.to_string();
90 };
91 let Some((site, path)) = rest.split_once('/') else {
92 return url.to_string();
93 };
94 let base = match site {
95 "gnu" => "https://ftp.gnu.org/gnu/",
96 "savannah" => "https://download.savannah.gnu.org/releases/",
97 "kernel" => "https://www.kernel.org/pub/",
98 "sourceforge" => "https://downloads.sourceforge.net/",
99 "gnome" => "https://download.gnome.org/",
100 "apache" => "https://dlcdn.apache.org/",
101 "xorg" => "https://www.x.org/releases/",
102 "pypi" => "https://files.pythonhosted.org/packages/",
103 "cpan" => "https://cpan.metacpan.org/",
104 "debian" => "https://deb.debian.org/debian/",
105 _ => return url.to_string(), // desconocido: dejamos el mirror:// (el humano resuelve)
106 };
107 format!("{base}{path}")
108}
109
110/// Extrae `(owner, repo, rev)` de una URL de descarga de GitHub (las que arma `fetchFromGitHub`):
111/// `https://github.com/<owner>/<repo>/archive/<rev>.tar.gz` o
112/// `https://codeload.github.com/<owner>/<repo>/tar.gz/<rev>`.
113fn parse_github_url(url: &str) -> Option<(String, String, String)> {
114 if let Some(rest) = url.strip_prefix("https://github.com/") {
115 // <owner>/<repo>/archive/<rev>.tar.gz (rev puede traer 'refs/tags/..' → tomamos el final)
116 let parts: Vec<&str> = rest.splitn(4, '/').collect();
117 if parts.len() == 4 && parts[2] == "archive" {
118 let rev = parts[3]
119 .trim_end_matches(".tar.gz")
120 .trim_end_matches(".zip")
121 .rsplit('/')
122 .next()?
123 .to_string();
124 return Some((parts[0].to_string(), parts[1].to_string(), rev));
125 }
126 }
127 if let Some(rest) = url.strip_prefix("https://codeload.github.com/") {
128 // <owner>/<repo>/tar.gz/<rev>
129 let parts: Vec<&str> = rest.splitn(4, '/').collect();
130 if parts.len() == 4 {
131 return Some((parts[0].to_string(), parts[1].to_string(), parts[3].to_string()));
132 }
133 }
134 None
135}
136
137/// Convierte una receta nix normalizada en el TOML de una `Recipe` de hammer. El resultado es un
138/// PUNTO DE PARTIDA: lleva comentarios marcando lo que falta adaptar (toolchain, nombres de deps).
139pub fn to_recipe_toml(pkg: &NixPkg) -> Result<String, String> {
140 let name = sanitize_name(&pkg.pname);
141 let version = if pkg.version.is_empty() { "0".to_string() } else { pkg.version.clone() };
142
143 let source = classify_source(&pkg.source)
144 .map_err(|e| format!("source de '{}': {e}", pkg.pname))?;
145 let source_block = match &source {
146 NixSource::Tarball { url, sha256 } => {
147 format!("tarball = \"{url}\"\nsha256 = \"{sha256}\"\n")
148 }
149 NixSource::Github { owner, repo, rev } => {
150 format!("repo = \"https://github.com/{owner}/{repo}\"\ncommit = \"{rev}\"\n")
151 }
152 };
153
154 // deps: build = nativeBuildInputs ++ buildInputs (por nombre nix; el humano remapea al corpus
155 // de hammer si difieren — p.ej. nix `pkg-config` ≈ hammer `pkgconf`).
156 let mut deps: Vec<String> = Vec::new();
157 for d in pkg.native_build_inputs.iter().chain(pkg.build_inputs.iter()) {
158 let n = sanitize_name(d);
159 if !n.is_empty() && !is_nix_noise(&n) && !deps.contains(&n) {
160 deps.push(n);
161 }
162 }
163 let deps_block = if deps.is_empty() {
164 String::new()
165 } else {
166 let list = deps.iter().map(|d| format!("\"{d}\"")).collect::<Vec<_>>().join(", ");
167 format!("\n[deps]\nbuild = [{list}]\n")
168 };
169
170 // Rust (buildRustPackage): emite la plantilla Cargo de hammer — `--bin <bin>` (cargo exige UN
171 // target) + install copiando `target/release/<bin>` a /out (mismo patrón que la receta ripgrep).
172 // El bin = meta.mainProgram de nix (ripgrep→rg), o el pname. Esto deja la receta build-ready.
173 let (flags_line, rust_install) = if pkg.is_rust {
174 let bin = if pkg.main_program.is_empty() { name.clone() } else { sanitize_name(&pkg.main_program) };
175 (
176 format!("flags = [\"--bin\", \"{bin}\"]\n"),
177 format!(
178 "\n[build.phases]\ninstall = \"mkdir -p /out/usr/bin && cp target/release/{bin} /out/usr/bin/{bin}\"\n"
179 ),
180 )
181 } else {
182 ("flags = []\n".to_string(), String::new())
183 };
184
185 // configureFlags → una fase configure explícita (si las hay). Si no, se deja a la heurística
186 // del lab. NOTA: muchos paquetes nix no son autotools (cmake/meson); esto es best-effort.
187 let phases_block = if !rust_install.is_empty() {
188 rust_install
189 } else if pkg.configure_flags.is_empty() {
190 String::new()
191 } else {
192 let flags = pkg
193 .configure_flags
194 .iter()
195 .map(|f| f.replace('"', "\\\""))
196 .collect::<Vec<_>>()
197 .join(" ");
198 format!("\n[build.phases]\nconfigure = \"./configure --prefix=/usr {flags}\"\n")
199 };
200
201 let toml = format!(
202 "# Importada de nixpkgs por `hammer import-nix` (Etapa G). PUNTO DE PARTIDA, no final:\n\
203 # - el build usa el lab de hammer (zig-cc / musl estático), NO el stdenv de nix ⇒ revisá\n\
204 # compiler/link/phases y adaptá hasta que compile.\n\
205 # - las deps van con su nombre NIX; remapealas a las recetas del corpus si difieren.\n\
206 name = \"{name}\"\n\
207 version = \"{version}\"\n\
208 \n\
209 [source]\n\
210 {source_block}\
211 \n\
212 [build]\n\
213 compiler = \"zig-cc\"\n\
214 target = \"x86_64-linux-musl\"\n\
215 link = \"static\"\n\
216 {flags_line}\
217 {phases_block}{deps_block}"
218 );
219 Ok(toml)
220}
221
222/// Filtra el RUIDO de stdenv de nix que no son paquetes reales: setup-hooks, wrappers de
223/// compilador, e infraestructura de lenguaje (cargo/rustc-hooks). El humano añade lo que falte;
224/// preferimos un punto de partida limpio a arrastrar 10 hooks por receta. Conservador: sólo
225/// patrones inequívocamente internos de nix.
226fn is_nix_noise(name: &str) -> bool {
227 name.contains("hook")
228 || name.ends_with("-wrapper")
229 || name == "install-shell-files"
230 || name == "auditable-cargo"
231 || name.starts_with("auditable-cargo-")
232 || name == "version-check"
233}
234
235/// Nombre de receta válido: minúsculas, sólo `[a-z0-9._-]`, sin espacios. nix usa nombres limpios
236/// pero por las dudas saneamos (también recorta sufijos de output tipo `-dev`).
237fn sanitize_name(s: &str) -> String {
238 let s = s.trim();
239 let s = s.strip_suffix("-dev").unwrap_or(s);
240 s.chars()
241 .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '+') { c.to_ascii_lowercase() } else { '-' })
242 .collect::<String>()
243 .trim_matches('-')
244 .to_string()
245}
246
247/// Convierte un hash de nix a sha256 HEX (lo que pide `Recipe.source.sha256`). Acepta:
248/// - SRI `sha256-<base64>` (formato moderno de nixpkgs),
249/// - hex de 64 chars (ya en el formato deseado),
250/// - nix-base32 de 52 chars (formato clásico de `outputHash`).
251pub fn nix_hash_to_hex(h: &str) -> Result<String, String> {
252 let h = h.trim();
253 // SRI: sha256-<base64>
254 if let Some(b64) = h.strip_prefix("sha256-") {
255 use base64::Engine;
256 let bytes = base64::engine::general_purpose::STANDARD
257 .decode(b64)
258 .map_err(|e| format!("SRI base64 inválido: {e}"))?;
259 if bytes.len() != 32 {
260 return Err(format!("SRI sha256: esperaba 32 bytes, hay {}", bytes.len()));
261 }
262 return Ok(hex_encode(&bytes));
263 }
264 // hex puro (64 chars, todos hex)
265 if h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit()) {
266 return Ok(h.to_ascii_lowercase());
267 }
268 // nix-base32 (52 chars para sha256)
269 if h.len() == 52 {
270 let bytes = nix_base32_decode(h)?;
271 return Ok(hex_encode(&bytes));
272 }
273 Err(format!("formato de hash no reconocido ({} chars): {h}", h.len()))
274}
275
276fn hex_encode(bytes: &[u8]) -> String {
277 let mut s = String::with_capacity(bytes.len() * 2);
278 for b in bytes {
279 s.push_str(&format!("{b:02x}"));
280 }
281 s
282}
283
284/// Decodifica el base32 PROPIO de nix (alfabeto `0123456789abcdfghijklmnpqrsvwxyz`, sin e/o/u/t;
285/// orden LSB-first). Devuelve los 32 bytes del sha256. Port directo de `nixbase32::decode`.
286fn nix_base32_decode(s: &str) -> Result<Vec<u8>, String> {
287 const ALPHABET: &[u8; 32] = b"0123456789abcdfghijklmnpqrsvwxyz";
288 let chars = s.as_bytes();
289 let out_len = 32usize; // sha256
290 let mut out = vec![0u8; out_len];
291 for (n, &c) in chars.iter().rev().enumerate() {
292 let digit = ALPHABET
293 .iter()
294 .position(|&a| a == c)
295 .ok_or_else(|| format!("carácter no base32-nix: '{}'", c as char))?
296 as u32;
297 let b = n * 5;
298 let i = b / 8;
299 let j = (b % 8) as u32;
300 out[i] |= ((digit << j) & 0xff) as u8;
301 let carry = digit >> (8 - j);
302 if carry != 0 {
303 if i + 1 >= out_len {
304 return Err("base32-nix: desbordamiento (hash mal formado)".into());
305 }
306 out[i + 1] |= carry as u8;
307 }
308 }
309 Ok(out)
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 #[test]
317 fn sri_hash_to_hex() {
318 // sha256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
319 // SRI base64 de esos 32 bytes:
320 let sri = "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=";
321 let hex = nix_hash_to_hex(sri).unwrap();
322 assert_eq!(hex, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
323 }
324
325 #[test]
326 fn hex_passthrough() {
327 let h = "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855";
328 assert_eq!(nix_hash_to_hex(h).unwrap(), h.to_ascii_lowercase());
329 }
330
331 #[test]
332 fn nixbase32_zeros_decode() {
333 // 52 dígitos '0' (el primer símbolo del alfabeto) ⇒ 32 bytes cero. Caso correcto por
334 // construcción que ejercita el lookup del alfabeto y el sin-desbordamiento. (El formato
335 // moderno de nixpkgs es SRI, cubierto arriba; base32 es fallback para drvs clásicos.)
336 let b32 = "0".repeat(52);
337 let hex = nix_hash_to_hex(&b32).unwrap();
338 assert_eq!(hex, "0".repeat(64));
339 }
340
341 #[test]
342 fn nixbase32_rejects_bad_char() {
343 // 'e' no está en el alfabeto de nix (omite e/o/u/t).
344 let b32 = format!("e{}", "0".repeat(51));
345 assert!(nix_hash_to_hex(&b32).is_err());
346 }
347
348 #[test]
349 fn classify_github_archive_url() {
350 let raw = RawSource {
351 url: "https://github.com/BurntSushi/ripgrep/archive/14.1.1.tar.gz".into(),
352 output_hash: "sha256-xxx".into(),
353 output_hash_mode: "recursive".into(),
354 };
355 assert_eq!(
356 classify_source(&raw).unwrap(),
357 NixSource::Github { owner: "BurntSushi".into(), repo: "ripgrep".into(), rev: "14.1.1".into() }
358 );
359 }
360
361 #[test]
362 fn classify_recursive_non_github_errors() {
363 let raw = RawSource {
364 url: "https://example.com/foo.tar.gz".into(),
365 output_hash: "sha256-xxx".into(),
366 output_hash_mode: "recursive".into(),
367 };
368 assert!(classify_source(&raw).is_err());
369 }
370
371 #[test]
372 fn import_tarball_autotools() {
373 let json = r#"{
374 "pname": "hello",
375 "version": "2.12.1",
376 "source": { "url": "mirror://gnu/hello/hello-2.12.1.tar.gz",
377 "output_hash": "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=",
378 "output_hash_mode": "flat" },
379 "configure_flags": ["--disable-nls"],
380 "build_inputs": ["zlib"],
381 "native_build_inputs": ["pkg-config"]
382 }"#;
383 let pkg: NixPkg = serde_json::from_str(json).unwrap();
384 let toml = to_recipe_toml(&pkg).unwrap();
385 // Parsea como Recipe válida de hammer.
386 let recipe = hammer_core::Recipe::from_toml(&toml).expect("recipe válida");
387 assert_eq!(recipe.name, "hello");
388 assert_eq!(recipe.version, "2.12.1");
389 // mirror://gnu/ se traduce a una URL concreta que el fetch de hammer entiende.
390 assert_eq!(recipe.source.tarball.as_deref(), Some("https://ftp.gnu.org/gnu/hello/hello-2.12.1.tar.gz"));
391 assert_eq!(
392 recipe.source.sha256.as_deref(),
393 Some("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
394 );
395 assert_eq!(recipe.deps.build, vec!["pkg-config", "zlib"]);
396 assert!(recipe.build.phases.configure.as_deref().unwrap().contains("--disable-nls"));
397 }
398
399 #[test]
400 fn rust_package_gets_bin_and_install_template() {
401 let json = r#"{
402 "pname": "ripgrep", "version": "14.1.1",
403 "source": { "url": "https://github.com/BurntSushi/ripgrep/archive/14.1.1.tar.gz", "output_hash_mode": "recursive" },
404 "is_rust": true, "main_program": "rg"
405 }"#;
406 let pkg: NixPkg = serde_json::from_str(json).unwrap();
407 let recipe = hammer_core::Recipe::from_toml(&to_recipe_toml(&pkg).unwrap()).unwrap();
408 assert_eq!(recipe.build.flags, vec!["--bin", "rg"]);
409 let inst = recipe.build.phases.install.as_deref().unwrap();
410 assert!(inst.contains("cp target/release/rg /out/usr/bin/rg"), "{inst}");
411 }
412
413 #[test]
414 fn filters_nix_stdenv_noise() {
415 let json = r#"{
416 "pname": "x", "version": "1",
417 "source": { "url": "https://github.com/o/r/archive/abc.tar.gz", "output_hash_mode": "recursive" },
418 "build_inputs": ["pcre2", "cargo-build-hook.sh", "rustc", "pkg-config-wrapper"],
419 "native_build_inputs": ["install-shell-files", "version-check-hook"]
420 }"#;
421 let pkg: NixPkg = serde_json::from_str(json).unwrap();
422 let recipe = hammer_core::Recipe::from_toml(&to_recipe_toml(&pkg).unwrap()).unwrap();
423 // pcre2 y rustc sobreviven (paquetes reales); los hooks/wrappers/shell-files se filtran.
424 // Orden = inserción (native primero, luego build): los native son todos ruido ⇒ quedan
425 // pcre2, rustc en el orden de build_inputs.
426 assert_eq!(recipe.deps.build, vec!["pcre2", "rustc"]);
427 }
428
429 #[test]
430 fn expands_common_nix_mirrors() {
431 assert_eq!(
432 expand_nix_mirror("mirror://gnu/hello/hello-2.12.1.tar.gz"),
433 "https://ftp.gnu.org/gnu/hello/hello-2.12.1.tar.gz"
434 );
435 assert_eq!(
436 expand_nix_mirror("mirror://savannah/foo/foo-1.tar.gz"),
437 "https://download.savannah.gnu.org/releases/foo/foo-1.tar.gz"
438 );
439 // desconocido se deja igual (no rompe, el humano resuelve).
440 assert_eq!(expand_nix_mirror("mirror://raro/x"), "mirror://raro/x");
441 // url normal pasa sin tocar.
442 assert_eq!(expand_nix_mirror("https://x/y.tar.gz"), "https://x/y.tar.gz");
443 }
444
445 #[test]
446 fn import_github_uses_git() {
447 let json = r#"{
448 "pname": "ripgrep",
449 "version": "14.1.1",
450 "source": { "url": "https://github.com/BurntSushi/ripgrep/archive/4649aa9700619f94cf9c66876e9549d83420e16c.tar.gz",
451 "output_hash": "sha256-zzz", "output_hash_mode": "recursive" }
452 }"#;
453 let pkg: NixPkg = serde_json::from_str(json).unwrap();
454 let toml = to_recipe_toml(&pkg).unwrap();
455 let recipe = hammer_core::Recipe::from_toml(&toml).unwrap();
456 assert_eq!(recipe.source.repo.as_deref(), Some("https://github.com/BurntSushi/ripgrep"));
457 assert_eq!(
458 recipe.source.commit.as_deref(),
459 Some("4649aa9700619f94cf9c66876e9549d83420e16c")
460 );
461 assert!(recipe.source.sha256.is_none(), "github ⇒ pin por commit, sin sha256");
462 }
463}