//! `takana metal` — instalar takana EN ESTA MÁQUINA, con UN verbo. //! //! ══ POR QUÉ EXISTE ════════════════════════════════════════════════════════════════════════════ //! El instalador funcionaba y no se podía usar. La forma de invocarlo era ésta: //! //! ```text //! sudo ./takana kernel kexec ./kernel --initrd ./takana-instalador.cpio.gz \ //! --cmdline 'console=ttyS0,115200 console=tty0' --jump //! ``` //! //! Eso no es un instalador: es el motor con la tapa abierta. Quien lo teclea tiene que saber que //! existe `kexec`, que el payload va por `--initrd`, que sin `--jump` sólo se carga, y qué consola //! pedirle al kernel nuevo. Ninguna de esas cuatro cosas es una decisión del que instala. //! //! `takana metal` es la tapa. Tres verbos, y el orden en que se usan es el orden en que se leen: //! //! ```text //! takana metal check ¿puede esta máquina instalarse sin USB? (no toca nada) //! takana metal survey ¿qué hay en los discos y qué se puede usar? (no toca nada) //! takana metal install el viaje entero: comprueba, avisa y salta //! ``` //! //! ── UN MOTOR, VARIAS CARAS (SDD 28 §8) ──────────────────────────────────────────────────────── //! `survey` **no reimplementa el censo**: llama al mismo `takana-install-partition.sh --survey` que //! corre dentro del instalador. Con `--json` emite el contrato que consume una cara gráfica //! (churay). El riesgo que esto evita está medido en otro frente: dos censos divergen, y el que se //! equivoca no muestra de menos — formatea de más. //! //! ── EL CONTRATO, VERSIÓN 2 (2026-09-25) ─────────────────────────────────────────────────────── //! La v1 alcanzaba para una LISTA y no para un DIBUJO: sin saber a qué disco pertenece cada fila ni //! en qué byte empieza, no se sabe qué va al lado de qué ni qué hueco queda entre dos. La v2 agrega //! lo que hace falta para pintar el terreno, y **ni un campo de decisión**: `veredicto` y `usable` //! siguen siendo los únicos que deciden, y siguen saliendo de acá. //! //! Por fila: `clase` (`particion` · `hueco` · `disco`), `disco`, `inicio_bytes`, `tamano_bytes`, //! `tipo_guid`. Y un array `discos` con el APARATO: `modelo`, `removible`, `tamano_bytes`, //! `sector_bytes`, `tabla`. //! //! ⚠ **`tamano_bytes` existe además de `mib` porque `mib` está redondeado**: una barra proporcional //! construida con MiB acumula error a lo largo del disco y el hueco final sale corrido. //! //! ⚠ **Un `hueco` NO tiene `dev` propio**: su `dev` es el del disco, y dos huecos del mismo disco //! traen el MISMO `dev`. Su identidad es `disco` + `inicio_bytes`. Una cara que los indexe por //! `dev` se queda con uno solo y pierde el otro sin que nada falle. //! //! ⚠ El aparato va en un array propio y **no repetido en cada fila**: un dato del disco copiado en //! cada partición se puede contradecir entre filas, y entonces hay que decidir a cuál creerle. //! //! ── LO QUE ESTE VERBO NO HACE ───────────────────────────────────────────────────────────────── //! No elige partición, no formatea y no escribe la NVRAM. Todo eso pasa DESPUÉS del salto, dentro //! del instalador, que es donde el disco destino puede estar desmontado. Acá sólo se comprueba, se //! avisa y se salta. use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use clap::Subcommand; use crate::kernel_ab; /// Nombres con los que viaja el payload. Se buscan en ese orden dentro del directorio del paquete. const INITRD: &[&str] = &["takana-instalador.cpio.gz", "instalador.cpio.gz", "initramfs.cpio.gz"]; const KERNEL: &[&str] = &["kernel/boot/bzImage", "bzImage", "boot/bzImage", "kernel"]; const GUION: &[&str] = &[ "scripts/takana-install-partition.sh", "takana-install-partition.sh", "instalador/takana-install-partition.sh", ]; /// La consola por defecto pide las DOS: `ttyS0` es la única forma de ver por qué murió cuando no /// llega a pintar nada, y `tty0` es la pantalla del que instala. /// /// ⚠ **El ORDEN no es cosmético: el kernel hace `/dev/console` = la ÚLTIMA `console=`.** Con `tty0` /// primero, `/dev/console` queda en el SERIAL — y el instalador interactivo, que lee y escribe por /// ahí, sale por un puerto que un portátil no tiene. La pantalla se queda en el último printk y el /// arranque parece colgado. `tty0` va ÚLTIMO. const CONSOLA: &str = "console=ttyS0,115200 console=tty0"; #[derive(Subcommand)] pub enum MetalCmd { /// ¿Puede esta máquina instalarse sin USB? Diagnóstico, no toca nada. Check, /// El terreno de esta máquina: qué hay en cada disco y qué se puede usar. No toca nada. Survey { /// Emitir el censo en JSON (el contrato que consume una cara gráfica). #[arg(long)] json: bool, /// Directorio del paquete (por defecto, el del propio binario). #[arg(long)] payload: Option, }, /// Instalar takana en esta máquina: comprueba, avisa y salta al instalador. Install { /// Directorio del paquete (por defecto, el del propio binario). #[arg(long)] payload: Option, /// Partición destino, ya elegida. Sin esto, el instalador muestra el censo y pregunta. /// **Usá `PARTUUID=…`**: los nombres `/dev/nvme…` no son estables entre arranques. #[arg(long)] root: Option, /// ESP a poblar. Sin esto se deduce del disco del destino. #[arg(long)] esp: Option, /// No formatear nada: instalar en el espacio SIN ASIGNAR más grande. #[arg(long)] free: bool, /// CONSERVAR una partición y montarla en el sistema nuevo sin formatearla: /// `--keep PARTUUID=…:/home`. Hoy el único punto admitido es `/home`. Los usuarios que /// haya ahí se crean con **su mismo uid**, para que sus archivos sigan siendo suyos. /// /// ⚠ Viaja por la cmdline del kernel, que separa por ESPACIOS: un `LABEL=` con espacios no /// se puede pasar por acá. Usá `PARTUUID=`, que además es la identidad estable. #[arg(long)] keep: Option, /// NO FORMATEAR el destino: mover el sistema que ya hay ahí a `/sistema-anterior` y dejar /// su `/home` donde está, con sus archivos y sus dueños. /// /// Es el caso más común de todos —una sola partición con un Linux y el `/home` adentro— y /// el que peor resuelve formatear: lo único irreponible de una máquina es el `/home` de /// quien la usa. Mover dentro del mismo filesystem no copia un byte, y el sistema anterior /// queda entero y accesible, que es mejor que un respaldo. #[arg(long)] adopt: bool, /// Disco sobre el que buscar el hueco de `--free`. #[arg(long)] disk: Option, /// No preguntar antes de saltar. **No** hace desatendido al instalador: eso es `--unattended`. #[arg(long, short = 'y')] yes: bool, /// Desatendido de punta a punta: el instalador tampoco pregunta. Exige `--root` o `--free`. #[arg(long)] unattended: bool, /// Cargar el instalador en memoria y NO saltar. Ensayo: no se pierde la sesión. #[arg(long)] load_only: bool, /// Saltar aunque no haga falta. Normalmente el salto sólo ocurre si el destino es la raíz /// viva; esto lo fuerza (arneses, y para probar el salto a propósito). #[arg(long)] jump: bool, /// Consolas para el kernel del instalador. #[arg(long, default_value = CONSOLA)] console: String, }, } pub fn run(cmd: MetalCmd) -> Result<()> { match cmd { MetalCmd::Check => check(), MetalCmd::Survey { json, payload } => survey(json, payload.as_deref()), MetalCmd::Install { payload, root, esp, free, keep, adopt, disk, yes, unattended, load_only, jump, console, } => install(Instalacion { payload, root, esp, free, keep, adopt, disk, yes, unattended, load_only, jump, console, }), } } struct Instalacion { payload: Option, root: Option, esp: Option, free: bool, keep: Option, adopt: bool, disk: Option, yes: bool, unattended: bool, load_only: bool, jump: bool, console: String, } // ── dónde está el paquete ───────────────────────────────────────────────────────────────────── // Por defecto, JUNTO AL BINARIO y no en el directorio actual: el paquete se copia entero a un pen // drive o a `~/Descargas` y se corre con `sudo ./takana …`, muchas veces desde otro sitio. Buscar // en el cwd manda a «no encuentro el instalador» a alguien que lo tiene al lado. fn dir_del_binario() -> Option { std::env::current_exe().ok()?.parent().map(|p| p.to_path_buf()) } fn buscar(dirs: &[PathBuf], nombres: &[&str]) -> Option { for d in dirs { for n in nombres { let p = d.join(n); if p.is_file() { return Some(p); } } } None } fn candidatos(payload: Option<&Path>) -> Vec { let mut v = Vec::new(); if let Some(p) = payload { v.push(p.to_path_buf()); } else { if let Some(d) = dir_del_binario() { v.push(d); } v.push(PathBuf::from(".")); // El árbol del repo, para el que trabaja desde adentro. v.push(PathBuf::from("work/instalador-salida")); // ⚠ Y las rutas de un takana INSTALADO. Sin esto, `takana metal survey` sólo funcionaba // con el paquete recién copiado: llamado desde una cara gráfica que encontró `takana` en // el `PATH` —que es exactamente como lo va a encontrar churay— el binario está en // `/usr/bin` y el censo no está al lado, así que el censo moría con «no encuentro // takana-install-partition.sh». Medido el 2026-09-23 desde `churay-cli metal`. for d in ["/usr/libexec/takana", "/usr/lib/takana", "/usr/share/takana"] { v.push(PathBuf::from(d)); } } v } // ── check ───────────────────────────────────────────────────────────────────────────────────── fn check() -> Result<()> { let (puede, motivos) = crate::kernel_cmd::kexec_probe(); if puede { println!("✓ esta máquina puede instalarse SIN USB: el salto al instalador está disponible."); println!(" Seguí con: takana metal survey (mirar el terreno, no toca nada)"); println!(" sudo takana metal install"); return Ok(()); } println!("✗ esta máquina NO puede saltar al instalador desde el sistema que corre:"); for m in &motivos { println!(" · {m}"); } println!(); // ⚠ Sin root el diagnóstico NO es concluyente —el kernel contesta EPERM antes de decir si // implementa el syscall— así que mandar al USB acá sería mandar a la solución cara por un // problema que se arregla con `sudo`. Medido en esta misma caja: como uid 1001 el motivo es // «falta root»; como root aparece el de verdad, que es otro. if !crate::kernel_cmd::soy_root() { println!("Antes de nada: repetilo con `sudo`. Sin privilegios este diagnóstico no distingue"); println!("«te falta ser root» de «tu kernel no lo trae», y son cosas distintas."); std::process::exit(1); } println!("Eso NO quiere decir que no se pueda instalar: quiere decir que hay que arrancar el"); println!("instalador desde otro medio. La imagen USB hace exactamente lo mismo que el salto —el"); println!("instalador es el mismo— y no depende de lo que traiga el kernel de esta máquina."); std::process::exit(1); } // ── survey ──────────────────────────────────────────────────────────────────────────────────── fn survey(json: bool, payload: Option<&Path>) -> Result<()> { let guion = buscar(&candidatos(payload), GUION) .ok_or_else(|| { anyhow::anyhow!( "no encuentro `takana-install-partition.sh` junto al binario ni en el directorio actual.\n \ Pasá --payload ." ) })?; let mut c = std::process::Command::new("sh"); c.arg(&guion).arg("--survey"); if json { c.arg("--json"); } let st = c .status() .with_context(|| format!("no puedo ejecutar {}", guion.display()))?; if !st.success() { bail!("el censo falló ({st})"); } Ok(()) } // ── install ─────────────────────────────────────────────────────────────────────────────────── fn install(i: Instalacion) -> Result<()> { if i.unattended && i.root.is_none() && !i.free { bail!( "--unattended sin destino: nadie va a estar ahí para elegirlo.\n \ Dale --root PARTUUID=… (mirá `takana metal survey`) o --free." ); } let dirs = candidatos(i.payload.as_deref()); let initrd = buscar(&dirs, INITRD).ok_or_else(|| { anyhow::anyhow!( "no encuentro el instalador ({}) en:\n {}\n Pasá --payload .", INITRD.join(" / "), dirs.iter().map(|d| d.display().to_string()).collect::>().join("\n ") ) })?; let bz = buscar(&dirs, KERNEL).ok_or_else(|| { anyhow::anyhow!( "encontré el instalador ({}) pero no su kernel ({}).\n \ El paquete va entero: el instalador corre sobre SU kernel, no sobre el de esta máquina.", initrd.display(), KERNEL.join(" / ") ) })?; let guion = buscar(&dirs, GUION); // ── 1) PRIMERO SE ELIGE, Y SE ELIGE ACÁ ─────────────────────────────────────────────────── // // ⚠ **Esto era al revés y costó un cuelgue en metal.** El orden viejo era «saltá primero, // elegí después»: toda la conversación pasaba DESPUÉS del kexec, en una máquina cuyo panel // puede no volver a encenderse tras el salto. El usuario dijo que sí, la pantalla se fue a // negro y se quedó así hasta apagar con el botón — el instalador estaba vivo del otro lado, // pidiendo una tecla que nadie podía ver que estaba pidiendo. // // Ahora se elige en el sistema que TODAVÍA FUNCIONA, y lo de después va con la respuesta // puesta. Un panel muerto pasa de ser un cuelgue a ser un detalle estético. let mut destino = i.root.clone(); let mut usar_hueco = i.free; let mut disco = i.disk.clone(); if destino.is_none() && !usar_hueco { let g = guion.as_ref().ok_or_else(|| { anyhow::anyhow!( "no encuentro el censo (`takana-install-partition.sh`) junto al paquete, así que no \ puedo preguntarte acá.\n Pasá --root PARTUUID=… o --payload ." ) })?; let elec = elegir(g)?; match elec { None => { println!("nada hecho."); return Ok(()); } Some(e) => { destino = e.dest; usar_hueco = e.free; if e.disk.is_some() { disco = e.disk; } } } } // ── 2) ¿HACE FALTA SALTAR? ──────────────────────────────────────────────────────────────── // Sólo si el destino es la raíz VIVA: es la única partición que no se puede formatear desde // adentro. Para cualquier otra, el salto sería matar la sesión del usuario sin necesidad. let censo = censo_de(guion.as_deref()); let sobre_la_raiz = destino .as_deref() .map(|d| es_raiz_viva(&censo, d)) .unwrap_or(false); let inst = Instalacion { root: destino.clone(), free: usar_hueco, disk: disco.clone(), ..i }; if !sobre_la_raiz && !inst.load_only && !inst.jump { // El destino está libre: se instala DESDE ACÁ y esta sesión no se muere. if let Some(g) = guion.as_ref() { return instalar_aqui(g, &dirs, &inst); } println!("⚠ el destino no es la raíz viva, así que no haría falta saltar — pero no encuentro"); println!(" el censo junto al paquete para instalar desde acá. Sigo por el salto."); } // ── 3) EL SALTO: sólo acá, y explicando qué implica ─────────────────────────────────────── let (puede, motivos) = crate::kernel_cmd::kexec_probe(); if !puede { println!("✗ el destino es la raíz que está corriendo AHORA, así que hay que soltarla antes"); println!(" de formatearla — y esta máquina no puede saltar:"); for m in &motivos { println!(" · {m}"); } println!(); if !crate::kernel_cmd::soy_root() { println!("Repetilo con `sudo`: sin privilegios esto no es concluyente."); } else { println!("Quedan dos caminos, los dos sin tocar tu sistema actual:"); println!(" · instalar AL LADO, en espacio sin asignar: takana metal install --free --disk /dev/…"); println!(" · la imagen USB, que hace exactamente lo mismo desde otro medio."); } std::process::exit(1); } let cmdline = cmdline_de(&inst); println!(); println!("── el salto ──────────────────────────────────────────────────────────────────"); if sobre_la_raiz { println!(" El destino es la raíz que estás usando: no se puede formatear desde adentro."); println!(" Para soltarla, esta máquina salta a un instalador que corre en RAM."); } else { println!(" El destino NO es la raíz viva, así que este salto no haría falta: lo pediste"); println!(" a mano con --jump. El instalador corre igual, en RAM."); } println!(); println!(" destino: {}", inst.root.as_deref().unwrap_or("el espacio sin asignar")); println!(" instalador: {} ({})", initrd.display(), tam(&initrd)); println!(" su kernel: {} ({})", bz.display(), tam(&bz)); println!(); println!(" Qué implica, en orden:"); println!(" 1. TODO lo que está corriendo muere en el acto. Guardá antes."); println!(" 2. La pantalla puede quedarse en negro un rato, o no volver hasta el reinicio:"); println!(" un kexec no reinicia el hardware de vídeo. **No es un cuelgue** — ya no hay"); println!(" nada que preguntarte: la elección ya está hecha y el instalador la lleva puesta."); println!(" 3. Al terminar, la máquina reinicia sola."); if en_sesion_grafica() { println!(); println!(" ⚠ Esto parece una sesión GRÁFICA. El salto es mucho más sano desde una consola de"); println!(" texto: pasá a una con Ctrl+Alt+F2, entrá, y corré esto ahí."); } println!("──────────────────────────────────────────────────────────────────────────────"); if !inst.yes && !inst.load_only && !confirmar()? { println!("nada hecho."); return Ok(()); } kernel_ab::kexec_cargar(&bz, Some(&initrd), &cmdline)?; println!("✓ instalador cargado en memoria"); if inst.load_only { println!(" (--load-only: no se salta. Queda cargado hasta el próximo reinicio.)"); return Ok(()); } println!("⚡ saltando — a partir de acá no hace falta mirar nada."); kernel_ab::kexec_saltar() } /// Lo que devolvió el menú. struct Eleccion { dest: Option, free: bool, disk: Option, } /// Corre el censo en modo `--pick`: muestra el terreno, deja elegir y **no toca nada**. fn elegir(guion: &Path) -> Result> { let tmp = std::env::temp_dir().join(format!("takana-eleccion.{}", std::process::id())); let st = std::process::Command::new("sh") .arg(guion) .arg("--pick") .arg(&tmp) .status() .with_context(|| format!("no puedo ejecutar {}", guion.display()))?; if !st.success() { let _ = std::fs::remove_file(&tmp); return Ok(None); } let txt = std::fs::read_to_string(&tmp).unwrap_or_default(); let _ = std::fs::remove_file(&tmp); let mut e = Eleccion { dest: None, free: false, disk: None }; for l in txt.lines() { match l.split_once('=') { Some(("DEST", v)) => e.dest = Some(v.to_string()), Some(("DISK", v)) => e.disk = Some(v.to_string()), Some(("FREE", _)) => e.free = true, _ => {} } } if e.dest.is_none() && !e.free { return Ok(None); } Ok(Some(e)) } /// El censo en JSON, o vacío si no se pudo. **Vacío no es «no hay discos»**: acá sólo se usa para /// una pregunta concreta —¿el destino es la raíz viva?— y no saberlo se trata como «no». fn censo_de(guion: Option<&Path>) -> String { let Some(g) = guion else { return String::new() }; std::process::Command::new("sh") .arg(g) .args(["--survey", "--json"]) .output() .ok() .filter(|o| o.status.success()) .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) .unwrap_or_default() } /// ¿El destino elegido es la raíz viva? Se lee del censo, que es quien lo decide. /// /// Sin parsear JSON de verdad: se busca el bloque de esa partición y se mira si su veredicto es /// `RAIZ_VIVA`. Un formato de una línea por partición hace que esto sea exacto; si el censo /// cambiara de forma, esto deja de encontrar nada y el resultado es «no es la raíz» ⇒ se instala /// sin saltar y el instalador aborta con su propia guarda. Se equivoca del lado que no destruye. fn es_raiz_viva(censo_json: &str, destino: &str) -> bool { let clave = destino.trim_start_matches("PARTUUID=").trim_start_matches("partuuid="); for linea in censo_json.lines() { let l = linea.trim(); if !l.starts_with('{') { continue; } let casa = l.contains(&format!("\"partuuid\": \"{clave}\"")) || l.contains(&format!("\"dev\": \"{destino}\"")); if casa { return l.contains("\"veredicto\": \"RAIZ_VIVA\""); } } false } /// ¿Estamos dentro de una sesión gráfica? Sólo para AVISAR, nunca para decidir. /// /// Un kexec no reinicia el hardware de vídeo, y saltar desde un compositor con la GPU tomada es la /// forma más fácil de quedarse con el panel apagado del otro lado. Desde una VT de texto el salto /// es mucho más sano. No se bloquea: se avisa, porque también hay quien sabe lo que hace. fn en_sesion_grafica() -> bool { ["WAYLAND_DISPLAY", "DISPLAY", "XDG_SESSION_TYPE"] .iter() .any(|v| std::env::var(v).map(|x| !x.is_empty() && x != "tty").unwrap_or(false)) } /// Instala **desde esta máquina**, sin saltar. Es el camino normal cuando el destino no es la raíz /// viva: no hay ninguna razón para matar la sesión del usuario. /// /// El árbol que se copia vive comprimido dentro del initramfs del instalador, así que se /// desempaqueta primero — con el `busybox` que viaja en el paquete y no con el del host, que puede /// no existir (en las tres máquinas de este proyecto no hay `cpio`). fn instalar_aqui(guion: &Path, dirs: &[PathBuf], i: &Instalacion) -> Result<()> { let bb = buscar(dirs, &["busybox", "bin/busybox"]).ok_or_else(|| { anyhow::anyhow!( "el paquete no trae `busybox`, que es con lo que se desempaqueta el árbol a instalar.\n \ Rearmá el paquete con scripts/instalador-paquete.sh." ) })?; let initrd = buscar(dirs, INITRD).expect("ya se resolvió antes"); println!(); println!("── instalar desde acá, SIN saltar ────────────────────────────────────────────"); println!(" El destino no es la raíz que estás usando, así que no hace falta soltarla:"); println!(" esta sesión sigue viva y al terminar reiniciás cuando quieras."); println!(" destino: {}", i.root.as_deref().unwrap_or("el espacio sin asignar")); println!("──────────────────────────────────────────────────────────────────────────────"); if !i.yes && !confirmar()? { println!("nada hecho."); return Ok(()); } let tmp = std::env::temp_dir().join(format!("takana-arbol.{}", std::process::id())); std::fs::create_dir_all(&tmp)?; println!("==> desempaquetando el árbol a instalar en {}", tmp.display()); let sh = format!( "{bb} gzip -d -c {ird} | ( cd {d} && {bb} cpio -i -d --quiet )", bb = bb.display(), ird = initrd.display(), d = tmp.display() ); let st = std::process::Command::new("sh").arg("-c").arg(&sh).status()?; if !st.success() { bail!("no pude desempaquetar {}", initrd.display()); } let mut c = std::process::Command::new("sh"); c.arg(guion); if let Some(r) = &i.root { c.arg("--root-part").arg(r); } if i.free { c.arg("--use-free-space"); } if let Some(d) = &i.disk { c.arg("--disk").arg(d); } c.arg("--yes"); // Las herramientas y el árbol salen del paquete: acá no hay store del que resolverlas por hash. for (v, rel) in [ ("TOOL_mke2fs", "bin/mke2fs"), ("TOOL_sfdisk", "bin/sfdisk"), ("TOOL_mmd", "bin/mmd"), ("TOOL_mcopy", "bin/mcopy"), ("TOOL_mdir", "bin/mdir"), ] { c.env(v, tmp.join(rel)); } c.env("STAGE", tmp.join("payload/stage")) .env("STAGE_LISTO", "1") .env("KERNEL", tmp.join("payload/bzImage")) .env("HAMMER", tmp.join("bin/takana")); let st = c.status()?; let _ = std::process::Command::new("rm").arg("-rf").arg(&tmp).status(); if !st.success() { bail!("el instalador falló ({st})"); } println!(); println!("✓ listo. Reiniciá cuando quieras y elegí takana en el firmware."); Ok(()) } /// La cmdline del instalador: las respuestas que ya se dieron viajan por acá, y las que no, no. /// Es el mismo canal que lee el `/init` del initramfs, así que **no hay nada horneado por máquina**. fn cmdline_de(i: &Instalacion) -> String { let mut s = i.console.clone(); if let Some(r) = &i.root { s.push_str(&format!(" takana.root={r}")); } if let Some(e) = &i.esp { s.push_str(&format!(" takana.esp={e}")); } if let Some(d) = &i.disk { s.push_str(&format!(" takana.disk={d}")); } if i.free { s.push_str(" takana.free"); } if let Some(k) = &i.keep { s.push_str(&format!(" takana.keep={k}")); } if i.adopt { s.push_str(" takana.adopt"); } if i.unattended { s.push_str(" takana.yes"); } s } fn tam(p: &Path) -> String { match std::fs::metadata(p).map(|m| m.len()) { Ok(b) if b >= 1 << 20 => format!("{} M", b >> 20), Ok(b) => format!("{} K", b >> 10), Err(_) => "?".to_string(), } } /// Confirmación explícita. **No** acepta un `enter` pelado: el enter es lo que se aprieta sin leer, /// y lo que sigue mata la sesión. fn confirmar() -> Result { use std::io::{BufRead, Write}; print!("¿Saltar al instalador? Escribí «si» para seguir: "); std::io::stdout().flush().ok(); let mut l = String::new(); if std::io::stdin().lock().read_line(&mut l).is_err() { return Ok(false); } let l = l.trim().to_lowercase(); Ok(l == "si" || l == "sí") } #[cfg(test)] mod tests { use super::*; fn base() -> Instalacion { Instalacion { payload: None, root: None, esp: None, free: false, keep: None, adopt: false, disk: None, yes: false, unattended: false, load_only: false, jump: false, console: CONSOLA.to_string(), } } #[test] fn sin_respuestas_la_cmdline_no_lleva_ninguna_maquina_adentro() { // El punto entero de la generalización: un paquete sirve en cualquier máquina porque no // trae respuestas horneadas. Si esto empieza a fallar, alguien volvió a hornear. assert_eq!(cmdline_de(&base()), CONSOLA); } #[test] fn las_respuestas_dadas_viajan_por_la_cmdline() { let i = Instalacion { root: Some("PARTUUID=55b5-0de7".into()), esp: Some("PARTUUID=aaaa-bbbb".into()), unattended: true, ..base() }; let c = cmdline_de(&i); assert!(c.contains("takana.root=PARTUUID=55b5-0de7"), "{c}"); assert!(c.contains("takana.esp=PARTUUID=aaaa-bbbb"), "{c}"); assert!(c.contains("takana.yes"), "{c}"); assert!(!c.contains("takana.free"), "{c}"); } #[test] fn adoptar_viaja_por_la_cmdline_y_no_aparece_si_no_se_pide() { // La mitad que importa es la segunda: un `takana.adopt` de más hace que el instalador NO // formatee cuando se le pidió formatear, y el síntoma es un sistema nuevo conviviendo con // la mitad de uno viejo. Es más caro que el error contrario. assert!(!cmdline_de(&base()).contains("takana.adopt"), "sin --adopt no viaja nada"); let i = Instalacion { root: Some("PARTUUID=1111-2222".into()), adopt: true, ..base() }; let c = cmdline_de(&i); assert!(c.contains("takana.adopt"), "{c}"); // Y no se parece a `takana.free`: son dos decisiones distintas sobre el mismo disco. assert!(!c.contains("takana.free"), "{c}"); } #[test] fn el_home_conservado_viaja_por_la_cmdline_y_no_aparece_si_no_se_pide() { // Que `--keep` NO aparezca cuando no se pide es la mitad que importa: un `takana.keep=` de // más en la cmdline haría que el instalador intente montar una partición que nadie nombró. assert!(!cmdline_de(&base()).contains("takana.keep"), "sin --keep no viaja nada"); let i = Instalacion { root: Some("PARTUUID=1111-2222".into()), keep: Some("PARTUUID=3333-4444:/home".into()), ..base() }; let c = cmdline_de(&i); assert!(c.contains("takana.keep=PARTUUID=3333-4444:/home"), "{c}"); // Y no se confunde con el destino: son dos particiones distintas y una se formatea. assert!(c.contains("takana.root=PARTUUID=1111-2222"), "{c}"); } #[test] fn free_y_disk_viajan_juntos_y_sin_destino() { let i = Instalacion { free: true, disk: Some("/dev/nvme0n1".into()), ..base() }; let c = cmdline_de(&i); assert!(c.contains("takana.free"), "{c}"); assert!(c.contains("takana.disk=/dev/nvme0n1"), "{c}"); assert!(!c.contains("takana.root="), "{c}"); } const CENSO: &str = r#"{ "version": 1, "raiz_viva": "8:2", "particiones": [ {"dev": "/dev/sda2", "partuuid": "AAAA", "mib": 6144, "veredicto": "RAIZ_VIVA", "usable": true}, {"dev": "/dev/sda5", "partuuid": "BBBB", "mib": 40960, "veredicto": "CANDIDATA", "usable": true} ] }"#; #[test] fn el_salto_hace_falta_solo_para_la_raiz_viva() { // Es la decisión que reordenó todo: saltar cuando no hace falta es matar la sesión del // usuario por nada, y el cuelgue que lo destapó pasó justamente ahí. assert!(es_raiz_viva(CENSO, "PARTUUID=AAAA")); assert!(!es_raiz_viva(CENSO, "PARTUUID=BBBB")); assert!(es_raiz_viva(CENSO, "/dev/sda2")); assert!(!es_raiz_viva(CENSO, "/dev/sda5")); } #[test] fn un_destino_que_el_censo_no_conoce_no_se_toma_por_la_raiz_viva() { // Se equivoca del lado que NO destruye: si esto diera «sí», se saltaría sin necesidad; si // da «no», se instala desde acá y la guarda del propio instalador aborta si el destino // resulta ser la raíz. Un error de más contra un error que mata la sesión. assert!(!es_raiz_viva(CENSO, "PARTUUID=ZZZZ")); assert!(!es_raiz_viva("", "PARTUUID=AAAA")); assert!(!es_raiz_viva("esto no es json", "PARTUUID=AAAA")); } #[test] fn buscar_respeta_el_orden_de_los_nombres_y_de_los_directorios() { let d = std::env::temp_dir().join(format!("metal-test-{}", std::process::id())); let a = d.join("a"); let b = d.join("b"); std::fs::create_dir_all(&a).unwrap(); std::fs::create_dir_all(&b).unwrap(); std::fs::write(b.join("initramfs.cpio.gz"), b"x").unwrap(); std::fs::write(b.join("takana-instalador.cpio.gz"), b"x").unwrap(); // El directorio gana sobre el nombre: `a` no lo tiene, así que se cae a `b`. let v = vec![a.clone(), b.clone()]; assert_eq!(buscar(&v, INITRD).unwrap(), b.join("takana-instalador.cpio.gz")); std::fs::write(a.join("initramfs.cpio.gz"), b"x").unwrap(); // Y ahora `a` gana aunque su nombre sea el último de la lista: primero el directorio. assert_eq!(buscar(&v, INITRD).unwrap(), a.join("initramfs.cpio.gz")); std::fs::remove_dir_all(&d).ok(); } }