gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 188Rust 55Shell 30Markdown 281211 Definiciones

⎇ 0a9622c7b055b93800c45afcac7b4774ed231bcc crates/takana-cli/src/kernel_cmd.rs Rust · 2185 líneas · 82.5 KB Historia Quién tocó qué crudo

kernel kexec: el reboot suave por kexec_file_load, sin kexec-tools 0a9622c7b · Sergio ·

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}