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()
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
# Catálogo del armador de kernel (SDD 22) — bundles de nivel 1 y perillas de nivel 2.
|
||||
#
|
||||
# UN BUNDLE NO ES UNA LISTA DE SÍMBOLOS. Es una raíz y su clausura sobre el grafo de Kconfig:
|
||||
# `disable = ["WIRELESS"]` son ~400 CONFIG_* que hammer calcula, no que alguien mantiene a mano.
|
||||
# Por eso se auto-cura: el driver wifi que entre en 6.17 cae dentro sin que nadie lo toque.
|
||||
#
|
||||
# LO QUE LA CLAUSURA NO CIERRA SOLA. `select` fuerza su destino IGNORANDO los `depends on` de éste,
|
||||
# así que un símbolo de fuera del bundle puede reencender lo que el bundle apagó. Cada fuga se
|
||||
# declara, una vez, de una de dos formas:
|
||||
# · `close_leaks` — se apaga también al que la provoca.
|
||||
# · `accept_leaks` — se deja abierta A SABIENDAS, con el motivo escrito.
|
||||
# `hammer kernel bundles --check` falla si aparece una fuga que no esté en ninguna de las dos: eso
|
||||
# es el catálogo envejeciendo, y es la mitad barata de la curación del delta entre versiones.
|
||||
#
|
||||
# CONTRADICTED_BY VA EN UN SOLO SENTIDO. Sirve para decirle al usuario «marcaste "no necesito wifi"
|
||||
# y tenés un AX211 activo ahora mismo». NUNCA para podar el config solo: no se puede detectar el
|
||||
# dock que se enchufa el mes que viene ni el fs del USB de rescate, y un kernel podado por
|
||||
# autodetección es cómo se fabrica un ladrillo.
|
||||
#
|
||||
# Regenerar los números / revisar las fugas:
|
||||
# hammer kernel bundles --kconfig <árbol> --check
|
||||
# hammer kernel closure --kconfig <árbol> WIRELESS --fixpoint
|
||||
|
||||
version = 1
|
||||
reviewed_against = "linux-6.16.12"
|
||||
|
||||
# =================================================================================================
|
||||
# NIVEL 1 — aserciones del dueño de la máquina. No son categorías de Kconfig: son decisiones de
|
||||
# vida. «No necesito wifi», «esta máquina nunca tuvo batería».
|
||||
# =================================================================================================
|
||||
|
||||
[[bundle]]
|
||||
id = "sin-wifi"
|
||||
title = "No necesito wifi"
|
||||
help = """
|
||||
Apaga el stack 802.11 entero: cfg80211, mac80211 y todos los drivers de tarjeta. La máquina sigue
|
||||
teniendo ethernet. NO apaga el interruptor de radio (RFKILL): eso es «sin-radios», porque lo
|
||||
comparten bluetooth y NFC."""
|
||||
disable = ["WIRELESS"]
|
||||
# WLAN no DEPENDE de WIRELESS: lo SELECCIONA. Sin cerrarlo, apagar WIRELESS es un no-op — el
|
||||
# olddefconfig lo reenciende y no lo dice. Fue el hallazgo que validó el diseño (SDD 22 §9).
|
||||
close_leaks = ["WLAN", "IWLEGACY", "GELIC_WIRELESS"]
|
||||
notes = "406 símbolos en 6.16.12; el bundle a mano de recipes/linux.toml da 421, y los 15 de diferencia son todos de sin-radios."
|
||||
[bundle.contradicted_by]
|
||||
pci_class = ["0280"]
|
||||
|
||||
[[bundle]]
|
||||
id = "sin-bluetooth"
|
||||
title = "No necesito bluetooth"
|
||||
disable = ["BT"]
|
||||
# Tarjeta combo wifi+BT: entra por select desde el lado wifi.
|
||||
close_leaks = ["RSI_91X"]
|
||||
[bundle.contradicted_by]
|
||||
driver = ["btusb", "btintel", "btrtl"]
|
||||
|
||||
[[bundle]]
|
||||
id = "sin-nfc"
|
||||
title = "No necesito NFC"
|
||||
disable = ["NFC"]
|
||||
|
||||
[[bundle]]
|
||||
id = "sin-radios"
|
||||
title = "No necesito el interruptor de radio (rfkill)"
|
||||
help = """
|
||||
RFKILL es el conmutador compartido de todas las radios: wifi, bluetooth y NFC lo declaran igual.
|
||||
Apagarlo sólo tiene sentido si ninguna de las tres se usa — y se lleva por delante los botones de
|
||||
radio de portátil (Dell, Lenovo, MSI…), que dependen de él."""
|
||||
disable = ["RFKILL"]
|
||||
notes = "Es un bundle propio, no parte de sin-wifi: el config a mano de linux.toml los escribía en la misma línea y por eso parecían uno solo."
|
||||
|
||||
[[bundle]]
|
||||
id = "sin-audio"
|
||||
title = "No necesito audio"
|
||||
help = "Apaga ALSA y todo el stack de sonido, incluido el códec HDMI."
|
||||
disable = ["SOUND"]
|
||||
notes = """
|
||||
No cierra: los drivers de GPU hacen `select` de los helpers de audio HDMI. Cerrarlo obligaría a
|
||||
apagar la GPU, que es un precio que un escritorio no puede pagar — así que las fugas se aceptan y
|
||||
quedan escritas. En recipes/linux.toml sale gratis sólo porque ahí los gráficos TAMBIÉN están
|
||||
apagados."""
|
||||
[bundle.accept_leaks]
|
||||
SND_HDA_COMPONENT = "lo selecciona DRM_AMD_DC / DRM_NOUVEAU / DRM_RADEON: cerrarlo = quedarse sin GPU"
|
||||
SND_HDA_I915 = "lo seleccionan DRM_I915 y DRM_XE: ídem para gráficos Intel"
|
||||
SND_SOC_HDMI_CODEC = "lo seleccionan diez drivers DRM (casi todos SoC ARM, inertes en x86_64)"
|
||||
SND_AUDIO_GRAPH_CARD = "DRM_TEGRA, inerte en x86_64"
|
||||
SND_SIMPLE_CARD = "DRM_TEGRA, inerte en x86_64"
|
||||
SND_UMP = "USB_F_MIDI2, gadget USB: sólo entra si la máquina hace de dispositivo USB"
|
||||
SND_UMP_LEGACY_RAWMIDI = "USB_F_MIDI2, ídem"
|
||||
[bundle.contradicted_by]
|
||||
pci_class = ["0403"]
|
||||
driver = ["snd_hda_intel"]
|
||||
|
||||
[[bundle]]
|
||||
id = "sin-graficos"
|
||||
title = "No necesito salida gráfica"
|
||||
help = """
|
||||
Kernel de servidor o de consola serie: sin DRM, sin framebuffer, sin retroiluminación. Si esta
|
||||
máquina tiene pantalla, este bundle la deja negra — es la aserción más peligrosa del catálogo."""
|
||||
disable = ["DRM", "FB", "AGP", "BACKLIGHT_CLASS_DEVICE"]
|
||||
# Framebuffers de PowerMac y de SoC ARM: no existen en x86_64, cerrarlos no cuesta nada.
|
||||
close_leaks = [
|
||||
"FB_CONTROL", "FB_IMSTT", "FB_MAC", "FB_PLATINUM", "FB_VALKYRIE",
|
||||
"FB_VT8500", "FB_WM8505", "PMAC_BACKLIGHT",
|
||||
]
|
||||
[bundle.accept_leaks]
|
||||
DRM_AUX_BRIDGE = "lo seleccionan los muxes USB-C (TYPEC_MUX_*): apagarlos rompería el USB-C de un portátil real"
|
||||
DRM_AUX_HPD_BRIDGE = "ídem, TYPEC_QCOM_PMIC / UCSI_HUAWEI_GAOKUN"
|
||||
BACKLIGHT_CLASS_DEVICE = "lo seleccionan drivers de plataforma (ACPI_CMPC, SAMSUNG_Q10, USB_APPLEDISPLAY)"
|
||||
DRM_DW_HDMI_I2S_AUDIO = "SND_SOC_RK3288_HDMI_ANALOG, SoC Rockchip: inerte en x86_64"
|
||||
[bundle.contradicted_by]
|
||||
pci_class = ["0300", "0380"]
|
||||
|
||||
[[bundle]]
|
||||
id = "solo-ext4"
|
||||
title = "Sólo uso ext4"
|
||||
help = "Apaga los demás sistemas de ficheros locales. Ojo con el USB de rescate: lo que no está no se monta."
|
||||
disable = ["XFS_FS", "BTRFS_FS", "F2FS_FS", "JFS_FS", "GFS2_FS", "NTFS3_FS"]
|
||||
close_leaks = ["NTFS_FS"]
|
||||
notes = "REISERFS_FS NO va acá: upstream lo retiró y 6.16.12 ya no lo tiene. recipes/linux.toml todavía lo apaga, y ese `-d` es un no-op silencioso."
|
||||
|
||||
[[bundle]]
|
||||
id = "sin-usb"
|
||||
title = "No necesito USB"
|
||||
help = "Kernel de VM con consola serie o de servidor sin puertos accesibles. En metal deja la máquina sin teclado."
|
||||
disable = ["USB_SUPPORT"]
|
||||
[bundle.contradicted_by]
|
||||
driver = ["usbhid", "usb_storage", "xhci_hcd"]
|
||||
|
||||
[[bundle]]
|
||||
id = "sin-entrada-humana"
|
||||
title = "No necesito teclado ni ratón"
|
||||
help = """
|
||||
La aserción de un kernel de QEMU con consola serie: nadie va a tocar esta máquina. Es exactamente
|
||||
lo que hace recipes/linux.toml, y por eso el gate de no-regresión tiene que ser POR OBJETIVO — un
|
||||
gate global rechazaría esa receta, que está sana."""
|
||||
disable = ["HID_SUPPORT", "INPUT_MOUSE", "INPUT_JOYSTICK", "INPUT_TABLET", "INPUT_TOUCHSCREEN"]
|
||||
[bundle.contradicted_by]
|
||||
driver = ["usbhid", "hid_generic", "i8042"]
|
||||
|
||||
[[bundle]]
|
||||
id = "sin-camaras-tv"
|
||||
title = "No necesito cámara ni sintonizador"
|
||||
disable = ["MEDIA_SUPPORT"]
|
||||
close_leaks = ["USB_F_UVC"]
|
||||
[bundle.accept_leaks]
|
||||
VIDEOBUF2_VMALLOC = "lo seleccionan pantallas táctiles (RMI4_F54, TOUCHSCREEN_ATMEL_MXT_T37) para sus modos de diagnóstico: decir «no quiero cámara» no debe matar el táctil"
|
||||
[bundle.contradicted_by]
|
||||
driver = ["uvcvideo"]
|
||||
|
||||
[[bundle]]
|
||||
id = "sin-infiniband"
|
||||
title = "No necesito InfiniBand / RDMA"
|
||||
disable = ["INFINIBAND"]
|
||||
|
||||
[[bundle]]
|
||||
id = "sin-bus-industrial"
|
||||
title = "No necesito buses industriales (CAN)"
|
||||
disable = ["CAN"]
|
||||
|
||||
[[bundle]]
|
||||
id = "sin-thunderbolt-firewire"
|
||||
title = "No necesito Thunderbolt ni FireWire"
|
||||
help = "El símbolo se llama USB4 desde que upstream fundió Thunderbolt y USB4 en un solo driver."
|
||||
disable = ["USB4", "FIREWIRE"]
|
||||
notes = "Las CUATRO recetas de kernel apagan `THUNDERBOLT`, que 6.16.12 ya no tiene: ese `-d` es un no-op y el driver sigue entrando por el defconfig."
|
||||
[bundle.contradicted_by]
|
||||
driver = ["thunderbolt"]
|
||||
|
||||
[[bundle]]
|
||||
id = "sin-lector-de-tarjetas"
|
||||
title = "No necesito lector de tarjetas SD/MMC"
|
||||
disable = ["MMC", "MEMSTICK"]
|
||||
[bundle.contradicted_by]
|
||||
driver = ["sdhci_pci", "rtsx_pci_sdmmc"]
|
||||
|
||||
[[bundle]]
|
||||
id = "sin-virtualizacion"
|
||||
title = "No voy a virtualizar en esta máquina"
|
||||
help = "Apaga KVM. No afecta a correr DENTRO de una VM — sólo a hospedar una."
|
||||
disable = ["VIRTUALIZATION"]
|
||||
[bundle.contradicted_by]
|
||||
cpu_flag = ["vmx", "svm"]
|
||||
|
||||
# =================================================================================================
|
||||
# NIVEL 2 — política y rendimiento. El campo `side` NO es decorativo: la mitad de N2 no es Kconfig
|
||||
# sino variables de receta. Las dos mitades mueven el ArtifactHash igual, pero se aplican en fases
|
||||
# distintas y fallan distinto; una UI que no lo sepa prometerá diffs que no puede explicar.
|
||||
# =================================================================================================
|
||||
|
||||
[[knob]]
|
||||
id = "jaula-y-eio-moderna"
|
||||
title = "Jaula (Landlock + audit) y E/S moderna (io_uring, BPF)"
|
||||
help = """
|
||||
El contrato de PLAN-KIKIN §4.bis. Sin SECURITY_LANDLOCK+AUDIT, harkaq (SDD 16) certifica TODO como
|
||||
hermético en silencio: sin auditoría no se emite un solo registro de denegación, y la evidencia
|
||||
negativa se vuelve vacía."""
|
||||
side = "kconfig"
|
||||
enable = ["SECURITY_LANDLOCK", "AUDIT", "IO_URING", "BPF_SYSCALL"]
|
||||
notes = "linux-metal y linux-generic ya los declaran; linux (el de QEMU) deja IO_URING y BPF_SYSCALL al defconfig."
|
||||
|
||||
[[knob]]
|
||||
id = "sin-info-de-depuracion"
|
||||
title = "Kernel sin información de depuración"
|
||||
help = "Baja mucho el tamaño de la imagen. DEBUG_INFO_BTF además exige pahole, que el toolchain de hammer no trae."
|
||||
side = "kconfig"
|
||||
disable = ["DEBUG_INFO", "DEBUG_INFO_BTF"]
|
||||
|
||||
[[knob]]
|
||||
id = "unwinder-frame-pointer"
|
||||
title = "Desenrollado por frame pointer en vez de ORC"
|
||||
help = """
|
||||
Reduce el trabajo de objtool, pero NO lo elimina: en x86_64 las mitigaciones (retpoline,
|
||||
RETHUNK) seleccionan CONFIG_OBJTOOL igual, y objtool necesita libelf."""
|
||||
side = "kconfig"
|
||||
enable = ["UNWINDER_FRAME_POINTER"]
|
||||
disable = ["UNWINDER_ORC"]
|
||||
|
||||
[[knob]]
|
||||
id = "preempcion-escritorio"
|
||||
title = "Preempción de escritorio (menos latencia, algo menos de rendimiento bruto)"
|
||||
side = "kconfig"
|
||||
enable = ["PREEMPT"]
|
||||
|
||||
[[knob]]
|
||||
id = "tick-1000"
|
||||
title = "Reloj del sistema a 1000 Hz"
|
||||
side = "kconfig"
|
||||
enable = ["HZ_1000"]
|
||||
disable = ["HZ_250"]
|
||||
|
||||
[[knob]]
|
||||
id = "compilador-lto"
|
||||
title = "LTO (optimización al enlazar)"
|
||||
help = """
|
||||
EL EJEMPLO DE PERILLA MIXTA. Su mitad Kconfig (LTO_CLANG_FULL) sólo se vuelve seleccionable si el
|
||||
kernel se compila con clang, que es una decisión de RECETA. Hoy recipes/linux.toml compila con gcc
|
||||
por decisión formal (la capa de traducción de flags de `zig cc` choca con el kbuild de x86_64), así
|
||||
que esta perilla está declarada y NO es aplicable todavía."""
|
||||
side = "recipe"
|
||||
recipe_field = "build.compiler + build.phases.compile (make LLVM=1)"
|
||||
notes = "Ver la cabecera de recipes/linux.toml: el kernel se queda en el gueto gcc hasta que se exponga el clang que zig empaqueta."
|
||||
|
||||
[[knob]]
|
||||
id = "march-nativo"
|
||||
title = "Compilar para esta CPU exacta"
|
||||
help = "Mata la reproducibilidad ENTRE máquinas distintas, no la de una misma máquina: el artefacto sigue siendo bit a bit el mismo para la misma huella."
|
||||
side = "recipe"
|
||||
recipe_field = "build.flags"
|
||||
|
||||
[[knob]]
|
||||
id = "debug-aparte"
|
||||
title = "Separar los símbolos de depuración del artefacto"
|
||||
side = "recipe"
|
||||
recipe_field = "build.strip_debug"
|
||||
notes = "SDD 23. Entra en hash_inputs sólo si está fijado, así que se puede activar receta a receta."
|
||||
Reference in New Issue
Block a user