kernel: modo reversa y catálogo de bundles — y las cuatro recetas apagan dos símbolos que ya no existen

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-<release>) 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) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-08-10 13:13:55 +00:00
co-authored by Claude Opus 5
parent 39873fd86e
commit cf2cf54914
6 changed files with 1640 additions and 0 deletions
+447
View File
@@ -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-<uname -r>`.
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
kconfig: Option<PathBuf>,
#[arg(long)]
catalog: Option<PathBuf>,
/// 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<PathBuf>,
#[arg(long)]
catalog: Option<PathBuf>,
/// 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<PathBuf>) -> Result<PathBuf> {
}
}
/// Resuelve el catálogo de bundles: `--catalog`, si no la variable de entorno, si no el del repo.
fn catalog_path(flag: Option<PathBuf>) -> Result<PathBuf> {
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 <path> 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<PathBuf> {
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-<release>).\n\
Pasá --config <path> para mirar el de otra máquina."
)
}
fn load_tree(root: &Path) -> Result<KconfigTree> {
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::<Vec<_>>()
.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<String> {
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<String>,
disable: Vec<String>,
close_leaks: Vec<String>,
closure_len: usize,
visible: usize,
unknown_symbols: Vec<String>,
undeclared_leaks: Vec<hammer_core::kernel::SelectLeak>,
accepted_leaks: usize,
}
#[derive(Serialize)]
struct BundlesReport {
catalog: String,
#[serde(skip_serializing_if = "Option::is_none")]
reviewed_against: Option<String>,
bundles: Vec<BundleView>,
knobs: Vec<KnobView>,
stale: bool,
}
#[derive(Serialize)]
struct KnobView {
id: String,
title: String,
side: hammer_core::kernel::Side,
enable: Vec<String>,
disable: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
recipe_field: Option<String>,
unknown_symbols: Vec<String>,
}
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<String> = 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<KnobView> = 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<String> =
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<String> {
}
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);
}
}
+254
View File
@@ -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<String>,
#[serde(default, rename = "bundle")]
pub bundles: Vec<Bundle>,
#[serde(default, rename = "knob")]
pub knobs: Vec<Knob>,
}
/// 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<String>,
/// **El predicado**: apagar estas raíces y, con ellas, todo lo que dependa duro de ellas.
#[serde(default)]
pub disable: Vec<String>,
/// Encender estos símbolos (bundles «positivos», raros en N1 pero necesarios en N2).
#[serde(default)]
pub enable: Vec<String>,
/// 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<String>,
/// Fugas que se dejan abiertas a sabiendas, con su motivo. Clave = símbolo destino.
#[serde(default)]
pub accept_leaks: BTreeMap<String, String>,
/// 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<String>,
}
/// 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<String>,
/// Drivers con dispositivo bindeado ahora mismo (`/sys/bus/*/devices/*/driver`).
#[serde(default)]
pub driver: Vec<String>,
/// Flags de CPU de `/proc/cpuinfo`.
#[serde(default)]
pub cpu_flag: Vec<String>,
}
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<String>,
/// La mitad de N2 no es Kconfig. Este campo obliga a decirlo.
pub side: Side,
#[serde(default)]
pub enable: Vec<String>,
#[serde(default)]
pub disable: Vec<String>,
/// 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<String>,
#[serde(default)]
pub notes: Option<String>,
}
impl Catalog {
pub fn load(path: &Path) -> crate::Result<Catalog> {
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());
}
}
+350
View File
@@ -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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsbDevice {
pub id: String,
pub vendor: String,
pub product: String,
pub driver: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Hardware {
pub dmi: BTreeMap<String, String>,
pub pci: Vec<PciDevice>,
/// Leído y reportado, pero **fuera** de la huella (ver doc del módulo).
pub usb: Vec<UsbDevice>,
pub cpu_model: Option<String>,
pub cpu_flags: BTreeSet<String>,
/// Todos los drivers con al menos un dispositivo bindeado, de cualquier bus.
pub bound_drivers: BTreeSet<String>,
/// 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<String>,
}
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<String> = 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<String> {
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<String> {
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());
}
}
+6
View File
@@ -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};
+328
View File
@@ -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<String> },
/// 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<String>,
/// Fugas `select` que el catálogo no declara (ni cierra ni acepta): el bundle envejeció.
pub undeclared_leaks: Vec<SelectLeak>,
}
/// 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<String>,
pub fingerprint: String,
pub config_symbols: usize,
pub bundles: Vec<BundleStatus>,
/// 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<String> = b.disable.clone();
roots.extend(b.close_leaks.iter().cloned());
let unknown_symbols: Vec<String> = 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<SelectLeak> = 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()
}]
);
}
}