//! El catálogo de bundles (N1) y perillas (N2). //! //! ## Por qué un bundle no es una lista de símbolos //! Escrito como lista, «no necesito wifi» son ~400 `CONFIG_*` que envejecen en cada release y //! exigen un curador humano para siempre (handoff §2.1). Escrito como **predicado sobre el grafo** //! —una raíz y su clausura— son dos líneas y se auto-cura: el driver que entra en 6.17 cae dentro //! sin que nadie lo toque. //! //! Lo que la medición del SDD 22 §9 agregó a esa idea: **la clausura sola no cierra**. `select` //! fuerza símbolos ignorando sus `depends on`, así que un bundle honesto declara además qué hace //! con cada fuga — cerrarla (`close_leaks`) o aceptarla a sabiendas (`accept_leaks`). Esa revisión //! se hace **una vez por bundle**, no una vez por release. //! //! ## Por qué las perillas N2 llevan un campo `side` //! «La mitad de N2 no es Kconfig sino variables de receta» (handoff §5). Con el hecho del §1 del //! SDD 22 en la mano —el config ES la identidad del artefacto— eso es más grave de lo que parece: //! las dos mitades mueven el hash igual, pero se aplican en fases distintas y fallan distinto. Una //! UI que no sepa de qué lado cae cada perilla prometerá diffs que no puede explicar. use std::collections::BTreeMap; use std::path::Path; use serde::{Deserialize, Serialize}; /// Formato del fichero de catálogo. Se versiona para poder migrarlo sin adivinar. pub const CATALOG_VERSION: u32 = 1; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Catalog { pub version: u32, /// Kernel contra el que se revisaron las fugas. Cambiar de versión no invalida el catálogo, /// pero sí obliga a re-mirar las fugas (`hammer kernel closure`). #[serde(default)] pub reviewed_against: Option, #[serde(default, rename = "bundle")] pub bundles: Vec, #[serde(default, rename = "knob")] pub knobs: Vec, #[serde(default, rename = "objective")] pub objectives: Vec, } /// Para qué es este kernel. **El gate de no-regresión no corre sin uno.** /// /// `recipes/linux.toml` apaga USB, HID e INPUT a propósito — es el kernel de QEMU con consola /// serie. Un gate global «todo dispositivo en uso debe seguir teniendo driver» rechazaría esa /// receta, que está sana. El objetivo declara qué pérdidas son legítimas para este uso. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Objective { pub id: String, pub title: String, #[serde(default)] pub help: Option, /// Bundles cuyas pérdidas de driver este objetivo autoriza. Todo lo demás bloquea. #[serde(default)] pub allow_bundles: Vec, } /// Un bundle de nivel 1: una aserción del dueño de la máquina («no necesito wifi»). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Bundle { /// Identificador estable, en kebab-case. Es lo que la UI y `plan` se pasan. pub id: String, /// Título en castellano, para el humano. pub title: String, #[serde(default)] pub help: Option, /// **El predicado**: apagar estas raíces y, con ellas, todo lo que dependa duro de ellas. #[serde(default)] pub disable: Vec, /// Encender estos símbolos (bundles «positivos», raros en N1 pero necesarios en N2). #[serde(default)] pub enable: Vec, /// Fugas `select` que se cierran también, revisadas a mano. Cada entrada es un símbolo de /// FUERA de la clausura que reencendería algo de dentro. #[serde(default)] pub close_leaks: Vec, /// Fugas que se dejan abiertas a sabiendas, con su motivo. Clave = símbolo destino. #[serde(default)] pub accept_leaks: BTreeMap, /// Detección de hardware, **en un solo sentido**: sirve para CONTRADECIR al usuario /// («marcaste "no necesito wifi" y tenés un AX211 activo ahora mismo»), nunca para podar solo. /// Un kernel podado por autodetección es cómo se fabrica un ladrillo (handoff §6). #[serde(default)] pub contradicted_by: Contradiction, #[serde(default)] pub notes: Option, } /// Señales de hardware que contradicen a un bundle si están presentes AHORA. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Contradiction { /// Clases PCI, como los 6 dígitos hex de `/sys/bus/pci/devices/*/class`, o un prefijo /// (`"0280"` = controlador de red inalámbrica). #[serde(default)] pub pci_class: Vec, /// Drivers con dispositivo bindeado ahora mismo (`/sys/bus/*/devices/*/driver`). #[serde(default)] pub driver: Vec, /// Flags de CPU de `/proc/cpuinfo`. #[serde(default)] pub cpu_flag: Vec, } impl Contradiction { pub fn is_empty(&self) -> bool { self.pci_class.is_empty() && self.driver.is_empty() && self.cpu_flag.is_empty() } } /// De qué lado del build cae una perilla de nivel 2. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Side { /// Símbolos de Kconfig: se aplican en la fase `configure`. Kconfig, /// Variables de la receta (LTO, `-march`, `zig_version`): otra fase, otro modo de fallo. /// Mueven el `ArtifactHash` igual que los símbolos. Recipe, } /// Una perilla de nivel 2: política y rendimiento. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Knob { pub id: String, pub title: String, #[serde(default)] pub help: Option, /// La mitad de N2 no es Kconfig. Este campo obliga a decirlo. pub side: Side, #[serde(default)] pub enable: Vec, #[serde(default)] pub disable: Vec, /// Sólo con `side = "recipe"`: qué campo de la receta toca, en texto, para que la UI pueda /// explicar el diff aunque `plan` todavía no sepa aplicarlo. #[serde(default)] pub recipe_field: Option, #[serde(default)] pub notes: Option, } impl Catalog { pub fn load(path: &Path) -> crate::Result { let text = std::fs::read_to_string(path)?; let cat: Catalog = toml::from_str(&text) .map_err(|e| crate::Error::Recipe(format!("{}: {e}", path.display())))?; cat.validate(path)?; Ok(cat) } fn validate(&self, path: &Path) -> crate::Result<()> { let p = path.display(); if self.version != CATALOG_VERSION { return Err(crate::Error::Recipe(format!( "{p}: version = {}, esperaba {CATALOG_VERSION}", self.version ))); } let mut ids = std::collections::BTreeSet::new(); for b in &self.bundles { if !ids.insert(("bundle", b.id.as_str())) { return Err(crate::Error::Recipe(format!("{p}: bundle duplicado «{}»", b.id))); } if b.disable.is_empty() && b.enable.is_empty() { return Err(crate::Error::Recipe(format!( "{p}: el bundle «{}» no apaga ni enciende nada", b.id ))); } } for k in &self.knobs { if !ids.insert(("knob", k.id.as_str())) { return Err(crate::Error::Recipe(format!("{p}: perilla duplicada «{}»", k.id))); } // Una perilla de receta que no dice qué campo toca es exactamente el diff que la UI no // va a poder explicar. if k.side == Side::Recipe && k.recipe_field.is_none() { return Err(crate::Error::Recipe(format!( "{p}: la perilla «{}» es side=recipe y no declara recipe_field", k.id ))); } if k.side == Side::Kconfig && k.enable.is_empty() && k.disable.is_empty() { return Err(crate::Error::Recipe(format!( "{p}: la perilla «{}» es side=kconfig y no toca ningún símbolo", k.id ))); } } let conocidos: std::collections::BTreeSet<&str> = self.bundles.iter().map(|b| b.id.as_str()).collect(); for o in &self.objectives { if !ids.insert(("objective", o.id.as_str())) { return Err(crate::Error::Recipe(format!("{p}: objetivo duplicado «{}»", o.id))); } // Un `allow_bundles` con un id mal escrito autorizaría NADA y el gate bloquearía sin // que se entienda por qué. Se rechaza al cargar, no en el portón. for b in &o.allow_bundles { if !conocidos.contains(b.as_str()) { return Err(crate::Error::Recipe(format!( "{p}: el objetivo «{}» autoriza el bundle «{b}», que no existe", o.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) } pub fn objective(&self, id: &str) -> Option<&Objective> { self.objectives.iter().find(|o| o.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()); } }