tawasuyu

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…

TOML 1572Shell 203Rust 109Markdown 993728 Definitions

⎇ 4b1f19d8d85eda4740af94810b184b1d192427f6 crates/takana-cli/src/metal.rs Rust · 743 lines · 34.1 KB History Who touched what raw

feat(instalador): adoptar la particion en vez de formatearla, con la mudanza de verdad 4b1f19d8d · sergio ·

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