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…
takana etapa 5d: comentarios de crates, CLAUDE.md y el skill de granja
1//! Huella de hardware: DMI + PCI + flags de CPU.
2//!
3//! Es la pieza de §2.2 del handoff — «un config que **booteó** en esta huella es un hecho
4//! atestable». Con el §1 del SDD 22 en la mano vale más de lo que el handoff suponía: como el
5//! config ES la identidad del artefacto, atestar por huella no necesita mecanismo nuevo; es un
6//! `ArtifactHash` más una firma.
7//!
8//! ## Qué entra en la huella y qué no
9//! Entran **DMI, PCI y los flags de CPU**: describen la máquina, no lo que hay enchufado hoy. El
10//! USB se **lee y se reporta pero no se hashea** — un pendrive no puede cambiar la clase de
11//! hardware bajo la que se cachea un kernel. Si entrara, la huella sería distinta cada vez y el
12//! CDN cachearía por máquina en vez de por clase, que es justo lo que §2.2 quiere evitar.
13//!
14//! ## Y para qué sirve leerla
15//! **Sólo para contradecir** (handoff §6). Detectar sirve para decirle al usuario «marcaste "no
16//! necesito wifi" y tenés un AX211 activo ahora mismo», nunca para podar el config solo: no se
17//! puede detectar el dock que se enchufa el mes que viene ni el fs del USB de rescate.
18
19use std::collections::{BTreeMap, BTreeSet};
20use std::path::{Path, PathBuf};
21
22use serde::{Deserialize, Serialize};
23
24use crate::hash::ArtifactHash;
25
26/// Campos de `/sys/class/dmi/id` que se leen. Deliberadamente **no** se lee `product_uuid` ni
27/// `product_serial`: identifican al equipo concreto (y son root-only), y la huella tiene que
28/// agrupar máquinas iguales, no distinguirlas.
29const DMI_FIELDS: &[&str] = &[
30 "sys_vendor",
31 "product_name",
32 "product_version",
33 "board_vendor",
34 "board_name",
35 "bios_vendor",
36 "bios_version",
37 "chassis_type",
38];
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct PciDevice {
42 /// `0000:00:1f.3`
43 pub slot: String,
44 /// `0x8086`
45 pub vendor: String,
46 pub device: String,
47 /// Clase de 6 dígitos hex: los 2 primeros son la clase base, los 2 siguientes la subclase.
48 pub class: String,
49 /// Driver bindeado ahora mismo, si hay.
50 pub driver: Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct UsbDevice {
55 pub id: String,
56 pub vendor: String,
57 pub product: String,
58 pub driver: Option<String>,
59}
60
61#[derive(Debug, Clone, Default, Serialize, Deserialize)]
62pub struct Hardware {
63 pub dmi: BTreeMap<String, String>,
64 pub pci: Vec<PciDevice>,
65 /// Leído y reportado, pero **fuera** de la huella (ver doc del módulo).
66 pub usb: Vec<UsbDevice>,
67 pub cpu_model: Option<String>,
68 pub cpu_flags: BTreeSet<String>,
69 /// Todos los drivers con al menos un dispositivo bindeado, de cualquier bus.
70 pub bound_drivers: BTreeSet<String>,
71 /// Qué no se pudo leer. Una huella incompleta es un dato, no una excepción: en una VM sin DMI
72 /// o con `/sys` restringido la huella sigue siendo útil, pero quien la lea tiene que saberlo.
73 pub gaps: Vec<String>,
74}
75
76impl Hardware {
77 /// Lee la máquina real. `sys` y `proc` se inyectan para poder testear con un árbol de juguete.
78 pub fn probe(sys: &Path, proc_dir: &Path) -> Hardware {
79 let mut hw = Hardware::default();
80 hw.read_dmi(sys);
81 hw.read_pci(sys);
82 hw.read_usb(sys);
83 hw.read_cpu(proc_dir);
84 hw.read_bound_drivers(sys);
85 hw
86 }
87
88 /// El caso normal: la máquina donde corre esto.
89 pub fn probe_local() -> Hardware {
90 Hardware::probe(Path::new("/sys"), Path::new("/proc"))
91 }
92
93 fn read_dmi(&mut self, sys: &Path) {
94 let dir = sys.join("class/dmi/id");
95 if !dir.is_dir() {
96 self.gaps.push("sin DMI (¿VM o /sys restringido?)".into());
97 return;
98 }
99 for f in DMI_FIELDS {
100 if let Ok(v) = std::fs::read_to_string(dir.join(f)) {
101 let v = v.trim();
102 if !v.is_empty() {
103 self.dmi.insert((*f).to_string(), v.to_string());
104 }
105 }
106 }
107 if self.dmi.is_empty() {
108 self.gaps.push("DMI presente pero ilegible".into());
109 }
110 }
111
112 fn read_pci(&mut self, sys: &Path) {
113 let dir = sys.join("bus/pci/devices");
114 let Ok(entries) = std::fs::read_dir(&dir) else {
115 self.gaps.push("sin bus PCI".into());
116 return;
117 };
118 let mut devs = Vec::new();
119 for e in entries.flatten() {
120 let p = e.path();
121 let slot = e.file_name().to_string_lossy().into_owned();
122 let g = |f: &str| read_trim(&p.join(f)).unwrap_or_default();
123 devs.push(PciDevice {
124 slot,
125 vendor: strip_0x(&g("vendor")),
126 device: strip_0x(&g("device")),
127 class: strip_0x(&g("class")),
128 driver: link_basename(&p.join("driver")),
129 });
130 }
131 devs.sort_by(|a, b| a.slot.cmp(&b.slot));
132 self.pci = devs;
133 }
134
135 fn read_usb(&mut self, sys: &Path) {
136 let dir = sys.join("bus/usb/devices");
137 let Ok(entries) = std::fs::read_dir(&dir) else {
138 return;
139 };
140 let mut devs = Vec::new();
141 for e in entries.flatten() {
142 let p = e.path();
143 // Las interfaces (`1-1:1.0`) no son dispositivos; sólo los nodos con idVendor.
144 let Some(vendor) = read_trim(&p.join("idVendor")) else {
145 continue;
146 };
147 devs.push(UsbDevice {
148 id: e.file_name().to_string_lossy().into_owned(),
149 vendor,
150 product: read_trim(&p.join("idProduct")).unwrap_or_default(),
151 driver: link_basename(&p.join("driver")),
152 });
153 }
154 devs.sort_by(|a, b| a.id.cmp(&b.id));
155 self.usb = devs;
156 }
157
158 fn read_cpu(&mut self, proc_dir: &Path) {
159 let Ok(text) = std::fs::read_to_string(proc_dir.join("cpuinfo")) else {
160 self.gaps.push("sin /proc/cpuinfo".into());
161 return;
162 };
163 for line in text.lines() {
164 let Some((k, v)) = line.split_once(':') else {
165 continue;
166 };
167 let (k, v) = (k.trim(), v.trim());
168 match k {
169 "model name" if self.cpu_model.is_none() => {
170 self.cpu_model = Some(v.to_string());
171 }
172 "flags" if self.cpu_flags.is_empty() => {
173 self.cpu_flags = v.split_whitespace().map(|s| s.to_string()).collect();
174 }
175 _ => {}
176 }
177 }
178 }
179
180 /// Un driver «en uso» es uno con al menos un dispositivo bindeado. Es el dato que necesita el
181 /// gate de no-regresión (#6): lo que hoy funciona tiene que seguir teniendo driver mañana.
182 fn read_bound_drivers(&mut self, sys: &Path) {
183 let Ok(buses) = std::fs::read_dir(sys.join("bus")) else {
184 self.gaps.push("sin /sys/bus".into());
185 return;
186 };
187 for bus in buses.flatten() {
188 let Ok(devs) = std::fs::read_dir(bus.path().join("devices")) else {
189 continue;
190 };
191 for d in devs.flatten() {
192 if let Some(drv) = link_basename(&d.path().join("driver")) {
193 self.bound_drivers.insert(drv);
194 }
195 }
196 }
197 }
198
199 /// Los bytes canónicos que se hashean. Se exponen para poder auditar una huella sin confiar en
200 /// que el hash se calculó sobre lo que uno cree.
201 pub fn fingerprint_material(&self) -> String {
202 let mut s = String::new();
203 for (k, v) in &self.dmi {
204 s.push_str(&format!("dmi\t{k}\t{v}\n"));
205 }
206 // Sin el slot: mover una tarjeta de ranura no cambia la clase de hardware.
207 let mut pci: Vec<String> = self
208 .pci
209 .iter()
210 .map(|d| format!("pci\t{}\t{}\t{}\n", d.vendor, d.device, d.class))
211 .collect();
212 pci.sort();
213 pci.dedup();
214 s.extend(pci);
215 for f in &self.cpu_flags {
216 s.push_str(&format!("cpuflag\t{f}\n"));
217 }
218 s
219 }
220
221 /// La huella. BLAKE3 sobre [`Hardware::fingerprint_material`], con el mismo tipo que cualquier
222 /// otro identificador por contenido de takana.
223 pub fn fingerprint(&self) -> ArtifactHash {
224 ArtifactHash::of_bytes(self.fingerprint_material().as_bytes())
225 }
226
227 /// ¿Hay algún dispositivo PCI cuya clase empiece por `prefix`? (`"0280"` = red inalámbrica.)
228 pub fn has_pci_class(&self, prefix: &str) -> bool {
229 self.pci.iter().any(|d| d.class.starts_with(prefix))
230 }
231
232 pub fn has_driver(&self, name: &str) -> bool {
233 self.bound_drivers.contains(name)
234 }
235
236 pub fn has_cpu_flag(&self, flag: &str) -> bool {
237 self.cpu_flags.contains(flag)
238 }
239}
240
241fn read_trim(p: &Path) -> Option<String> {
242 let s = std::fs::read_to_string(p).ok()?;
243 let t = s.trim();
244 if t.is_empty() {
245 None
246 } else {
247 Some(t.to_string())
248 }
249}
250
251fn strip_0x(s: &str) -> String {
252 s.trim().trim_start_matches("0x").to_string()
253}
254
255fn link_basename(p: &Path) -> Option<String> {
256 let target: PathBuf = std::fs::read_link(p).ok()?;
257 Some(target.file_name()?.to_string_lossy().into_owned())
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 fn arbol_de_juguete() -> tempfile::TempDir {
265 let d = tempfile::tempdir().unwrap();
266 let sys = d.path().join("sys");
267 std::fs::create_dir_all(sys.join("class/dmi/id")).unwrap();
268 std::fs::write(sys.join("class/dmi/id/sys_vendor"), "LENOVO\n").unwrap();
269 std::fs::write(sys.join("class/dmi/id/product_name"), "20XW\n").unwrap();
270 // Un serial NO debe entrar aunque exista: la huella agrupa, no identifica.
271 std::fs::write(sys.join("class/dmi/id/product_serial"), "PF3ABCDE\n").unwrap();
272
273 let dev = sys.join("bus/pci/devices/0000:00:14.3");
274 std::fs::create_dir_all(&dev).unwrap();
275 std::fs::write(dev.join("vendor"), "0x8086\n").unwrap();
276 std::fs::write(dev.join("device"), "0xa0f0\n").unwrap();
277 std::fs::write(dev.join("class"), "0x028000\n").unwrap();
278 let drv = sys.join("bus/pci/drivers/iwlwifi");
279 std::fs::create_dir_all(&drv).unwrap();
280 std::os::unix::fs::symlink(&drv, dev.join("driver")).unwrap();
281
282 let proc_dir = d.path().join("proc");
283 std::fs::create_dir_all(&proc_dir).unwrap();
284 std::fs::write(
285 proc_dir.join("cpuinfo"),
286 "model name\t: Intel(R) Core(TM) i7-1165G7\nflags\t\t: fpu vme avx2 aes\n",
287 )
288 .unwrap();
289 d
290 }
291
292 #[test]
293 fn lee_dmi_pci_cpu_y_driver_bindeado() {
294 let d = arbol_de_juguete();
295 let hw = Hardware::probe(&d.path().join("sys"), &d.path().join("proc"));
296 assert_eq!(hw.dmi.get("sys_vendor").map(|s| s.as_str()), Some("LENOVO"));
297 assert_eq!(hw.pci.len(), 1);
298 assert_eq!(hw.pci[0].class, "028000");
299 assert_eq!(hw.pci[0].driver.as_deref(), Some("iwlwifi"));
300 assert!(hw.has_pci_class("0280"));
301 assert!(hw.has_driver("iwlwifi"));
302 assert!(hw.has_cpu_flag("avx2"));
303 assert_eq!(hw.cpu_model.as_deref(), Some("Intel(R) Core(TM) i7-1165G7"));
304 }
305
306 #[test]
307 fn el_serial_no_entra_en_la_huella() {
308 let d = arbol_de_juguete();
309 let hw = Hardware::probe(&d.path().join("sys"), &d.path().join("proc"));
310 let m = hw.fingerprint_material();
311 assert!(!m.contains("PF3ABCDE"), "la huella agrupa máquinas, no las identifica:\n{m}");
312 assert!(m.contains("LENOVO"));
313 }
314
315 #[test]
316 fn mover_la_tarjeta_de_ranura_no_cambia_la_huella() {
317 let d = arbol_de_juguete();
318 let sys = d.path().join("sys");
319 let hw1 = Hardware::probe(&sys, &d.path().join("proc"));
320 // Mismo dispositivo, otra ranura.
321 let viejo = sys.join("bus/pci/devices/0000:00:14.3");
322 let nuevo = sys.join("bus/pci/devices/0000:03:00.0");
323 std::fs::rename(&viejo, &nuevo).unwrap();
324 let hw2 = Hardware::probe(&sys, &d.path().join("proc"));
325 assert_eq!(hw1.fingerprint(), hw2.fingerprint());
326 }
327
328 #[test]
329 fn el_usb_no_mueve_la_huella() {
330 let d = arbol_de_juguete();
331 let sys = d.path().join("sys");
332 let hw1 = Hardware::probe(&sys, &d.path().join("proc"));
333 let usb = sys.join("bus/usb/devices/1-1");
334 std::fs::create_dir_all(&usb).unwrap();
335 std::fs::write(usb.join("idVendor"), "0781\n").unwrap();
336 std::fs::write(usb.join("idProduct"), "5581\n").unwrap();
337 let hw2 = Hardware::probe(&sys, &d.path().join("proc"));
338 assert_eq!(hw2.usb.len(), 1, "se lee");
339 assert_eq!(hw1.fingerprint(), hw2.fingerprint(), "pero no se hashea");
340 }
341
342 #[test]
343 fn maquina_sin_dmi_deja_hueco_en_vez_de_fallar() {
344 let d = tempfile::tempdir().unwrap();
345 let hw = Hardware::probe(&d.path().join("sys"), &d.path().join("proc"));
346 assert!(!hw.gaps.is_empty());
347 // Y aun así da una huella: incompleta, pero declarada.
348 assert!(!hw.fingerprint().as_str().is_empty());
349 }
350}