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.
351 lines
13 KiB
Rust
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());
|
|
}
|
|
}
|