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…
⎇ d797fc472d7c648b433d956ade3fe7713e4e93e4
crates/hammer-core/src/kernel/gate.rs
History
Who touched what
raw
kernel: el gate contra un .config producido — ve los huecos que la receta base ya traía
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}