//! **El contrato de capacidades**: qué símbolos del kernel exige el userland, y quién se rompe //! callado si faltan (SDD 25 §4 y §8-W6). //! //! ## Por qué existe este módulo //! SDD 25 encontró, midiendo otra cosa, que los kernels de hammer se construyen **sin //! `CONFIG_MEMCG`**: `memory.max` no existe, y el escritor del otro lado (`arje-incarnate::cgroup`) //! descarta el error. Una Card que pide un tope de memoria arranca **sin tope** y la única huella //! es una línea de log. Es la forma de fallo que `CLAUDE.md` §3 nombra: *un ausente falla //! ruidosamente; un vacío llega hasta el final diciendo que todo fue bien*. //! //! El arreglo de fondo es de receta (`-e MEMCG`), y cuesta un re-hasheo del kernel. Lo que este //! módulo arregla es lo otro, que es peor: **que nadie se entere**. El contrato se declara una vez, //! se comprueba contra un `.config` YA PRODUCIDO —el que la receta instala en //! `/out/boot/config-`— y sale distinto de cero cuando una capacidad prometida no está. //! //! ## Por qué POR PERFIL y no global //! Misma lección que el gate de hardware ([`super::gate`]): `recipes/linux.toml` es el kernel de //! QEMU con consola serie del selfhost-verify, y no hospeda Cards. Exigirle contabilidad de memoria //! sería rechazar una receta sana. El perfil declara **para qué es este kernel**; el contrato sólo //! obliga donde el consumidor existe. //! //! ## Lo que este contrato NO comprueba, lo dice //! Un símbolo **ausente** del `.config` no es lo mismo que uno apagado a mano: puede no existir en //! esa versión del kernel. Los dos cuentan como capacidad ausente —porque el fichero de cgroup no //! va a aparecer por buena voluntad— pero se informan **por separado**, y una capacidad sin perfil //! declarado queda en [`ContractReport::unprofiled`] sin contar como aprobada. use std::collections::BTreeMap; use std::path::Path; use serde::{Deserialize, Serialize}; use super::config::{ConfigValue, KernelConfig}; use super::gate::Verdict; /// Formato del fichero de contrato. Se versiona para poder migrarlo sin adivinar. pub const CONTRACT_VERSION: u32 = 1; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Contract { pub version: u32, /// Versión de kernel contra la que se revisaron los nombres de símbolo. No invalida nada: /// avisa de que un `ausente` puede ser un renombrado. #[serde(default)] pub reviewed_against: Option, #[serde(default, rename = "capability")] pub capabilities: Vec, #[serde(default, rename = "profile")] pub profiles: Vec, #[serde(default, rename = "target")] pub targets: Vec, } /// Una capacidad: un pedazo de interfaz de kernel que alguien del userland USA por nombre. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Capability { /// Identificador estable, en kebab-case. pub id: String, pub title: String, /// Los símbolos que la construyen. **Todos** deben estar en `y`/`m`: son conjunción, no lista /// de candidatos — si con uno bastara, sería otra capacidad. pub symbols: Vec, /// Qué se puede tocar cuando está: ficheros de cgroup, rutas de `/proc`, syscalls. #[serde(default)] pub interface: Vec, /// Quién la usa, con ruta y símbolo. Sin consumidor una capacidad no entra al contrato. pub consumer: String, /// **Cómo falla HOY si no está.** Es el campo que justifica el guardián: si el fallo fuera /// ruidoso, bastaría con correr el programa. #[serde(default)] pub silent: Option, #[serde(default)] pub notes: Option, } /// Para qué es este kernel. El contrato no corre sin uno. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Profile { pub id: String, pub title: String, #[serde(default)] pub help: Option, /// Capacidades cuya ausencia **bloquea**. #[serde(default)] pub requires: Vec, /// Capacidades que se quieren pero cuya ausencia sólo se informa. #[serde(default)] pub wants: Vec, } /// Qué perfil le toca a cada artefacto de kernel del store. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Target { /// Nombre del artefacto tal como lo nombra la receta (campo `name`), p.ej. `linux-generic`. pub artifact: String, pub profile: String, #[serde(default)] pub notes: Option, } /// Estado de UNA capacidad contra UN config. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CapabilityState { pub id: String, pub title: String, /// `true` si el perfil la exige; `false` si sólo la quiere. pub required: bool, pub present: bool, /// Símbolos apagados a mano (`# CONFIG_X is not set`): prueba dura de que la decisión se tomó. #[serde(default)] pub explicit_off: Vec, /// Símbolos que el `.config` ni nombra. Puede ser un renombrado entre versiones ⇒ se informa /// aparte, aunque para la capacidad cuente igual de ausente. #[serde(default)] pub absent: Vec, pub consumer: String, #[serde(default)] pub silent: Option, } /// Una capacidad nombrada por un perfil que no está declarada: el contrato envejeció. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UnknownCapability { pub id: String, pub profile: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContractReport { /// Etiqueta del config mirado (ruta, o nombre del artefacto). pub config: String, pub profile: String, #[serde(default)] pub kernel: Option, pub checked: usize, /// Exigidas y ausentes. Éstas bloquean. pub missing_required: Vec, /// Queridas y ausentes: se informan y se dejan pasar. pub missing_wanted: Vec, pub ok: Vec, /// Ids que el perfil nombra y el contrato no define: NO se comprobaron. #[serde(default)] pub unknown: Vec, pub verdict: Verdict, } impl Contract { pub fn load(path: &Path) -> crate::Result { let text = std::fs::read_to_string(path) .map_err(|e| crate::Error::Recipe(format!("leyendo {}: {e}", path.display())))?; Contract::parse(&text) } pub fn parse(text: &str) -> crate::Result { let c: Contract = toml::from_str(text) .map_err(|e| crate::Error::Recipe(format!("parseando el contrato de kernel: {e}")))?; if c.version != CONTRACT_VERSION { return Err(crate::Error::Recipe(format!( "contrato de kernel versión {} (esperaba {CONTRACT_VERSION})", c.version ))); } Ok(c) } pub fn capability(&self, id: &str) -> Option<&Capability> { self.capabilities.iter().find(|c| c.id == id) } pub fn profile(&self, id: &str) -> Option<&Profile> { self.profiles.iter().find(|p| p.id == id) } /// Qué perfil le toca a un artefacto. `None` = no declarado ⇒ **no se comprueba**, se informa. pub fn profile_for(&self, artifact: &str) -> Option<&str> { self.targets .iter() .find(|t| t.artifact == artifact) .map(|t| t.profile.as_str()) } /// Corre el contrato de un perfil contra un `.config` ya producido. pub fn check(&self, cfg: &KernelConfig, profile_id: &str, label: &str) -> crate::Result { let prof = self.profile(profile_id).ok_or_else(|| { crate::Error::Recipe(format!( "no existe el perfil «{profile_id}» en el contrato. El contrato NO corre sin \ perfil declarado: uno global rechazaría kernels sanos (el de QEMU no hospeda Cards)." )) })?; let mut rep = ContractReport { config: label.to_string(), profile: profile_id.to_string(), kernel: cfg.banner.clone(), checked: 0, missing_required: Vec::new(), missing_wanted: Vec::new(), ok: Vec::new(), unknown: Vec::new(), verdict: Verdict::Pasa, }; for (ids, required) in [(&prof.requires, true), (&prof.wants, false)] { for id in ids { let Some(cap) = self.capability(id) else { rep.unknown.push(UnknownCapability { id: id.clone(), profile: profile_id.to_string(), }); continue; }; rep.checked += 1; let st = evaluate(cap, cfg, required); match (st.present, required) { (true, _) => rep.ok.push(st), (false, true) => rep.missing_required.push(st), (false, false) => rep.missing_wanted.push(st), } } } if !rep.missing_required.is_empty() { rep.verdict = Verdict::Bloquea; } Ok(rep) } } fn evaluate(cap: &Capability, cfg: &KernelConfig, required: bool) -> CapabilityState { let mut explicit_off = Vec::new(); let mut absent = Vec::new(); for s in &cap.symbols { match cfg.get(s) { Some(v) if v.is_on() => {} Some(ConfigValue::No) => explicit_off.push(s.clone()), // Un `=n` literal es rarísimo en un `.config` generado, pero se lee igual que el // apagado explícito: la decisión está escrita. Some(_) => explicit_off.push(s.clone()), None => absent.push(s.clone()), } } CapabilityState { id: cap.id.clone(), title: cap.title.clone(), required, present: explicit_off.is_empty() && absent.is_empty(), explicit_off, absent, consumer: cap.consumer.clone(), silent: cap.silent.clone(), } } /// Barrido de un store: los `.config` que instaló cada kernel sellado. /// /// Devuelve `(nombre del artefacto, ruta del config)`. Un kernel que no instale su config **no /// aparece**: sin config no hay nada que comprobar, y fingir que pasó sería el mismo pecado que /// el guardián viene a arreglar. pub fn sealed_kernel_configs(store: &Path) -> Vec<(String, std::path::PathBuf)> { let mut out: Vec<(String, std::path::PathBuf)> = Vec::new(); let Ok(rd) = std::fs::read_dir(store) else { return out; }; for e in rd.flatten() { let name = e.file_name().to_string_lossy().to_string(); // `<64 hex>-`: el nombre del artefacto es lo que va detrás del primer guion. let Some((hash, artifact)) = name.split_once('-') else { continue; }; if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) { continue; } let boot = e.path().join("boot"); let Ok(bd) = std::fs::read_dir(&boot) else { continue; }; for b in bd.flatten() { let f = b.file_name().to_string_lossy().to_string(); if f.starts_with("config-") { out.push((artifact.to_string(), b.path())); } } } out.sort(); out } /// Índice `capacidad → perfiles que la exigen`, para el informe legible. pub fn required_by(contract: &Contract) -> BTreeMap<&str, Vec<&str>> { let mut m: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); for p in &contract.profiles { for id in &p.requires { m.entry(id.as_str()).or_default().push(p.id.as_str()); } } m } #[cfg(test)] mod tests { use super::*; const CONTRATO: &str = r#" version = 1 reviewed_against = "7.1.2" [[capability]] id = "limite-memoria" title = "Tope de memoria por unidad" symbols = ["MEMCG"] interface = ["memory.max", "memory.current"] consumer = "arje-incarnate::cgroup" silent = "el escritor descarta el error" [[capability]] id = "peso-cpu" title = "Reparto de CPU por unidad" symbols = ["CGROUPS", "CGROUP_SCHED"] consumer = "arje-incarnate::cgroup" [profile.anfitrion] id = "anfitrion" title = "Hospeda Cards" requires = ["limite-memoria", "peso-cpu"] wants = ["no-declarada"] [[target]] artifact = "linux-generic" profile = "anfitrion" "#; fn contrato() -> Contract { // `[profile.x]` con `id` dentro no es lo que usa el fichero real (usa `[[profile]]`); // se arma a mano para no depender del formato en el test de lógica. let mut c = Contract::parse(&CONTRATO.replace("[profile.anfitrion]", "[[profile]]")).unwrap(); c.targets.push(Target { artifact: "linux".into(), profile: "sin-perfil".into(), notes: None, }); c } #[test] fn el_apagado_explicito_bloquea_y_se_distingue_del_ausente() { let c = contrato(); let cfg = KernelConfig::parse( "# CONFIG_MEMCG is not set\nCONFIG_CGROUPS=y\nCONFIG_CGROUP_SCHED=y\n", ); let r = c.check(&cfg, "anfitrion", "test").unwrap(); assert_eq!(r.verdict, Verdict::Bloquea); assert_eq!(r.missing_required.len(), 1); assert_eq!(r.missing_required[0].explicit_off, vec!["MEMCG"]); assert!(r.missing_required[0].absent.is_empty()); assert_eq!(r.ok.len(), 1); } #[test] fn el_ausente_cuenta_pero_se_informa_aparte() { let c = contrato(); let cfg = KernelConfig::parse("CONFIG_CGROUPS=y\nCONFIG_CGROUP_SCHED=y\n"); let r = c.check(&cfg, "anfitrion", "test").unwrap(); assert_eq!(r.verdict, Verdict::Bloquea); assert_eq!(r.missing_required[0].absent, vec!["MEMCG"]); assert!(r.missing_required[0].explicit_off.is_empty()); } #[test] fn cumplir_todo_pasa() { let c = contrato(); let cfg = KernelConfig::parse("CONFIG_MEMCG=y\nCONFIG_CGROUPS=y\nCONFIG_CGROUP_SCHED=y\n"); let r = c.check(&cfg, "anfitrion", "test").unwrap(); assert_eq!(r.verdict, Verdict::Pasa); assert_eq!(r.ok.len(), 2); } #[test] fn una_capacidad_no_declarada_no_cuenta_como_aprobada() { let c = contrato(); let cfg = KernelConfig::parse("CONFIG_MEMCG=y\nCONFIG_CGROUPS=y\nCONFIG_CGROUP_SCHED=y\n"); let r = c.check(&cfg, "anfitrion", "test").unwrap(); assert_eq!(r.unknown.len(), 1); assert_eq!(r.unknown[0].id, "no-declarada"); assert_eq!(r.checked, 2); } /// Control de frescura del fichero real: los perfiles no pueden nombrar capacidades que no /// existen, ni los objetivos perfiles que no existen. Un contrato que se desincroniza de sí /// mismo miente igual que el kernel al que vigila. #[test] fn el_contrato_del_repo_cierra_sobre_si_mismo() { let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../docs/state/kernel-contract.toml"); if !p.is_file() { return; // instalado fuera del repo: no hay nada que comprobar } let c = Contract::load(&p).expect("el contrato del repo debe parsear"); for prof in &c.profiles { for id in prof.requires.iter().chain(prof.wants.iter()) { assert!( c.capability(id).is_some(), "el perfil {} nombra la capacidad inexistente {id}", prof.id ); } } for t in &c.targets { assert!( c.profile(&t.profile).is_some(), "el artefacto {} apunta al perfil inexistente {}", t.artifact, t.profile ); } for cap in &c.capabilities { // Una capacidad SIN símbolos es legítima sólo si es de INTERFAZ: algo que el kernel da // siempre a partir de cierta versión y que ningún `CONFIG_*` enciende ni apaga (pidfd // desde 5.3). Entra al contrato igual, porque el contrato es la lista de lo que se USA // —no la de lo que puede faltar— y es la línea que lo dice si mañana baja el mínimo de // kernel. Pero entonces TIENE que nombrar la interfaz: sin símbolos ni interfaz, la // capacidad no afirma nada comprobable y lo más probable es que sea un campo a medias. assert!( !cap.symbols.is_empty() || !cap.interface.is_empty(), "{} no declara símbolos NI interfaz: no afirma nada comprobable", cap.id ); assert!(!cap.consumer.trim().is_empty(), "{} no declara consumidor", cap.id); } } #[test] fn sin_perfil_declarado_no_corre() { let c = contrato(); let cfg = KernelConfig::parse("CONFIG_MEMCG=y\n"); assert!(c.check(&cfg, "no-existe", "test").is_err()); assert_eq!(c.profile_for("linux-generic"), Some("anfitrion")); assert_eq!(c.profile_for("linux-metal"), None); } }