From cf2cf54914e5917613be71ffb49109894c1791c7 Mon Sep 17 00:00:00 2001 From: Sergio Date: Mon, 10 Aug 2026 13:13:55 +0000 Subject: [PATCH] =?UTF-8?q?kernel:=20modo=20reversa=20y=20cat=C3=A1logo=20?= =?UTF-8?q?de=20bundles=20=E2=80=94=20y=20las=20cuatro=20recetas=20apagan?= =?UTF-8?q?=20dos=20s=C3=ADmbolos=20que=20ya=20no=20existen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paso 1 del §8 del SDD 22 (va después de la clausura porque necesitaba el grafo). Sigue sin compilar ni escribir nada. hammer kernel probe — lee /proc/config.gz (o /boot/config-) y muestra el kernel que YA CORRE por el lente de los bundles: cuánto de cada uno rige, qué capacidad carga esta máquina y no usa, y dónde el hardware CONTRADICE a un bundle aplicado. Corrido en gioser: 10.551 símbolos, 20 dispositivos PCI, 38 drivers bindeados, 15 bundles, 7 con capacidad que este hardware no usa. hammer kernel bundles [--check] — el catálogo con las clausuras resueltas contra un árbol concreto, y el control de frescura. Piezas nuevas en hammer-core/src/kernel/: catalog.rs bundles N1 y perillas N2. El campo `side` NO es decorativo: la mitad de N2 son variables de receta, no símbolos; mueven el hash igual pero se aplican en otra fase y fallan distinto. Una perilla side=recipe sin recipe_field es error de carga, porque es un diff que la UI no podría explicar. hw.rs huella DMI+PCI+flags de CPU. El USB se LEE y se REPORTA pero NO se hashea: un pendrive no puede cambiar la clase de hardware bajo la que se cachea un kernel. Tampoco entra el serial: la huella agrupa máquinas, no las identifica. Tres tests fijan esas tres propiedades. reverse.rs el análisis. Y una tercera salida que no estaba pedida: cada fuga `select` que entra a un bundle y NO está declarada en el catálogo es un símbolo que upstream agregó y nadie revisó ⇒ la mitad barata de la curación del delta (§3 del handoff) sale de comparar grafo con catálogo, sin IA. docs/state/kernel-bundles.toml — 15 bundles N1 y 8 perillas N2, cada fuga resuelta a mano una vez: `close_leaks` (se apaga también al que la provoca) o `accept_leaks` (se deja abierta a sabiendas, con el motivo escrito). Ejemplo de por qué hacían falta las dos: "sin-audio" NO cierra — DRM_I915/NOUVEAU/AMD_DC hacen select del códec HDMI, y cerrarlo sería quedarse sin GPU. Se acepta y queda por escrito. LO QUE DESTAPÓ EL CONTROL DE FRESCURA: las CUATRO recetas de kernel (linux, linux-metal, linux-metal-dual, linux-generic) apagan `THUNDERBOLT` y `REISERFS_FS`, y 6.16.12 NO TIENE NINGUNO DE LOS DOS. Thunderbolt se llama USB4 desde que upstream lo fundió con USB4; reiserfs fue retirado. Los dos `-d` son no-ops silenciosos: el driver USB4 sigue entrando por el defconfig mientras la receta dice que está apagado. NO las toco — cambiarlo mueve el ArtifactHash de los cuatro kernels y es una decisión, no una limpieza. Y el propio probe destapó un desajuste que ahora avisa: el config vivo de gioser es de la serie 7.1 y el catálogo se revisó contra la 6.16 ⇒ las clausuras son aproximadas. Se dice en vez de callarlo. Co-Authored-By: Claude Opus 5 (1M context) --- crates/hammer-cli/src/kernel_cmd.rs | 447 +++++++++++++++++++++++ crates/hammer-core/src/kernel/catalog.rs | 254 +++++++++++++ crates/hammer-core/src/kernel/hw.rs | 350 ++++++++++++++++++ crates/hammer-core/src/kernel/mod.rs | 6 + crates/hammer-core/src/kernel/reverse.rs | 328 +++++++++++++++++ docs/state/kernel-bundles.toml | 255 +++++++++++++ 6 files changed, 1640 insertions(+) create mode 100644 crates/hammer-core/src/kernel/catalog.rs create mode 100644 crates/hammer-core/src/kernel/hw.rs create mode 100644 crates/hammer-core/src/kernel/reverse.rs create mode 100644 docs/state/kernel-bundles.toml diff --git a/crates/hammer-cli/src/kernel_cmd.rs b/crates/hammer-cli/src/kernel_cmd.rs index 054a652d..b1b2254b 100644 --- a/crates/hammer-cli/src/kernel_cmd.rs +++ b/crates/hammer-cli/src/kernel_cmd.rs @@ -14,6 +14,13 @@ use serde::Serialize; /// Dónde busca el árbol de Kconfig si no se pasa `--kconfig`. const KCONFIG_ENV: &str = "HAMMER_KCONFIG_ROOT"; +/// Ídem para el catálogo de bundles. +const CATALOG_ENV: &str = "HAMMER_KERNEL_BUNDLES"; +/// Sitios donde buscarlo sin que nadie diga nada: el del repo y el del sistema instalado. +const CATALOG_DEFAULTS: &[&str] = &[ + "docs/state/kernel-bundles.toml", + "/usr/share/hammer/kernel-bundles.toml", +]; #[derive(Subcommand)] pub enum KernelCmd { @@ -43,6 +50,40 @@ pub enum KernelCmd { #[arg(long, default_value_t = 20)] limit: usize, }, + /// **Modo reversa**: lee el kernel que ya corre y lo muestra por el lente de los bundles. + /// + /// No compila ni escribe nada. Contesta tres cosas: cuánto de cada bundle ya rige, qué + /// capacidad carga este kernel que esta máquina no usa, y dónde el hardware CONTRADICE a un + /// bundle aplicado. + Probe { + /// `.config` a mirar. Por defecto `/proc/config.gz` y si no `/boot/config-`. + #[arg(long)] + config: Option, + #[arg(long)] + kconfig: Option, + #[arg(long)] + catalog: Option, + /// No leer el hardware (útil para analizar el config de OTRA máquina). + #[arg(long)] + no_hardware: bool, + #[arg(long)] + json: bool, + }, + /// Lista el catálogo con sus clausuras ya resueltas contra un árbol de Kconfig concreto. + /// + /// Es también el control de frescura: `--check` sale distinto de cero si algún bundle nombra + /// símbolos que ya no existen o dejó fugas `select` sin declarar. + Bundles { + #[arg(long)] + kconfig: Option, + #[arg(long)] + catalog: Option, + /// Falla si el catálogo envejeció respecto de este árbol. + #[arg(long)] + check: bool, + #[arg(long)] + json: bool, + }, /// Estadísticas del árbol de Kconfig parseado (control de salud del lector). Stats { #[arg(long)] @@ -56,6 +97,25 @@ pub enum KernelCmd { pub fn run(cmd: KernelCmd) -> Result<()> { match cmd { KernelCmd::Stats { kconfig, warnings } => stats(&kconfig_root(kconfig)?, warnings), + KernelCmd::Probe { + config, + kconfig, + catalog, + no_hardware, + json, + } => probe( + config.as_deref(), + &kconfig_root(kconfig)?, + &catalog_path(catalog)?, + !no_hardware, + json, + ), + KernelCmd::Bundles { + kconfig, + catalog, + check, + json, + } => bundles(&kconfig_root(kconfig)?, &catalog_path(catalog)?, check, json), KernelCmd::Closure { symbols, kconfig, @@ -87,6 +147,45 @@ fn kconfig_root(flag: Option) -> Result { } } +/// Resuelve el catálogo de bundles: `--catalog`, si no la variable de entorno, si no el del repo. +fn catalog_path(flag: Option) -> Result { + if let Some(p) = flag { + return Ok(p); + } + if let Some(v) = std::env::var_os(CATALOG_ENV) { + return Ok(PathBuf::from(v)); + } + for c in CATALOG_DEFAULTS { + let p = PathBuf::from(c); + if p.is_file() { + return Ok(p); + } + } + anyhow::bail!( + "no encontré el catálogo de bundles: pasá --catalog o exportá {CATALOG_ENV}" + ) +} + +/// Dónde está el `.config` del kernel que corre. `/proc/config.gz` es el camino soberano (lo emite +/// el propio kernel); el de `/boot` es el respaldo para kernels compilados sin `IKCONFIG_PROC`. +fn running_config_path() -> Result { + let gz = PathBuf::from("/proc/config.gz"); + if gz.is_file() { + return Ok(gz); + } + if let Ok(out) = std::process::Command::new("uname").arg("-r").output() { + let rel = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let p = PathBuf::from(format!("/boot/config-{rel}")); + if p.is_file() { + return Ok(p); + } + } + anyhow::bail!( + "no encontré el config del kernel vivo (ni /proc/config.gz ni /boot/config-).\n\ + Pasá --config para mirar el de otra máquina." + ) +} + fn load_tree(root: &Path) -> Result { if !root.join("Kconfig").is_file() { anyhow::bail!( @@ -340,6 +439,342 @@ fn closure( Ok(()) } +// --------------------------------------------------------------------------------------------- +// probe — el modo reversa (#9) +// --------------------------------------------------------------------------------------------- + +fn probe( + config: Option<&Path>, + kroot: &Path, + catalog: &Path, + with_hw: bool, + json: bool, +) -> Result<()> { + use hammer_core::kernel::reverse::{BundleState, HwSignal}; + + let cfg_path = match config { + Some(p) => p.to_path_buf(), + None => running_config_path()?, + }; + let cfg = KernelConfig::parse(&read_maybe_gz(&cfg_path)?); + if cfg.is_empty() { + anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", cfg_path.display()); + } + let tree = load_tree(kroot)?; + let cat = hammer_core::kernel::Catalog::load(catalog)?; + let hw = with_hw.then(hammer_core::kernel::Hardware::probe_local); + let rep = hammer_core::kernel::reverse::analyze(&cat, &tree, &cfg, hw.as_ref()); + + if json { + println!("{}", serde_json::to_string_pretty(&rep)?); + return Ok(()); + } + + println!("config {}", cfg_path.display()); + if let Some(k) = &rep.kernel { + println!("kernel {k}"); + } + // El modo reversa cruza DOS árboles: el del config vivo y el que se parseó. Si no son la misma + // versión, las clausuras son aproximadas — un símbolo puede haberse renombrado o retirado entre + // medias (que es justo lo que le pasó a THUNDERBOLT y a REISERFS_FS). Vale igual, pero decirlo. + if let (Some(banner), Some(rev)) = (&rep.kernel, &cat.reviewed_against) { + if let (Some(a), Some(b)) = (serie(banner), serie(rev)) { + if a != b { + println!( + " ⚠ el config vivo es de la serie {a} y el catálogo se revisó contra \ + la {b}: las clausuras son aproximadas" + ); + } + } + } + println!("símbolos {} declarados", rep.config_symbols); + if let Some(h) = &hw { + println!("huella {}", rep.fingerprint); + if let Some(m) = &h.cpu_model { + println!(" {m}"); + } + let maquina = [h.dmi.get("sys_vendor"), h.dmi.get("product_name")] + .into_iter() + .flatten() + .cloned() + .collect::>() + .join(" "); + if !maquina.is_empty() { + println!(" {maquina}"); + } + println!( + " {} dispositivos PCI · {} drivers bindeados", + h.pci.len(), + h.bound_drivers.len() + ); + for g in &h.gaps { + println!(" ⚠ {g}"); + } + } + println!(); + + for b in &rep.bundles { + let on = b.on_builtin + b.on_module; + let estado = match b.state { + BundleState::Aplicado => "APLICADO ", + BundleState::Parcial => "parcial ", + BundleState::Sin => "sin ", + }; + let hwtxt = match &b.hardware { + HwSignal::SinSeñal => String::new(), + HwSignal::Ausente => " · hardware ausente".to_string(), + HwSignal::Presente { evidencia } => { + format!(" · ⚠ HARDWARE PRESENTE ({})", evidencia.len()) + } + }; + println!( + "{estado} {:<28} {on:>5}/{:<5} encendidos{hwtxt}", + b.id, b.closure_len + ); + if let HwSignal::Presente { evidencia } = &b.hardware { + for e in evidencia.iter().take(4) { + println!(" {e}"); + } + } + if !b.unknown_symbols.is_empty() { + println!( + " ⚠ símbolos que este kernel no tiene: {}", + b.unknown_symbols.join(", ") + ); + } + if !b.undeclared_leaks.is_empty() { + println!( + " ⚠ {} fuga(s) `select` sin declarar en el catálogo", + b.undeclared_leaks.len() + ); + } + } + + println!(); + println!( + "resumen {} bundle(s): {} ya aplicados, {} con capacidad que este hardware NO usa", + rep.bundles.len(), + rep.bundles + .iter() + .filter(|b| b.state == BundleState::Aplicado) + .count(), + rep.applicable_savings + ); + if rep.contradictions > 0 { + println!( + " ⚠ {} bundle(s) aplicados CONTRADICEN al hardware presente", + rep.contradictions + ); + } + println!(" (detectar sirve para contradecir, nunca para podar solo)"); + Ok(()) +} + +/// Serie `mayor.menor` de un texto que contenga una versión (`"Linux/x86 7.1.4-artix1 …"` → `7.1`, +/// `"linux-6.16.12"` → `6.16`). `None` si no hay ninguna. +fn serie(texto: &str) -> Option { + for tok in texto.split(|c: char| !(c.is_ascii_digit() || c == '.')) { + let mut it = tok.split('.'); + if let (Some(a), Some(b)) = (it.next(), it.next()) { + if !a.is_empty() && !b.is_empty() { + return Some(format!("{a}.{b}")); + } + } + } + None +} + +// --------------------------------------------------------------------------------------------- +// bundles — catálogo + control de frescura +// --------------------------------------------------------------------------------------------- + +#[derive(Serialize)] +struct BundleView { + id: String, + title: String, + #[serde(skip_serializing_if = "Option::is_none")] + help: Option, + disable: Vec, + close_leaks: Vec, + closure_len: usize, + visible: usize, + unknown_symbols: Vec, + undeclared_leaks: Vec, + accepted_leaks: usize, +} + +#[derive(Serialize)] +struct BundlesReport { + catalog: String, + #[serde(skip_serializing_if = "Option::is_none")] + reviewed_against: Option, + bundles: Vec, + knobs: Vec, + stale: bool, +} + +#[derive(Serialize)] +struct KnobView { + id: String, + title: String, + side: hammer_core::kernel::Side, + enable: Vec, + disable: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + recipe_field: Option, + unknown_symbols: Vec, +} + +fn bundles(kroot: &Path, catalog: &Path, check: bool, json: bool) -> Result<()> { + let tree = load_tree(kroot)?; + let cat = hammer_core::kernel::Catalog::load(catalog)?; + + let mut views = Vec::new(); + for b in &cat.bundles { + let mut roots = b.disable.clone(); + roots.extend(b.close_leaks.iter().cloned()); + let unknown: Vec = roots + .iter() + .chain(b.enable.iter()) + .filter(|s| !tree.symbols.contains_key(*s)) + .cloned() + .collect(); + let cl = tree.closure_off(&roots); + let aceptadas: BTreeSet<&str> = b.accept_leaks.keys().map(|s| s.as_str()).collect(); + let undeclared: Vec<_> = tree + .select_leaks(&cl) + .into_iter() + .filter(|l| !aceptadas.contains(l.target.as_str())) + .collect(); + views.push(BundleView { + id: b.id.clone(), + title: b.title.clone(), + help: b.help.clone(), + disable: b.disable.clone(), + close_leaks: b.close_leaks.clone(), + closure_len: cl.len(), + visible: cl + .iter() + .filter(|s| tree.symbols.get(*s).is_some_and(|x| x.is_visible())) + .count(), + unknown_symbols: unknown, + undeclared_leaks: undeclared, + accepted_leaks: b.accept_leaks.len(), + }); + } + + let knobs: Vec = cat + .knobs + .iter() + .map(|k| KnobView { + id: k.id.clone(), + title: k.title.clone(), + side: k.side, + enable: k.enable.clone(), + disable: k.disable.clone(), + recipe_field: k.recipe_field.clone(), + unknown_symbols: k + .enable + .iter() + .chain(k.disable.iter()) + .filter(|s| !tree.symbols.contains_key(*s)) + .cloned() + .collect(), + }) + .collect(); + + let stale = views + .iter() + .any(|v| !v.unknown_symbols.is_empty() || !v.undeclared_leaks.is_empty()) + || knobs.iter().any(|k| !k.unknown_symbols.is_empty()); + + let rep = BundlesReport { + catalog: catalog.display().to_string(), + reviewed_against: cat.reviewed_against.clone(), + bundles: views, + knobs, + stale, + }; + + if json { + println!("{}", serde_json::to_string_pretty(&rep)?); + } else { + println!("catálogo {}", rep.catalog); + if let Some(r) = &rep.reviewed_against { + println!("revisado contra {r}"); + } + println!("árbol {}", kroot.display()); + println!(); + println!("NIVEL 1 — aserciones del dueño de la máquina"); + for v in &rep.bundles { + println!( + " {:<26} {:>5} símbolos ({} visibles) ← {}", + v.id, + v.closure_len, + v.visible, + v.disable.join(" ") + ); + println!(" {}", v.title); + if !v.close_leaks.is_empty() { + println!(" fugas cerradas: {}", v.close_leaks.join(", ")); + } + if v.accepted_leaks > 0 { + println!(" fugas aceptadas a sabiendas: {}", v.accepted_leaks); + } + if !v.unknown_symbols.is_empty() { + println!( + " ⚠ NO EXISTEN en este árbol: {}", + v.unknown_symbols.join(", ") + ); + } + for l in v.undeclared_leaks.iter().take(6) { + println!(" ⚠ fuga sin declarar: {} ← {}", l.target, l.selector); + } + if v.undeclared_leaks.len() > 6 { + println!(" ⚠ … y {} fuga(s) más", v.undeclared_leaks.len() - 6); + } + } + println!(); + println!("NIVEL 2 — política y rendimiento (ojo al lado del que cae cada perilla)"); + for k in &rep.knobs { + let lado = match k.side { + hammer_core::kernel::Side::Kconfig => "kconfig", + hammer_core::kernel::Side::Recipe => "receta ", + }; + let que = match k.side { + hammer_core::kernel::Side::Kconfig => { + let mut v: Vec = + k.enable.iter().map(|s| format!("+{s}")).collect(); + v.extend(k.disable.iter().map(|s| format!("-{s}"))); + v.join(" ") + } + hammer_core::kernel::Side::Recipe => { + k.recipe_field.clone().unwrap_or_default() + } + }; + println!(" [{lado}] {:<26} {que}", k.id); + println!(" {}", k.title); + if !k.unknown_symbols.is_empty() { + println!( + " ⚠ NO EXISTEN en este árbol: {}", + k.unknown_symbols.join(", ") + ); + } + } + println!(); + if rep.stale { + println!("⚠ el catálogo ENVEJECIÓ respecto de este árbol (símbolos idos o fugas nuevas)"); + } else { + println!("✓ el catálogo está al día con este árbol"); + } + } + + if check && rep.stale { + anyhow::bail!("catálogo desactualizado"); + } + Ok(()) +} + /// Lee un `.config`, descomprimiendo si es `.gz` (el caso de `/proc/config.gz`). /// /// Se delega a `gzip -dc` en vez de arrastrar un descompresor al core: es una entrada de @@ -362,3 +797,15 @@ pub fn read_maybe_gz(p: &Path) -> Result { } std::fs::read_to_string(p).with_context(|| format!("leyendo {}", p.display())) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serie_de_las_dos_formas_que_se_cruzan() { + assert_eq!(serie("Linux/x86 7.1.4-artix1 Kernel Configuration").as_deref(), Some("7.1")); + assert_eq!(serie("linux-6.16.12").as_deref(), Some("6.16")); + assert_eq!(serie("sin versión"), None); + } +} diff --git a/crates/hammer-core/src/kernel/catalog.rs b/crates/hammer-core/src/kernel/catalog.rs new file mode 100644 index 00000000..524e390b --- /dev/null +++ b/crates/hammer-core/src/kernel/catalog.rs @@ -0,0 +1,254 @@ +//! El catálogo de bundles (N1) y perillas (N2). +//! +//! ## Por qué un bundle no es una lista de símbolos +//! Escrito como lista, «no necesito wifi» son ~400 `CONFIG_*` que envejecen en cada release y +//! exigen un curador humano para siempre (handoff §2.1). Escrito como **predicado sobre el grafo** +//! —una raíz y su clausura— son dos líneas y se auto-cura: el driver que entra en 6.17 cae dentro +//! sin que nadie lo toque. +//! +//! Lo que la medición del SDD 22 §9 agregó a esa idea: **la clausura sola no cierra**. `select` +//! fuerza símbolos ignorando sus `depends on`, así que un bundle honesto declara además qué hace +//! con cada fuga — cerrarla (`close_leaks`) o aceptarla a sabiendas (`accept_leaks`). Esa revisión +//! se hace **una vez por bundle**, no una vez por release. +//! +//! ## Por qué las perillas N2 llevan un campo `side` +//! «La mitad de N2 no es Kconfig sino variables de receta» (handoff §5). Con el hecho del §1 del +//! SDD 22 en la mano —el config ES la identidad del artefacto— eso es más grave de lo que parece: +//! las dos mitades mueven el hash igual, pero se aplican en fases distintas y fallan distinto. Una +//! UI que no sepa de qué lado cae cada perilla prometerá diffs que no puede explicar. + +use std::collections::BTreeMap; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +/// Formato del fichero de catálogo. Se versiona para poder migrarlo sin adivinar. +pub const CATALOG_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Catalog { + pub version: u32, + /// Kernel contra el que se revisaron las fugas. Cambiar de versión no invalida el catálogo, + /// pero sí obliga a re-mirar las fugas (`hammer kernel closure`). + #[serde(default)] + pub reviewed_against: Option, + #[serde(default, rename = "bundle")] + pub bundles: Vec, + #[serde(default, rename = "knob")] + pub knobs: Vec, +} + +/// Un bundle de nivel 1: una aserción del dueño de la máquina («no necesito wifi»). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Bundle { + /// Identificador estable, en kebab-case. Es lo que la UI y `plan` se pasan. + pub id: String, + /// Título en castellano, para el humano. + pub title: String, + #[serde(default)] + pub help: Option, + /// **El predicado**: apagar estas raíces y, con ellas, todo lo que dependa duro de ellas. + #[serde(default)] + pub disable: Vec, + /// Encender estos símbolos (bundles «positivos», raros en N1 pero necesarios en N2). + #[serde(default)] + pub enable: Vec, + /// Fugas `select` que se cierran también, revisadas a mano. Cada entrada es un símbolo de + /// FUERA de la clausura que reencendería algo de dentro. + #[serde(default)] + pub close_leaks: Vec, + /// Fugas que se dejan abiertas a sabiendas, con su motivo. Clave = símbolo destino. + #[serde(default)] + pub accept_leaks: BTreeMap, + /// Detección de hardware, **en un solo sentido**: sirve para CONTRADECIR al usuario + /// («marcaste "no necesito wifi" y tenés un AX211 activo ahora mismo»), nunca para podar solo. + /// Un kernel podado por autodetección es cómo se fabrica un ladrillo (handoff §6). + #[serde(default)] + pub contradicted_by: Contradiction, + #[serde(default)] + pub notes: Option, +} + +/// Señales de hardware que contradicen a un bundle si están presentes AHORA. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Contradiction { + /// Clases PCI, como los 6 dígitos hex de `/sys/bus/pci/devices/*/class`, o un prefijo + /// (`"0280"` = controlador de red inalámbrica). + #[serde(default)] + pub pci_class: Vec, + /// Drivers con dispositivo bindeado ahora mismo (`/sys/bus/*/devices/*/driver`). + #[serde(default)] + pub driver: Vec, + /// Flags de CPU de `/proc/cpuinfo`. + #[serde(default)] + pub cpu_flag: Vec, +} + +impl Contradiction { + pub fn is_empty(&self) -> bool { + self.pci_class.is_empty() && self.driver.is_empty() && self.cpu_flag.is_empty() + } +} + +/// De qué lado del build cae una perilla de nivel 2. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Side { + /// Símbolos de Kconfig: se aplican en la fase `configure`. + Kconfig, + /// Variables de la receta (LTO, `-march`, `zig_version`): otra fase, otro modo de fallo. + /// Mueven el `ArtifactHash` igual que los símbolos. + Recipe, +} + +/// Una perilla de nivel 2: política y rendimiento. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Knob { + pub id: String, + pub title: String, + #[serde(default)] + pub help: Option, + /// La mitad de N2 no es Kconfig. Este campo obliga a decirlo. + pub side: Side, + #[serde(default)] + pub enable: Vec, + #[serde(default)] + pub disable: Vec, + /// Sólo con `side = "recipe"`: qué campo de la receta toca, en texto, para que la UI pueda + /// explicar el diff aunque `plan` todavía no sepa aplicarlo. + #[serde(default)] + pub recipe_field: Option, + #[serde(default)] + pub notes: Option, +} + +impl Catalog { + pub fn load(path: &Path) -> crate::Result { + let text = std::fs::read_to_string(path)?; + let cat: Catalog = toml::from_str(&text) + .map_err(|e| crate::Error::Recipe(format!("{}: {e}", path.display())))?; + cat.validate(path)?; + Ok(cat) + } + + fn validate(&self, path: &Path) -> crate::Result<()> { + let p = path.display(); + if self.version != CATALOG_VERSION { + return Err(crate::Error::Recipe(format!( + "{p}: version = {}, esperaba {CATALOG_VERSION}", + self.version + ))); + } + let mut ids = std::collections::BTreeSet::new(); + for b in &self.bundles { + if !ids.insert(("bundle", b.id.as_str())) { + return Err(crate::Error::Recipe(format!("{p}: bundle duplicado «{}»", b.id))); + } + if b.disable.is_empty() && b.enable.is_empty() { + return Err(crate::Error::Recipe(format!( + "{p}: el bundle «{}» no apaga ni enciende nada", + b.id + ))); + } + } + for k in &self.knobs { + if !ids.insert(("knob", k.id.as_str())) { + return Err(crate::Error::Recipe(format!("{p}: perilla duplicada «{}»", k.id))); + } + // Una perilla de receta que no dice qué campo toca es exactamente el diff que la UI no + // va a poder explicar. + if k.side == Side::Recipe && k.recipe_field.is_none() { + return Err(crate::Error::Recipe(format!( + "{p}: la perilla «{}» es side=recipe y no declara recipe_field", + k.id + ))); + } + if k.side == Side::Kconfig && k.enable.is_empty() && k.disable.is_empty() { + return Err(crate::Error::Recipe(format!( + "{p}: la perilla «{}» es side=kconfig y no toca ningún símbolo", + k.id + ))); + } + } + Ok(()) + } + + pub fn bundle(&self, id: &str) -> Option<&Bundle> { + self.bundles.iter().find(|b| b.id == id) + } + + pub fn knob(&self, id: &str) -> Option<&Knob> { + self.knobs.iter().find(|k| k.id == id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn escribir(s: &str) -> (tempfile::TempDir, std::path::PathBuf) { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("bundles.toml"); + std::fs::write(&p, s).unwrap(); + (d, p) + } + + #[test] + fn carga_y_resuelve_por_id() { + let (_d, p) = escribir( + r#" +version = 1 +[[bundle]] +id = "sin-wifi" +title = "No necesito wifi" +disable = ["WIRELESS"] +close_leaks = ["WLAN"] +[bundle.accept_leaks] +MAC80211_LEDS = "lo deja encendido IWLEGACY, que igual no se compila" +[bundle.contradicted_by] +pci_class = ["0280"] +"#, + ); + let c = Catalog::load(&p).unwrap(); + let b = c.bundle("sin-wifi").unwrap(); + assert_eq!(b.disable, vec!["WIRELESS"]); + assert_eq!(b.close_leaks, vec!["WLAN"]); + assert_eq!(b.accept_leaks.len(), 1); + assert_eq!(b.contradicted_by.pci_class, vec!["0280"]); + } + + #[test] + fn perilla_de_receta_sin_campo_es_error() { + let (_d, p) = escribir( + r#" +version = 1 +[[knob]] +id = "lto" +title = "LTO" +side = "recipe" +"#, + ); + // Sin `recipe_field` la UI prometería un diff que no puede explicar (handoff §5). + let e = Catalog::load(&p).unwrap_err().to_string(); + assert!(e.contains("recipe_field"), "{e}"); + } + + #[test] + fn bundle_vacio_es_error() { + let (_d, p) = escribir( + r#" +version = 1 +[[bundle]] +id = "nada" +title = "Nada" +"#, + ); + assert!(Catalog::load(&p).is_err()); + } + + #[test] + fn version_ajena_es_error() { + let (_d, p) = escribir("version = 99\n"); + assert!(Catalog::load(&p).is_err()); + } +} diff --git a/crates/hammer-core/src/kernel/hw.rs b/crates/hammer-core/src/kernel/hw.rs new file mode 100644 index 00000000..fa65c322 --- /dev/null +++ b/crates/hammer-core/src/kernel/hw.rs @@ -0,0 +1,350 @@ +//! Huella de hardware: DMI + PCI + flags de CPU. +//! +//! Es la pieza de §2.2 del handoff — «un config que **booteó** en esta huella es un hecho +//! atestable». Con el §1 del SDD 22 en la mano vale más de lo que el handoff suponía: como el +//! config ES la identidad del artefacto, atestar por huella no necesita mecanismo nuevo; es un +//! `ArtifactHash` más una firma. +//! +//! ## Qué entra en la huella y qué no +//! Entran **DMI, PCI y los flags de CPU**: describen la máquina, no lo que hay enchufado hoy. El +//! USB se **lee y se reporta pero no se hashea** — un pendrive no puede cambiar la clase de +//! hardware bajo la que se cachea un kernel. Si entrara, la huella sería distinta cada vez y el +//! CDN cachearía por máquina en vez de por clase, que es justo lo que §2.2 quiere evitar. +//! +//! ## Y para qué sirve leerla +//! **Sólo para contradecir** (handoff §6). Detectar sirve para decirle al usuario «marcaste "no +//! necesito wifi" y tenés un AX211 activo ahora mismo», nunca para podar el config solo: no se +//! puede detectar el dock que se enchufa el mes que viene ni el fs del USB de rescate. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::hash::ArtifactHash; + +/// Campos de `/sys/class/dmi/id` que se leen. Deliberadamente **no** se lee `product_uuid` ni +/// `product_serial`: identifican al equipo concreto (y son root-only), y la huella tiene que +/// agrupar máquinas iguales, no distinguirlas. +const DMI_FIELDS: &[&str] = &[ + "sys_vendor", + "product_name", + "product_version", + "board_vendor", + "board_name", + "bios_vendor", + "bios_version", + "chassis_type", +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PciDevice { + /// `0000:00:1f.3` + pub slot: String, + /// `0x8086` + pub vendor: String, + pub device: String, + /// Clase de 6 dígitos hex: los 2 primeros son la clase base, los 2 siguientes la subclase. + pub class: String, + /// Driver bindeado ahora mismo, si hay. + pub driver: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UsbDevice { + pub id: String, + pub vendor: String, + pub product: String, + pub driver: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Hardware { + pub dmi: BTreeMap, + pub pci: Vec, + /// Leído y reportado, pero **fuera** de la huella (ver doc del módulo). + pub usb: Vec, + pub cpu_model: Option, + pub cpu_flags: BTreeSet, + /// Todos los drivers con al menos un dispositivo bindeado, de cualquier bus. + pub bound_drivers: BTreeSet, + /// Qué no se pudo leer. Una huella incompleta es un dato, no una excepción: en una VM sin DMI + /// o con `/sys` restringido la huella sigue siendo útil, pero quien la lea tiene que saberlo. + pub gaps: Vec, +} + +impl Hardware { + /// Lee la máquina real. `sys` y `proc` se inyectan para poder testear con un árbol de juguete. + pub fn probe(sys: &Path, proc_dir: &Path) -> Hardware { + let mut hw = Hardware::default(); + hw.read_dmi(sys); + hw.read_pci(sys); + hw.read_usb(sys); + hw.read_cpu(proc_dir); + hw.read_bound_drivers(sys); + hw + } + + /// El caso normal: la máquina donde corre esto. + pub fn probe_local() -> Hardware { + Hardware::probe(Path::new("/sys"), Path::new("/proc")) + } + + fn read_dmi(&mut self, sys: &Path) { + let dir = sys.join("class/dmi/id"); + if !dir.is_dir() { + self.gaps.push("sin DMI (¿VM o /sys restringido?)".into()); + return; + } + for f in DMI_FIELDS { + if let Ok(v) = std::fs::read_to_string(dir.join(f)) { + let v = v.trim(); + if !v.is_empty() { + self.dmi.insert((*f).to_string(), v.to_string()); + } + } + } + if self.dmi.is_empty() { + self.gaps.push("DMI presente pero ilegible".into()); + } + } + + fn read_pci(&mut self, sys: &Path) { + let dir = sys.join("bus/pci/devices"); + let Ok(entries) = std::fs::read_dir(&dir) else { + self.gaps.push("sin bus PCI".into()); + return; + }; + let mut devs = Vec::new(); + for e in entries.flatten() { + let p = e.path(); + let slot = e.file_name().to_string_lossy().into_owned(); + let g = |f: &str| read_trim(&p.join(f)).unwrap_or_default(); + devs.push(PciDevice { + slot, + vendor: strip_0x(&g("vendor")), + device: strip_0x(&g("device")), + class: strip_0x(&g("class")), + driver: link_basename(&p.join("driver")), + }); + } + devs.sort_by(|a, b| a.slot.cmp(&b.slot)); + self.pci = devs; + } + + fn read_usb(&mut self, sys: &Path) { + let dir = sys.join("bus/usb/devices"); + let Ok(entries) = std::fs::read_dir(&dir) else { + return; + }; + let mut devs = Vec::new(); + for e in entries.flatten() { + let p = e.path(); + // Las interfaces (`1-1:1.0`) no son dispositivos; sólo los nodos con idVendor. + let Some(vendor) = read_trim(&p.join("idVendor")) else { + continue; + }; + devs.push(UsbDevice { + id: e.file_name().to_string_lossy().into_owned(), + vendor, + product: read_trim(&p.join("idProduct")).unwrap_or_default(), + driver: link_basename(&p.join("driver")), + }); + } + devs.sort_by(|a, b| a.id.cmp(&b.id)); + self.usb = devs; + } + + fn read_cpu(&mut self, proc_dir: &Path) { + let Ok(text) = std::fs::read_to_string(proc_dir.join("cpuinfo")) else { + self.gaps.push("sin /proc/cpuinfo".into()); + return; + }; + for line in text.lines() { + let Some((k, v)) = line.split_once(':') else { + continue; + }; + let (k, v) = (k.trim(), v.trim()); + match k { + "model name" if self.cpu_model.is_none() => { + self.cpu_model = Some(v.to_string()); + } + "flags" if self.cpu_flags.is_empty() => { + self.cpu_flags = v.split_whitespace().map(|s| s.to_string()).collect(); + } + _ => {} + } + } + } + + /// Un driver «en uso» es uno con al menos un dispositivo bindeado. Es el dato que necesita el + /// gate de no-regresión (#6): lo que hoy funciona tiene que seguir teniendo driver mañana. + fn read_bound_drivers(&mut self, sys: &Path) { + let Ok(buses) = std::fs::read_dir(sys.join("bus")) else { + self.gaps.push("sin /sys/bus".into()); + return; + }; + for bus in buses.flatten() { + let Ok(devs) = std::fs::read_dir(bus.path().join("devices")) else { + continue; + }; + for d in devs.flatten() { + if let Some(drv) = link_basename(&d.path().join("driver")) { + self.bound_drivers.insert(drv); + } + } + } + } + + /// Los bytes canónicos que se hashean. Se exponen para poder auditar una huella sin confiar en + /// que el hash se calculó sobre lo que uno cree. + pub fn fingerprint_material(&self) -> String { + let mut s = String::new(); + for (k, v) in &self.dmi { + s.push_str(&format!("dmi\t{k}\t{v}\n")); + } + // Sin el slot: mover una tarjeta de ranura no cambia la clase de hardware. + let mut pci: Vec = self + .pci + .iter() + .map(|d| format!("pci\t{}\t{}\t{}\n", d.vendor, d.device, d.class)) + .collect(); + pci.sort(); + pci.dedup(); + s.extend(pci); + for f in &self.cpu_flags { + s.push_str(&format!("cpuflag\t{f}\n")); + } + s + } + + /// La huella. BLAKE3 sobre [`Hardware::fingerprint_material`], con el mismo tipo que cualquier + /// otro identificador por contenido de hammer. + pub fn fingerprint(&self) -> ArtifactHash { + ArtifactHash::of_bytes(self.fingerprint_material().as_bytes()) + } + + /// ¿Hay algún dispositivo PCI cuya clase empiece por `prefix`? (`"0280"` = red inalámbrica.) + pub fn has_pci_class(&self, prefix: &str) -> bool { + self.pci.iter().any(|d| d.class.starts_with(prefix)) + } + + pub fn has_driver(&self, name: &str) -> bool { + self.bound_drivers.contains(name) + } + + pub fn has_cpu_flag(&self, flag: &str) -> bool { + self.cpu_flags.contains(flag) + } +} + +fn read_trim(p: &Path) -> Option { + let s = std::fs::read_to_string(p).ok()?; + let t = s.trim(); + if t.is_empty() { + None + } else { + Some(t.to_string()) + } +} + +fn strip_0x(s: &str) -> String { + s.trim().trim_start_matches("0x").to_string() +} + +fn link_basename(p: &Path) -> Option { + let target: PathBuf = std::fs::read_link(p).ok()?; + Some(target.file_name()?.to_string_lossy().into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn arbol_de_juguete() -> tempfile::TempDir { + let d = tempfile::tempdir().unwrap(); + let sys = d.path().join("sys"); + std::fs::create_dir_all(sys.join("class/dmi/id")).unwrap(); + std::fs::write(sys.join("class/dmi/id/sys_vendor"), "LENOVO\n").unwrap(); + std::fs::write(sys.join("class/dmi/id/product_name"), "20XW\n").unwrap(); + // Un serial NO debe entrar aunque exista: la huella agrupa, no identifica. + std::fs::write(sys.join("class/dmi/id/product_serial"), "PF3ABCDE\n").unwrap(); + + let dev = sys.join("bus/pci/devices/0000:00:14.3"); + std::fs::create_dir_all(&dev).unwrap(); + std::fs::write(dev.join("vendor"), "0x8086\n").unwrap(); + std::fs::write(dev.join("device"), "0xa0f0\n").unwrap(); + std::fs::write(dev.join("class"), "0x028000\n").unwrap(); + let drv = sys.join("bus/pci/drivers/iwlwifi"); + std::fs::create_dir_all(&drv).unwrap(); + std::os::unix::fs::symlink(&drv, dev.join("driver")).unwrap(); + + let proc_dir = d.path().join("proc"); + std::fs::create_dir_all(&proc_dir).unwrap(); + std::fs::write( + proc_dir.join("cpuinfo"), + "model name\t: Intel(R) Core(TM) i7-1165G7\nflags\t\t: fpu vme avx2 aes\n", + ) + .unwrap(); + d + } + + #[test] + fn lee_dmi_pci_cpu_y_driver_bindeado() { + let d = arbol_de_juguete(); + let hw = Hardware::probe(&d.path().join("sys"), &d.path().join("proc")); + assert_eq!(hw.dmi.get("sys_vendor").map(|s| s.as_str()), Some("LENOVO")); + assert_eq!(hw.pci.len(), 1); + assert_eq!(hw.pci[0].class, "028000"); + assert_eq!(hw.pci[0].driver.as_deref(), Some("iwlwifi")); + assert!(hw.has_pci_class("0280")); + assert!(hw.has_driver("iwlwifi")); + assert!(hw.has_cpu_flag("avx2")); + assert_eq!(hw.cpu_model.as_deref(), Some("Intel(R) Core(TM) i7-1165G7")); + } + + #[test] + fn el_serial_no_entra_en_la_huella() { + let d = arbol_de_juguete(); + let hw = Hardware::probe(&d.path().join("sys"), &d.path().join("proc")); + let m = hw.fingerprint_material(); + assert!(!m.contains("PF3ABCDE"), "la huella agrupa máquinas, no las identifica:\n{m}"); + assert!(m.contains("LENOVO")); + } + + #[test] + fn mover_la_tarjeta_de_ranura_no_cambia_la_huella() { + let d = arbol_de_juguete(); + let sys = d.path().join("sys"); + let hw1 = Hardware::probe(&sys, &d.path().join("proc")); + // Mismo dispositivo, otra ranura. + let viejo = sys.join("bus/pci/devices/0000:00:14.3"); + let nuevo = sys.join("bus/pci/devices/0000:03:00.0"); + std::fs::rename(&viejo, &nuevo).unwrap(); + let hw2 = Hardware::probe(&sys, &d.path().join("proc")); + assert_eq!(hw1.fingerprint(), hw2.fingerprint()); + } + + #[test] + fn el_usb_no_mueve_la_huella() { + let d = arbol_de_juguete(); + let sys = d.path().join("sys"); + let hw1 = Hardware::probe(&sys, &d.path().join("proc")); + let usb = sys.join("bus/usb/devices/1-1"); + std::fs::create_dir_all(&usb).unwrap(); + std::fs::write(usb.join("idVendor"), "0781\n").unwrap(); + std::fs::write(usb.join("idProduct"), "5581\n").unwrap(); + let hw2 = Hardware::probe(&sys, &d.path().join("proc")); + assert_eq!(hw2.usb.len(), 1, "se lee"); + assert_eq!(hw1.fingerprint(), hw2.fingerprint(), "pero no se hashea"); + } + + #[test] + fn maquina_sin_dmi_deja_hueco_en_vez_de_fallar() { + let d = tempfile::tempdir().unwrap(); + let hw = Hardware::probe(&d.path().join("sys"), &d.path().join("proc")); + assert!(!hw.gaps.is_empty()); + // Y aun así da una huella: incompleta, pero declarada. + assert!(!hw.fingerprint().as_str().is_empty()); + } +} diff --git a/crates/hammer-core/src/kernel/mod.rs b/crates/hammer-core/src/kernel/mod.rs index c6873fa8..d4217473 100644 --- a/crates/hammer-core/src/kernel/mod.rs +++ b/crates/hammer-core/src/kernel/mod.rs @@ -16,8 +16,14 @@ //! (`scripts/config -e/-d`) y deja que el `olddefconfig` del propio kernel produzca el `.config`. //! Ver [`kconfig`]. +pub mod catalog; pub mod config; +pub mod hw; pub mod kconfig; +pub mod reverse; +pub use catalog::{Bundle, Catalog, Knob, Side}; pub use config::{ConfigValue, KernelConfig}; +pub use hw::Hardware; pub use kconfig::{Expr, KconfigTree, SelectLeak, SymKind, Symbol}; +pub use reverse::{BundleState, HwSignal, ReverseReport}; diff --git a/crates/hammer-core/src/kernel/reverse.rs b/crates/hammer-core/src/kernel/reverse.rs new file mode 100644 index 00000000..1dea1bfb --- /dev/null +++ b/crates/hammer-core/src/kernel/reverse.rs @@ -0,0 +1,328 @@ +//! **Modo reversa** (#9 del handoff): leer el kernel que ya corre a través del lente de bundles. +//! +//! Es el primer movimiento por una razón concreta: no compila nada, no arriesga nada, y **valida el +//! catálogo contra la realidad antes de que exista el compilador**. Si el modo reversa muestra +//! basura, el diseño de N1 está mal y se sabe por el precio de dos días en vez de dos meses. +//! +//! Da tres cosas que ninguna lista de símbolos da sola: +//! +//! 1. **Cuánto de cada bundle ya está aplicado** en el kernel que arrancó esta máquina. +//! 2. **Si el hardware lo contradice** — y sólo en ese sentido (ver [`super::hw`]). +//! 3. **Si el catálogo envejeció**: cada `select` que entra a la clausura y no está declarado en el +//! bundle es un símbolo que upstream agregó y nadie revisó. Es la mitad barata de la curación +//! del delta (§3 del handoff), y sale de comparar el grafo con el catálogo, sin IA. + +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; + +use super::catalog::{Bundle, Catalog}; +use super::hw::Hardware; +use super::kconfig::{KconfigTree, SelectLeak}; +use super::{ConfigValue, KernelConfig}; + +/// Cuánto de un bundle está ya en efecto en el `.config` mirado. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum BundleState { + /// Ni un símbolo de la clausura está encendido: el bundle ya rige. + Aplicado, + /// Algunos sí, algunos no. + Parcial, + /// Todo encendido: el kernel trae el subsistema entero. + Sin, +} + +/// Qué dice el hardware sobre un bundle. **Sólo contradice, nunca poda.** +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", tag = "señal")] +pub enum HwSignal { + /// El bundle no declara ninguna señal: no se puede decir nada. + SinSeñal, + /// Hay hardware presente que este bundle apagaría. Aplicarlo es una decisión, no un descuido. + Presente { evidencia: Vec }, + /// Ninguna de las señales declaradas está presente. + Ausente, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BundleStatus { + pub id: String, + pub title: String, + /// Tamaño de la clausura del bundle en ESTE árbol de Kconfig. + pub closure_len: usize, + /// De la clausura, cuántos están `=y` en el config mirado. + pub on_builtin: usize, + /// …y cuántos `=m`. + pub on_module: usize, + pub state: BundleState, + pub hardware: HwSignal, + /// Símbolos que el bundle nombra y **no existen en este árbol**. Hoy un `-d` sobre uno de éstos + /// se pierde en silencio; acá se ve. + pub unknown_symbols: Vec, + /// Fugas `select` que el catálogo no declara (ni cierra ni acepta): el bundle envejeció. + pub undeclared_leaks: Vec, +} + +/// El informe entero del modo reversa. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReverseReport { + /// Banner del `.config` leído, si lo trae. + pub kernel: Option, + pub fingerprint: String, + pub config_symbols: usize, + pub bundles: Vec, + /// Bundles con `state != Aplicado` cuyo hardware está **ausente**: capacidad que este kernel + /// carga y esta máquina no usa. Es la frase del handoff §9 vuelta número. + pub applicable_savings: usize, + /// Bundles ya aplicados cuyo hardware SÍ está presente: la contradicción que hay que mirar. + pub contradictions: usize, +} + +/// Corre el modo reversa. `hw` opcional: sin hardware el informe sigue valiendo, sólo que toda +/// señal queda en `SinSeñal`. +pub fn analyze( + catalog: &Catalog, + tree: &KconfigTree, + config: &KernelConfig, + hw: Option<&Hardware>, +) -> ReverseReport { + let mut bundles = Vec::new(); + for b in &catalog.bundles { + bundles.push(status_of(b, tree, config, hw)); + } + let applicable_savings = bundles + .iter() + .filter(|s| s.state != BundleState::Aplicado && s.hardware == HwSignal::Ausente) + .count(); + let contradictions = bundles + .iter() + .filter(|s| s.state == BundleState::Aplicado && matches!(s.hardware, HwSignal::Presente { .. })) + .count(); + ReverseReport { + kernel: config.banner.clone(), + fingerprint: hw.map(|h| h.fingerprint().to_string()).unwrap_or_default(), + config_symbols: config.len(), + bundles, + applicable_savings, + contradictions, + } +} + +fn status_of( + b: &Bundle, + tree: &KconfigTree, + config: &KernelConfig, + hw: Option<&Hardware>, +) -> BundleStatus { + // Las raíces del bundle son lo que apaga MÁS las fugas que declaró cerrar. + let mut roots: Vec = b.disable.clone(); + roots.extend(b.close_leaks.iter().cloned()); + + let unknown_symbols: Vec = roots + .iter() + .chain(b.enable.iter()) + .filter(|s| !tree.symbols.contains_key(*s)) + .cloned() + .collect(); + + let closure = tree.closure_off(&roots); + let mut on_builtin = 0usize; + let mut on_module = 0usize; + for s in &closure { + match config.get(s) { + Some(ConfigValue::Yes) => on_builtin += 1, + Some(ConfigValue::Module) => on_module += 1, + _ => {} + } + } + let on = on_builtin + on_module; + let state = if on == 0 { + BundleState::Aplicado + } else if on < closure.len() { + BundleState::Parcial + } else { + BundleState::Sin + }; + + // Fugas que el catálogo no contempla: ni las cerró (estarían dentro de la clausura) ni las + // aceptó por escrito. + let aceptadas: BTreeSet<&str> = b.accept_leaks.keys().map(|s| s.as_str()).collect(); + let undeclared_leaks: Vec = tree + .select_leaks(&closure) + .into_iter() + .filter(|l| !aceptadas.contains(l.target.as_str())) + .collect(); + + BundleStatus { + id: b.id.clone(), + title: b.title.clone(), + closure_len: closure.len(), + on_builtin, + on_module, + state, + hardware: hw_signal(b, hw), + unknown_symbols, + undeclared_leaks, + } +} + +fn hw_signal(b: &Bundle, hw: Option<&Hardware>) -> HwSignal { + let c = &b.contradicted_by; + if c.is_empty() { + return HwSignal::SinSeñal; + } + let Some(hw) = hw else { + return HwSignal::SinSeñal; + }; + let mut ev = Vec::new(); + for p in &c.pci_class { + for d in hw.pci.iter().filter(|d| d.class.starts_with(p)) { + ev.push(format!( + "pci {}:{} clase {} ({})", + d.vendor, + d.device, + d.class, + d.driver.as_deref().unwrap_or("sin driver") + )); + } + } + for d in &c.driver { + if hw.has_driver(d) { + ev.push(format!("driver {d} con dispositivo bindeado")); + } + } + for f in &c.cpu_flag { + if hw.has_cpu_flag(f) { + ev.push(format!("cpu flag {f}")); + } + } + if ev.is_empty() { + HwSignal::Ausente + } else { + HwSignal::Presente { evidencia: ev } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + fn escenario() -> (tempfile::TempDir, KconfigTree, Catalog) { + let d = tempfile::tempdir().unwrap(); + std::fs::write( + d.path().join("Kconfig"), + r#" +config WIRELESS + bool "Wireless" + +config CFG80211 + tristate "cfg80211" + depends on WIRELESS + +config WLAN + bool "WLAN" + select WIRELESS + +config SOUND + bool "Sound" +"#, + ) + .unwrap(); + let tree = KconfigTree::parse(d.path(), &BTreeMap::new()).unwrap(); + let cat: Catalog = toml::from_str( + r#" +version = 1 +[[bundle]] +id = "sin-wifi" +title = "No necesito wifi" +disable = ["WIRELESS"] +close_leaks = ["WLAN"] +[bundle.contradicted_by] +pci_class = ["0280"] + +[[bundle]] +id = "sin-audio" +title = "No necesito audio" +disable = ["SOUND", "SND_QUE_NO_EXISTE"] +"#, + ) + .unwrap(); + (d, tree, cat) + } + + #[test] + fn cuenta_lo_aplicado_y_lo_que_falta() { + let (_d, tree, cat) = escenario(); + let cfg = KernelConfig::parse( + "CONFIG_WIRELESS=y\nCONFIG_CFG80211=m\nCONFIG_WLAN=y\n# CONFIG_SOUND is not set\n", + ); + let r = analyze(&cat, &tree, &cfg, None); + let wifi = &r.bundles[0]; + assert_eq!(wifi.closure_len, 3, "WIRELESS + CFG80211 + WLAN"); + assert_eq!(wifi.on_builtin, 2); + assert_eq!(wifi.on_module, 1); + assert_eq!(wifi.state, BundleState::Sin); + + let audio = &r.bundles[1]; + assert_eq!(audio.state, BundleState::Aplicado); + // El símbolo inventado se ve en vez de perderse en silencio. + assert_eq!(audio.unknown_symbols, vec!["SND_QUE_NO_EXISTE"]); + } + + #[test] + fn el_hardware_contradice_pero_no_poda() { + let (_d, tree, cat) = escenario(); + let cfg = KernelConfig::parse("# CONFIG_WIRELESS is not set\n# CONFIG_WLAN is not set\n"); + let mut hw = Hardware::default(); + hw.pci.push(super::super::hw::PciDevice { + slot: "0000:00:14.3".into(), + vendor: "8086".into(), + device: "a0f0".into(), + class: "028000".into(), + driver: Some("iwlwifi".into()), + }); + let r = analyze(&cat, &tree, &cfg, Some(&hw)); + let wifi = &r.bundles[0]; + assert_eq!(wifi.state, BundleState::Aplicado); + assert!(matches!(wifi.hardware, HwSignal::Presente { .. })); + // Aplicado + hardware presente = la contradicción que hay que mirar, no un error. + assert_eq!(r.contradictions, 1); + } + + #[test] + fn el_ahorro_es_bundle_no_aplicado_con_hardware_ausente() { + let (_d, tree, cat) = escenario(); + let cfg = KernelConfig::parse("CONFIG_WIRELESS=y\nCONFIG_CFG80211=y\nCONFIG_WLAN=y\n"); + let hw = Hardware::default(); // máquina sin PCI wifi + let r = analyze(&cat, &tree, &cfg, Some(&hw)); + assert_eq!(r.bundles[0].hardware, HwSignal::Ausente); + assert_eq!(r.applicable_savings, 1); + } + + #[test] + fn una_fuga_select_no_declarada_se_reporta() { + let (_d, tree, _cat) = escenario(); + // Un catálogo que NO cierra la fuga WLAN→WIRELESS: es el bundle envejecido. + let cat: Catalog = toml::from_str( + r#" +version = 1 +[[bundle]] +id = "sin-wifi" +title = "No necesito wifi" +disable = ["WIRELESS"] +"#, + ) + .unwrap(); + let cfg = KernelConfig::parse("CONFIG_WIRELESS=y\n"); + let r = analyze(&cat, &tree, &cfg, None); + assert_eq!( + r.bundles[0].undeclared_leaks, + vec![SelectLeak { + selector: "WLAN".into(), + target: "WIRELESS".into() + }] + ); + } +} diff --git a/docs/state/kernel-bundles.toml b/docs/state/kernel-bundles.toml new file mode 100644 index 00000000..191a021c --- /dev/null +++ b/docs/state/kernel-bundles.toml @@ -0,0 +1,255 @@ +# Catálogo del armador de kernel (SDD 22) — bundles de nivel 1 y perillas de nivel 2. +# +# UN BUNDLE NO ES UNA LISTA DE SÍMBOLOS. Es una raíz y su clausura sobre el grafo de Kconfig: +# `disable = ["WIRELESS"]` son ~400 CONFIG_* que hammer calcula, no que alguien mantiene a mano. +# Por eso se auto-cura: el driver wifi que entre en 6.17 cae dentro sin que nadie lo toque. +# +# LO QUE LA CLAUSURA NO CIERRA SOLA. `select` fuerza su destino IGNORANDO los `depends on` de éste, +# así que un símbolo de fuera del bundle puede reencender lo que el bundle apagó. Cada fuga se +# declara, una vez, de una de dos formas: +# · `close_leaks` — se apaga también al que la provoca. +# · `accept_leaks` — se deja abierta A SABIENDAS, con el motivo escrito. +# `hammer kernel bundles --check` falla si aparece una fuga que no esté en ninguna de las dos: eso +# es el catálogo envejeciendo, y es la mitad barata de la curación del delta entre versiones. +# +# CONTRADICTED_BY VA EN UN SOLO SENTIDO. Sirve para decirle al usuario «marcaste "no necesito wifi" +# y tenés un AX211 activo ahora mismo». NUNCA para podar el config solo: no se puede detectar el +# dock que se enchufa el mes que viene ni el fs del USB de rescate, y un kernel podado por +# autodetección es cómo se fabrica un ladrillo. +# +# Regenerar los números / revisar las fugas: +# hammer kernel bundles --kconfig <árbol> --check +# hammer kernel closure --kconfig <árbol> WIRELESS --fixpoint + +version = 1 +reviewed_against = "linux-6.16.12" + +# ================================================================================================= +# NIVEL 1 — aserciones del dueño de la máquina. No son categorías de Kconfig: son decisiones de +# vida. «No necesito wifi», «esta máquina nunca tuvo batería». +# ================================================================================================= + +[[bundle]] +id = "sin-wifi" +title = "No necesito wifi" +help = """ +Apaga el stack 802.11 entero: cfg80211, mac80211 y todos los drivers de tarjeta. La máquina sigue +teniendo ethernet. NO apaga el interruptor de radio (RFKILL): eso es «sin-radios», porque lo +comparten bluetooth y NFC.""" +disable = ["WIRELESS"] +# WLAN no DEPENDE de WIRELESS: lo SELECCIONA. Sin cerrarlo, apagar WIRELESS es un no-op — el +# olddefconfig lo reenciende y no lo dice. Fue el hallazgo que validó el diseño (SDD 22 §9). +close_leaks = ["WLAN", "IWLEGACY", "GELIC_WIRELESS"] +notes = "406 símbolos en 6.16.12; el bundle a mano de recipes/linux.toml da 421, y los 15 de diferencia son todos de sin-radios." +[bundle.contradicted_by] +pci_class = ["0280"] + +[[bundle]] +id = "sin-bluetooth" +title = "No necesito bluetooth" +disable = ["BT"] +# Tarjeta combo wifi+BT: entra por select desde el lado wifi. +close_leaks = ["RSI_91X"] +[bundle.contradicted_by] +driver = ["btusb", "btintel", "btrtl"] + +[[bundle]] +id = "sin-nfc" +title = "No necesito NFC" +disable = ["NFC"] + +[[bundle]] +id = "sin-radios" +title = "No necesito el interruptor de radio (rfkill)" +help = """ +RFKILL es el conmutador compartido de todas las radios: wifi, bluetooth y NFC lo declaran igual. +Apagarlo sólo tiene sentido si ninguna de las tres se usa — y se lleva por delante los botones de +radio de portátil (Dell, Lenovo, MSI…), que dependen de él.""" +disable = ["RFKILL"] +notes = "Es un bundle propio, no parte de sin-wifi: el config a mano de linux.toml los escribía en la misma línea y por eso parecían uno solo." + +[[bundle]] +id = "sin-audio" +title = "No necesito audio" +help = "Apaga ALSA y todo el stack de sonido, incluido el códec HDMI." +disable = ["SOUND"] +notes = """ +No cierra: los drivers de GPU hacen `select` de los helpers de audio HDMI. Cerrarlo obligaría a +apagar la GPU, que es un precio que un escritorio no puede pagar — así que las fugas se aceptan y +quedan escritas. En recipes/linux.toml sale gratis sólo porque ahí los gráficos TAMBIÉN están +apagados.""" +[bundle.accept_leaks] +SND_HDA_COMPONENT = "lo selecciona DRM_AMD_DC / DRM_NOUVEAU / DRM_RADEON: cerrarlo = quedarse sin GPU" +SND_HDA_I915 = "lo seleccionan DRM_I915 y DRM_XE: ídem para gráficos Intel" +SND_SOC_HDMI_CODEC = "lo seleccionan diez drivers DRM (casi todos SoC ARM, inertes en x86_64)" +SND_AUDIO_GRAPH_CARD = "DRM_TEGRA, inerte en x86_64" +SND_SIMPLE_CARD = "DRM_TEGRA, inerte en x86_64" +SND_UMP = "USB_F_MIDI2, gadget USB: sólo entra si la máquina hace de dispositivo USB" +SND_UMP_LEGACY_RAWMIDI = "USB_F_MIDI2, ídem" +[bundle.contradicted_by] +pci_class = ["0403"] +driver = ["snd_hda_intel"] + +[[bundle]] +id = "sin-graficos" +title = "No necesito salida gráfica" +help = """ +Kernel de servidor o de consola serie: sin DRM, sin framebuffer, sin retroiluminación. Si esta +máquina tiene pantalla, este bundle la deja negra — es la aserción más peligrosa del catálogo.""" +disable = ["DRM", "FB", "AGP", "BACKLIGHT_CLASS_DEVICE"] +# Framebuffers de PowerMac y de SoC ARM: no existen en x86_64, cerrarlos no cuesta nada. +close_leaks = [ + "FB_CONTROL", "FB_IMSTT", "FB_MAC", "FB_PLATINUM", "FB_VALKYRIE", + "FB_VT8500", "FB_WM8505", "PMAC_BACKLIGHT", +] +[bundle.accept_leaks] +DRM_AUX_BRIDGE = "lo seleccionan los muxes USB-C (TYPEC_MUX_*): apagarlos rompería el USB-C de un portátil real" +DRM_AUX_HPD_BRIDGE = "ídem, TYPEC_QCOM_PMIC / UCSI_HUAWEI_GAOKUN" +BACKLIGHT_CLASS_DEVICE = "lo seleccionan drivers de plataforma (ACPI_CMPC, SAMSUNG_Q10, USB_APPLEDISPLAY)" +DRM_DW_HDMI_I2S_AUDIO = "SND_SOC_RK3288_HDMI_ANALOG, SoC Rockchip: inerte en x86_64" +[bundle.contradicted_by] +pci_class = ["0300", "0380"] + +[[bundle]] +id = "solo-ext4" +title = "Sólo uso ext4" +help = "Apaga los demás sistemas de ficheros locales. Ojo con el USB de rescate: lo que no está no se monta." +disable = ["XFS_FS", "BTRFS_FS", "F2FS_FS", "JFS_FS", "GFS2_FS", "NTFS3_FS"] +close_leaks = ["NTFS_FS"] +notes = "REISERFS_FS NO va acá: upstream lo retiró y 6.16.12 ya no lo tiene. recipes/linux.toml todavía lo apaga, y ese `-d` es un no-op silencioso." + +[[bundle]] +id = "sin-usb" +title = "No necesito USB" +help = "Kernel de VM con consola serie o de servidor sin puertos accesibles. En metal deja la máquina sin teclado." +disable = ["USB_SUPPORT"] +[bundle.contradicted_by] +driver = ["usbhid", "usb_storage", "xhci_hcd"] + +[[bundle]] +id = "sin-entrada-humana" +title = "No necesito teclado ni ratón" +help = """ +La aserción de un kernel de QEMU con consola serie: nadie va a tocar esta máquina. Es exactamente +lo que hace recipes/linux.toml, y por eso el gate de no-regresión tiene que ser POR OBJETIVO — un +gate global rechazaría esa receta, que está sana.""" +disable = ["HID_SUPPORT", "INPUT_MOUSE", "INPUT_JOYSTICK", "INPUT_TABLET", "INPUT_TOUCHSCREEN"] +[bundle.contradicted_by] +driver = ["usbhid", "hid_generic", "i8042"] + +[[bundle]] +id = "sin-camaras-tv" +title = "No necesito cámara ni sintonizador" +disable = ["MEDIA_SUPPORT"] +close_leaks = ["USB_F_UVC"] +[bundle.accept_leaks] +VIDEOBUF2_VMALLOC = "lo seleccionan pantallas táctiles (RMI4_F54, TOUCHSCREEN_ATMEL_MXT_T37) para sus modos de diagnóstico: decir «no quiero cámara» no debe matar el táctil" +[bundle.contradicted_by] +driver = ["uvcvideo"] + +[[bundle]] +id = "sin-infiniband" +title = "No necesito InfiniBand / RDMA" +disable = ["INFINIBAND"] + +[[bundle]] +id = "sin-bus-industrial" +title = "No necesito buses industriales (CAN)" +disable = ["CAN"] + +[[bundle]] +id = "sin-thunderbolt-firewire" +title = "No necesito Thunderbolt ni FireWire" +help = "El símbolo se llama USB4 desde que upstream fundió Thunderbolt y USB4 en un solo driver." +disable = ["USB4", "FIREWIRE"] +notes = "Las CUATRO recetas de kernel apagan `THUNDERBOLT`, que 6.16.12 ya no tiene: ese `-d` es un no-op y el driver sigue entrando por el defconfig." +[bundle.contradicted_by] +driver = ["thunderbolt"] + +[[bundle]] +id = "sin-lector-de-tarjetas" +title = "No necesito lector de tarjetas SD/MMC" +disable = ["MMC", "MEMSTICK"] +[bundle.contradicted_by] +driver = ["sdhci_pci", "rtsx_pci_sdmmc"] + +[[bundle]] +id = "sin-virtualizacion" +title = "No voy a virtualizar en esta máquina" +help = "Apaga KVM. No afecta a correr DENTRO de una VM — sólo a hospedar una." +disable = ["VIRTUALIZATION"] +[bundle.contradicted_by] +cpu_flag = ["vmx", "svm"] + +# ================================================================================================= +# NIVEL 2 — política y rendimiento. El campo `side` NO es decorativo: la mitad de N2 no es Kconfig +# sino variables de receta. Las dos mitades mueven el ArtifactHash igual, pero se aplican en fases +# distintas y fallan distinto; una UI que no lo sepa prometerá diffs que no puede explicar. +# ================================================================================================= + +[[knob]] +id = "jaula-y-eio-moderna" +title = "Jaula (Landlock + audit) y E/S moderna (io_uring, BPF)" +help = """ +El contrato de PLAN-KIKIN §4.bis. Sin SECURITY_LANDLOCK+AUDIT, harkaq (SDD 16) certifica TODO como +hermético en silencio: sin auditoría no se emite un solo registro de denegación, y la evidencia +negativa se vuelve vacía.""" +side = "kconfig" +enable = ["SECURITY_LANDLOCK", "AUDIT", "IO_URING", "BPF_SYSCALL"] +notes = "linux-metal y linux-generic ya los declaran; linux (el de QEMU) deja IO_URING y BPF_SYSCALL al defconfig." + +[[knob]] +id = "sin-info-de-depuracion" +title = "Kernel sin información de depuración" +help = "Baja mucho el tamaño de la imagen. DEBUG_INFO_BTF además exige pahole, que el toolchain de hammer no trae." +side = "kconfig" +disable = ["DEBUG_INFO", "DEBUG_INFO_BTF"] + +[[knob]] +id = "unwinder-frame-pointer" +title = "Desenrollado por frame pointer en vez de ORC" +help = """ +Reduce el trabajo de objtool, pero NO lo elimina: en x86_64 las mitigaciones (retpoline, +RETHUNK) seleccionan CONFIG_OBJTOOL igual, y objtool necesita libelf.""" +side = "kconfig" +enable = ["UNWINDER_FRAME_POINTER"] +disable = ["UNWINDER_ORC"] + +[[knob]] +id = "preempcion-escritorio" +title = "Preempción de escritorio (menos latencia, algo menos de rendimiento bruto)" +side = "kconfig" +enable = ["PREEMPT"] + +[[knob]] +id = "tick-1000" +title = "Reloj del sistema a 1000 Hz" +side = "kconfig" +enable = ["HZ_1000"] +disable = ["HZ_250"] + +[[knob]] +id = "compilador-lto" +title = "LTO (optimización al enlazar)" +help = """ +EL EJEMPLO DE PERILLA MIXTA. Su mitad Kconfig (LTO_CLANG_FULL) sólo se vuelve seleccionable si el +kernel se compila con clang, que es una decisión de RECETA. Hoy recipes/linux.toml compila con gcc +por decisión formal (la capa de traducción de flags de `zig cc` choca con el kbuild de x86_64), así +que esta perilla está declarada y NO es aplicable todavía.""" +side = "recipe" +recipe_field = "build.compiler + build.phases.compile (make LLVM=1)" +notes = "Ver la cabecera de recipes/linux.toml: el kernel se queda en el gueto gcc hasta que se exponga el clang que zig empaqueta." + +[[knob]] +id = "march-nativo" +title = "Compilar para esta CPU exacta" +help = "Mata la reproducibilidad ENTRE máquinas distintas, no la de una misma máquina: el artefacto sigue siendo bit a bit el mismo para la misma huella." +side = "recipe" +recipe_field = "build.flags" + +[[knob]] +id = "debug-aparte" +title = "Separar los símbolos de depuración del artefacto" +side = "recipe" +recipe_field = "build.strip_debug" +notes = "SDD 23. Entra en hash_inputs sólo si está fijado, así que se puede activar receta a receta."