gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 604Rust 56Shell 36Markdown 281227 Definitions

⎇ d797fc472d7c648b433d956ade3fe7713e4e93e4 crates/hammer-core/src/kernel/gate.rs Rust · 476 lines · 16.3 KB History Who touched what raw

kernel: el gate contra un .config producido — ve los huecos que la receta base ya traía d797fc472 · Sergio ·

1//! **Gate de no-regresión de hardware, por objetivo** (#6 del handoff, §5 del SDD 22).
2//!
3//! La regla, en una línea: *todo dispositivo en uso ahora mismo debe seguir teniendo driver en el
4//! config nuevo*. No es un consejo, es un portón. Corre en un segundo, sin IA y sin VM, y cubre la
5//! mayoría de los desastres de «me quedé sin red / sin teclado».
6//!
7//! ## Por qué POR OBJETIVO y no global
8//! `recipes/linux.toml` apaga `USB_SUPPORT`, `HID`, `INPUT_MOUSE` **a propósito**: es el kernel de
9//! QEMU con consola serie, donde nadie va a tocar la máquina. Un gate global «todo dispositivo en
10//! uso debe seguir teniendo driver» **rechazaría una receta sana**. El objetivo declara qué
11//! pérdidas son legítimas; el portón se queda, sólo que en el sitio correcto.
12//!
13//! ## Y la máquina destino no es la de build
14//! Los drivers en uso se pasan como dato ([`Hardware::bound_drivers`]), así que pueden venir de la
15//! máquina donde el kernel va a correr y no de la que lo compila.
16//!
17//! ## Lo que el gate NO puede comprobar, lo dice
18//! Un driver que no se pueda mapear a ningún símbolo queda en [`GateReport::unmapped`] y **no**
19//! cuenta como aprobado. Un portón que calla lo que no pudo mirar no es un portón.
20
21use std::collections::{BTreeMap, BTreeSet};
22
23use serde::{Deserialize, Serialize};
24
25use super::catalog::Catalog;
26use super::kconfig::KconfigTree;
27use super::modmap::ModMap;
28use super::plan::{Action, Plan};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "kebab-case")]
32pub enum Verdict {
33    Pasa,
34    Bloquea,
35}
36
37/// Un driver en uso que el plan dejaría sin símbolo.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct DriverLoss {
40    pub driver: String,
41    /// Los símbolos que lo construyen. Se pierde sólo si caen TODOS.
42    pub symbols: Vec<String>,
43    /// Qué bundle(s) del plan se lo llevan por delante.
44    pub blamed_on: Vec<String>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct GateReport {
49    pub objective: String,
50    pub plan: String,
51    /// Cuántos drivers en uso se miraron.
52    pub drivers_in_use: usize,
53    /// Pérdidas que el objetivo **no** autoriza. Éstas bloquean.
54    pub regressions: Vec<DriverLoss>,
55    /// Pérdidas autorizadas por el objetivo: se informan y se dejan pasar.
56    pub allowed: Vec<DriverLoss>,
57    /// Drivers que no se pudieron mapear a ningún `CONFIG_*`: el gate NO los comprobó.
58    pub unmapped: Vec<String>,
59    pub verdict: Verdict,
60}
61
62/// Corre el gate.
63///
64/// `drivers_in_use` son los nombres tal como salen de `/sys` (con `_`).
65pub fn run(
66    plan: &Plan,
67    cat: &Catalog,
68    tree: &KconfigTree,
69    modmap: &ModMap,
70    drivers_in_use: &BTreeSet<String>,
71    objective_id: &str,
72) -> crate::Result<GateReport> {
73    let obj = cat.objective(objective_id).ok_or_else(|| {
74        crate::Error::Recipe(format!(
75            "no existe el objetivo «{objective_id}» en el catálogo. \
76             El gate NO corre sin objetivo declarado: uno global rechazaría recetas sanas."
77        ))
78    })?;
79
80    // Lo que el plan apaga, con su clausura. Se recalcula desde las raíces del plan: es la misma
81    // cuenta que hará `olddefconfig`, no una copia que pueda desincronizarse.
82    let roots: Vec<String> = plan
83        .symbols
84        .iter()
85        .filter(|s| s.action == Action::Disable)
86        .map(|s| s.symbol.clone())
87        .collect();
88    let apagados = tree.closure_off(&roots);
89
90    // Atribución: qué bundle se lleva cada símbolo. Un símbolo puede caer por varios.
91    let mut culpa: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
92    for id in &plan.bundles {
93        let Some(b) = cat.bundle(id) else { continue };
94        let mut r = b.disable.clone();
95        r.extend(b.close_leaks.iter().cloned());
96        for s in tree.closure_off(&r) {
97            if let Some(k) = apagados.get(&s) {
98                culpa.entry(k.as_str()).or_default().push(id.as_str());
99            }
100        }
101    }
102
103    let permitidos: BTreeSet<&str> = obj.allow_bundles.iter().map(|s| s.as_str()).collect();
104    let mut rep = GateReport {
105        objective: objective_id.to_string(),
106        plan: plan.derived_name.clone(),
107        drivers_in_use: drivers_in_use.len(),
108        regressions: Vec::new(),
109        allowed: Vec::new(),
110        unmapped: Vec::new(),
111        verdict: Verdict::Pasa,
112    };
113
114    for drv in drivers_in_use {
115        let Some(syms) = modmap.symbols_for(drv) else {
116            rep.unmapped.push(drv.clone());
117            continue;
118        };
119        // Sobrevive si le queda AL MENOS UN símbolo en pie: un módulo puede construirse desde
120        // varias opciones y basta una.
121        if syms.iter().any(|s| !apagados.contains(s)) {
122            continue;
123        }
124        let mut culpables: Vec<String> = syms
125            .iter()
126            .filter_map(|s| culpa.get(s.as_str()))
127            .flatten()
128            .map(|s| s.to_string())
129            .collect();
130        culpables.sort();
131        culpables.dedup();
132
133        let loss = DriverLoss {
134            driver: drv.clone(),
135            symbols: syms.iter().cloned().collect(),
136            blamed_on: culpables.clone(),
137        };
138        // Autorizado sólo si TODO lo que se lo lleva está permitido por el objetivo. Si algún
139        // culpable no lo está —o si no se pudo atribuir a ninguno— es regresión.
140        let autorizado = !culpables.is_empty()
141            && culpables.iter().all(|c| permitidos.contains(c.as_str()));
142        if autorizado {
143            rep.allowed.push(loss);
144        } else {
145            rep.regressions.push(loss);
146        }
147    }
148
149    if !rep.regressions.is_empty() {
150        rep.verdict = Verdict::Bloquea;
151    }
152    Ok(rep)
153}
154
155/// El gate contra un `.config` **ya producido**, midiendo contra el que corre hoy.
156///
157/// Es una pregunta distinta —y más fuerte— que [`run`]. Aquélla mira lo que el **plan** apaga, así
158/// que sólo ve regresiones que introduce el plan: un hueco que ya venía en la receta base le pasa
159/// por debajo. Ésta compara dos configs y define regresión como **«funcionaba y dejó de
160/// funcionar»**, que es la formulación literal del #6 del handoff.
161///
162/// El referente natural es `/proc/config.gz`: el kernel que arrancó esta máquina es la prueba viva
163/// de qué hace falta para arrancarla.
164///
165/// # Por qué comparar dos configs y no mirar sólo el nuevo
166/// Los nombres de módulo cortos colisionan: el driver que `/sys` llama `usb` mapea a `QE_USB` (el
167/// USB de las QUICC Engine de Freescale) y `port` a `PORT_CHAN`. Mirando sólo el config nuevo, esos
168/// aparecen como «perdidos» y el gate bloquearía un plan sano. Exigiendo que **estuvieran
169/// encendidos en el referente** el falso positivo desaparece solo: lo que nunca estuvo no puede
170/// regresar.
171pub fn run_against_config(
172    baseline: &super::KernelConfig,
173    produced: &super::KernelConfig,
174    cat: &Catalog,
175    modmap: &ModMap,
176    drivers_in_use: &BTreeSet<String>,
177    objective_id: &str,
178    plan: Option<&Plan>,
179    tree: Option<&KconfigTree>,
180) -> crate::Result<GateReport> {
181    let obj = cat.objective(objective_id).ok_or_else(|| {
182        crate::Error::Recipe(format!(
183            "no existe el objetivo «{objective_id}» en el catálogo. \
184             El gate NO corre sin objetivo declarado: uno global rechazaría recetas sanas."
185        ))
186    })?;
187    let permitidos: BTreeSet<&str> = obj.allow_bundles.iter().map(|s| s.as_str()).collect();
188
189    // Atribución opcional: con el plan y el árbol se puede decir QUÉ bundle se lo llevó.
190    let mut culpa: BTreeMap<String, Vec<String>> = BTreeMap::new();
191    if let (Some(plan), Some(tree)) = (plan, tree) {
192        for id in &plan.bundles {
193            let Some(b) = cat.bundle(id) else { continue };
194            let mut r = b.disable.clone();
195            r.extend(b.close_leaks.iter().cloned());
196            for s in tree.closure_off(&r) {
197                culpa.entry(s).or_default().push(id.clone());
198            }
199        }
200    }
201
202    let mut rep = GateReport {
203        objective: objective_id.to_string(),
204        plan: plan.map(|p| p.derived_name.clone()).unwrap_or_default(),
205        drivers_in_use: drivers_in_use.len(),
206        regressions: Vec::new(),
207        allowed: Vec::new(),
208        unmapped: Vec::new(),
209        verdict: Verdict::Pasa,
210    };
211
212    for drv in drivers_in_use {
213        let Some(syms) = modmap.symbols_for(drv) else {
214            rep.unmapped.push(drv.clone());
215            continue;
216        };
217        // Sólo cuentan los símbolos que HOY lo sostienen. Si ninguno estaba encendido en el
218        // referente, este driver no viene de ahí (o el mapa colisionó) y no hay nada que proteger.
219        let sostenes: Vec<&String> = syms.iter().filter(|s| baseline.is_on(s)).collect();
220        if sostenes.is_empty() {
221            rep.unmapped.push(drv.clone());
222            continue;
223        }
224        if sostenes.iter().any(|s| produced.is_on(s)) {
225            continue;
226        }
227        let mut culpables: Vec<String> = sostenes
228            .iter()
229            .filter_map(|s| culpa.get(s.as_str()))
230            .flatten()
231            .cloned()
232            .collect();
233        culpables.sort();
234        culpables.dedup();
235
236        let loss = DriverLoss {
237            driver: drv.clone(),
238            symbols: sostenes.iter().map(|s| (*s).clone()).collect(),
239            blamed_on: culpables.clone(),
240        };
241        let autorizado =
242            !culpables.is_empty() && culpables.iter().all(|c| permitidos.contains(c.as_str()));
243        if autorizado {
244            rep.allowed.push(loss);
245        } else {
246            rep.regressions.push(loss);
247        }
248    }
249
250    if !rep.regressions.is_empty() {
251        rep.verdict = Verdict::Bloquea;
252    }
253    Ok(rep)
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::kernel::plan::make_plan;
260    use crate::recipe::Recipe;
261
262    fn escenario() -> (tempfile::TempDir, KconfigTree, Catalog, Recipe, ModMap) {
263        let d = tempfile::tempdir().unwrap();
264        std::fs::write(
265            d.path().join("Kconfig"),
266            r#"
267config WIRELESS
268	bool "Wireless"
269
270config IWLWIFI
271	tristate "iwlwifi"
272	depends on WIRELESS
273
274config USB_SUPPORT
275	bool "USB"
276
277config USB_HID
278	tristate "usbhid"
279	depends on USB_SUPPORT
280
281config E1000
282	tristate "e1000"
283"#,
284        )
285        .unwrap();
286        let tree = KconfigTree::parse(d.path(), &std::collections::BTreeMap::new()).unwrap();
287
288        let mut modmap = ModMap::default();
289        std::fs::write(
290            d.path().join("Makefile"),
291            "obj-$(CONFIG_IWLWIFI) += iwlwifi.o\n\
292             obj-$(CONFIG_USB_HID) += usbhid.o\n\
293             obj-$(CONFIG_E1000) += e1000.o\n",
294        )
295        .unwrap();
296        modmap = ModMap::parse(d.path()).unwrap_or(modmap);
297
298        let cat: Catalog = toml::from_str(
299            r#"
300version = 1
301[[bundle]]
302id = "sin-wifi"
303title = "Sin wifi"
304disable = ["WIRELESS"]
305
306[[bundle]]
307id = "sin-usb"
308title = "Sin USB"
309disable = ["USB_SUPPORT"]
310
311[[objective]]
312id = "qemu-serial"
313title = "VM con consola serie"
314allow_bundles = ["sin-usb"]
315
316[[objective]]
317id = "metal"
318title = "Metal"
319allow_bundles = []
320"#,
321        )
322        .unwrap();
323
324        let recipe: Recipe = toml::from_str(
325            r#"
326name = "linux"
327version = "6.16.12"
328[source]
329tarball = "https://x/l.tar.gz"
330sha256 = "aa"
331[build]
332compiler = "zig-cc"
333target = "x86_64-linux-musl"
334link = "dynamic"
335[build.phases]
336configure = "make ARCH=x86_64 defconfig && make ARCH=x86_64 olddefconfig"
337"#,
338        )
339        .unwrap();
340        (d, tree, cat, recipe, modmap)
341    }
342
343    fn drivers(v: &[&str]) -> BTreeSet<String> {
344        v.iter().map(|s| s.to_string()).collect()
345    }
346
347    #[test]
348    fn perder_un_driver_en_uso_bloquea() {
349        let (_d, tree, cat, base, mm) = escenario();
350        let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
351        let r = run(&p, &cat, &tree, &mm, &drivers(&["iwlwifi", "e1000"]), "metal").unwrap();
352        assert_eq!(r.verdict, Verdict::Bloquea);
353        assert_eq!(r.regressions.len(), 1);
354        assert_eq!(r.regressions[0].driver, "iwlwifi");
355        assert_eq!(r.regressions[0].blamed_on, vec!["sin-wifi"]);
356    }
357
358    #[test]
359    fn el_objetivo_autoriza_la_perdida_que_declara() {
360        let (_d, tree, cat, base, mm) = escenario();
361        let p = make_plan(&base, "r", &cat, &tree, &["sin-usb".into()], &[]).unwrap();
362        // El MISMO plan: bloquea en metal y pasa en qemu-serial. Ése es todo el punto del §5.
363        let metal = run(&p, &cat, &tree, &mm, &drivers(&["usbhid"]), "metal").unwrap();
364        assert_eq!(metal.verdict, Verdict::Bloquea);
365        let qemu = run(&p, &cat, &tree, &mm, &drivers(&["usbhid"]), "qemu-serial").unwrap();
366        assert_eq!(qemu.verdict, Verdict::Pasa);
367        assert_eq!(qemu.allowed.len(), 1);
368    }
369
370    #[test]
371    fn el_driver_que_no_se_pudo_mapear_no_cuenta_como_aprobado() {
372        let (_d, tree, cat, base, mm) = escenario();
373        let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
374        let r = run(&p, &cat, &tree, &mm, &drivers(&["misterio"]), "metal").unwrap();
375        assert_eq!(r.unmapped, vec!["misterio"]);
376        // No está ni en pérdidas ni en aprobados: está declarado como no comprobado.
377        assert!(r.regressions.is_empty() && r.allowed.is_empty());
378    }
379
380    #[test]
381    fn sin_objetivo_declarado_el_gate_no_corre() {
382        let (_d, tree, cat, base, mm) = escenario();
383        let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
384        let e = run(&p, &cat, &tree, &mm, &drivers(&["iwlwifi"]), "inventado")
385            .unwrap_err()
386            .to_string();
387        assert!(e.contains("inventado"), "{e}");
388    }
389
390    #[test]
391    fn un_driver_intacto_no_molesta() {
392        let (_d, tree, cat, base, mm) = escenario();
393        let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
394        let r = run(&p, &cat, &tree, &mm, &drivers(&["e1000"]), "metal").unwrap();
395        assert_eq!(r.verdict, Verdict::Pasa);
396        assert!(r.regressions.is_empty() && r.allowed.is_empty() && r.unmapped.is_empty());
397    }
398}
399
400#[cfg(test)]
401mod tests_contra_config {
402    use super::*;
403    use crate::kernel::KernelConfig;
404
405    fn catalogo() -> Catalog {
406        toml::from_str(
407            r#"
408version = 1
409[[bundle]]
410id = "sin-wifi"
411title = "Sin wifi"
412disable = ["WIRELESS"]
413[[objective]]
414id = "servidor"
415title = "Servidor"
416allow_bundles = []
417"#,
418        )
419        .unwrap()
420    }
421
422    fn mm() -> ModMap {
423        let mut m = ModMap::default();
424        super::super::modmap::tests_support::feed(
425            &mut m,
426            "obj-$(CONFIG_IWLWIFI) += iwlwifi.o\n\
427             obj-$(CONFIG_VIRTIO_BALLOON) += virtio_balloon.o\n\
428             obj-$(CONFIG_QE_USB) += usb.o\n",
429        );
430        m
431    }
432
433    fn drv(v: &[&str]) -> BTreeSet<String> {
434        v.iter().map(|s| s.to_string()).collect()
435    }
436
437    #[test]
438    fn regresion_es_estaba_encendido_y_dejo_de_estarlo() {
439        let base = KernelConfig::parse("CONFIG_VIRTIO_BALLOON=y\n");
440        let nuevo = KernelConfig::parse("# CONFIG_VIRTIO_BALLOON is not set\n");
441        let r = run_against_config(
442            &base, &nuevo, &catalogo(), &mm(), &drv(&["virtio_balloon"]), "servidor", None, None,
443        )
444        .unwrap();
445        assert_eq!(r.verdict, Verdict::Bloquea);
446        assert_eq!(r.regressions[0].driver, "virtio_balloon");
447    }
448
449    /// El driver que `/sys` llama `usb` mapea a `QE_USB` (Freescale). Mirando sólo el config nuevo
450    /// aparecería como perdido y bloquearía un plan sano; exigiendo que estuviera encendido en el
451    /// referente, el falso positivo se cae solo.
452    #[test]
453    fn la_colision_de_nombres_no_produce_falso_positivo() {
454        let base = KernelConfig::parse("# CONFIG_QE_USB is not set\n");
455        let nuevo = KernelConfig::parse("# CONFIG_QE_USB is not set\n");
456        let r = run_against_config(
457            &base, &nuevo, &catalogo(), &mm(), &drv(&["usb"]), "servidor", None, None,
458        )
459        .unwrap();
460        assert_eq!(r.verdict, Verdict::Pasa);
461        assert!(r.regressions.is_empty());
462        // Queda declarado como NO comprobado, que es la verdad: no sabemos de dónde sale.
463        assert_eq!(r.unmapped, vec!["usb"]);
464    }
465
466    #[test]
467    fn lo_que_sigue_encendido_no_molesta() {
468        let base = KernelConfig::parse("CONFIG_IWLWIFI=m\n");
469        let nuevo = KernelConfig::parse("CONFIG_IWLWIFI=y\n");
470        let r = run_against_config(
471            &base, &nuevo, &catalogo(), &mm(), &drv(&["iwlwifi"]), "servidor", None, None,
472        )
473        .unwrap();
474        assert_eq!(r.verdict, Verdict::Pasa);
475    }
476}