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…
⎇ 36152da9284ad15039abe44e3cb91ef42aa43bb7
crates/takana-cli/src/metal.rs
Historia
Quién tocó qué
crudo
primero se elige, después se salta — y sólo si hace falta (SDD 32)
1//! `takana metal` — instalar takana EN ESTA MÁQUINA, con UN verbo.
2//!
3//! ══ POR QUÉ EXISTE ════════════════════════════════════════════════════════════════════════════
4//! El instalador funcionaba y no se podía usar. La forma de invocarlo era ésta:
5//!
6//! ```text
7//! sudo ./takana kernel kexec ./kernel --initrd ./takana-instalador.cpio.gz \
8//! --cmdline 'console=ttyS0,115200 console=tty0' --jump
9//! ```
10//!
11//! Eso no es un instalador: es el motor con la tapa abierta. Quien lo teclea tiene que saber que
12//! existe `kexec`, que el payload va por `--initrd`, que sin `--jump` sólo se carga, y qué consola
13//! pedirle al kernel nuevo. Ninguna de esas cuatro cosas es una decisión del que instala.
14//!
15//! `takana metal` es la tapa. Tres verbos, y el orden en que se usan es el orden en que se leen:
16//!
17//! ```text
18//! takana metal check ¿puede esta máquina instalarse sin USB? (no toca nada)
19//! takana metal survey ¿qué hay en los discos y qué se puede usar? (no toca nada)
20//! takana metal install el viaje entero: comprueba, avisa y salta
21//! ```
22//!
23//! ── UN MOTOR, VARIAS CARAS (SDD 28 §8) ────────────────────────────────────────────────────────
24//! `survey` **no reimplementa el censo**: llama al mismo `takana-install-partition.sh --survey` que
25//! corre dentro del instalador. Con `--json` emite el contrato que consume una cara gráfica
26//! (churay). El riesgo que esto evita está medido en otro frente: dos censos divergen, y el que se
27//! equivoca no muestra de menos — formatea de más.
28//!
29//! ── LO QUE ESTE VERBO NO HACE ─────────────────────────────────────────────────────────────────
30//! No elige partición, no formatea y no escribe la NVRAM. Todo eso pasa DESPUÉS del salto, dentro
31//! del instalador, que es donde el disco destino puede estar desmontado. Acá sólo se comprueba, se
32//! avisa y se salta.
33
34use std::path::{Path, PathBuf};
35
36use anyhow::{bail, Context, Result};
37use clap::Subcommand;
38
39use crate::kernel_ab;
40
41/// Nombres con los que viaja el payload. Se buscan en ese orden dentro del directorio del paquete.
42const INITRD: &[&str] = &["takana-instalador.cpio.gz", "instalador.cpio.gz", "initramfs.cpio.gz"];
43const KERNEL: &[&str] = &["kernel/boot/bzImage", "bzImage", "boot/bzImage", "kernel"];
44const GUION: &[&str] = &[
45 "scripts/takana-install-partition.sh",
46 "takana-install-partition.sh",
47 "instalador/takana-install-partition.sh",
48];
49/// La consola por defecto pide las DOS: `ttyS0` es la única forma de ver por qué murió cuando no
50/// llega a pintar nada, y `tty0` es la pantalla del que instala.
51///
52/// ⚠ **El ORDEN no es cosmético: el kernel hace `/dev/console` = la ÚLTIMA `console=`.** Con `tty0`
53/// primero, `/dev/console` queda en el SERIAL — y el instalador interactivo, que lee y escribe por
54/// ahí, sale por un puerto que un portátil no tiene. La pantalla se queda en el último printk y el
55/// arranque parece colgado. `tty0` va ÚLTIMO.
56const CONSOLA: &str = "console=ttyS0,115200 console=tty0";
57
58#[derive(Subcommand)]
59pub enum MetalCmd {
60 /// ¿Puede esta máquina instalarse sin USB? Diagnóstico, no toca nada.
61 Check,
62 /// El terreno de esta máquina: qué hay en cada disco y qué se puede usar. No toca nada.
63 Survey {
64 /// Emitir el censo en JSON (el contrato que consume una cara gráfica).
65 #[arg(long)]
66 json: bool,
67 /// Directorio del paquete (por defecto, el del propio binario).
68 #[arg(long)]
69 payload: Option<PathBuf>,
70 },
71 /// Instalar takana en esta máquina: comprueba, avisa y salta al instalador.
72 Install {
73 /// Directorio del paquete (por defecto, el del propio binario).
74 #[arg(long)]
75 payload: Option<PathBuf>,
76 /// Partición destino, ya elegida. Sin esto, el instalador muestra el censo y pregunta.
77 /// **Usá `PARTUUID=…`**: los nombres `/dev/nvme…` no son estables entre arranques.
78 #[arg(long)]
79 root: Option<String>,
80 /// ESP a poblar. Sin esto se deduce del disco del destino.
81 #[arg(long)]
82 esp: Option<String>,
83 /// No formatear nada: instalar en el espacio SIN ASIGNAR más grande.
84 #[arg(long)]
85 free: bool,
86 /// Disco sobre el que buscar el hueco de `--free`.
87 #[arg(long)]
88 disk: Option<String>,
89 /// No preguntar antes de saltar. **No** hace desatendido al instalador: eso es `--unattended`.
90 #[arg(long, short = 'y')]
91 yes: bool,
92 /// Desatendido de punta a punta: el instalador tampoco pregunta. Exige `--root` o `--free`.
93 #[arg(long)]
94 unattended: bool,
95 /// Cargar el instalador en memoria y NO saltar. Ensayo: no se pierde la sesión.
96 #[arg(long)]
97 load_only: bool,
98 /// Saltar aunque no haga falta. Normalmente el salto sólo ocurre si el destino es la raíz
99 /// viva; esto lo fuerza (arneses, y para probar el salto a propósito).
100 #[arg(long)]
101 jump: bool,
102 /// Consolas para el kernel del instalador.
103 #[arg(long, default_value = CONSOLA)]
104 console: String,
105 },
106}
107
108pub fn run(cmd: MetalCmd) -> Result<()> {
109 match cmd {
110 MetalCmd::Check => check(),
111 MetalCmd::Survey { json, payload } => survey(json, payload.as_deref()),
112 MetalCmd::Install {
113 payload, root, esp, free, disk, yes, unattended, load_only, jump, console,
114 } => install(Instalacion {
115 payload, root, esp, free, disk, yes, unattended, load_only, jump, console,
116 }),
117 }
118}
119
120struct Instalacion {
121 payload: Option<PathBuf>,
122 root: Option<String>,
123 esp: Option<String>,
124 free: bool,
125 disk: Option<String>,
126 yes: bool,
127 unattended: bool,
128 load_only: bool,
129 jump: bool,
130 console: String,
131}
132
133// ── dónde está el paquete ─────────────────────────────────────────────────────────────────────
134// Por defecto, JUNTO AL BINARIO y no en el directorio actual: el paquete se copia entero a un pen
135// drive o a `~/Descargas` y se corre con `sudo ./takana …`, muchas veces desde otro sitio. Buscar
136// en el cwd manda a «no encuentro el instalador» a alguien que lo tiene al lado.
137fn dir_del_binario() -> Option<PathBuf> {
138 std::env::current_exe().ok()?.parent().map(|p| p.to_path_buf())
139}
140
141fn buscar(dirs: &[PathBuf], nombres: &[&str]) -> Option<PathBuf> {
142 for d in dirs {
143 for n in nombres {
144 let p = d.join(n);
145 if p.is_file() {
146 return Some(p);
147 }
148 }
149 }
150 None
151}
152
153fn candidatos(payload: Option<&Path>) -> Vec<PathBuf> {
154 let mut v = Vec::new();
155 if let Some(p) = payload {
156 v.push(p.to_path_buf());
157 } else {
158 if let Some(d) = dir_del_binario() {
159 v.push(d);
160 }
161 v.push(PathBuf::from("."));
162 // El árbol del repo, para el que trabaja desde adentro.
163 v.push(PathBuf::from("work/instalador-salida"));
164 // ⚠ Y las rutas de un takana INSTALADO. Sin esto, `takana metal survey` sólo funcionaba
165 // con el paquete recién copiado: llamado desde una cara gráfica que encontró `takana` en
166 // el `PATH` —que es exactamente como lo va a encontrar churay— el binario está en
167 // `/usr/bin` y el censo no está al lado, así que el censo moría con «no encuentro
168 // takana-install-partition.sh». Medido el 2026-09-23 desde `churay-cli metal`.
169 for d in ["/usr/libexec/takana", "/usr/lib/takana", "/usr/share/takana"] {
170 v.push(PathBuf::from(d));
171 }
172 }
173 v
174}
175
176// ── check ─────────────────────────────────────────────────────────────────────────────────────
177fn check() -> Result<()> {
178 let (puede, motivos) = crate::kernel_cmd::kexec_probe();
179 if puede {
180 println!("✓ esta máquina puede instalarse SIN USB: el salto al instalador está disponible.");
181 println!(" Seguí con: takana metal survey (mirar el terreno, no toca nada)");
182 println!(" sudo takana metal install");
183 return Ok(());
184 }
185 println!("✗ esta máquina NO puede saltar al instalador desde el sistema que corre:");
186 for m in &motivos {
187 println!(" · {m}");
188 }
189 println!();
190 // ⚠ Sin root el diagnóstico NO es concluyente —el kernel contesta EPERM antes de decir si
191 // implementa el syscall— así que mandar al USB acá sería mandar a la solución cara por un
192 // problema que se arregla con `sudo`. Medido en esta misma caja: como uid 1001 el motivo es
193 // «falta root»; como root aparece el de verdad, que es otro.
194 if !crate::kernel_cmd::soy_root() {
195 println!("Antes de nada: repetilo con `sudo`. Sin privilegios este diagnóstico no distingue");
196 println!("«te falta ser root» de «tu kernel no lo trae», y son cosas distintas.");
197 std::process::exit(1);
198 }
199 println!("Eso NO quiere decir que no se pueda instalar: quiere decir que hay que arrancar el");
200 println!("instalador desde otro medio. La imagen USB hace exactamente lo mismo que el salto —el");
201 println!("instalador es el mismo— y no depende de lo que traiga el kernel de esta máquina.");
202 std::process::exit(1);
203}
204
205// ── survey ────────────────────────────────────────────────────────────────────────────────────
206fn survey(json: bool, payload: Option<&Path>) -> Result<()> {
207 let guion = buscar(&candidatos(payload), GUION)
208 .ok_or_else(|| {
209 anyhow::anyhow!(
210 "no encuentro `takana-install-partition.sh` junto al binario ni en el directorio actual.\n \
211 Pasá --payload <directorio del paquete>."
212 )
213 })?;
214 let mut c = std::process::Command::new("sh");
215 c.arg(&guion).arg("--survey");
216 if json {
217 c.arg("--json");
218 }
219 let st = c
220 .status()
221 .with_context(|| format!("no puedo ejecutar {}", guion.display()))?;
222 if !st.success() {
223 bail!("el censo falló ({st})");
224 }
225 Ok(())
226}
227
228// ── install ───────────────────────────────────────────────────────────────────────────────────
229fn install(i: Instalacion) -> Result<()> {
230 if i.unattended && i.root.is_none() && !i.free {
231 bail!(
232 "--unattended sin destino: nadie va a estar ahí para elegirlo.\n \
233 Dale --root PARTUUID=… (mirá `takana metal survey`) o --free."
234 );
235 }
236 let dirs = candidatos(i.payload.as_deref());
237 let initrd = buscar(&dirs, INITRD).ok_or_else(|| {
238 anyhow::anyhow!(
239 "no encuentro el instalador ({}) en:\n {}\n Pasá --payload <directorio del paquete>.",
240 INITRD.join(" / "),
241 dirs.iter().map(|d| d.display().to_string()).collect::<Vec<_>>().join("\n ")
242 )
243 })?;
244 let bz = buscar(&dirs, KERNEL).ok_or_else(|| {
245 anyhow::anyhow!(
246 "encontré el instalador ({}) pero no su kernel ({}).\n \
247 El paquete va entero: el instalador corre sobre SU kernel, no sobre el de esta máquina.",
248 initrd.display(),
249 KERNEL.join(" / ")
250 )
251 })?;
252 let guion = buscar(&dirs, GUION);
253
254 // ── 1) PRIMERO SE ELIGE, Y SE ELIGE ACÁ ───────────────────────────────────────────────────
255 //
256 // ⚠ **Esto era al revés y costó un cuelgue en metal.** El orden viejo era «saltá primero,
257 // elegí después»: toda la conversación pasaba DESPUÉS del kexec, en una máquina cuyo panel
258 // puede no volver a encenderse tras el salto. El usuario dijo que sí, la pantalla se fue a
259 // negro y se quedó así hasta apagar con el botón — el instalador estaba vivo del otro lado,
260 // pidiendo una tecla que nadie podía ver que estaba pidiendo.
261 //
262 // Ahora se elige en el sistema que TODAVÍA FUNCIONA, y lo de después va con la respuesta
263 // puesta. Un panel muerto pasa de ser un cuelgue a ser un detalle estético.
264 let mut destino = i.root.clone();
265 let mut usar_hueco = i.free;
266 let mut disco = i.disk.clone();
267 if destino.is_none() && !usar_hueco {
268 let g = guion.as_ref().ok_or_else(|| {
269 anyhow::anyhow!(
270 "no encuentro el censo (`takana-install-partition.sh`) junto al paquete, así que no \
271 puedo preguntarte acá.\n Pasá --root PARTUUID=… o --payload <dir del paquete>."
272 )
273 })?;
274 let elec = elegir(g)?;
275 match elec {
276 None => {
277 println!("nada hecho.");
278 return Ok(());
279 }
280 Some(e) => {
281 destino = e.dest;
282 usar_hueco = e.free;
283 if e.disk.is_some() {
284 disco = e.disk;
285 }
286 }
287 }
288 }
289
290 // ── 2) ¿HACE FALTA SALTAR? ────────────────────────────────────────────────────────────────
291 // Sólo si el destino es la raíz VIVA: es la única partición que no se puede formatear desde
292 // adentro. Para cualquier otra, el salto sería matar la sesión del usuario sin necesidad.
293 let censo = censo_de(guion.as_deref());
294 let sobre_la_raiz = destino
295 .as_deref()
296 .map(|d| es_raiz_viva(&censo, d))
297 .unwrap_or(false);
298
299 let inst = Instalacion { root: destino.clone(), free: usar_hueco, disk: disco.clone(), ..i };
300
301 if !sobre_la_raiz && !inst.load_only && !inst.jump {
302 // El destino está libre: se instala DESDE ACÁ y esta sesión no se muere.
303 if let Some(g) = guion.as_ref() {
304 return instalar_aqui(g, &dirs, &inst);
305 }
306 println!("⚠ el destino no es la raíz viva, así que no haría falta saltar — pero no encuentro");
307 println!(" el censo junto al paquete para instalar desde acá. Sigo por el salto.");
308 }
309
310 // ── 3) EL SALTO: sólo acá, y explicando qué implica ───────────────────────────────────────
311 let (puede, motivos) = crate::kernel_cmd::kexec_probe();
312 if !puede {
313 println!("✗ el destino es la raíz que está corriendo AHORA, así que hay que soltarla antes");
314 println!(" de formatearla — y esta máquina no puede saltar:");
315 for m in &motivos {
316 println!(" · {m}");
317 }
318 println!();
319 if !crate::kernel_cmd::soy_root() {
320 println!("Repetilo con `sudo`: sin privilegios esto no es concluyente.");
321 } else {
322 println!("Quedan dos caminos, los dos sin tocar tu sistema actual:");
323 println!(" · instalar AL LADO, en espacio sin asignar: takana metal install --free --disk /dev/…");
324 println!(" · la imagen USB, que hace exactamente lo mismo desde otro medio.");
325 }
326 std::process::exit(1);
327 }
328
329 let cmdline = cmdline_de(&inst);
330 println!();
331 println!("── el salto ──────────────────────────────────────────────────────────────────");
332 if sobre_la_raiz {
333 println!(" El destino es la raíz que estás usando: no se puede formatear desde adentro.");
334 println!(" Para soltarla, esta máquina salta a un instalador que corre en RAM.");
335 } else {
336 println!(" El destino NO es la raíz viva, así que este salto no haría falta: lo pediste");
337 println!(" a mano con --jump. El instalador corre igual, en RAM.");
338 }
339 println!();
340 println!(" destino: {}", inst.root.as_deref().unwrap_or("el espacio sin asignar"));
341 println!(" instalador: {} ({})", initrd.display(), tam(&initrd));
342 println!(" su kernel: {} ({})", bz.display(), tam(&bz));
343 println!();
344 println!(" Qué implica, en orden:");
345 println!(" 1. TODO lo que está corriendo muere en el acto. Guardá antes.");
346 println!(" 2. La pantalla puede quedarse en negro un rato, o no volver hasta el reinicio:");
347 println!(" un kexec no reinicia el hardware de vídeo. **No es un cuelgue** — ya no hay");
348 println!(" nada que preguntarte: la elección ya está hecha y el instalador la lleva puesta.");
349 println!(" 3. Al terminar, la máquina reinicia sola.");
350 if en_sesion_grafica() {
351 println!();
352 println!(" ⚠ Esto parece una sesión GRÁFICA. El salto es mucho más sano desde una consola de");
353 println!(" texto: pasá a una con Ctrl+Alt+F2, entrá, y corré esto ahí.");
354 }
355 println!("──────────────────────────────────────────────────────────────────────────────");
356
357 if !inst.yes && !inst.load_only && !confirmar()? {
358 println!("nada hecho.");
359 return Ok(());
360 }
361
362 kernel_ab::kexec_cargar(&bz, Some(&initrd), &cmdline)?;
363 println!("✓ instalador cargado en memoria");
364 if inst.load_only {
365 println!(" (--load-only: no se salta. Queda cargado hasta el próximo reinicio.)");
366 return Ok(());
367 }
368 println!("⚡ saltando — a partir de acá no hace falta mirar nada.");
369 kernel_ab::kexec_saltar()
370}
371
372/// Lo que devolvió el menú.
373struct Eleccion {
374 dest: Option<String>,
375 free: bool,
376 disk: Option<String>,
377}
378
379/// Corre el censo en modo `--pick`: muestra el terreno, deja elegir y **no toca nada**.
380fn elegir(guion: &Path) -> Result<Option<Eleccion>> {
381 let tmp = std::env::temp_dir().join(format!("takana-eleccion.{}", std::process::id()));
382 let st = std::process::Command::new("sh")
383 .arg(guion)
384 .arg("--pick")
385 .arg(&tmp)
386 .status()
387 .with_context(|| format!("no puedo ejecutar {}", guion.display()))?;
388 if !st.success() {
389 let _ = std::fs::remove_file(&tmp);
390 return Ok(None);
391 }
392 let txt = std::fs::read_to_string(&tmp).unwrap_or_default();
393 let _ = std::fs::remove_file(&tmp);
394 let mut e = Eleccion { dest: None, free: false, disk: None };
395 for l in txt.lines() {
396 match l.split_once('=') {
397 Some(("DEST", v)) => e.dest = Some(v.to_string()),
398 Some(("DISK", v)) => e.disk = Some(v.to_string()),
399 Some(("FREE", _)) => e.free = true,
400 _ => {}
401 }
402 }
403 if e.dest.is_none() && !e.free {
404 return Ok(None);
405 }
406 Ok(Some(e))
407}
408
409/// El censo en JSON, o vacío si no se pudo. **Vacío no es «no hay discos»**: acá sólo se usa para
410/// una pregunta concreta —¿el destino es la raíz viva?— y no saberlo se trata como «no».
411fn censo_de(guion: Option<&Path>) -> String {
412 let Some(g) = guion else { return String::new() };
413 std::process::Command::new("sh")
414 .arg(g)
415 .args(["--survey", "--json"])
416 .output()
417 .ok()
418 .filter(|o| o.status.success())
419 .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
420 .unwrap_or_default()
421}
422
423/// ¿El destino elegido es la raíz viva? Se lee del censo, que es quien lo decide.
424///
425/// Sin parsear JSON de verdad: se busca el bloque de esa partición y se mira si su veredicto es
426/// `RAIZ_VIVA`. Un formato de una línea por partición hace que esto sea exacto; si el censo
427/// cambiara de forma, esto deja de encontrar nada y el resultado es «no es la raíz» ⇒ se instala
428/// sin saltar y el instalador aborta con su propia guarda. Se equivoca del lado que no destruye.
429fn es_raiz_viva(censo_json: &str, destino: &str) -> bool {
430 let clave = destino.trim_start_matches("PARTUUID=").trim_start_matches("partuuid=");
431 for linea in censo_json.lines() {
432 let l = linea.trim();
433 if !l.starts_with('{') {
434 continue;
435 }
436 let casa = l.contains(&format!("\"partuuid\": \"{clave}\""))
437 || l.contains(&format!("\"dev\": \"{destino}\""));
438 if casa {
439 return l.contains("\"veredicto\": \"RAIZ_VIVA\"");
440 }
441 }
442 false
443}
444
445/// ¿Estamos dentro de una sesión gráfica? Sólo para AVISAR, nunca para decidir.
446///
447/// Un kexec no reinicia el hardware de vídeo, y saltar desde un compositor con la GPU tomada es la
448/// forma más fácil de quedarse con el panel apagado del otro lado. Desde una VT de texto el salto
449/// es mucho más sano. No se bloquea: se avisa, porque también hay quien sabe lo que hace.
450fn en_sesion_grafica() -> bool {
451 ["WAYLAND_DISPLAY", "DISPLAY", "XDG_SESSION_TYPE"]
452 .iter()
453 .any(|v| std::env::var(v).map(|x| !x.is_empty() && x != "tty").unwrap_or(false))
454}
455
456/// Instala **desde esta máquina**, sin saltar. Es el camino normal cuando el destino no es la raíz
457/// viva: no hay ninguna razón para matar la sesión del usuario.
458///
459/// El árbol que se copia vive comprimido dentro del initramfs del instalador, así que se
460/// desempaqueta primero — con el `busybox` que viaja en el paquete y no con el del host, que puede
461/// no existir (en las tres máquinas de este proyecto no hay `cpio`).
462fn instalar_aqui(guion: &Path, dirs: &[PathBuf], i: &Instalacion) -> Result<()> {
463 let bb = buscar(dirs, &["busybox", "bin/busybox"]).ok_or_else(|| {
464 anyhow::anyhow!(
465 "el paquete no trae `busybox`, que es con lo que se desempaqueta el árbol a instalar.\n \
466 Rearmá el paquete con scripts/instalador-paquete.sh."
467 )
468 })?;
469 let initrd = buscar(dirs, INITRD).expect("ya se resolvió antes");
470
471 println!();
472 println!("── instalar desde acá, SIN saltar ────────────────────────────────────────────");
473 println!(" El destino no es la raíz que estás usando, así que no hace falta soltarla:");
474 println!(" esta sesión sigue viva y al terminar reiniciás cuando quieras.");
475 println!(" destino: {}", i.root.as_deref().unwrap_or("el espacio sin asignar"));
476 println!("──────────────────────────────────────────────────────────────────────────────");
477 if !i.yes && !confirmar()? {
478 println!("nada hecho.");
479 return Ok(());
480 }
481
482 let tmp = std::env::temp_dir().join(format!("takana-arbol.{}", std::process::id()));
483 std::fs::create_dir_all(&tmp)?;
484 println!("==> desempaquetando el árbol a instalar en {}", tmp.display());
485 let sh = format!(
486 "{bb} gzip -d -c {ird} | ( cd {d} && {bb} cpio -i -d --quiet )",
487 bb = bb.display(),
488 ird = initrd.display(),
489 d = tmp.display()
490 );
491 let st = std::process::Command::new("sh").arg("-c").arg(&sh).status()?;
492 if !st.success() {
493 bail!("no pude desempaquetar {}", initrd.display());
494 }
495
496 let mut c = std::process::Command::new("sh");
497 c.arg(guion);
498 if let Some(r) = &i.root {
499 c.arg("--root-part").arg(r);
500 }
501 if i.free {
502 c.arg("--use-free-space");
503 }
504 if let Some(d) = &i.disk {
505 c.arg("--disk").arg(d);
506 }
507 c.arg("--yes");
508 // Las herramientas y el árbol salen del paquete: acá no hay store del que resolverlas por hash.
509 for (v, rel) in [
510 ("TOOL_mke2fs", "bin/mke2fs"),
511 ("TOOL_sfdisk", "bin/sfdisk"),
512 ("TOOL_mmd", "bin/mmd"),
513 ("TOOL_mcopy", "bin/mcopy"),
514 ("TOOL_mdir", "bin/mdir"),
515 ] {
516 c.env(v, tmp.join(rel));
517 }
518 c.env("STAGE", tmp.join("payload/stage"))
519 .env("STAGE_LISTO", "1")
520 .env("KERNEL", tmp.join("payload/bzImage"))
521 .env("HAMMER", tmp.join("bin/takana"));
522 let st = c.status()?;
523 let _ = std::process::Command::new("rm").arg("-rf").arg(&tmp).status();
524 if !st.success() {
525 bail!("el instalador falló ({st})");
526 }
527 println!();
528 println!("✓ listo. Reiniciá cuando quieras y elegí takana en el firmware.");
529 Ok(())
530}
531
532/// La cmdline del instalador: las respuestas que ya se dieron viajan por acá, y las que no, no.
533/// Es el mismo canal que lee el `/init` del initramfs, así que **no hay nada horneado por máquina**.
534fn cmdline_de(i: &Instalacion) -> String {
535 let mut s = i.console.clone();
536 if let Some(r) = &i.root {
537 s.push_str(&format!(" takana.root={r}"));
538 }
539 if let Some(e) = &i.esp {
540 s.push_str(&format!(" takana.esp={e}"));
541 }
542 if let Some(d) = &i.disk {
543 s.push_str(&format!(" takana.disk={d}"));
544 }
545 if i.free {
546 s.push_str(" takana.free");
547 }
548 if i.unattended {
549 s.push_str(" takana.yes");
550 }
551 s
552}
553
554fn tam(p: &Path) -> String {
555 match std::fs::metadata(p).map(|m| m.len()) {
556 Ok(b) if b >= 1 << 20 => format!("{} M", b >> 20),
557 Ok(b) => format!("{} K", b >> 10),
558 Err(_) => "?".to_string(),
559 }
560}
561
562/// Confirmación explícita. **No** acepta un `enter` pelado: el enter es lo que se aprieta sin leer,
563/// y lo que sigue mata la sesión.
564fn confirmar() -> Result<bool> {
565 use std::io::{BufRead, Write};
566 print!("¿Saltar al instalador? Escribí «si» para seguir: ");
567 std::io::stdout().flush().ok();
568 let mut l = String::new();
569 if std::io::stdin().lock().read_line(&mut l).is_err() {
570 return Ok(false);
571 }
572 let l = l.trim().to_lowercase();
573 Ok(l == "si" || l == "sí")
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579
580 fn base() -> Instalacion {
581 Instalacion {
582 payload: None, root: None, esp: None, free: false, disk: None,
583 yes: false, unattended: false, load_only: false, jump: false,
584 console: CONSOLA.to_string(),
585 }
586 }
587
588 #[test]
589 fn sin_respuestas_la_cmdline_no_lleva_ninguna_maquina_adentro() {
590 // El punto entero de la generalización: un paquete sirve en cualquier máquina porque no
591 // trae respuestas horneadas. Si esto empieza a fallar, alguien volvió a hornear.
592 assert_eq!(cmdline_de(&base()), CONSOLA);
593 }
594
595 #[test]
596 fn las_respuestas_dadas_viajan_por_la_cmdline() {
597 let i = Instalacion {
598 root: Some("PARTUUID=55b5-0de7".into()),
599 esp: Some("PARTUUID=aaaa-bbbb".into()),
600 unattended: true,
601 ..base()
602 };
603 let c = cmdline_de(&i);
604 assert!(c.contains("takana.root=PARTUUID=55b5-0de7"), "{c}");
605 assert!(c.contains("takana.esp=PARTUUID=aaaa-bbbb"), "{c}");
606 assert!(c.contains("takana.yes"), "{c}");
607 assert!(!c.contains("takana.free"), "{c}");
608 }
609
610 #[test]
611 fn free_y_disk_viajan_juntos_y_sin_destino() {
612 let i = Instalacion { free: true, disk: Some("/dev/nvme0n1".into()), ..base() };
613 let c = cmdline_de(&i);
614 assert!(c.contains("takana.free"), "{c}");
615 assert!(c.contains("takana.disk=/dev/nvme0n1"), "{c}");
616 assert!(!c.contains("takana.root="), "{c}");
617 }
618
619 const CENSO: &str = r#"{
620 "version": 1,
621 "raiz_viva": "8:2",
622 "particiones": [
623 {"dev": "/dev/sda2", "partuuid": "AAAA", "mib": 6144, "veredicto": "RAIZ_VIVA", "usable": true},
624 {"dev": "/dev/sda5", "partuuid": "BBBB", "mib": 40960, "veredicto": "CANDIDATA", "usable": true}
625 ]
626}"#;
627
628 #[test]
629 fn el_salto_hace_falta_solo_para_la_raiz_viva() {
630 // Es la decisión que reordenó todo: saltar cuando no hace falta es matar la sesión del
631 // usuario por nada, y el cuelgue que lo destapó pasó justamente ahí.
632 assert!(es_raiz_viva(CENSO, "PARTUUID=AAAA"));
633 assert!(!es_raiz_viva(CENSO, "PARTUUID=BBBB"));
634 assert!(es_raiz_viva(CENSO, "/dev/sda2"));
635 assert!(!es_raiz_viva(CENSO, "/dev/sda5"));
636 }
637
638 #[test]
639 fn un_destino_que_el_censo_no_conoce_no_se_toma_por_la_raiz_viva() {
640 // Se equivoca del lado que NO destruye: si esto diera «sí», se saltaría sin necesidad; si
641 // da «no», se instala desde acá y la guarda del propio instalador aborta si el destino
642 // resulta ser la raíz. Un error de más contra un error que mata la sesión.
643 assert!(!es_raiz_viva(CENSO, "PARTUUID=ZZZZ"));
644 assert!(!es_raiz_viva("", "PARTUUID=AAAA"));
645 assert!(!es_raiz_viva("esto no es json", "PARTUUID=AAAA"));
646 }
647
648 #[test]
649 fn buscar_respeta_el_orden_de_los_nombres_y_de_los_directorios() {
650 let d = std::env::temp_dir().join(format!("metal-test-{}", std::process::id()));
651 let a = d.join("a");
652 let b = d.join("b");
653 std::fs::create_dir_all(&a).unwrap();
654 std::fs::create_dir_all(&b).unwrap();
655 std::fs::write(b.join("initramfs.cpio.gz"), b"x").unwrap();
656 std::fs::write(b.join("takana-instalador.cpio.gz"), b"x").unwrap();
657 // El directorio gana sobre el nombre: `a` no lo tiene, así que se cae a `b`.
658 let v = vec![a.clone(), b.clone()];
659 assert_eq!(buscar(&v, INITRD).unwrap(), b.join("takana-instalador.cpio.gz"));
660 std::fs::write(a.join("initramfs.cpio.gz"), b"x").unwrap();
661 // Y ahora `a` gana aunque su nombre sea el último de la lista: primero el directorio.
662 assert_eq!(buscar(&v, INITRD).unwrap(), a.join("initramfs.cpio.gz"));
663 std::fs::remove_dir_all(&d).ok();
664 }
665}