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