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…
⎇ 0a9622c7b055b93800c45afcac7b4774ed231bcc
crates/takana-cli/src/kernel_cmd.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
kernel kexec: el reboot suave por kexec_file_load, sin kexec-tools
1//! `takana kernel` — el armador de kernel (SDD 22).
2//!
3//! Superficie en inglés (Regla 7.bis), mensajes en castellano. El contrato con las UIs es JSON
4//! estable, mismo patrón que `/run/hammer/boot-graph.json`.
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::path::{Path, PathBuf};
8
9use anyhow::{Context, Result};
10use clap::Subcommand;
11use takana_core::kernel::kconfig::KconfigTree;
12use takana_core::kernel::KernelConfig;
13use serde::Serialize;
14
15/// Dónde busca el árbol de Kconfig si no se pasa `--kconfig`.
16const KCONFIG_ENV: &str = "TAKANA_KCONFIG_ROOT";
17/// Ídem para el catálogo de bundles.
18const CATALOG_ENV: &str = "TAKANA_KERNEL_BUNDLES";
19/// Ídem para el contrato de capacidades (SDD 25 §4).
20const CONTRACT_ENV: &str = "TAKANA_KERNEL_CONTRACT";
21/// Sitios donde buscarlo sin que nadie diga nada: el del repo y el del sistema instalado.
22const CATALOG_DEFAULTS: &[&str] = &[
23 "docs/state/kernel-bundles.toml",
24 "/usr/share/hammer/kernel-bundles.toml",
25];
26const CONTRACT_DEFAULTS: &[&str] = &[
27 "docs/state/kernel-contract.toml",
28 "/usr/share/hammer/kernel-contract.toml",
29];
30
31#[derive(Subcommand)]
32pub enum KernelCmd {
33 /// Analiza el grafo de Kconfig: qué muere si se apagan estos símbolos, y por dónde se cuela un
34 /// `select` de vuelta.
35 ///
36 /// Es la herramienta con la que se decide si un bundle «por clausura» es viable (SDD 22 §3).
37 /// No construye ni escribe nada.
38 Closure {
39 /// Símbolos raíz a apagar, SIN el prefijo `CONFIG_` (p.ej. `WIRELESS`).
40 #[arg(required = true)]
41 symbols: Vec<String>,
42 /// Raíz del árbol de fuentes del kernel (el directorio con el `Kconfig` de arriba).
43 #[arg(long)]
44 kconfig: Option<PathBuf>,
45 /// Contrasta la clausura contra los símbolos apagados en este `.config`.
46 #[arg(long)]
47 against: Option<PathBuf>,
48 /// Cierra la clausura contra sus propias fugas: apaga también a cada selector, y repite.
49 /// Muestra el PRECIO (qué hubo que tragarse de más) en vez de aplicarlo a ciegas.
50 #[arg(long)]
51 fixpoint: bool,
52 /// Emite JSON en vez del informe legible.
53 #[arg(long)]
54 json: bool,
55 /// Cuántos símbolos de la clausura listar en el informe legible.
56 #[arg(long, default_value_t = 20)]
57 limit: usize,
58 },
59 /// **Modo reversa**: lee el kernel que ya corre y lo muestra por el lente de los bundles.
60 ///
61 /// No compila ni escribe nada. Contesta tres cosas: cuánto de cada bundle ya rige, qué
62 /// capacidad carga este kernel que esta máquina no usa, y dónde el hardware CONTRADICE a un
63 /// bundle aplicado.
64 Probe {
65 /// `.config` a mirar. Por defecto `/proc/config.gz` y si no `/boot/config-<uname -r>`.
66 #[arg(long)]
67 config: Option<PathBuf>,
68 #[arg(long)]
69 kconfig: Option<PathBuf>,
70 #[arg(long)]
71 catalog: Option<PathBuf>,
72 /// No leer el hardware (útil para analizar el config de OTRA máquina).
73 #[arg(long)]
74 no_hardware: bool,
75 #[arg(long)]
76 json: bool,
77 },
78 /// Lista el catálogo con sus clausuras ya resueltas contra un árbol de Kconfig concreto.
79 ///
80 /// Es también el control de frescura: `--check` sale distinto de cero si algún bundle nombra
81 /// símbolos que ya no existen o dejó fugas `select` sin declarar.
82 Bundles {
83 #[arg(long)]
84 kconfig: Option<PathBuf>,
85 #[arg(long)]
86 catalog: Option<PathBuf>,
87 /// Falla si el catálogo envejeció respecto de este árbol.
88 #[arg(long)]
89 check: bool,
90 #[arg(long)]
91 json: bool,
92 },
93 /// De una selección de bundles a una **receta derivada**, sin construir nada.
94 ///
95 /// El config del kernel vive en la fase `configure` y las fases entran en `hash_inputs` ⇒ el
96 /// config ES la identidad del artefacto. Por eso esto emite una receta con su propio
97 /// `ArtifactHash`, y no finge que el kernel sea un binario parametrizable.
98 Plan {
99 /// Receta base (p.ej. `recipes/linux.toml`).
100 #[arg(long)]
101 recipe: PathBuf,
102 /// Id de bundle del catálogo. Repetible.
103 #[arg(long = "bundle")]
104 bundles: Vec<String>,
105 /// Id de perilla del catálogo. Repetible.
106 #[arg(long = "knob")]
107 knobs: Vec<String>,
108 #[arg(long)]
109 kconfig: Option<PathBuf>,
110 #[arg(long)]
111 catalog: Option<PathBuf>,
112 /// Escribe el plan en JSON (el contrato con las UIs).
113 #[arg(long)]
114 out: Option<PathBuf>,
115 /// Escribe la receta derivada. Para construirla hay que dejarla donde sus `deps.build`
116 /// resuelvan — es decir, junto a la receta base.
117 #[arg(long)]
118 recipe_out: Option<PathBuf>,
119 #[arg(long)]
120 json: bool,
121 },
122 /// **Diff-back**: contrasta un plan contra el `.config` que produjo el kernel.
123 ///
124 /// Sin esto la UI miente: un `-e FOO` cuya dependencia no se cumple se pierde en silencio.
125 DiffBack {
126 /// El JSON que emitió `takana kernel plan --out`.
127 #[arg(long)]
128 plan: PathBuf,
129 /// El `.config` resultante (el que la receta instala en `/out/boot/config-<versión>`).
130 #[arg(long)]
131 config: PathBuf,
132 #[arg(long)]
133 json: bool,
134 },
135 /// **Gate de no-regresión, por objetivo**: ¿el plan deja sin driver algún dispositivo en uso?
136 ///
137 /// No corre sin `--objective`: la regla aplicada global rechazaría recetas sanas (el kernel de
138 /// QEMU apaga USB, HID e INPUT a propósito). Sale distinto de cero si bloquea.
139 Gate {
140 /// El JSON que emitió `takana kernel plan --out`. Con `--config` es opcional: sólo sirve
141 /// para atribuir cada pérdida a su bundle.
142 #[arg(long)]
143 plan: Option<PathBuf>,
144 /// `.config` YA PRODUCIDO. Cambia la pregunta: en vez de «¿qué apaga el plan?» mide
145 /// «¿qué funcionaba y deja de funcionar?», que además ve los huecos de la receta base.
146 #[arg(long)]
147 config: Option<PathBuf>,
148 /// Referente contra el que medir la regresión (default `/proc/config.gz`): el kernel que
149 /// arrancó esta máquina es la prueba viva de qué hace falta para arrancarla.
150 #[arg(long)]
151 baseline: Option<PathBuf>,
152 /// Id de objetivo del catálogo (`qemu-serial`, `servidor`, `metal-escritorio`, `portatil`).
153 #[arg(long)]
154 objective: String,
155 /// Hardware de la máquina DESTINO, capturado con `takana kernel hw --out`. Sin esto se
156 /// mira el de esta máquina, que no siempre es la misma.
157 #[arg(long)]
158 devices: Option<PathBuf>,
159 #[arg(long)]
160 kconfig: Option<PathBuf>,
161 #[arg(long)]
162 catalog: Option<PathBuf>,
163 #[arg(long)]
164 json: bool,
165 },
166 /// Vuelca el hardware de esta máquina (huella + dispositivos + drivers bindeados).
167 ///
168 /// Se corre en la máquina DESTINO y el JSON viaja a la de build: el gate necesita saber qué
169 /// funciona allá, no acá.
170 Hw {
171 #[arg(long)]
172 out: Option<PathBuf>,
173 },
174 /// **Contrato de capacidades**: ¿este kernel trae lo que el userland usa por nombre?
175 ///
176 /// El punto ciego que cierra (SDD 25 §4): sin `CONFIG_MEMCG` el fichero `memory.max` no
177 /// existe, el escritor de arje descarta el error y una Card corre SIN tope de memoria dejando
178 /// sólo un `warn!`. La máquina de desarrollo no lo ve porque su kernel sí lo trae. Se comprueba
179 /// contra un `.config` YA PRODUCIDO, no contra la receta: entre el `scripts/config -e X` y el
180 /// `.config` hay un `olddefconfig` que puede tragarse el símbolo en silencio.
181 ///
182 /// Sale distinto de cero si falta una capacidad EXIGIDA por el perfil.
183 Contract {
184 /// `.config` a comprobar. Repetible. Por defecto, el del kernel vivo.
185 #[arg(long = "config")]
186 configs: Vec<PathBuf>,
187 /// Barre todos los kernels sellados del store (`<hash>-<nombre>/boot/config-*`).
188 #[arg(long)]
189 sealed: bool,
190 /// Dónde buscar las recetas de kernel para saber qué artefacto es el VIGENTE. Repetible.
191 /// Por defecto `recipes/` y `docs/state/kernel-plans/`.
192 #[arg(long = "recipes")]
193 recipes: Vec<PathBuf>,
194 /// Perfil del contrato. Obligatorio salvo que el nombre del artefacto ya lo declare.
195 #[arg(long)]
196 profile: Option<String>,
197 #[arg(long)]
198 contract: Option<PathBuf>,
199 /// Lista el contrato (capacidades, perfiles y objetivos) y no comprueba nada.
200 #[arg(long)]
201 list: bool,
202 #[arg(long)]
203 json: bool,
204 },
205 /// Stage a kernel artifact as the boot candidate for the NEXT boot only (ADR 0017).
206 ///
207 /// Copia su bzImage a una ranura PROPIA de la ESP —nunca encima del estable— y pone `BootNext`.
208 /// `BootNext` es de un solo uso: si el candidato no arranca, el firmware ya no la encuentra en
209 /// el arranque siguiente y vuelve solo al estable. La vuelta atrás del caso «no arranca» no la
210 /// hace takana: la hace el firmware, y por eso no se puede romper.
211 Stage {
212 /// Artefacto del kernel en el store (nombre de directorio o ruta).
213 artefacto: String,
214 /// ESP ya montada.
215 #[arg(long, default_value = "/boot/efi")]
216 esp: PathBuf,
217 /// Root of efivarfs.
218 #[arg(long, default_value = crate::efi_boot::EFIVARS)]
219 efivars: PathBuf,
220 /// Arranques que tiene el candidato para ser confirmado.
221 #[arg(long, default_value_t = 1)]
222 tries: u32,
223 /// A/B state file.
224 #[arg(long, default_value = crate::kernel_ab::ESTADO)]
225 state: PathBuf,
226 /// Say what would happen and touch nothing.
227 #[arg(long)]
228 dry_run: bool,
229 },
230 /// Soft reboot into a kernel: skip firmware and bootloader (ADR 0017 §1).
231 ///
232 /// ⚠ **NO es «actualizar sin rebootear».** El userspace muere igual — es un reboot que se salta
233 /// el firmware. Ahorra los 20-30 s de POST/UEFI, que en un servidor remoto es la mitad del
234 /// downtime y en un portátil no es nada. Se dice acá porque media industria vende esto como
235 /// «live kernel update» y no lo es.
236 Kexec {
237 /// Artefacto del kernel en el store (nombre de directorio o ruta).
238 artefacto: String,
239 /// initramfs a cargar con él.
240 #[arg(long)]
241 initrd: Option<PathBuf>,
242 /// Kernel command line for the new kernel.
243 #[arg(long, default_value = "console=tty0 console=ttyS0,115200")]
244 cmdline: String,
245 /// Load it and stop there: the jump needs `--jump`.
246 #[arg(long)]
247 load_only: bool,
248 /// Actually jump. Without this it only loads and reports.
249 #[arg(long)]
250 jump: bool,
251 },
252 /// Say where this boot came from and what is on trial.
253 BootStatus {
254 #[arg(long, default_value = crate::efi_boot::EFIVARS)]
255 efivars: PathBuf,
256 #[arg(long, default_value = crate::kernel_ab::ESTADO)]
257 state: PathBuf,
258 },
259 /// Promote the candidate that is currently running to stable.
260 Confirm {
261 #[arg(long, default_value = "/boot/efi")]
262 esp: PathBuf,
263 #[arg(long, default_value = crate::efi_boot::EFIVARS)]
264 efivars: PathBuf,
265 #[arg(long, default_value = crate::kernel_ab::ESTADO)]
266 state: PathBuf,
267 },
268 /// Drop the candidate and keep the stable kernel.
269 Rollback {
270 #[arg(long, default_value = "/boot/efi")]
271 esp: PathBuf,
272 #[arg(long, default_value = crate::efi_boot::EFIVARS)]
273 efivars: PathBuf,
274 #[arg(long, default_value = crate::kernel_ab::ESTADO)]
275 state: PathBuf,
276 },
277 /// Estadísticas del árbol de Kconfig parseado (control de salud del lector).
278 Stats {
279 #[arg(long)]
280 kconfig: Option<PathBuf>,
281 /// Muestra las primeras N advertencias del parseo.
282 #[arg(long, default_value_t = 10)]
283 warnings: usize,
284 },
285}
286
287pub fn run(cmd: KernelCmd, store: &str) -> Result<()> {
288 match cmd {
289 KernelCmd::Stage { artefacto, esp, efivars, tries, state, dry_run } =>
290 ab_stage(&artefacto, store, &esp, &efivars, tries, &state, dry_run),
291 KernelCmd::Kexec { artefacto, initrd, cmdline, load_only, jump } =>
292 kexec_cmd(&artefacto, store, initrd.as_deref(), &cmdline, load_only, jump),
293 KernelCmd::BootStatus { efivars, state } => ab_status(&efivars, &state),
294 KernelCmd::Confirm { esp, efivars, state } => ab_confirm(&esp, &efivars, &state),
295 KernelCmd::Rollback { esp, efivars, state } => ab_rollback(&esp, &efivars, &state),
296 KernelCmd::Stats { kconfig, warnings } => stats(&kconfig_root(kconfig)?, warnings),
297 KernelCmd::Probe {
298 config,
299 kconfig,
300 catalog,
301 no_hardware,
302 json,
303 } => probe(
304 config.as_deref(),
305 &kconfig_root(kconfig)?,
306 &catalog_path(catalog)?,
307 !no_hardware,
308 json,
309 ),
310 KernelCmd::Bundles {
311 kconfig,
312 catalog,
313 check,
314 json,
315 } => bundles(&kconfig_root(kconfig)?, &catalog_path(catalog)?, check, json),
316 KernelCmd::Plan {
317 recipe,
318 bundles: bs,
319 knobs,
320 kconfig,
321 catalog,
322 out,
323 recipe_out,
324 json,
325 } => plan(
326 &recipe,
327 &bs,
328 &knobs,
329 &kconfig_root(kconfig)?,
330 &catalog_path(catalog)?,
331 out.as_deref(),
332 recipe_out.as_deref(),
333 store,
334 json,
335 ),
336 KernelCmd::DiffBack { plan, config, json } => diff_back(&plan, &config, json),
337 KernelCmd::Gate {
338 plan,
339 config,
340 baseline,
341 objective,
342 devices,
343 kconfig,
344 catalog,
345 json,
346 } => gate(
347 plan.as_deref(),
348 config.as_deref(),
349 baseline.as_deref(),
350 &objective,
351 devices.as_deref(),
352 &kconfig_root(kconfig)?,
353 &catalog_path(catalog)?,
354 json,
355 ),
356 KernelCmd::Hw { out } => hw(out.as_deref()),
357 KernelCmd::Contract {
358 configs,
359 sealed,
360 recipes,
361 profile,
362 contract,
363 list,
364 json,
365 } => contract_cmd(
366 &configs,
367 sealed,
368 &recipes,
369 profile.as_deref(),
370 &contract_path(contract)?,
371 store,
372 list,
373 json,
374 ),
375 KernelCmd::Closure {
376 symbols,
377 kconfig,
378 against,
379 fixpoint,
380 json,
381 limit,
382 } => closure(
383 &symbols,
384 &kconfig_root(kconfig)?,
385 against.as_deref(),
386 fixpoint,
387 json,
388 limit,
389 ),
390 }
391}
392
393/// Resuelve la raíz del árbol de Kconfig: `--kconfig` gana, si no la variable de entorno.
394fn kconfig_root(flag: Option<PathBuf>) -> Result<PathBuf> {
395 if let Some(p) = flag {
396 return Ok(p);
397 }
398 match takana_core::env::var_os(KCONFIG_ENV) {
399 Some(v) => Ok(PathBuf::from(v)),
400 None => anyhow::bail!(
401 "falta el árbol de fuentes del kernel: pasá --kconfig <dir> o exportá {KCONFIG_ENV}"
402 ),
403 }
404}
405
406/// Resuelve el catálogo de bundles: `--catalog`, si no la variable de entorno, si no el del repo.
407fn catalog_path(flag: Option<PathBuf>) -> Result<PathBuf> {
408 if let Some(p) = flag {
409 return Ok(p);
410 }
411 if let Some(v) = takana_core::env::var_os(CATALOG_ENV) {
412 return Ok(PathBuf::from(v));
413 }
414 for c in CATALOG_DEFAULTS {
415 let p = PathBuf::from(c);
416 if p.is_file() {
417 return Ok(p);
418 }
419 }
420 anyhow::bail!(
421 "no encontré el catálogo de bundles: pasá --catalog <path> o exportá {CATALOG_ENV}"
422 )
423}
424
425/// Resuelve el contrato de capacidades: `--contract`, si no la variable de entorno, si no el repo.
426fn contract_path(flag: Option<PathBuf>) -> Result<PathBuf> {
427 if let Some(p) = flag {
428 return Ok(p);
429 }
430 if let Some(v) = takana_core::env::var_os(CONTRACT_ENV) {
431 return Ok(PathBuf::from(v));
432 }
433 for c in CONTRACT_DEFAULTS {
434 let p = PathBuf::from(c);
435 if p.is_file() {
436 return Ok(p);
437 }
438 }
439 anyhow::bail!("no encontré el contrato de kernel: pasá --contract <path> o exportá {CONTRACT_ENV}")
440}
441
442/// Dónde está el `.config` del kernel que corre. `/proc/config.gz` es el camino soberano (lo emite
443/// el propio kernel); el de `/boot` es el respaldo para kernels compilados sin `IKCONFIG_PROC`.
444fn running_config_path() -> Result<PathBuf> {
445 let gz = PathBuf::from("/proc/config.gz");
446 if gz.is_file() {
447 return Ok(gz);
448 }
449 if let Ok(out) = std::process::Command::new("uname").arg("-r").output() {
450 let rel = String::from_utf8_lossy(&out.stdout).trim().to_string();
451 let p = PathBuf::from(format!("/boot/config-{rel}"));
452 if p.is_file() {
453 return Ok(p);
454 }
455 }
456 anyhow::bail!(
457 "no encontré el config del kernel vivo (ni /proc/config.gz ni /boot/config-<release>).\n\
458 Pasá --config <path> para mirar el de otra máquina."
459 )
460}
461
462fn load_tree(root: &Path) -> Result<KconfigTree> {
463 if !root.join("Kconfig").is_file() {
464 anyhow::bail!(
465 "{} no parece un árbol de fuentes de kernel (falta ./Kconfig).\n\
466 Pasá --kconfig <dir> o exportá {KCONFIG_ENV}.",
467 root.display()
468 );
469 }
470 KconfigTree::parse(root, &KconfigTree::vars_x86_64())
471 .with_context(|| format!("parseando {}", root.display()))
472}
473
474fn stats(root: &Path, show_warnings: usize) -> Result<()> {
475 let t = load_tree(root)?;
476 let visible = t.symbols.values().filter(|s| s.is_visible()).count();
477 let multi = t.symbols.values().filter(|s| s.defs.len() > 1).count();
478 let with_help = t.symbols.values().filter(|s| s.help().is_some()).count();
479 let selects: usize = t.symbols.values().map(|s| s.select_targets().len()).sum();
480 let implies: usize = t.symbols.values().map(|s| s.imply_targets().len()).sum();
481
482 println!("árbol {}", root.display());
483 println!("ficheros {}", t.files.len());
484 println!("símbolos {}", t.symbols.len());
485 println!(" visibles {visible} (marcables en menuconfig)");
486 println!(" con ayuda {with_help}");
487 println!(" definidos en >1 sitio {multi}");
488 println!("aristas select {selects} · imply {implies}");
489 println!("avisos {}", t.warnings.len());
490 for w in t.warnings.iter().take(show_warnings) {
491 println!(" · {w}");
492 }
493 if t.warnings.len() > show_warnings {
494 println!(" … y {} más", t.warnings.len() - show_warnings);
495 }
496 Ok(())
497}
498
499#[derive(Serialize)]
500struct ClosureReport {
501 roots: Vec<String>,
502 /// Todo lo que queda inalcanzable si se apagan las raíces (incluidas las raíces).
503 closure: Vec<String>,
504 /// De la clausura, los que un humano puede marcar en `menuconfig`.
505 visible: usize,
506 /// `select` que entran desde fuera: reencienden algo de la clausura ignorando sus `depends on`.
507 leaks: Vec<takana_core::kernel::SelectLeak>,
508 /// Símbolos distintos de la clausura alcanzados por esas fugas.
509 leaked_targets: usize,
510 #[serde(skip_serializing_if = "Option::is_none")]
511 fixpoint: Option<FixpointReport>,
512 #[serde(skip_serializing_if = "Option::is_none")]
513 against: Option<AgainstReport>,
514}
515
516/// El precio de cerrar el bundle contra sus fugas.
517#[derive(Serialize)]
518struct FixpointReport {
519 /// La clausura ya cerrada, entera.
520 closure: Vec<String>,
521 /// Tamaño de la clausura una vez cerrada.
522 closure_len: usize,
523 /// Cuánto creció respecto de la clausura estricta.
524 grew_by: usize,
525 rounds: usize,
526 /// Lo que hubo que apagar de más, con la ronda en que apareció.
527 added: Vec<(usize, String)>,
528 /// Fugas que sobrevivieron al tope de rondas. Vacío = cerró.
529 remaining_leaks: Vec<takana_core::kernel::SelectLeak>,
530}
531
532/// Contraste contra un `.config` real: la validación §3 del SDD 22.
533#[derive(Serialize)]
534struct AgainstReport {
535 config: String,
536 /// De la clausura, los que el `.config` tiene ENCENDIDOS (la clausura predice de más, o el
537 /// config tiene una fuga real).
538 on_in_config: Vec<String>,
539 /// De la clausura, los que el `.config` apaga explícitamente.
540 off_in_config: usize,
541 /// De la clausura, los que el `.config` ni menciona.
542 absent_in_config: usize,
543}
544
545fn closure(
546 roots: &[String],
547 kroot: &Path,
548 against: Option<&Path>,
549 fixpoint: bool,
550 json: bool,
551 limit: usize,
552) -> Result<()> {
553 let t = load_tree(kroot)?;
554
555 // Avisar de raíces que no existen: un símbolo mal escrito daría clausura vacía en silencio,
556 // que es exactamente el modo de fallo que este comando existe para no tener.
557 let desconocidas: Vec<&String> = roots.iter().filter(|r| !t.symbols.contains_key(*r)).collect();
558 if !desconocidas.is_empty() {
559 eprintln!(
560 "aviso: {} símbolo(s) no existen en este árbol: {}",
561 desconocidas.len(),
562 desconocidas
563 .iter()
564 .map(|s| s.as_str())
565 .collect::<Vec<_>>()
566 .join(", ")
567 );
568 }
569
570 let cl = t.closure_off(roots);
571 let leaks = t.select_leaks(&cl);
572 let leaked_targets = leaks
573 .iter()
574 .map(|l| l.target.as_str())
575 .collect::<BTreeSet<_>>()
576 .len();
577 let visible = cl
578 .iter()
579 .filter(|s| t.symbols.get(*s).is_some_and(|s| s.is_visible()))
580 .count();
581
582 let fx = fixpoint.then(|| {
583 let f = t.closure_off_fixpoint(roots, 16);
584 FixpointReport {
585 closure_len: f.closure.len(),
586 closure: f.closure.iter().cloned().collect(),
587 grew_by: f.closure.len().saturating_sub(cl.len()),
588 rounds: f.rounds,
589 added: f.added,
590 remaining_leaks: f.remaining_leaks,
591 }
592 });
593
594 let against_report = match against {
595 None => None,
596 Some(p) => {
597 let text = read_maybe_gz(p)?;
598 let cfg = KernelConfig::parse(&text);
599 let on: Vec<String> = cl.iter().filter(|s| cfg.is_on(s)).cloned().collect();
600 let off = cl
601 .iter()
602 .filter(|s| matches!(cfg.get(s), Some(takana_core::kernel::ConfigValue::No)))
603 .count();
604 let absent = cl.iter().filter(|s| cfg.get(s).is_none()).count();
605 Some(AgainstReport {
606 config: p.display().to_string(),
607 on_in_config: on,
608 off_in_config: off,
609 absent_in_config: absent,
610 })
611 }
612 };
613
614 let rep = ClosureReport {
615 roots: roots.to_vec(),
616 closure: cl.iter().cloned().collect(),
617 visible,
618 leaks,
619 leaked_targets,
620 fixpoint: fx,
621 against: against_report,
622 };
623
624 if json {
625 println!("{}", serde_json::to_string_pretty(&rep)?);
626 return Ok(());
627 }
628
629 println!("raíces apagadas {}", rep.roots.join(" "));
630 println!(
631 "clausura {} símbolo(s) ({} visibles en menuconfig)",
632 rep.closure.len(),
633 rep.visible
634 );
635 for s in rep.closure.iter().take(limit) {
636 let prompt = t
637 .symbols
638 .get(s)
639 .and_then(|x| x.prompt())
640 .unwrap_or("(sin prompt: sólo entra por select)");
641 println!(" · {s} — {prompt}");
642 }
643 if rep.closure.len() > limit {
644 println!(" … y {} más", rep.closure.len() - limit);
645 }
646
647 println!();
648 if rep.leaks.is_empty() {
649 println!("fugas por `select` ninguna — la clausura es cerrada");
650 } else {
651 println!(
652 "fugas por `select` {} arista(s) desde fuera reencienden {} símbolo(s) de la clausura",
653 rep.leaks.len(),
654 rep.leaked_targets
655 );
656 println!(" (`select` FUERZA el destino ignorando sus `depends on`: apagar la raíz no basta)");
657 let mut por_destino: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
658 for l in &rep.leaks {
659 por_destino
660 .entry(&l.target)
661 .or_default()
662 .push(&l.selector);
663 }
664 for (target, selectors) in por_destino.iter().take(limit) {
665 println!(" · {target} ← {}", selectors.join(", "));
666 }
667 if por_destino.len() > limit {
668 println!(" … y {} destino(s) más", por_destino.len() - limit);
669 }
670 }
671
672 if let Some(f) = &rep.fixpoint {
673 println!();
674 println!(
675 "punto fijo {} símbolo(s) tras {} ronda(s) — {:+} sobre la clausura estricta",
676 f.closure_len, f.rounds, f.grew_by as i64
677 );
678 if f.remaining_leaks.is_empty() {
679 println!(" cerró: ninguna fuga sobrevive");
680 } else {
681 println!(
682 " ⚠ NO cerró: {} fuga(s) siguen abiertas tras el tope de rondas",
683 f.remaining_leaks.len()
684 );
685 }
686 if f.added.is_empty() {
687 println!(" no hizo falta apagar nada de más");
688 } else {
689 println!(" precio: {} símbolo(s) apagados de más", f.added.len());
690 for (r, s) in f.added.iter().take(limit) {
691 println!(" · ronda {r}: {s}");
692 }
693 if f.added.len() > limit {
694 println!(" … y {} más", f.added.len() - limit);
695 }
696 }
697 }
698
699 if let Some(a) = &rep.against {
700 println!();
701 println!("contraste contra {}", a.config);
702 println!(" apagados explícitos {}", a.off_in_config);
703 println!(" ausentes {}", a.absent_in_config);
704 println!(" ENCENDIDOS {}", a.on_in_config.len());
705 for s in a.on_in_config.iter().take(limit) {
706 println!(" · {s}");
707 }
708 if a.on_in_config.len() > limit {
709 println!(" … y {} más", a.on_in_config.len() - limit);
710 }
711 }
712 Ok(())
713}
714
715// ---------------------------------------------------------------------------------------------
716// probe — el modo reversa (#9)
717// ---------------------------------------------------------------------------------------------
718
719fn probe(
720 config: Option<&Path>,
721 kroot: &Path,
722 catalog: &Path,
723 with_hw: bool,
724 json: bool,
725) -> Result<()> {
726 use takana_core::kernel::reverse::{BundleState, HwSignal};
727
728 let cfg_path = match config {
729 Some(p) => p.to_path_buf(),
730 None => running_config_path()?,
731 };
732 let cfg = KernelConfig::parse(&read_maybe_gz(&cfg_path)?);
733 if cfg.is_empty() {
734 anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", cfg_path.display());
735 }
736 let tree = load_tree(kroot)?;
737 let cat = takana_core::kernel::Catalog::load(catalog)?;
738 let hw = with_hw.then(takana_core::kernel::Hardware::probe_local);
739 let rep = takana_core::kernel::reverse::analyze(&cat, &tree, &cfg, hw.as_ref());
740
741 if json {
742 println!("{}", serde_json::to_string_pretty(&rep)?);
743 return Ok(());
744 }
745
746 println!("config {}", cfg_path.display());
747 if let Some(k) = &rep.kernel {
748 println!("kernel {k}");
749 }
750 // El modo reversa cruza DOS árboles: el del config vivo y el que se parseó. Si no son la misma
751 // versión, las clausuras son aproximadas — un símbolo puede haberse renombrado o retirado entre
752 // medias (que es justo lo que le pasó a THUNDERBOLT y a REISERFS_FS). Vale igual, pero decirlo.
753 if let (Some(banner), Some(rev)) = (&rep.kernel, &cat.reviewed_against) {
754 if let (Some(a), Some(b)) = (serie(banner), serie(rev)) {
755 if a != b {
756 println!(
757 " ⚠ el config vivo es de la serie {a} y el catálogo se revisó contra \
758 la {b}: las clausuras son aproximadas"
759 );
760 }
761 }
762 }
763 println!("símbolos {} declarados", rep.config_symbols);
764 if let Some(h) = &hw {
765 println!("huella {}", rep.fingerprint);
766 if let Some(m) = &h.cpu_model {
767 println!(" {m}");
768 }
769 let maquina = [h.dmi.get("sys_vendor"), h.dmi.get("product_name")]
770 .into_iter()
771 .flatten()
772 .cloned()
773 .collect::<Vec<_>>()
774 .join(" ");
775 if !maquina.is_empty() {
776 println!(" {maquina}");
777 }
778 println!(
779 " {} dispositivos PCI · {} drivers bindeados",
780 h.pci.len(),
781 h.bound_drivers.len()
782 );
783 for g in &h.gaps {
784 println!(" ⚠ {g}");
785 }
786 }
787 println!();
788
789 for b in &rep.bundles {
790 let on = b.on_builtin + b.on_module;
791 let estado = match b.state {
792 BundleState::Aplicado => "APLICADO ",
793 BundleState::Parcial => "parcial ",
794 BundleState::Sin => "sin ",
795 };
796 let hwtxt = match &b.hardware {
797 HwSignal::SinSeñal => String::new(),
798 HwSignal::Ausente => " · hardware ausente".to_string(),
799 HwSignal::Presente { evidencia } => {
800 format!(" · ⚠ HARDWARE PRESENTE ({})", evidencia.len())
801 }
802 };
803 println!(
804 "{estado} {:<28} {on:>5}/{:<5} encendidos{hwtxt}",
805 b.id, b.closure_len
806 );
807 if let HwSignal::Presente { evidencia } = &b.hardware {
808 for e in evidencia.iter().take(4) {
809 println!(" {e}");
810 }
811 }
812 if !b.unknown_symbols.is_empty() {
813 println!(
814 " ⚠ símbolos que este kernel no tiene: {}",
815 b.unknown_symbols.join(", ")
816 );
817 }
818 if !b.undeclared_leaks.is_empty() {
819 println!(
820 " ⚠ {} fuga(s) `select` sin declarar en el catálogo",
821 b.undeclared_leaks.len()
822 );
823 }
824 }
825
826 println!();
827 println!(
828 "resumen {} bundle(s): {} ya aplicados, {} con capacidad que este hardware NO usa",
829 rep.bundles.len(),
830 rep.bundles
831 .iter()
832 .filter(|b| b.state == BundleState::Aplicado)
833 .count(),
834 rep.applicable_savings
835 );
836 if rep.contradictions > 0 {
837 println!(
838 " ⚠ {} bundle(s) aplicados CONTRADICEN al hardware presente",
839 rep.contradictions
840 );
841 }
842 println!(" (detectar sirve para contradecir, nunca para podar solo)");
843 Ok(())
844}
845
846/// Serie `mayor.menor` de un texto que contenga una versión (`"Linux/x86 7.1.4-artix1 …"` → `7.1`,
847/// `"linux-6.16.12"` → `6.16`). `None` si no hay ninguna.
848fn serie(texto: &str) -> Option<String> {
849 for tok in texto.split(|c: char| !(c.is_ascii_digit() || c == '.')) {
850 let mut it = tok.split('.');
851 if let (Some(a), Some(b)) = (it.next(), it.next()) {
852 if !a.is_empty() && !b.is_empty() {
853 return Some(format!("{a}.{b}"));
854 }
855 }
856 }
857 None
858}
859
860// ---------------------------------------------------------------------------------------------
861// bundles — catálogo + control de frescura
862// ---------------------------------------------------------------------------------------------
863
864#[derive(Serialize)]
865struct BundleView {
866 id: String,
867 title: String,
868 #[serde(skip_serializing_if = "Option::is_none")]
869 help: Option<String>,
870 disable: Vec<String>,
871 close_leaks: Vec<String>,
872 closure_len: usize,
873 visible: usize,
874 unknown_symbols: Vec<String>,
875 undeclared_leaks: Vec<takana_core::kernel::SelectLeak>,
876 accepted_leaks: usize,
877}
878
879#[derive(Serialize)]
880struct BundlesReport {
881 catalog: String,
882 #[serde(skip_serializing_if = "Option::is_none")]
883 reviewed_against: Option<String>,
884 bundles: Vec<BundleView>,
885 knobs: Vec<KnobView>,
886 stale: bool,
887}
888
889#[derive(Serialize)]
890struct KnobView {
891 id: String,
892 title: String,
893 side: takana_core::kernel::Side,
894 enable: Vec<String>,
895 disable: Vec<String>,
896 #[serde(skip_serializing_if = "Option::is_none")]
897 recipe_field: Option<String>,
898 unknown_symbols: Vec<String>,
899}
900
901fn bundles(kroot: &Path, catalog: &Path, check: bool, json: bool) -> Result<()> {
902 let tree = load_tree(kroot)?;
903 let cat = takana_core::kernel::Catalog::load(catalog)?;
904
905 let mut views = Vec::new();
906 for b in &cat.bundles {
907 let mut roots = b.disable.clone();
908 roots.extend(b.close_leaks.iter().cloned());
909 let unknown: Vec<String> = roots
910 .iter()
911 .chain(b.enable.iter())
912 .filter(|s| !tree.symbols.contains_key(*s))
913 .cloned()
914 .collect();
915 let cl = tree.closure_off(&roots);
916 let aceptadas: BTreeSet<&str> = b.accept_leaks.keys().map(|s| s.as_str()).collect();
917 let undeclared: Vec<_> = tree
918 .select_leaks(&cl)
919 .into_iter()
920 .filter(|l| !aceptadas.contains(l.target.as_str()))
921 .collect();
922 views.push(BundleView {
923 id: b.id.clone(),
924 title: b.title.clone(),
925 help: b.help.clone(),
926 disable: b.disable.clone(),
927 close_leaks: b.close_leaks.clone(),
928 closure_len: cl.len(),
929 visible: cl
930 .iter()
931 .filter(|s| tree.symbols.get(*s).is_some_and(|x| x.is_visible()))
932 .count(),
933 unknown_symbols: unknown,
934 undeclared_leaks: undeclared,
935 accepted_leaks: b.accept_leaks.len(),
936 });
937 }
938
939 let knobs: Vec<KnobView> = cat
940 .knobs
941 .iter()
942 .map(|k| KnobView {
943 id: k.id.clone(),
944 title: k.title.clone(),
945 side: k.side,
946 enable: k.enable.clone(),
947 disable: k.disable.clone(),
948 recipe_field: k.recipe_field.clone(),
949 unknown_symbols: k
950 .enable
951 .iter()
952 .chain(k.disable.iter())
953 .filter(|s| !tree.symbols.contains_key(*s))
954 .cloned()
955 .collect(),
956 })
957 .collect();
958
959 let stale = views
960 .iter()
961 .any(|v| !v.unknown_symbols.is_empty() || !v.undeclared_leaks.is_empty())
962 || knobs.iter().any(|k| !k.unknown_symbols.is_empty());
963
964 let rep = BundlesReport {
965 catalog: catalog.display().to_string(),
966 reviewed_against: cat.reviewed_against.clone(),
967 bundles: views,
968 knobs,
969 stale,
970 };
971
972 if json {
973 println!("{}", serde_json::to_string_pretty(&rep)?);
974 } else {
975 println!("catálogo {}", rep.catalog);
976 if let Some(r) = &rep.reviewed_against {
977 println!("revisado contra {r}");
978 }
979 println!("árbol {}", kroot.display());
980 println!();
981 println!("NIVEL 1 — aserciones del dueño de la máquina");
982 for v in &rep.bundles {
983 println!(
984 " {:<26} {:>5} símbolos ({} visibles) ← {}",
985 v.id,
986 v.closure_len,
987 v.visible,
988 v.disable.join(" ")
989 );
990 println!(" {}", v.title);
991 if !v.close_leaks.is_empty() {
992 println!(" fugas cerradas: {}", v.close_leaks.join(", "));
993 }
994 if v.accepted_leaks > 0 {
995 println!(" fugas aceptadas a sabiendas: {}", v.accepted_leaks);
996 }
997 if !v.unknown_symbols.is_empty() {
998 println!(
999 " ⚠ NO EXISTEN en este árbol: {}",
1000 v.unknown_symbols.join(", ")
1001 );
1002 }
1003 for l in v.undeclared_leaks.iter().take(6) {
1004 println!(" ⚠ fuga sin declarar: {} ← {}", l.target, l.selector);
1005 }
1006 if v.undeclared_leaks.len() > 6 {
1007 println!(" ⚠ … y {} fuga(s) más", v.undeclared_leaks.len() - 6);
1008 }
1009 }
1010 println!();
1011 println!("NIVEL 2 — política y rendimiento (ojo al lado del que cae cada perilla)");
1012 for k in &rep.knobs {
1013 let lado = match k.side {
1014 takana_core::kernel::Side::Kconfig => "kconfig",
1015 takana_core::kernel::Side::Recipe => "receta ",
1016 };
1017 let que = match k.side {
1018 takana_core::kernel::Side::Kconfig => {
1019 let mut v: Vec<String> =
1020 k.enable.iter().map(|s| format!("+{s}")).collect();
1021 v.extend(k.disable.iter().map(|s| format!("-{s}")));
1022 v.join(" ")
1023 }
1024 takana_core::kernel::Side::Recipe => {
1025 k.recipe_field.clone().unwrap_or_default()
1026 }
1027 };
1028 println!(" [{lado}] {:<26} {que}", k.id);
1029 println!(" {}", k.title);
1030 if !k.unknown_symbols.is_empty() {
1031 println!(
1032 " ⚠ NO EXISTEN en este árbol: {}",
1033 k.unknown_symbols.join(", ")
1034 );
1035 }
1036 }
1037 println!();
1038 if rep.stale {
1039 println!("⚠ el catálogo ENVEJECIÓ respecto de este árbol (símbolos idos o fugas nuevas)");
1040 } else {
1041 println!("✓ el catálogo está al día con este árbol");
1042 }
1043 }
1044
1045 if check && rep.stale {
1046 anyhow::bail!("catálogo desactualizado");
1047 }
1048 Ok(())
1049}
1050
1051// ---------------------------------------------------------------------------------------------
1052// plan / diff-back
1053// ---------------------------------------------------------------------------------------------
1054
1055#[allow(clippy::too_many_arguments)]
1056fn plan(
1057 recipe_path: &Path,
1058 bundle_ids: &[String],
1059 knob_ids: &[String],
1060 kroot: &Path,
1061 catalog: &Path,
1062 out: Option<&Path>,
1063 recipe_out: Option<&Path>,
1064 store: &str,
1065 json: bool,
1066) -> Result<()> {
1067 if bundle_ids.is_empty() && knob_ids.is_empty() {
1068 anyhow::bail!("un plan sin bundles ni perillas es la receta base: pasá --bundle/--knob");
1069 }
1070 let base = takana_core::Recipe::load_from_path(recipe_path)?;
1071 let tree = load_tree(kroot)?;
1072 let cat = takana_core::kernel::Catalog::load(catalog)?;
1073
1074 // Las recetas de kernel del repo NO son todas la misma versión (`linux`/`linux-metal` van por
1075 // 6.16.12; `linux-metal-dual`/`linux-generic` por 7.1.2). Planear una contra el árbol de la otra
1076 // calcularía clausuras sobre símbolos que ahí no existen, y saldría SIN RUIDO. Como el plan
1077 // determina un artefacto, acá se falla en vez de avisar.
1078 match tree.version.as_deref() {
1079 Some(v) if v != base.version => anyhow::bail!(
1080 "la receta «{}» es del kernel {} y el árbol de {} es el {v}.\n\
1081 Las clausuras saldrían calculadas sobre otro juego de símbolos. Pasá el árbol correcto \
1082 con --kconfig.",
1083 base.name,
1084 base.version,
1085 kroot.display()
1086 ),
1087 None => eprintln!(
1088 "aviso: {} no trae el Makefile de arriba, así que no puedo comprobar que sea el kernel \
1089 {} de la receta «{}»",
1090 kroot.display(),
1091 base.version,
1092 base.name
1093 ),
1094 Some(_) => {}
1095 }
1096
1097 let mut p = takana_core::kernel::plan::make_plan(
1098 &base,
1099 &recipe_path.display().to_string(),
1100 &cat,
1101 &tree,
1102 bundle_ids,
1103 knob_ids,
1104 )?;
1105
1106 // El plan DETERMINA el artefacto (SDD 22 §1), así que el hash va en el plan: la UI puede decir
1107 // «esto ya está construido y firmado» sin construir nada. Si las deps no están resueltas, se
1108 // dice que no se pudo en vez de emitir un hash inventado.
1109 let derived = takana_core::kernel::plan::derive_recipe(&base, &p);
1110 match takana_core::Store::open(Path::new(store))
1111 .map_err(anyhow::Error::from)
1112 .and_then(|s| {
1113 let cfg = takana_build::BuildConfig::from_env_or_defaults(s.root());
1114 let lab = takana_core::lab::LabFingerprint::from_rootfs(&cfg.rootfs)
1115 .map_err(anyhow::Error::from)?;
1116 takana_build::artifact_hash(&derived, &s, &lab).map_err(anyhow::Error::from)
1117 })
1118 {
1119 Ok(h) => p.artifact_hash = Some(h.to_string()),
1120 Err(e) => eprintln!("aviso: no pude calcular el ArtifactHash ({e})"),
1121 }
1122
1123 if let Some(o) = out {
1124 if let Some(d) = o.parent() {
1125 std::fs::create_dir_all(d).ok();
1126 }
1127 std::fs::write(o, serde_json::to_string_pretty(&p)?)
1128 .with_context(|| format!("escribiendo {}", o.display()))?;
1129 }
1130 if let Some(ro) = recipe_out {
1131 if let Some(d) = ro.parent() {
1132 std::fs::create_dir_all(d).ok();
1133 }
1134 let cabecera = format!(
1135 "# Receta DERIVADA, generada por `hammer kernel plan`. No editar a mano: regenerar.\n\
1136 # base {}\n# bundles {}\n# perillas {}\n\
1137 # Para construirla tiene que vivir junto a la receta base, o sus `deps.build` no\n\
1138 # resuelven ({}).\n",
1139 p.base_recipe,
1140 if p.bundles.is_empty() { "—".into() } else { p.bundles.join(" ") },
1141 if p.knobs.is_empty() { "—".into() } else { p.knobs.join(" ") },
1142 recipe_path
1143 .parent()
1144 .map(|d| d.display().to_string())
1145 .unwrap_or_default(),
1146 );
1147 let cuerpo = takana_core::kernel::plan::render_recipe_toml(&derived)?;
1148 std::fs::write(ro, format!("{cabecera}{cuerpo}"))
1149 .with_context(|| format!("escribiendo {}", ro.display()))?;
1150 }
1151
1152 if json {
1153 println!("{}", serde_json::to_string_pretty(&p)?);
1154 return Ok(());
1155 }
1156
1157 println!("base {} ({})", p.base_name, p.base_recipe);
1158 println!("derivada {}", p.derived_name);
1159 match &p.artifact_hash {
1160 Some(h) => println!("hash {h}"),
1161 None => println!("hash (no calculado)"),
1162 }
1163 println!(
1164 "selección {} bundle(s), {} perilla(s)",
1165 p.bundles.len(),
1166 p.knobs.len()
1167 );
1168 println!();
1169 println!("emite {} bandera(s); con su clausura caen {} símbolo(s)", p.symbols.len(), p.closure_size);
1170 println!("(se emiten RAÍCES: la clausura la calcula el olddefconfig del propio kernel)");
1171 for s in &p.symbols {
1172 let signo = match s.action {
1173 takana_core::kernel::plan::Action::Enable => "+",
1174 takana_core::kernel::plan::Action::Disable => "-",
1175 };
1176 println!(" {signo}{:<24} ← {} ({})", s.symbol, s.from, s.reason);
1177 }
1178 if !p.unknown_symbols.is_empty() {
1179 println!();
1180 println!("⚠ {} símbolo(s) pedidos NO EXISTEN en este árbol y no se emiten:", p.unknown_symbols.len());
1181 for s in &p.unknown_symbols {
1182 println!(" {} ← {}", s.symbol, s.from);
1183 }
1184 }
1185 if !p.recipe_side.is_empty() {
1186 println!();
1187 println!("perillas del lado RECETA (declaradas, este plan no las aplica):");
1188 for k in &p.recipe_side {
1189 println!(" {:<24} {} — {}", k.id, k.recipe_field, k.title);
1190 }
1191 }
1192 println!();
1193 if let Some(o) = out {
1194 println!("plan → {}", o.display());
1195 }
1196 if let Some(ro) = recipe_out {
1197 println!("receta → {}", ro.display());
1198 }
1199 Ok(())
1200}
1201
1202fn diff_back(plan_path: &Path, config: &Path, json: bool) -> Result<()> {
1203 let p: takana_core::kernel::Plan = serde_json::from_str(
1204 &std::fs::read_to_string(plan_path)
1205 .with_context(|| format!("leyendo {}", plan_path.display()))?,
1206 )
1207 .with_context(|| format!("{} no es un plan de `hammer kernel plan`", plan_path.display()))?;
1208 let cfg = KernelConfig::parse(&read_maybe_gz(config)?);
1209 if cfg.is_empty() {
1210 anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", config.display());
1211 }
1212 let d = takana_core::kernel::diff_back(&p, &cfg);
1213
1214 if json {
1215 println!("{}", serde_json::to_string_pretty(&d)?);
1216 } else {
1217 println!("plan {} ({})", p.derived_name, plan_path.display());
1218 println!("config {}", config.display());
1219 println!("cumplidos {}", d.honored.len());
1220 // Kconfig no emite un símbolo cuyas dependencias no se cumplen: para un `-d`, que no
1221 // aparezca ES el resultado buscado. Contarlo como fallo reportaba planes perfectos como
1222 // rotos — lo aprendí corriendo un olddefconfig de verdad.
1223 println!("apagados por dependencia {} (no aparecen porque cayeron con su raíz)", d.off_by_dependency.len());
1224 if !d.never_existed.is_empty() {
1225 println!("no existen en este árbol {}", d.never_existed.len());
1226 for s in &d.never_existed {
1227 println!(" {s} — la bandera no hace nada");
1228 }
1229 }
1230 println!("INCUMPLIDOS {}", d.violated.len());
1231 for v in &d.violated {
1232 println!(
1233 " {} — se pidió {:?} y quedó {} (lo pidió {})",
1234 v.symbol, v.asked, v.got, v.from
1235 );
1236 }
1237 }
1238 if !d.is_clean() {
1239 anyhow::bail!("el .config no honra el plan");
1240 }
1241 Ok(())
1242}
1243
1244// ---------------------------------------------------------------------------------------------
1245// gate / hw
1246// ---------------------------------------------------------------------------------------------
1247
1248fn hw(out: Option<&Path>) -> Result<()> {
1249 let h = takana_core::kernel::Hardware::probe_local();
1250 let texto = serde_json::to_string_pretty(&h)?;
1251 match out {
1252 Some(o) => {
1253 if let Some(d) = o.parent() {
1254 std::fs::create_dir_all(d).ok();
1255 }
1256 std::fs::write(o, &texto).with_context(|| format!("escribiendo {}", o.display()))?;
1257 println!("huella {}", h.fingerprint());
1258 println!("hardware → {}", o.display());
1259 println!(" {} PCI · {} USB · {} drivers bindeados", h.pci.len(), h.usb.len(), h.bound_drivers.len());
1260 for g in &h.gaps {
1261 println!(" ⚠ {g}");
1262 }
1263 }
1264 None => println!("{texto}"),
1265 }
1266 Ok(())
1267}
1268
1269#[allow(clippy::too_many_arguments)]
1270fn gate(
1271 plan_path: Option<&Path>,
1272 config: Option<&Path>,
1273 baseline: Option<&Path>,
1274 objective: &str,
1275 devices: Option<&Path>,
1276 kroot: &Path,
1277 catalog: &Path,
1278 json: bool,
1279) -> Result<()> {
1280 if plan_path.is_none() && config.is_none() {
1281 anyhow::bail!("pasá --plan (qué apagaría) o --config (qué quedó): sin uno de los dos no hay nada que comparar");
1282 }
1283 let p: Option<takana_core::kernel::Plan> = match plan_path {
1284 None => None,
1285 Some(pp) => Some(
1286 serde_json::from_str(
1287 &std::fs::read_to_string(pp).with_context(|| format!("leyendo {}", pp.display()))?,
1288 )
1289 .with_context(|| format!("{} no es un plan de `hammer kernel plan`", pp.display()))?,
1290 ),
1291 };
1292 let cat = takana_core::kernel::Catalog::load(catalog)?;
1293 let tree = load_tree(kroot)?;
1294 let modmap = takana_core::kernel::ModMap::parse(kroot)
1295 .with_context(|| format!("leyendo los Makefiles de {}", kroot.display()))?;
1296 if modmap.is_empty() {
1297 anyhow::bail!(
1298 "no encontré una sola regla `obj-$(CONFIG_…)` en {}: sin los Makefiles del kernel el \
1299 gate no puede mapear driver → símbolo, y un gate que no puede mirar no es un gate.",
1300 kroot.display()
1301 );
1302 }
1303
1304 let hardware = match devices {
1305 Some(d) => serde_json::from_str::<takana_core::kernel::Hardware>(
1306 &std::fs::read_to_string(d).with_context(|| format!("leyendo {}", d.display()))?,
1307 )
1308 .with_context(|| format!("{} no es un volcado de `hammer kernel hw`", d.display()))?,
1309 None => takana_core::kernel::Hardware::probe_local(),
1310 };
1311
1312 let r = match config {
1313 // Modo fuerte: comparar dos configs reales. Ve también los huecos que ya venían en la base.
1314 Some(c) => {
1315 let base_path = baseline
1316 .map(|b| b.to_path_buf())
1317 .map_or_else(running_config_path, Ok)?;
1318 let base = KernelConfig::parse(&read_maybe_gz(&base_path)?);
1319 let nuevo = KernelConfig::parse(&read_maybe_gz(c)?);
1320 if nuevo.is_empty() {
1321 anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", c.display());
1322 }
1323 println!("referente {} ({} símbolos)", base_path.display(), base.len());
1324 takana_core::kernel::gate::run_against_config(
1325 &base,
1326 &nuevo,
1327 &cat,
1328 &modmap,
1329 &hardware.bound_drivers,
1330 objective,
1331 p.as_ref(),
1332 Some(&tree),
1333 )?
1334 }
1335 None => takana_core::kernel::gate::run(
1336 p.as_ref().expect("validado arriba"),
1337 &cat,
1338 &tree,
1339 &modmap,
1340 &hardware.bound_drivers,
1341 objective,
1342 )?,
1343 };
1344
1345 if json {
1346 println!("{}", serde_json::to_string_pretty(&r)?);
1347 } else {
1348 let obj = cat.objective(objective).map(|o| o.title.as_str()).unwrap_or("");
1349 if !r.plan.is_empty() {
1350 println!("plan {}", r.plan);
1351 }
1352 println!("objetivo {} — {obj}", r.objective);
1353 println!(
1354 "hardware {} ({} drivers bindeados, {} reglas obj- en {} Makefiles)",
1355 devices.map(|d| d.display().to_string()).unwrap_or_else(|| "esta máquina".into()),
1356 r.drivers_in_use,
1357 modmap.len(),
1358 modmap.files
1359 );
1360 println!();
1361 if r.regressions.is_empty() {
1362 println!("✓ ningún dispositivo en uso se queda sin driver");
1363 } else {
1364 println!("✗ {} REGRESIÓN(ES) — dispositivos en uso que se quedarían sin driver:", r.regressions.len());
1365 for l in &r.regressions {
1366 // Sin culpable atribuido la causa NO es el plan: es que la receta base nunca
1367 // enciende ese símbolo. Decir «lo apaga una perilla» mandaría a buscar donde no está.
1368 let causa = if l.blamed_on.is_empty() {
1369 "la receta base no lo enciende".to_string()
1370 } else {
1371 format!("lo apaga {}", l.blamed_on.join(", "))
1372 };
1373 println!(" {:<20} {causa} (símbolos: {})", l.driver, l.symbols.join(", "));
1374 }
1375 }
1376 if !r.allowed.is_empty() {
1377 println!();
1378 println!("{} pérdida(s) AUTORIZADAS por el objetivo:", r.allowed.len());
1379 for l in &r.allowed {
1380 println!(" {:<20} ← {}", l.driver, l.blamed_on.join(", "));
1381 }
1382 }
1383 if !r.unmapped.is_empty() {
1384 println!();
1385 println!(
1386 "⚠ {} driver(s) SIN COMPROBAR (no se pudieron mapear a ningún CONFIG_*):",
1387 r.unmapped.len()
1388 );
1389 println!(" {}", r.unmapped.join(" "));
1390 println!(" no cuentan como aprobados: el gate no los miró.");
1391 }
1392 }
1393
1394 if r.verdict == takana_core::kernel::Verdict::Bloquea {
1395 anyhow::bail!("el gate bloquea");
1396 }
1397 Ok(())
1398}
1399
1400// ---------------------------------------------------------------------------------------------
1401// contract — el contrato de capacidades (SDD 25 §4)
1402// ---------------------------------------------------------------------------------------------
1403
1404/// Nombre del artefacto si la ruta cae dentro de un directorio `<64 hex>-<nombre>` del store.
1405///
1406/// Es lo que permite que `--sealed` sepa qué perfil le toca a cada kernel sin que nadie lo escriba
1407/// dos veces. Fuera del store devuelve `None` y el perfil hay que pasarlo a mano.
1408fn artifact_of(path: &Path) -> Option<String> {
1409 for a in path.ancestors() {
1410 let name = a.file_name()?.to_string_lossy().to_string();
1411 if let Some((hash, art)) = name.split_once('-') {
1412 if hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) {
1413 return Some(art.to_string());
1414 }
1415 }
1416 }
1417 None
1418}
1419
1420/// Dónde viven las recetas de kernel cuando nadie pasa `--recipes`. La segunda es la de las
1421/// recetas DERIVADAS que emite `takana kernel plan` (p. ej. `linux-gioser`).
1422const RECIPE_DEFAULTS: [&str; 2] = ["recipes", "docs/state/kernel-plans"];
1423
1424/// Nombre del directorio del store (`<64 hex>-<nombre>`) que contiene esta ruta.
1425fn store_dir_of(path: &Path) -> Option<String> {
1426 for a in path.ancestors() {
1427 let name = a.file_name()?.to_string_lossy().to_string();
1428 if let Some((hash, _)) = name.split_once('-') {
1429 if hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) {
1430 return Some(name);
1431 }
1432 }
1433 }
1434 None
1435}
1436
1437/// ¿Resuelven las `deps.build` de esta receta desde `dir`? Misma regla que el build: hermana, y
1438/// si no, en el directorio padre (que es como una receta de `recipes/incoming-*/` alcanza el
1439/// catálogo).
1440fn deps_resuelven_desde(dir: &Path, deps: &[String]) -> bool {
1441 deps.iter().all(|d| {
1442 let f = format!("{d}.toml");
1443 dir.join(&f).exists() || dir.parent().is_some_and(|p| p.join(&f).exists())
1444 })
1445}
1446
1447/// La receta cuyo campo `name` es `artifact`, buscada en `dirs`.
1448///
1449/// Primero por convención (`<dir>/<artifact>.toml`) y sólo si eso no da, barriendo el directorio:
1450/// **el artefacto se nombra por el campo `name`, no por el fichero**, y en este corpus hay recetas
1451/// donde los dos difieren.
1452fn receta_de(artifact: &str, dirs: &[PathBuf]) -> Option<takana_core::Recipe> {
1453 for d in dirs {
1454 let directo = d.join(format!("{artifact}.toml"));
1455 if let Ok(r) = takana_core::Recipe::load_from_path(&directo) {
1456 if r.name == artifact {
1457 return Some(con_catalogo(r, dirs));
1458 }
1459 }
1460 let Ok(rd) = std::fs::read_dir(d) else { continue };
1461 for e in rd.flatten() {
1462 let p = e.path();
1463 if p.extension().is_some_and(|x| x == "toml") {
1464 if let Ok(r) = takana_core::Recipe::load_from_path(&p) {
1465 if r.name == artifact {
1466 return Some(con_catalogo(r, dirs));
1467 }
1468 }
1469 }
1470 }
1471 }
1472 None
1473}
1474
1475/// Le presta un catálogo a una receta que vive fuera de uno.
1476///
1477/// Las recetas DERIVADAS (`takana kernel plan --recipe-out`) se guardan a propósito fuera de
1478/// `recipes/`: dejarlas ahí las mete en el grafo compartido como deuda. Pero sus `deps.build` son
1479/// las del catálogo padre, así que desde su directorio no resuelven y el hash no se puede calcular
1480/// — y sin hash no hay vigencia que comparar. Acá se les apunta el `base_dir` al primer directorio
1481/// de `dirs` donde sí resuelven, que es exactamente lo que hace el runbook cuando la copia a
1482/// `recipes/incoming-kernel/` para construirla.
1483///
1484/// NO se toca si la receta trae `patches`: `base_dir` también los resuelve, y moverlo los rompería
1485/// en silencio. Ahí es mejor no saber la vigencia (se comprueba todo) que hashear otra cosa.
1486fn con_catalogo(mut r: takana_core::Recipe, dirs: &[PathBuf]) -> takana_core::Recipe {
1487 if r.deps.build.is_empty() || deps_resuelven_desde(&r.base_dir, &r.deps.build) {
1488 return r;
1489 }
1490 if !r.source.patches.is_empty() {
1491 eprintln!(
1492 "aviso: «{}» vive fuera de un catálogo y trae patches ⇒ no le presto base_dir",
1493 r.name
1494 );
1495 return r;
1496 }
1497 if let Some(d) = dirs.iter().find(|d| deps_resuelven_desde(d, &r.deps.build)) {
1498 r.base_dir = d.clone();
1499 }
1500 r
1501}
1502
1503/// Qué directorio del store le toca HOY a cada kernel del contrato: `artefacto → <hash>-<nombre>`.
1504///
1505/// POR QUÉ. El store guarda TODOS los sellados, no el último. Sin esta pregunta `--sealed` mide
1506/// también los kernels que la receta de hoy ya no produce, y entonces el gate se queda rojo para
1507/// siempre por artefactos que nadie va a volver a construir: **un portón que no puede ponerse
1508/// verde deja de leerse, que es exactamente como se pierde un guardián**. Con ella, el superado se
1509/// informa (sigue siendo cierto que le falta la capacidad: si un USB por ahí arranca ese kernel,
1510/// sus Cards corren sin tope) pero no bloquea.
1511///
1512/// Es la misma vigencia que `hammer hash --check`: la del ArtifactHash de la receta de hoy con el
1513/// lab de hoy — o sea que un kernel sellado con otro rootfs también sale superado, y es correcto
1514/// (SDD 25: el lab entra en el artefacto).
1515fn dirs_vigentes(
1516 c: &takana_core::kernel::Contract,
1517 dirs: &[PathBuf],
1518 store: &str,
1519) -> Result<std::collections::BTreeMap<String, String>> {
1520 let st = takana_core::Store::open(Path::new(store))?;
1521 let cfg = takana_build::BuildConfig::from_env_or_defaults(st.root());
1522 let lab = takana_core::lab::LabFingerprint::from_rootfs(&cfg.rootfs)?;
1523 let mut out = std::collections::BTreeMap::new();
1524 for t in &c.targets {
1525 let Some(r) = receta_de(&t.artifact, dirs) else {
1526 eprintln!(
1527 "aviso: no encontré la receta de «{}» en {} ⇒ sus sellados se comprueban todos",
1528 t.artifact,
1529 dirs.iter().map(|d| d.display().to_string()).collect::<Vec<_>>().join(" ")
1530 );
1531 continue;
1532 };
1533 match takana_build::artifact_hash(&r, &st, &lab) {
1534 Ok(h) => {
1535 out.insert(t.artifact.clone(), h.store_dir_name(&r.name));
1536 }
1537 Err(e) => eprintln!("aviso: no pude calcular el hash vigente de «{}»: {e}", t.artifact),
1538 }
1539 }
1540 Ok(out)
1541}
1542
1543#[derive(Serialize)]
1544struct ContractRun {
1545 contract: String,
1546 reports: Vec<takana_core::kernel::ContractReport>,
1547 /// Sellados que la receta de HOY ya no produce. Se informan y no bloquean: ver `dirs_vigentes`.
1548 #[serde(default)]
1549 superados: Vec<takana_core::kernel::ContractReport>,
1550 /// Configs que NO se comprobaron por no tener perfil declarado. No cuentan como aprobados.
1551 sin_perfil: Vec<String>,
1552 /// Objetivos del contrato cuya receta de hoy NO tiene ni un sellado en el store. No se
1553 /// comprobó nada de ellos: se nombran para que la ausencia no pase por aprobado.
1554 #[serde(default)]
1555 sin_construir: Vec<String>,
1556}
1557
1558fn contract_cmd(
1559 configs: &[PathBuf],
1560 sealed: bool,
1561 recipes: &[PathBuf],
1562 profile: Option<&str>,
1563 contract: &Path,
1564 store: &str,
1565 list: bool,
1566 json: bool,
1567) -> Result<()> {
1568 use takana_core::kernel::contract::sealed_kernel_configs;
1569 use takana_core::kernel::Contract;
1570
1571 let c = Contract::load(contract)?;
1572
1573 if list {
1574 if json {
1575 println!("{}", serde_json::to_string_pretty(&c)?);
1576 return Ok(());
1577 }
1578 println!("contrato {} (v{}", contract.display(), c.version);
1579 if let Some(r) = &c.reviewed_against {
1580 println!("revisado contra kernel {r}");
1581 }
1582 println!();
1583 let req = takana_core::kernel::contract::required_by(&c);
1584 for cap in &c.capabilities {
1585 let quien = req.get(cap.id.as_str()).map(|v| v.join(", ")).unwrap_or_default();
1586 let quien = if quien.is_empty() { "—".to_string() } else { quien };
1587 println!("{:<24} {}", cap.id, cap.title);
1588 println!("{:<24} símbolos: {}", "", cap.symbols.join(" "));
1589 println!("{:<24} consumidor: {}", "", cap.consumer.trim().replace('\n', " "));
1590 println!("{:<24} exigida por: {quien}", "");
1591 println!();
1592 }
1593 for p in &c.profiles {
1594 println!("perfil {:<17} {}", p.id, p.title);
1595 println!("{:<24} exige: {}", "", p.requires.join(" "));
1596 println!("{:<24} quiere: {}", "", p.wants.join(" "));
1597 }
1598 println!();
1599 for t in &c.targets {
1600 println!("{:<24} → perfil {}", t.artifact, t.profile);
1601 }
1602 return Ok(());
1603 }
1604
1605 // Qué configs se miran, en orden: --sealed, --config, y si no hay nada, el kernel vivo.
1606 let mut objetivos: Vec<(String, Option<String>, PathBuf)> = Vec::new();
1607
1608 // Vigencia: sólo tiene sentido en el barrido del store. Un `--config` a mano es una pregunta
1609 // explícita sobre ESE fichero y se comprueba siempre.
1610 let vigentes = if sealed {
1611 let dirs: Vec<PathBuf> = if recipes.is_empty() {
1612 RECIPE_DEFAULTS.iter().map(PathBuf::from).filter(|d| d.is_dir()).collect()
1613 } else {
1614 recipes.to_vec()
1615 };
1616 if dirs.is_empty() {
1617 eprintln!(
1618 "aviso: no hay dónde mirar las recetas ({}) ⇒ no puedo distinguir vigente de \
1619 superado y comprueba TODO lo sellado",
1620 RECIPE_DEFAULTS.join(" ")
1621 );
1622 None
1623 } else {
1624 match dirs_vigentes(&c, &dirs, store) {
1625 Ok(v) => Some(v),
1626 Err(e) => {
1627 // Callar acá sería fingir que todo está al día: si no se puede clasificar, se
1628 // comprueba todo (ruidoso) y se dice por qué.
1629 eprintln!("aviso: no pude resolver los artefactos vigentes ({e}) ⇒ comprueba TODO lo sellado");
1630 None
1631 }
1632 }
1633 }
1634 } else {
1635 None
1636 };
1637
1638 if sealed {
1639 let s = sealed_kernel_configs(Path::new(store));
1640 if s.is_empty() {
1641 anyhow::bail!(
1642 "no hay ningún kernel sellado con `boot/config-*` en {store}.\n\
1643 Un kernel que no instale su config no se puede comprobar — y fingir que pasó \
1644 sería el mismo fallo que este guardián viene a arreglar."
1645 );
1646 }
1647 for (art, p) in s {
1648 objetivos.push((format!("{art} · {}", p.display()), Some(art), p));
1649 }
1650 }
1651 for p in configs {
1652 objetivos.push((p.display().to_string(), artifact_of(p), p.clone()));
1653 }
1654 if objetivos.is_empty() {
1655 let p = running_config_path()?;
1656 objetivos.push((format!("kernel vivo · {}", p.display()), None, p));
1657 }
1658
1659 let mut run = ContractRun {
1660 contract: contract.display().to_string(),
1661 reports: Vec::new(),
1662 superados: Vec::new(),
1663 sin_perfil: Vec::new(),
1664 sin_construir: Vec::new(),
1665 };
1666
1667 for (label, artifact, path) in &objetivos {
1668 let cfg = takana_core::kernel::KernelConfig::parse(&read_maybe_gz(path)?);
1669 if cfg.is_empty() {
1670 anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", path.display());
1671 }
1672 let prof = match profile {
1673 Some(p) => Some(p.to_string()),
1674 None => artifact
1675 .as_deref()
1676 .and_then(|a| c.profile_for(a))
1677 .map(str::to_string),
1678 };
1679 let Some(prof) = prof else {
1680 run.sin_perfil.push(label.clone());
1681 continue;
1682 };
1683 let rep = c.check(&cfg, &prof, label)?;
1684 let superado = match (&vigentes, artifact.as_deref(), store_dir_of(path)) {
1685 (Some(v), Some(art), Some(dir)) => v.get(art).is_some_and(|d| *d != dir),
1686 _ => false,
1687 };
1688 if superado {
1689 run.superados.push(rep);
1690 } else {
1691 run.reports.push(rep);
1692 }
1693 }
1694
1695 // Un objetivo del contrato cuya receta de hoy no tiene NINGÚN sellado no aparece en el
1696 // barrido — y no aparecer se lee como que no hay nada que objetar. Se nombra.
1697 if let Some(v) = &vigentes {
1698 let vistos: std::collections::BTreeSet<String> =
1699 objetivos.iter().filter_map(|(_, _, p)| store_dir_of(p)).collect();
1700 for (art, dir) in v {
1701 if !vistos.contains(dir) {
1702 run.sin_construir.push(format!("{art} · {dir}"));
1703 }
1704 }
1705 }
1706
1707 if json {
1708 println!("{}", serde_json::to_string_pretty(&run)?);
1709 } else {
1710 imprimir_contrato(&run);
1711 }
1712
1713 let bloquean = run
1714 .reports
1715 .iter()
1716 .filter(|r| r.verdict == takana_core::kernel::Verdict::Bloquea)
1717 .count();
1718 if bloquean > 0 {
1719 anyhow::bail!("el contrato NO se cumple en {bloquean} kernel(es)");
1720 }
1721 // Un objetivo sin sellado vigente no se comprobó, y no comprobar no es aprobar: es el fallo
1722 // de CLAUDE.md §3 —el vacío leído como presencia— en el propio guardián.
1723 if !run.sin_construir.is_empty() {
1724 anyhow::bail!(
1725 "{} objetivo(s) del contrato sin un sellado vigente: no se comprobó nada de ellos. \
1726 Construí sus recetas antes de leer esto como aprobado.",
1727 run.sin_construir.len()
1728 );
1729 }
1730 Ok(())
1731}
1732
1733fn imprimir_contrato(run: &ContractRun) {
1734 println!("contrato {}", run.contract);
1735 println!();
1736 for r in &run.reports {
1737 let marca = if r.verdict == takana_core::kernel::Verdict::Bloquea { "✗" } else { "✓" };
1738 println!("{marca} {}", r.config);
1739 println!(" perfil {} ({} capacidad(es) miradas)", r.profile, r.checked);
1740 if let Some(k) = &r.kernel {
1741 println!(" kernel {k}");
1742 }
1743 for m in &r.missing_required {
1744 println!(" ✗ FALTA {} — {}", m.id, m.title);
1745 if !m.explicit_off.is_empty() {
1746 println!(" apagado a mano: {}", m.explicit_off.join(" "));
1747 }
1748 if !m.absent.is_empty() {
1749 println!(
1750 " ni nombrado en el .config: {} (¿renombrado entre versiones?)",
1751 m.absent.join(" ")
1752 );
1753 }
1754 println!(" lo usa: {}", m.consumer.trim().replace('\n', " "));
1755 if let Some(s) = &m.silent {
1756 println!(" cómo falla hoy: {}", s.trim().replace('\n', " "));
1757 }
1758 }
1759 for m in &r.missing_wanted {
1760 println!(
1761 " · falta (no bloquea) {} — {} [{}]",
1762 m.id,
1763 m.title,
1764 [m.explicit_off.clone(), m.absent.clone()].concat().join(" ")
1765 );
1766 }
1767 for u in &r.unknown {
1768 println!(
1769 " ⚠ el perfil nombra «{}», que el contrato no define: NO se comprobó",
1770 u.id
1771 );
1772 }
1773 if r.missing_required.is_empty() {
1774 println!(" ✓ las {} capacidades exigidas están", r.ok.iter().filter(|o| o.required).count());
1775 }
1776 println!();
1777 }
1778 let malos = run
1779 .reports
1780 .iter()
1781 .filter(|r| r.verdict == takana_core::kernel::Verdict::Bloquea)
1782 .count();
1783 if !run.superados.is_empty() {
1784 let sup_malos = run
1785 .superados
1786 .iter()
1787 .filter(|r| r.verdict == takana_core::kernel::Verdict::Bloquea)
1788 .count();
1789 println!(
1790 "· {} sellado(s) SUPERADO(s) por la receta de hoy — no bloquean, {} con faltas:",
1791 run.superados.len(),
1792 sup_malos
1793 );
1794 for r in &run.superados {
1795 let faltan: Vec<&str> = r.missing_required.iter().map(|m| m.id.as_str()).collect();
1796 println!(
1797 " {} {}",
1798 r.config,
1799 if faltan.is_empty() { "cumplía".to_string() } else { format!("le falta: {}", faltan.join(" ")) }
1800 );
1801 }
1802 println!(
1803 " Siguen siendo ciertos: si una máquina arranca uno de éstos, sus Cards corren con lo \
1804 que ese kernel traiga. Se reconstruyen o se podan (`scripts/store-gc.sh`)."
1805 );
1806 println!();
1807 }
1808 println!(
1809 "resumen {} de {} config(s) VIGENTES cumplen su perfil{}{}",
1810 run.reports.len() - malos,
1811 run.reports.len(),
1812 if run.superados.is_empty() {
1813 String::new()
1814 } else {
1815 format!(" · {} superado(s)", run.superados.len())
1816 },
1817 if run.sin_perfil.is_empty() {
1818 String::new()
1819 } else {
1820 format!(" · {} sin comprobar", run.sin_perfil.len())
1821 }
1822 );
1823 println!();
1824 if !run.sin_construir.is_empty() {
1825 println!(
1826 "⚠ {} objetivo(s) del contrato SIN UN SELLADO VIGENTE — no se comprobó nada de ellos:",
1827 run.sin_construir.len()
1828 );
1829 for s in &run.sin_construir {
1830 println!(" {s}");
1831 }
1832 println!(" no cuentan como aprobados: el barrido no tuvo qué mirar. Construí sus recetas.");
1833 println!();
1834 }
1835 if !run.sin_perfil.is_empty() {
1836 println!("⚠ {} config(s) SIN COMPROBAR (ningún perfil declarado):", run.sin_perfil.len());
1837 for s in &run.sin_perfil {
1838 println!(" {s}");
1839 }
1840 println!(" no cuentan como aprobados. Declaralos en [[target]] o pasá --profile.");
1841 }
1842}
1843
1844/// Lee un `.config`, descomprimiendo si es `.gz` (el caso de `/proc/config.gz`).
1845///
1846/// Se delega a `gzip -dc` en vez de arrastrar un descompresor al core: es una entrada de
1847/// diagnóstico, no del camino de build, y el core queda sin dependencias nuevas.
1848pub fn read_maybe_gz(p: &Path) -> Result<String> {
1849 if p.extension().is_some_and(|e| e == "gz") {
1850 let out = std::process::Command::new("gzip")
1851 .arg("-dc")
1852 .arg(p)
1853 .output()
1854 .with_context(|| format!("descomprimiendo {}", p.display()))?;
1855 if !out.status.success() {
1856 anyhow::bail!(
1857 "gzip -dc {} falló: {}",
1858 p.display(),
1859 String::from_utf8_lossy(&out.stderr).trim()
1860 );
1861 }
1862 return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
1863 }
1864 std::fs::read_to_string(p).with_context(|| format!("leyendo {}", p.display()))
1865}
1866
1867#[cfg(test)]
1868mod tests {
1869 use super::*;
1870
1871 #[test]
1872 fn store_dir_of_saca_el_directorio_y_artifact_of_el_nombre() {
1873 let hex = "a".repeat(64);
1874 let p = PathBuf::from(format!("./store/{hex}-linux-metal/boot/config-6.16.12-metal"));
1875 assert_eq!(store_dir_of(&p).as_deref(), Some(format!("{hex}-linux-metal").as_str()));
1876 assert_eq!(artifact_of(&p).as_deref(), Some("linux-metal"));
1877 // Fuera del store no hay vigencia que comparar: mejor None que una comparación inventada.
1878 assert_eq!(store_dir_of(Path::new("/boot/config-7.1.4")), None);
1879 }
1880
1881 #[test]
1882 fn serie_de_las_dos_formas_que_se_cruzan() {
1883 assert_eq!(serie("Linux/x86 7.1.4-artix1 Kernel Configuration").as_deref(), Some("7.1"));
1884 assert_eq!(serie("linux-6.16.12").as_deref(), Some("6.16"));
1885 assert_eq!(serie("sin versión"), None);
1886 }
1887}
1888
1889// ══ A/B del kernel (ADR 0017 §2) ═══════════════════════════════════════════════════════════════
1890
1891use crate::efi_boot;
1892use crate::kernel_ab::{self, Veredicto};
1893use anyhow::bail;
1894
1895/// Localiza el bzImage de un artefacto del store, aceptando nombre de directorio, hash o ruta.
1896fn bzimage_de(artefacto: &str, store: &str) -> Result<(PathBuf, String)> {
1897 let directo = PathBuf::from(artefacto);
1898 let dir = if directo.is_dir() {
1899 directo
1900 } else {
1901 let s = PathBuf::from(store);
1902 let exacto = s.join(artefacto);
1903 if exacto.is_dir() {
1904 exacto
1905 } else {
1906 // Por prefijo de hash o por nombre de receta: útil a mano, y si hay varios se dice
1907 // en vez de elegir por orden de lectura del directorio.
1908 let pat = artefacto.trim_start_matches("b3:");
1909 let mut cand: Vec<PathBuf> = std::fs::read_dir(&s)
1910 .with_context(|| format!("no puedo leer el store {}", s.display()))?
1911 .filter_map(|e| e.ok())
1912 .map(|e| e.path())
1913 .filter(|p| {
1914 let n = p.file_name().unwrap_or_default().to_string_lossy().to_string();
1915 p.is_dir() && (n.starts_with(pat) || n.split_once('-').map(|(_, r)| r == pat).unwrap_or(false))
1916 })
1917 .collect();
1918 cand.sort();
1919 match cand.len() {
1920 1 => cand.remove(0),
1921 0 => bail!("no encuentro el artefacto «{artefacto}» en {}", s.display()),
1922 n => bail!(
1923 "«{artefacto}» casa con {n} artefactos; usá el nombre completo:\n {}",
1924 cand.iter()
1925 .map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string())
1926 .collect::<Vec<_>>()
1927 .join("\n ")
1928 ),
1929 }
1930 }
1931 };
1932 let bz = dir.join("boot/bzImage");
1933 if !bz.is_file() {
1934 bail!("{} no trae boot/bzImage: ¿es un artefacto de kernel?", dir.display());
1935 }
1936 let nombre = dir.file_name().unwrap_or_default().to_string_lossy().to_string();
1937 Ok((bz, nombre))
1938}
1939
1940fn ab_stage(
1941 artefacto: &str,
1942 store: &str,
1943 esp: &Path,
1944 efivars: &Path,
1945 tries: u32,
1946 state: &Path,
1947 dry_run: bool,
1948) -> Result<()> {
1949 let (bz, nombre) = bzimage_de(artefacto, store)?;
1950 let tam = std::fs::metadata(&bz)?.len();
1951 println!("candidato: {nombre}");
1952 println!(" bzImage: {} ({:.1} MiB)", bz.display(), tam as f64 / 1048576.0);
1953
1954 if !esp.is_dir() {
1955 bail!(
1956 "la ESP no está montada en {}: montala primero\n mount -t vfat <partición-ESP> {}",
1957 esp.display(),
1958 esp.display()
1959 );
1960 }
1961 if !efi_boot::hay_firmware_efi(efivars) {
1962 bail!(
1963 "sin NVRAM en {}: sin firmware UEFI no hay BootNext, y sin BootNext no hay A/B",
1964 efivars.display()
1965 );
1966 }
1967
1968 // Que entre ANTES de copiar: una ESP llena a media copia deja un candidato truncado, y un
1969 // candidato truncado no falla — arranca y muere en el firmware.
1970 let destino = kernel_ab::en_esp(esp, kernel_ab::RUTA_CANDIDATO);
1971 println!(" ranura: {} (propia; el estable no se toca)", destino.display());
1972
1973 if dry_run {
1974 println!("--dry-run: no se copió nada ni se tocó la NVRAM");
1975 return Ok(());
1976 }
1977
1978 let n = kernel_ab::instalar_candidato(&bz, esp, kernel_ab::RUTA_CANDIDATO)?;
1979 println!(" ✓ copiado ({n} bytes) de forma atómica (tmp + rename)");
1980
1981 // Entrada NVRAM propia del candidato, apuntando a SU ranura.
1982 let (disco, parte_n) = {
1983 let discos = efi_boot::block_disks(Path::new("/sys/block")).unwrap_or_default();
1984 let esps = efi_boot::find_esps(&discos);
1985 match esps.len() {
1986 1 => esps[0].clone(),
1987 0 => bail!("no encuentro ninguna ESP para apuntar la entrada de arranque"),
1988 n => bail!("hay {n} ESP: no voy a adivinar cuál es la de este arranque"),
1989 }
1990 };
1991 let parte = efi_boot::read_partition(&disco, parte_n)?;
1992 let r = efi_boot::reconcile(
1993 efivars,
1994 &parte,
1995 kernel_ab::RUTA_CANDIDATO,
1996 kernel_ab::ETIQUETA_CANDIDATO,
1997 false,
1998 )?;
1999 println!(" ✓ entrada Boot{:04X} «{}»", r.slot, kernel_ab::ETIQUETA_CANDIDATO);
2000
2001 // OJO: `reconcile` deja al candidato PRIMERO en BootOrder, y eso no es lo que queremos — el
2002 // candidato tiene que arrancar UNA vez, no volverse el default. Se repone el orden y se usa
2003 // BootNext, que es de un solo uso.
2004 if r.orden_cambiado {
2005 let ruta_orden = efivars.join(format!("BootOrder-{}", efi_boot::GLOBAL_GUID));
2006 efi_boot::write_var(
2007 &ruta_orden,
2008 efi_boot::ATTRS,
2009 &efi_boot::serialize_boot_order(&r.orden_antes),
2010 )?;
2011 println!(" ✓ BootOrder repuesto: el candidato NO se vuelve el default");
2012 }
2013 kernel_ab::poner_boot_next(efivars, r.slot)?;
2014 println!(" ✓ BootNext = Boot{:04X} (un solo uso)", r.slot);
2015
2016 let mut e = kernel_ab::leer_estado(state);
2017 e.candidato = Some(kernel_ab::Candidato {
2018 hash: nombre.split_once('-').map(|(h, _)| format!("b3:{h}")).unwrap_or(nombre.clone()),
2019 ruta: kernel_ab::RUTA_CANDIDATO.into(),
2020 slot: r.slot,
2021 intentos: tries,
2022 });
2023 kernel_ab::escribir_estado(state, &e)?;
2024 println!("\nEl PRÓXIMO arranque usa el candidato. Si no arranca, el firmware vuelve solo al");
2025 println!("estable — BootNext es de un solo uso. Si arranca y anda: `takana kernel confirm`.");
2026 Ok(())
2027}
2028
2029fn ab_status(efivars: &Path, state: &Path) -> Result<()> {
2030 let e = kernel_ab::leer_estado(state);
2031 let actual = kernel_ab::boot_current(efivars);
2032 match actual {
2033 Some(a) => println!("arrancamos por Boot{a:04X}"),
2034 None => println!("el firmware no publica BootCurrent (o no hay NVRAM)"),
2035 }
2036 if let Some(n) = kernel_ab::boot_next(efivars) {
2037 println!("BootNext pendiente: Boot{n:04X} (el próximo arranque irá por ahí)");
2038 }
2039 match kernel_ab::evaluar(&e, actual) {
2040 Veredicto::SinCandidato => {
2041 println!("sin candidato: el kernel que corre es el estable");
2042 // El firmware es la fuente de verdad, no nuestro fichero. Si el estado se perdió —el
2043 // caso real es que viviera en algo que no persiste— pero la NVRAM todavía tiene la
2044 // entrada del candidato, hay basura que nadie va a limpiar porque nadie sabe que está.
2045 // Medido: con el estado en un tmpfs, `stage` + reboot dejaba exactamente esto.
2046 let huerfanas: Vec<u16> = efi_boot::list_entries(efivars)
2047 .unwrap_or_default()
2048 .into_iter()
2049 .filter(|(_, lo)| lo.description == kernel_ab::ETIQUETA_CANDIDATO)
2050 .map(|(n, _)| n)
2051 .collect();
2052 if !huerfanas.is_empty() {
2053 println!("⚠ pero la NVRAM tiene {} entrada(s) «{}» sin estado que las respalde:",
2054 huerfanas.len(), kernel_ab::ETIQUETA_CANDIDATO);
2055 for n in &huerfanas {
2056 println!(" Boot{n:04X}");
2057 }
2058 println!(" El fichero de estado se perdió o nunca persistió. `kernel rollback` no");
2059 println!(" puede limpiarlas sin saber cuál era: borralas a mano o volvé a hacer `stage`.");
2060 }
2061 }
2062 Veredicto::EnPrueba { slot, intentos_restantes } => {
2063 println!("⏳ EN PRUEBA: Boot{slot:04X} es el candidato y es el que está corriendo");
2064 println!(" le quedan {intentos_restantes} arranque(s) sin confirmar");
2065 println!(" → `takana kernel confirm` si anda, `takana kernel rollback` si no");
2066 }
2067 Veredicto::CandidatoFallo { slot } => {
2068 println!("✗ el candidato Boot{slot:04X} NO arrancó: esta sesión vino del estable");
2069 println!(" → `takana kernel rollback` lo descarta (o `stage` otra vez para reintentar)");
2070 }
2071 Veredicto::CandidatoAgotado { slot } => {
2072 println!("✗ el candidato Boot{slot:04X} agotó sus intentos sin que nadie lo confirmara");
2073 println!(" → `takana kernel rollback`");
2074 }
2075 }
2076 Ok(())
2077}
2078
2079fn ab_confirm(esp: &Path, efivars: &Path, state: &Path) -> Result<()> {
2080 let mut e = kernel_ab::leer_estado(state);
2081 let Some(c) = e.candidato.clone() else {
2082 println!("no hay candidato que confirmar");
2083 return Ok(());
2084 };
2085 let actual = kernel_ab::boot_current(efivars);
2086 // Confirmar un kernel que NO es el que está corriendo sería promover algo que nadie probó, que
2087 // es exactamente lo contrario de para qué existe esto.
2088 if let Some(a) = actual {
2089 if a != c.slot {
2090 bail!(
2091 "esta sesión arrancó por Boot{a:04X} y el candidato es Boot{:04X}: no se confirma \
2092 un kernel que no es el que está corriendo",
2093 c.slot
2094 );
2095 }
2096 }
2097 let cand = kernel_ab::en_esp(esp, &c.ruta);
2098 let estable = kernel_ab::en_esp(esp, kernel_ab::RUTA_ESTABLE);
2099 let dir = estable.parent().context("ruta estable sin directorio")?;
2100 let tmp = dir.join("takana-prom.tmp");
2101 std::fs::copy(&cand, &tmp)
2102 .with_context(|| format!("copiando {} → {}", cand.display(), tmp.display()))?;
2103 std::fs::rename(&tmp, &estable)?;
2104 println!("✓ promovido: el candidato es ahora el kernel estable ({})", estable.display());
2105
2106 // La ruta fallback también, que es la otra mitad del seguro del ADR 0018 §3.
2107 let fallback = kernel_ab::en_esp(esp, "/EFI/BOOT/BOOTX64.EFI");
2108 if fallback.exists() {
2109 let t2 = dir.join("takana-fb.tmp");
2110 if std::fs::copy(&cand, &t2).is_ok() && std::fs::rename(&t2, &fallback).is_ok() {
2111 println!("✓ y la ruta fallback, que apunta al mismo kernel (ADR 0018 §3)");
2112 }
2113 }
2114 let _ = std::fs::remove_file(&cand);
2115 kernel_ab::borrar_boot_next(efivars);
2116 e.estable_hash = Some(c.hash.clone());
2117 e.candidato = None;
2118 kernel_ab::escribir_estado(state, &e)?;
2119 println!(" estable: {}", c.hash);
2120 Ok(())
2121}
2122
2123fn ab_rollback(esp: &Path, efivars: &Path, state: &Path) -> Result<()> {
2124 let mut e = kernel_ab::leer_estado(state);
2125 let Some(c) = e.candidato.clone() else {
2126 println!("no hay candidato que descartar");
2127 return Ok(());
2128 };
2129 kernel_ab::borrar_boot_next(efivars);
2130 let cand = kernel_ab::en_esp(esp, &c.ruta);
2131 if cand.exists() {
2132 let _ = std::fs::remove_file(&cand);
2133 println!("✓ borrada la ranura del candidato ({})", cand.display());
2134 }
2135 // La entrada NVRAM del candidato se va también: dejarla sería ofrecer en el menú del firmware
2136 // un arranque que apunta a un fichero que ya no está.
2137 let var = efi_boot::boot_var_path(efivars, c.slot);
2138 if var.exists() {
2139 let _ = std::fs::remove_file(&var);
2140 println!("✓ borrada la entrada Boot{:04X}", c.slot);
2141 }
2142 e.candidato = None;
2143 kernel_ab::escribir_estado(state, &e)?;
2144 println!("el kernel estable sigue siendo el que estaba. No se tocó.");
2145 Ok(())
2146}
2147
2148fn kexec_cmd(
2149 artefacto: &str,
2150 store: &str,
2151 initrd: Option<&Path>,
2152 cmdline: &str,
2153 load_only: bool,
2154 jump: bool,
2155) -> Result<()> {
2156 let (bz, nombre) = bzimage_de(artefacto, store)?;
2157 println!("kexec: {nombre}");
2158 println!(" kernel: {}", bz.display());
2159 match initrd {
2160 Some(p) => println!(" initrd: {}", p.display()),
2161 None => println!(" initrd: (ninguno)"),
2162 }
2163 println!(" cmdline: {cmdline}");
2164
2165 // Ojo: esto detecta si el SYSCALL existe, no si podemos usarlo. Un kernel con el flag puesto y
2166 // un usuario sin privilegios pasa esta puerta y falla después con EPERM, que es lo correcto:
2167 // mejor el error real del syscall que una sospecha nuestra.
2168 if !kernel_ab::kexec_file_disponible() {
2169 bail!(
2170 "este kernel no implementa kexec_file_load (falta CONFIG_KEXEC_FILE).\n \
2171 Los kernels del corpus traen CONFIG_KEXEC —el syscall viejo— que exige armar los \
2172 segmentos y el purgatory a mano, o sea kexec-tools entero.\n \
2173 La variante `recipes/linux-metal-kexec.toml` lo trae encendido."
2174 );
2175 }
2176
2177 kernel_ab::kexec_cargar(&bz, initrd, cmdline)?;
2178 println!("✓ kernel cargado en memoria");
2179 if load_only || !jump {
2180 println!(" (no se salta: pasá --jump. El kernel queda cargado hasta el próximo reboot.)");
2181 return Ok(());
2182 }
2183 println!("⚡ saltando — el userspace muere ACÁ. Esto no vuelve.");
2184 kernel_ab::kexec_saltar()
2185}