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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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()
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user