Files
takana/crates/takana-core/src/kernel/hw.rs
T
Sergio 24bcf1783c takana etapa 4: los 10 crates de librería y el CLI pasan a takana-*
hammer-{core,build,bootstrap,overlay,journal,mirror,upgrade,agent,recover,cli}
→ takana-*, con sus deps de workspace, sus identificadores en el fuente y las
referencias -p de los scripts.

VERIFICADO que no mueve nada del corpus: `takana hash recipes/zlib.toml`
devuelve b3:dc363f26… , idéntico a antes del renombre. Los nombres de crate no
entran en hash_inputs, pero eso se comprueba, no se supone. 600 tests en verde.

DOS BINARIOS SE CONGELAN, y no por prolijidad:

- `hammerd` — paquete Y binario. Es componente de Stage 1 de la distro (musl,
  busybox, hammerd, arje-zero), lo supervisa arje-zero en el sistema arrancado,
  `PRESEED=hammerd` lo nombra en selfhost-verify y sus bytes anclan el baseline
  of_tree. El nombre del crate va en los símbolos ⇒ renombrarlo mueve los bytes.

- `hammer-recover` — el PAQUETE se renombra a takana-recover, el BINARIO no.
  hammer-live-install.sh lo copia a /usr/sbin/hammer-recover en sistemas ya
  instalados y hornea un hook de arranque que lo invoca por ese nombre:
  renombrarlo rompe máquinas instaladas, no el repo.

Consecuencia que hay que anotar igual: al renombrar hammer-core, los bytes de
hammerd cambian de todos modos porque linkea contra un crate con otro nombre.
El baseline of_tree del selfhost hay que rehacerlo — es efecto de la etapa 4,
no de un cambio de hammerd.

Las referencias en comentarios de recetas y docs (rutas hammer-core/src/…)
quedan para la etapa 5: son texto, no mueven hash.
2026-09-09 18:46:41 +00:00

351 lines
13 KiB
Rust

//! 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());
}
}