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.
This commit is contained in:
Sergio
2026-09-09 18:46:41 +00:00
parent d47cafa05d
commit 24bcf1783c
105 changed files with 943 additions and 940 deletions
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "takana-core"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
description = "Tipos compartidos de hammer: Recipe, Swm, hashing CAS y el store."
[dependencies]
anyhow.workspace = true
thiserror.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
toml.workspace = true
blake3.workspace = true
base64.workspace = true
ed25519-dalek.workspace = true
getrandom.workspace = true
[dev-dependencies]
tempfile.workspace = true
+515
View File
@@ -0,0 +1,515 @@
//! Primitivas para aplicar mutaciones de un `.swm` a disco. Ver `docs/06-swm-format.md` §3.
//!
//! Estas funciones son **puras** sobre el sistema de archivos: reciben paths absolutos (ya
//! re-rooteados al overlay o a un prefix de test) y aplican un único `Mutation` cada una.
//! La orquestación (montar overlay, iterar mutaciones, build de source_patch) vive en
//! `hammer-cli`, no aquí — hammer-core no depende de hammer-build ni del overlay.
//!
//! ## `config_edit`
//!
//! El `inline_diff` se parsea como una secuencia de hunks. Un hunk es un bloque contiguo de
//! líneas `- ` (a eliminar) seguido de un bloque contiguo de líneas `+ ` (a insertar). Las
//! líneas que no empiezan por `- ` ni `+ ` son separadores entre hunks (líneas vacías,
//! contexto humano) y se ignoran.
//!
//! Aplicación: para cada hunk, se busca el bloque "removed" **exacto** en el archivo y se
//! sustituye por el "added". Si aparece varias veces ⇒ error (ambiguo). Si no aparece ⇒
//! error. No hay 3-way ni fuzz por ahora; un `.swm` que pinche dos veces falla pronto.
//!
//! ## `file_drop`
//!
//! Decodifica el base64, verifica que el BLAKE3 del contenido coincide con el
//! `content_hash` declarado, y escribe el archivo (creando los directorios padre).
//! `content_url` se rechaza aquí — el fetch externo lo decide la capa CLI.
use std::path::{Path, PathBuf};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use serde::Serialize;
use crate::hash::ArtifactHash;
/// Directorio canónico donde `init_rule` materializa una regla por servicio. El init
/// (arje, PID 1) lee este árbol para saber qué supervisar. Es el contrato on-disk entre
/// hammer (declara) y el init (ejecuta) — análogo a `/etc/systemd/system` pero nativo.
pub const INIT_RULES_DIR: &str = "/etc/hammer/init.d";
#[derive(Debug, thiserror::Error)]
pub enum ApplyError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("config_edit: {0}")]
ConfigEdit(String),
#[error("file_drop: {0}")]
FileDrop(String),
#[error("init_rule: {0}")]
InitRule(String),
}
pub type ApplyResult<T> = std::result::Result<T, ApplyError>;
/// Aplica un `config_edit` re-rooteado: `file` ya es la ruta final en disco (el caller
/// re-rootea al upper del overlay o a un prefix de test antes de llamar).
pub fn apply_config_edit(file: &Path, inline_diff: &str) -> ApplyResult<()> {
let original = std::fs::read_to_string(file).map_err(|e| {
ApplyError::ConfigEdit(format!("leyendo {}: {e}", file.display()))
})?;
let hunks = parse_hunks(inline_diff)?;
if hunks.is_empty() {
return Err(ApplyError::ConfigEdit(
"inline_diff no contiene hunks (líneas con '- ' o '+ ')".into(),
));
}
let mut content = original;
for (i, hunk) in hunks.iter().enumerate() {
content = apply_hunk(&content, hunk).map_err(|e| {
ApplyError::ConfigEdit(format!(
"hunk #{} en {}: {e}",
i + 1,
file.display()
))
})?;
}
// Escribimos con `cp -a`-equivalente: para preservar permisos, usamos write directo y
// confiamos en que el archivo destino ya existe (config_edit nunca crea archivos).
std::fs::write(file, content)?;
Ok(())
}
/// Aplica un `file_drop`: decodifica `content_b64`, verifica el hash, escribe `path`.
/// El `content_hash` admite el prefijo `b3:` (es el formato canónico) o el hex puro.
pub fn apply_file_drop(
path: &Path,
content_b64: &str,
content_hash: &str,
) -> ApplyResult<()> {
let bytes = STANDARD
.decode(content_b64.as_bytes())
.map_err(|e| ApplyError::FileDrop(format!("base64 inválido: {e}")))?;
let actual = ArtifactHash::of_inputs(&[bytes.as_slice()]);
// Normalizamos ambos al hex puro (sin "b3:") para comparar.
let declared_hex = content_hash.strip_prefix("b3:").unwrap_or(content_hash);
let actual_hex = actual
.as_str()
.strip_prefix("b3:")
.expect("of_inputs siempre devuelve 'b3:'");
if declared_hex != actual_hex {
return Err(ApplyError::FileDrop(format!(
"hash de contenido no coincide: declarado={declared_hex}, calculado={actual_hex}",
)));
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, &bytes)?;
Ok(())
}
/// Acción de un `init_rule`. Decide si la regla se **materializa** (el servicio debe
/// existir/correr) o se **retira** (no debe correr).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InitAction {
Enable,
Disable,
Start,
Stop,
Restart,
}
impl InitAction {
pub fn parse(s: &str) -> Result<Self, String> {
match s {
"enable" => Ok(Self::Enable),
"disable" => Ok(Self::Disable),
"start" => Ok(Self::Start),
"stop" => Ok(Self::Stop),
"restart" => Ok(Self::Restart),
other => Err(format!(
"acción desconocida '{other}' (enable|disable|start|stop|restart)"
)),
}
}
/// `true` si la acción implica que el servicio debe quedar declarado en disco.
/// `disable`/`stop` son las "negativas": retiran la regla.
fn materializes(self) -> bool {
!matches!(self, Self::Disable | Self::Stop)
}
}
/// Forma serializada de una regla de init en disco (TOML).
#[derive(Serialize)]
struct InitRuleFile<'a> {
service: &'a str,
action: &'a str,
command: &'a str,
}
/// Aplica un `init_rule`: materializa (o retira) `{service}.rule` bajo `rules_dir` (el
/// caller pasa [`INIT_RULES_DIR`] re-rooteado al overlay o a un prefix de test). Acciones
/// positivas (enable/start/restart) escriben la regla con su `command`; negativas
/// (disable/stop) la borran (idempotente). Devuelve el path de la regla afectada.
pub fn apply_init_rule(
rules_dir: &Path,
action: &str,
service: &str,
command: &str,
) -> ApplyResult<PathBuf> {
let act = InitAction::parse(action).map_err(ApplyError::InitRule)?;
// El nombre del servicio se vuelve un nombre de archivo: no debe escapar del dir.
if service.is_empty() || service.contains('/') || service.contains("..") {
return Err(ApplyError::InitRule(format!(
"nombre de servicio inválido: '{service}'"
)));
}
let rule_path = rules_dir.join(format!("{service}.rule"));
if !act.materializes() {
// disable/stop: retiramos la regla si existe. No es error que falte.
match std::fs::remove_file(&rule_path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(ApplyError::Io(e)),
}
return Ok(rule_path);
}
if command.is_empty() {
return Err(ApplyError::InitRule(format!(
"init_rule '{action} {service}': command vacío"
)));
}
let body = toml::to_string(&InitRuleFile { service, action, command })
.map_err(|e| ApplyError::InitRule(format!("serializando regla: {e}")))?;
if let Some(parent) = rule_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&rule_path, body)?;
Ok(rule_path)
}
/// Para el caller que ya quiere chequear el hash sin escribir nada (p. ej. `swm verify`).
pub fn verify_content_hash(content_b64: &str, content_hash: &str) -> ApplyResult<()> {
let bytes = STANDARD
.decode(content_b64.as_bytes())
.map_err(|e| ApplyError::FileDrop(format!("base64 inválido: {e}")))?;
let actual = ArtifactHash::of_inputs(&[bytes.as_slice()]);
let declared_hex = content_hash.strip_prefix("b3:").unwrap_or(content_hash);
let actual_hex = actual.as_str().strip_prefix("b3:").unwrap();
if declared_hex != actual_hex {
return Err(ApplyError::FileDrop(format!(
"hash no coincide: declarado={declared_hex}, calculado={actual_hex}",
)));
}
Ok(())
}
/// Conveniencia para `file_drop` cuando el llamador (CLI/test) ya leyó los bytes desde una
/// URL u otro lado: pasa los bytes crudos y se verifica + escribe igual que el caso inline.
pub fn write_filedrop_bytes(
path: &Path,
bytes: &[u8],
content_hash: &str,
) -> ApplyResult<()> {
let actual = ArtifactHash::of_inputs(&[bytes]);
let declared_hex = content_hash.strip_prefix("b3:").unwrap_or(content_hash);
let actual_hex = actual.as_str().strip_prefix("b3:").unwrap();
if declared_hex != actual_hex {
return Err(ApplyError::FileDrop(format!(
"hash no coincide: declarado={declared_hex}, calculado={actual_hex}",
)));
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, bytes)?;
Ok(())
}
/// Re-rootea una ruta absoluta del `.swm` bajo un prefix. `/etc/foo` con prefix
/// `/tmp/overlay/etc` → `/tmp/overlay/etc/etc/foo`. Para overlay real, prefix es vacío y
/// devuelve la propia ruta.
pub fn rebase_path(path: &str, prefix: Option<&Path>) -> PathBuf {
let p = Path::new(path);
let rel = p.strip_prefix("/").unwrap_or(p);
match prefix {
Some(pre) => pre.join(rel),
None => p.to_path_buf(),
}
}
/// Un hunk del inline_diff: el bloque de líneas a quitar (sin el prefijo `- `) y el bloque
/// a añadir (sin `+ `). Cualquiera puede ser vacío, pero no ambos a la vez.
#[derive(Debug, Clone, PartialEq, Eq)]
struct Hunk {
remove: Vec<String>,
add: Vec<String>,
}
fn parse_hunks(inline_diff: &str) -> ApplyResult<Vec<Hunk>> {
let mut hunks = Vec::new();
let mut cur = Hunk { remove: Vec::new(), add: Vec::new() };
let mut seen_add = false;
for raw in inline_diff.lines() {
if let Some(rest) = raw.strip_prefix("- ") {
// Un `-` después de `+` cierra el hunk actual.
if seen_add {
hunks.push(std::mem::replace(
&mut cur,
Hunk { remove: Vec::new(), add: Vec::new() },
));
seen_add = false;
}
cur.remove.push(rest.to_string());
} else if let Some(rest) = raw.strip_prefix("+ ") {
seen_add = true;
cur.add.push(rest.to_string());
} else {
// Línea que no es ni `- ` ni `+ `: separador. Cierra el hunk en curso si lo hay.
if !cur.remove.is_empty() || !cur.add.is_empty() {
hunks.push(std::mem::replace(
&mut cur,
Hunk { remove: Vec::new(), add: Vec::new() },
));
seen_add = false;
}
}
}
if !cur.remove.is_empty() || !cur.add.is_empty() {
hunks.push(cur);
}
Ok(hunks)
}
fn apply_hunk(content: &str, hunk: &Hunk) -> Result<String, String> {
// Hunk vacío (no debería ocurrir, parse lo filtra) → no-op.
if hunk.remove.is_empty() && hunk.add.is_empty() {
return Ok(content.to_string());
}
// Hunk de pura inserción: sin remove no sabemos dónde insertar.
if hunk.remove.is_empty() {
return Err(
"hunk sin líneas '- ' (no sabemos dónde insertar); usa '- ' como anclaje"
.to_string(),
);
}
// Buscar el bloque remove como secuencia EXACTA de líneas en el archivo. Trabajamos
// a nivel de línea (no de bytes) para que la coincidencia no se rompa por finales de
// línea o por sub-cadenas accidentales dentro de líneas más largas.
let file_lines: Vec<&str> = content.split('\n').collect();
let n = hunk.remove.len();
let mut matches = Vec::new();
if file_lines.len() >= n {
for i in 0..=(file_lines.len() - n) {
if (0..n).all(|k| file_lines[i + k] == hunk.remove[k]) {
matches.push(i);
}
}
}
match matches.len() {
0 => Err(format!(
"bloque a eliminar no aparece en el archivo: primeras líneas = [{}]",
hunk.remove.first().cloned().unwrap_or_default()
)),
1 => {
let i = matches[0];
let mut new_lines: Vec<&str> = Vec::with_capacity(file_lines.len() - n + hunk.add.len());
new_lines.extend_from_slice(&file_lines[..i]);
for a in &hunk.add {
new_lines.push(a.as_str());
}
new_lines.extend_from_slice(&file_lines[i + n..]);
Ok(new_lines.join("\n"))
}
m => Err(format!(
"bloque a eliminar aparece {m} veces: el hunk es ambiguo. Añade más contexto."
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple_hunk() {
let d = "- DHCP=yes\n+ IP=192.168.1.100\n";
let hs = parse_hunks(d).unwrap();
assert_eq!(hs.len(), 1);
assert_eq!(hs[0].remove, vec!["DHCP=yes"]);
assert_eq!(hs[0].add, vec!["IP=192.168.1.100"]);
}
#[test]
fn parse_multi_hunk_separated_by_blank() {
let d = "- a\n+ A\n\n- b\n+ B\n";
let hs = parse_hunks(d).unwrap();
assert_eq!(hs.len(), 2);
assert_eq!(hs[0].remove, vec!["a"]);
assert_eq!(hs[1].add, vec!["B"]);
}
#[test]
fn parse_multi_hunk_separated_by_back_to_dash() {
// Sin línea en blanco entre hunks: en cuanto vemos un `-` después de un `+`, cierra.
let d = "- a\n+ A\n- b\n+ B\n";
let hs = parse_hunks(d).unwrap();
assert_eq!(hs.len(), 2);
assert_eq!(hs[1].remove, vec!["b"]);
assert_eq!(hs[1].add, vec!["B"]);
}
#[test]
fn apply_replace_single_line() {
let d = tempfile::tempdir().unwrap();
let f = d.path().join("net.conf");
std::fs::write(&f, "USE=eth0\nDHCP=yes\nMTU=1500\n").unwrap();
apply_config_edit(&f, "- DHCP=yes\n+ IP=192.168.1.100\n").unwrap();
let got = std::fs::read_to_string(&f).unwrap();
assert_eq!(got, "USE=eth0\nIP=192.168.1.100\nMTU=1500\n");
}
#[test]
fn apply_replace_multi_line_block() {
let d = tempfile::tempdir().unwrap();
let f = d.path().join("conf");
std::fs::write(
&f,
"[net]\nUSE=eth0\nDHCP=yes\nMTU=1500\n[end]\n",
)
.unwrap();
let diff = "- USE=eth0\n- DHCP=yes\n+ USE=eth1\n+ STATIC=1\n+ IP=10.0.0.1\n";
apply_config_edit(&f, diff).unwrap();
let got = std::fs::read_to_string(&f).unwrap();
assert_eq!(
got,
"[net]\nUSE=eth1\nSTATIC=1\nIP=10.0.0.1\nMTU=1500\n[end]\n"
);
}
#[test]
fn apply_pure_deletion() {
let d = tempfile::tempdir().unwrap();
let f = d.path().join("conf");
std::fs::write(&f, "keep\nDROP_ME\nkeep2\n").unwrap();
apply_config_edit(&f, "- DROP_ME\n").unwrap();
let got = std::fs::read_to_string(&f).unwrap();
assert_eq!(got, "keep\nkeep2\n");
}
#[test]
fn apply_fails_when_block_not_found() {
let d = tempfile::tempdir().unwrap();
let f = d.path().join("conf");
std::fs::write(&f, "A\nB\nC\n").unwrap();
let err = apply_config_edit(&f, "- NO-EXISTE\n+ X\n")
.unwrap_err()
.to_string();
assert!(err.contains("no aparece"), "{err}");
}
#[test]
fn apply_fails_when_block_ambiguous() {
let d = tempfile::tempdir().unwrap();
let f = d.path().join("conf");
std::fs::write(&f, "X=1\nX=1\n").unwrap();
let err = apply_config_edit(&f, "- X=1\n+ X=2\n").unwrap_err().to_string();
assert!(err.contains("ambiguo"), "{err}");
}
#[test]
fn apply_pure_insertion_rejected() {
let d = tempfile::tempdir().unwrap();
let f = d.path().join("conf");
std::fs::write(&f, "A\n").unwrap();
let err = apply_config_edit(&f, "+ extra\n").unwrap_err().to_string();
assert!(err.contains("anclaje"), "{err}");
}
#[test]
fn file_drop_roundtrip() {
let d = tempfile::tempdir().unwrap();
let path = d.path().join("var/lib/x/data");
let payload = b"hola hammer\n";
let b64 = STANDARD.encode(payload);
let h = ArtifactHash::of_inputs(&[payload.as_slice()]);
apply_file_drop(&path, &b64, h.as_str()).unwrap();
let got = std::fs::read(&path).unwrap();
assert_eq!(got, payload);
}
#[test]
fn file_drop_accepts_hex_without_prefix() {
let d = tempfile::tempdir().unwrap();
let path = d.path().join("x");
let payload = b"abc";
let b64 = STANDARD.encode(payload);
let h = ArtifactHash::of_inputs(&[payload.as_slice()]);
let hex = h.as_str().strip_prefix("b3:").unwrap().to_string();
apply_file_drop(&path, &b64, &hex).unwrap();
}
#[test]
fn file_drop_rejects_wrong_hash() {
let d = tempfile::tempdir().unwrap();
let path = d.path().join("x");
let b64 = STANDARD.encode(b"abc");
let err = apply_file_drop(&path, &b64, "b3:deadbeef").unwrap_err().to_string();
assert!(err.contains("no coincide"), "{err}");
assert!(!path.exists(), "no debe haberse escrito si el hash falla");
}
#[test]
fn file_drop_rejects_invalid_b64() {
let d = tempfile::tempdir().unwrap();
let path = d.path().join("x");
let err = apply_file_drop(&path, "not base64!!!", "b3:00").unwrap_err().to_string();
assert!(err.contains("base64"), "{err}");
}
#[test]
fn rebase_with_prefix() {
let r = rebase_path("/etc/foo", Some(Path::new("/tmp/overlay")));
assert_eq!(r, PathBuf::from("/tmp/overlay/etc/foo"));
let r = rebase_path("/etc/foo", None);
assert_eq!(r, PathBuf::from("/etc/foo"));
}
#[test]
fn init_rule_enable_escribe_regla() {
let d = tempfile::tempdir().unwrap();
let dir = d.path().join("etc/hammer/init.d");
let p = apply_init_rule(&dir, "enable", "nginx", "/usr/sbin/nginx -g 'daemon off;'").unwrap();
assert_eq!(p, dir.join("nginx.rule"));
let body = std::fs::read_to_string(&p).unwrap();
assert!(body.contains("service = \"nginx\""), "{body}");
assert!(body.contains("action = \"enable\""), "{body}");
assert!(body.contains("daemon off"), "{body}");
}
#[test]
fn init_rule_disable_retira_la_regla() {
let d = tempfile::tempdir().unwrap();
let dir = d.path().join("init.d");
let p = apply_init_rule(&dir, "enable", "foo", "/bin/foo").unwrap();
assert!(p.exists());
// disable borra; y es idempotente (segunda vez no falla aunque ya no esté).
apply_init_rule(&dir, "disable", "foo", "/bin/foo").unwrap();
assert!(!p.exists(), "disable debió borrar la regla");
apply_init_rule(&dir, "stop", "foo", "/bin/foo").unwrap();
}
#[test]
fn init_rule_rechaza_accion_y_servicio_invalidos() {
let d = tempfile::tempdir().unwrap();
let dir = d.path().join("init.d");
let e1 = apply_init_rule(&dir, "frobnicate", "x", "/bin/x").unwrap_err().to_string();
assert!(e1.contains("acción desconocida"), "{e1}");
let e2 = apply_init_rule(&dir, "enable", "../escape", "/bin/x").unwrap_err().to_string();
assert!(e2.contains("servicio inválido"), "{e2}");
let e3 = apply_init_rule(&dir, "enable", "x", "").unwrap_err().to_string();
assert!(e3.contains("command vacío"), "{e3}");
}
}
+179
View File
@@ -0,0 +1,179 @@
//! Política de capacidades del bus de agente, declarativa. Ver `docs/07-agent-bus.md` §4 y
//! `docs/09-trust-model.md`.
//!
//! El daemon concede caps a cada conexión según su `Peer` (UID/GID ya autenticados vía
//! `SO_PEERCRED`). Hasta ahora esa política vivía hardcodeada en código; este módulo la lee de
//! `/etc/hammer/agent-caps.toml` para que el operador la ajuste sin recompilar. El peer NUNCA
//! elige sus caps: las decide el daemon a partir de esta config.
//!
//! ```toml
//! # Caps para cualquier UID/GID no cubierto por una regla.
//! default = ["query"]
//!
//! # Reglas evaluadas en orden; gana la PRIMERA que casa. Una regla casa si todos los campos
//! # que declara (`uid` y/o `gid`) coinciden con el peer.
//! [[rule]]
//! uid = 0
//! caps = ["query", "compile", "inject", "inject-real", "init"]
//!
//! [[rule]]
//! gid = 994 # p. ej. grupo `hammer`
//! caps = ["query", "compile", "inject", "init"]
//! ```
use serde::Deserialize;
use crate::proto::Cap;
/// Config declarativa de la política de capacidades del bus.
#[derive(Debug, Clone, Deserialize)]
pub struct AgentCapsConfig {
/// Caps para peers que no casan ninguna regla.
#[serde(default = "default_caps")]
pub default: Vec<Cap>,
/// Reglas, evaluadas en orden (la primera que casa gana).
#[serde(default)]
pub rule: Vec<CapRule>,
}
/// Una regla: a quién aplica (por `uid` y/o `gid`) y qué caps concede.
#[derive(Debug, Clone, Deserialize)]
pub struct CapRule {
#[serde(default)]
pub uid: Option<u32>,
#[serde(default)]
pub gid: Option<u32>,
pub caps: Vec<Cap>,
}
fn default_caps() -> Vec<Cap> {
vec![Cap::Query]
}
impl Default for AgentCapsConfig {
fn default() -> Self {
AgentCapsConfig {
default: default_caps(),
rule: Vec::new(),
}
}
}
impl CapRule {
/// ¿Casa esta regla con `(uid, gid)`? Casa si todos los campos declarados coinciden. Una
/// regla sin `uid` ni `gid` NO casa (sería un catch-all redundante con `default`, y casi
/// siempre un error de config); se ignora con cuidado en `caps_for`.
fn matches(&self, uid: u32, gid: u32) -> bool {
if self.uid.is_none() && self.gid.is_none() {
return false;
}
self.uid.map(|u| u == uid).unwrap_or(true) && self.gid.map(|g| g == gid).unwrap_or(true)
}
}
impl AgentCapsConfig {
pub fn from_toml(s: &str) -> crate::Result<AgentCapsConfig> {
toml::from_str(s).map_err(|e| crate::Error::Serde(e.to_string()))
}
/// Carga la config desde `path`. Devuelve `Ok(None)` si el fichero no existe — el daemon
/// decide entonces su política por defecto.
pub fn load(path: &std::path::Path) -> crate::Result<Option<AgentCapsConfig>> {
match std::fs::read_to_string(path) {
Ok(s) => Ok(Some(Self::from_toml(&s)?)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
/// Caps concedidas a un peer `(uid, gid)`: la primera regla que casa, o `default`.
pub fn caps_for(&self, uid: u32, gid: u32) -> Vec<Cap> {
for r in &self.rule {
if r.matches(uid, gid) {
return r.caps.clone();
}
}
self.default.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
const CFG: &str = r#"
default = ["query"]
[[rule]]
uid = 0
caps = ["query", "compile", "inject", "inject-real", "init"]
[[rule]]
gid = 994
caps = ["query", "compile", "inject", "init"]
[[rule]]
uid = 1000
gid = 1000
caps = ["query", "compile"]
"#;
#[test]
fn root_gets_everything() {
let c = AgentCapsConfig::from_toml(CFG).unwrap();
assert_eq!(
c.caps_for(0, 0),
vec![Cap::Query, Cap::Compile, Cap::Inject, Cap::InjectReal, Cap::Init]
);
}
#[test]
fn first_matching_rule_wins() {
let c = AgentCapsConfig::from_toml(CFG).unwrap();
// uid 1000 + gid 994: la regla del gid 994 viene ANTES que la de uid+gid 1000,
// así que gana la del grupo.
assert_eq!(
c.caps_for(1000, 994),
vec![Cap::Query, Cap::Compile, Cap::Inject, Cap::Init]
);
}
#[test]
fn both_fields_must_match() {
let c = AgentCapsConfig::from_toml(CFG).unwrap();
// uid 1000 + gid 1000: no casa root, no casa gid 994; casa la regla uid=1000,gid=1000.
assert_eq!(c.caps_for(1000, 1000), vec![Cap::Query, Cap::Compile]);
}
#[test]
fn unmatched_falls_back_to_default() {
let c = AgentCapsConfig::from_toml(CFG).unwrap();
assert_eq!(c.caps_for(31337, 31337), vec![Cap::Query]);
}
#[test]
fn empty_config_defaults_to_query() {
let c = AgentCapsConfig::from_toml("").unwrap();
assert_eq!(c.caps_for(0, 0), vec![Cap::Query]);
}
#[test]
fn rule_without_uid_or_gid_is_ignored() {
let c = AgentCapsConfig::from_toml(
r#"
default = ["query"]
[[rule]]
caps = ["init", "inject-real"]
"#,
)
.unwrap();
// La regla vacía no casa a nadie ⇒ cae al default.
assert_eq!(c.caps_for(0, 0), vec![Cap::Query]);
}
#[test]
fn load_missing_file_is_none() {
let got = AgentCapsConfig::load(std::path::Path::new("/no/existe.toml")).unwrap();
assert!(got.is_none());
}
}
+426
View File
@@ -0,0 +1,426 @@
//! H4b — el gate de **compatibilidad de configs** sobre la receta real (SDD 15 §H4).
//!
//! Sube a hammer el modelo de slots que `wawa-memo/src/compat.rs` demostró como
//! prototipo (host/std, 52 tests verdes): una config/paquete declara qué
//! superficies del sistema **reclama** (`Slots::claims`) y **requiere**
//! (`Slots::requires`), ambas por hash. Antes de tocar el sistema, `install`
//! evalúa la config entrante contra el **estado instalado**
//! ([`crate::InstalledDb::system_state`]) y decide:
//!
//! - **Compatible** — requisitos resueltos y reclamos disjuntos (o coincidentes):
//! se puede instalar sin fricción.
//! - **Colisión** — reclama una superficie ya ocupada por OTRO contenido: es una
//! **elección** del usuario (el caso "ya cambié el logo y bajo otra config que
//! también lo cambia"), no un error. `install` la respeta salvo `--force-slots`.
//! - **Incompatible** — un requisito no resuelve contra el estado: **rechazo
//! duro** (el caso "modifiqué el protocolo de Wayland y bajo algo que depende
//! del Wayland stock"). Es el análogo de sistema a una referencia colgante.
//!
//! El chequeo es **local y reproducible** — el mismo "verificar, no confiar" de
//! SDD 09, ahora sobre la topología de superficies. "Compatible" (este módulo) es
//! el eje NUEVO; "completa" y "segura" ya viven en el stack (cierre transitivo /
//! reproducir el resultado) — ver el prototipo `wawa-memo` para la composición.
use std::collections::BTreeSet;
use crate::recipe::Slots;
use crate::swm::{Mutation, Swm};
/// El estado instalado como `slot → hash`. Alias de lo que produce
/// [`crate::InstalledDb::system_state`].
pub type Estado = std::collections::BTreeMap<String, String>;
/// El veredicto de evaluar unos [`Slots`] contra un [`Estado`]. Tres resultados
/// **mecánicamente distintos**: la incompatibilidad domina a la colisión.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Veredicto {
/// Requisitos resueltos y reclamos disjuntos (o coincidentes en hash): instalable.
Compatible,
/// Un slot que la config reclama ya está ocupado por **otro** contenido. NO es
/// error: es una **elección** (caso *logo*).
Colision {
slot: String,
/// El hash que el slot tiene ahora en el estado.
instalado: String,
/// El hash que la config quiere poner.
entrante: String,
},
/// Un requisito **no resuelve** contra el estado (caso *wayland*): rechazo duro.
/// `presente = None` si el estado ni ofrece el slot; `Some(otro)` si otra versión.
Incompatible {
slot: String,
/// El hash que la config exige para ese slot.
requerido: String,
/// Lo que el estado ofrece para ese slot (o `None`).
presente: Option<String>,
},
}
impl Veredicto {
/// `true` sólo si es [`Veredicto::Compatible`].
pub fn es_compatible(&self) -> bool {
matches!(self, Veredicto::Compatible)
}
}
/// Evalúa unos `slots` contra el `estado` instalado. Primero **requisitos**
/// (incompatibilidad dura), luego **reclamos** (colisión = elección). Devuelve la
/// **primera** objeción en orden determinista (`BTreeMap`), o
/// [`Veredicto::Compatible`]. Un reclamo que coincide en hash es idempotente.
pub fn evaluar(estado: &Estado, slots: &Slots) -> Veredicto {
// 1. Requisitos: cada superficie requerida debe estar presente al hash exacto
// contra el que la config fue construida. Es la referencia colgante de sistema.
for (slot, requerido) in &slots.requires {
match estado.get(slot) {
Some(h) if h == requerido => {}
otro => {
return Veredicto::Incompatible {
slot: slot.clone(),
requerido: requerido.clone(),
presente: otro.cloned(),
};
}
}
}
// 2. Reclamos: un slot ya ocupado por otro contenido es colisión (elección).
for (slot, entrante) in &slots.claims {
if let Some(instalado) = estado.get(slot) {
if instalado != entrante {
return Veredicto::Colision {
slot: slot.clone(),
instalado: instalado.clone(),
entrante: entrante.clone(),
};
}
}
}
Veredicto::Compatible
}
// ─────────────────────────────────────────────────────────────────────────
// H4c — superficies OBSERVADAS: la vía "no prometas, observá"
// ─────────────────────────────────────────────────────────────────────────
//
// H4b confía en que el AUTOR declare `slots` a mano. La misma subida que hizo H1
// (de "prometer" a "verificar") aplica acá: la superficie más común y observable
// de un paquete es **el conjunto de paths que escribe**, y esos paths ya están
// DECLARADOS en el `.swm` (`target_bin` de cada source_patch, `path` de cada
// file_drop) — se conocen ANTES de hidratar, así que el gate puede abortar sin
// tocar nada. El "slot" es el path; la señal de colisión es que OTRO paquete
// instalado ya lo posea. No hace falta que el autor prometa nada: se observa.
/// Los paths ABSOLUTOS que un paquete **escribirá**, leídos de su `.swm`: el
/// `target_bin` de cada `source_patch` y el `path` de cada `file_drop`. Es la
/// superficie observada de H4c — conocida sin construir.
pub fn output_paths(swm: &Swm) -> BTreeSet<String> {
let mut paths = BTreeSet::new();
for m in &swm.mutations {
match m {
Mutation::SourcePatch { target_bin, .. } => {
paths.insert(target_bin.clone());
}
Mutation::FileDrop { path, .. } => {
paths.insert(path.clone());
}
// config_edit MODIFICA un fichero ajeno (no lo crea/posee); init_rule escribe en el
// dir de reglas del init. Ninguno "reclama" una superficie propia — se omiten.
Mutation::ConfigEdit { .. } | Mutation::InitRule { .. } => {}
}
}
paths
}
/// Una colisión de path observada: `entrante` escribirá un `path` que YA posee otro
/// paquete instalado (`propietario`). Sobreescribir el fichero de otro paquete es una
/// **elección** (el caso *logo* a nivel de fichero), no un error.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColisionPath {
pub path: String,
/// El paquete instalado que ya posee ese path.
pub propietario: String,
}
/// Colisiones de path del `entrante` (por nombre + los paths que escribirá) contra el estado
/// instalado. Reinstalar el MISMO paquete sobre sus propios paths NO colisiona (es upgrade);
/// sólo pisar un fichero de OTRO paquete cuenta. Reusa lo que `InstalledDb` ya registra — no
/// necesita que la receta declare nada (H4c: observar, no prometer).
pub fn path_collisions(
db: &crate::InstalledDb,
entrante_name: &str,
entrante_paths: &BTreeSet<String>,
) -> Vec<ColisionPath> {
let mut cols = Vec::new();
for p in entrante_paths {
if let Some(dueno) = db.owner_of(p) {
if dueno != entrante_name {
cols.push(ColisionPath {
path: p.clone(),
propietario: dueno.to_string(),
});
}
}
}
cols
}
// ─────────────────────────────────────────────────────────────────────────
// H4e — `requires` OBSERVADOS: la versión contra la que el paquete se construyó
// ─────────────────────────────────────────────────────────────────────────
//
// H4c observó lo que un paquete *escribe*; falta observar de qué *depende a una
// versión* (el caso wayland automático). La fuente observable: un paquete se
// construyó contra la versión de sus deps que hay EN EL REPO. Si el usuario tiene
// esa dep instalada a OTRO hash, la diverguió ⇒ el paquete no encaja. No hace
// falta que el autor prometa `requires`: se lee del cierre (deps del `.swm` +
// `expected_hash` de cada dep en el índice). Es el análogo de sistema a un
// requisito que no resuelve — el mismo rechazo duro de H4b, ahora derivado.
/// [H4e] Los `requires` OBSERVADOS de un paquete: por cada dep de **runtime**
/// declarada en su `.swm`, la versión (`expected_hash`) que esa dep tiene **en el
/// índice** — o sea, contra la que el paquete se construyó. Deps sin `expected_hash`
/// en el índice se omiten (no hay versión observable que exigir).
pub fn observed_requires(
swm: &Swm,
index: &crate::RepoIndex,
) -> std::collections::BTreeMap<String, String> {
let mut req = std::collections::BTreeMap::new();
for m in &swm.mutations {
if let Mutation::SourcePatch { deps, .. } = m {
for dep in &deps.runtime {
if let Some(h) = index.find(dep).and_then(|e| e.expected_hash.clone()) {
req.insert(dep.clone(), h);
}
}
}
}
req
}
/// Un conflicto de versión observado: una dep de runtime está instalada a un hash
/// **distinto** del que el paquete espera (el que hay en el repo). Es el caso
/// *wayland* derivado: divergiste una superficie de la que este paquete depende.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConflictoVersion {
pub dep: String,
/// El hash que el paquete espera (la versión del repo contra la que se construyó).
pub requerido: String,
/// El hash al que la dep está instalada AHORA (divergido).
pub instalado: String,
}
/// Conflictos de versión del entrante contra el estado instalado: por cada
/// `requires` observado, si la dep está instalada a otro hash. Una dep que NO está
/// instalada no cuenta acá (es un asunto de presencia, no de divergencia) — sólo
/// pisar una versión distinta rechaza. Reusa lo que `InstalledDb` ya registra.
pub fn version_conflicts(
db: &crate::InstalledDb,
requires: &std::collections::BTreeMap<String, String>,
) -> Vec<ConflictoVersion> {
let mut out = Vec::new();
for (dep, requerido) in requires {
if let Some(pkg) = db.find(dep) {
if let Some(instalado) = &pkg.hash {
if instalado != requerido {
out.push(ConflictoVersion {
dep: dep.clone(),
requerido: requerido.clone(),
instalado: instalado.clone(),
});
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn estado(pares: &[(&str, &str)]) -> Estado {
pares.iter().map(|(s, h)| (s.to_string(), h.to_string())).collect()
}
fn slots(claims: &[(&str, &str)], requires: &[(&str, &str)]) -> Slots {
Slots {
claims: claims.iter().map(|(s, h)| (s.to_string(), h.to_string())).collect(),
requires: requires.iter().map(|(s, h)| (s.to_string(), h.to_string())).collect(),
}
}
#[test]
fn logo_ya_cambiado_es_eleccion() {
let mio = estado(&[("logo", "b3:rojo")]);
let entrante = slots(&[("logo", "b3:azul")], &[]);
match evaluar(&mio, &entrante) {
Veredicto::Colision { slot, instalado, entrante } => {
assert_eq!(slot, "logo");
assert_eq!(instalado, "b3:rojo");
assert_eq!(entrante, "b3:azul");
}
otro => panic!("esperaba Colision, fue {otro:?}"),
}
}
#[test]
fn reclamar_lo_mismo_es_idempotente() {
let mio = estado(&[("logo", "b3:rojo")]);
assert!(evaluar(&mio, &slots(&[("logo", "b3:rojo")], &[])).es_compatible());
}
#[test]
fn wayland_divergido_rechaza_dependiente() {
let mio = estado(&[("wayland-protocol", "b3:mio")]);
let descargado = slots(&[("app-foo", "b3:v1")], &[("wayland-protocol", "b3:stock")]);
match evaluar(&mio, &descargado) {
Veredicto::Incompatible { slot, requerido, presente } => {
assert_eq!(slot, "wayland-protocol");
assert_eq!(requerido, "b3:stock");
assert_eq!(presente, Some("b3:mio".to_string()));
}
otro => panic!("esperaba Incompatible, fue {otro:?}"),
}
// Con el wayland stock, la misma descarga es compatible.
let stock = estado(&[("wayland-protocol", "b3:stock")]);
assert!(evaluar(&stock, &descargado).es_compatible());
}
#[test]
fn requisito_ausente_incompatible() {
let vacio = estado(&[]);
match evaluar(&vacio, &slots(&[], &[("wayland-protocol", "b3:stock")])) {
Veredicto::Incompatible { presente, .. } => assert_eq!(presente, None),
otro => panic!("esperaba Incompatible/None, fue {otro:?}"),
}
}
// ── H4c: superficies observadas (paths escritos, sin declarar slots) ──
fn pkg_con_files(name: &str, files: &[&str]) -> crate::InstalledPackage {
crate::InstalledPackage {
name: name.into(),
version: "1".into(),
hash: None,
files: files.iter().map(|s| s.to_string()).collect(),
claims: Default::default(),
}
}
#[test]
fn colision_de_path_observada_contra_otro_paquete() {
let mut db = crate::InstalledDb::default();
db.record(pkg_con_files("tema-claro", &["/usr/share/logo.png"]));
// 'tema-oscuro' escribirá el mismo path ⇒ colisión con 'tema-claro'.
let entrante: BTreeSet<String> = ["/usr/share/logo.png".to_string()].into_iter().collect();
let cols = path_collisions(&db, "tema-oscuro", &entrante);
assert_eq!(cols.len(), 1);
assert_eq!(cols[0].path, "/usr/share/logo.png");
assert_eq!(cols[0].propietario, "tema-claro");
}
#[test]
fn reinstalar_el_mismo_paquete_no_colisiona() {
let mut db = crate::InstalledDb::default();
db.record(pkg_con_files("tema-oscuro", &["/usr/share/logo.png"]));
// El mismo paquete sobre sus propios paths = upgrade, no colisión.
let paths: BTreeSet<String> = ["/usr/share/logo.png".to_string()].into_iter().collect();
assert!(path_collisions(&db, "tema-oscuro", &paths).is_empty());
}
// ── H4e: requires observados (versión de la dep contra la que se construyó) ──
#[test]
fn wayland_divergido_observado_da_conflicto_de_version() {
// El paquete 'app' depende (runtime) de 'wayland-protocol'. En el repo, wayland está a
// b3:stock (la versión contra la que app se construyó).
let swm = crate::Swm {
swm_version: 1,
base: crate::swm::Base { distro_version: "dev".into(), pins: Default::default() },
mutations: vec![Mutation::SourcePatch {
repo: Some("git://x/app.git".into()),
commit: Some("abc".into()),
tarball: None,
sha256: None,
strip_components: None,
patch: None,
patch_url: None,
build: crate::swm::SwmBuild {
compiler: "zig-cc".into(),
target: "x86_64-linux-musl".into(),
link: "static".into(),
flags: vec![],
phases: Default::default(),
zig_version: None,
},
target_bin: "/usr/bin/app".into(),
expected_hash: Some("b3:app".into()),
deps: crate::recipe::Deps { build: vec![], runtime: vec!["wayland-protocol".into()] },
evidence: Default::default(),
slots: Default::default(),
}],
signature: None,
};
let index: crate::RepoIndex = serde_json::from_str(
r#"{"packages":[{"name":"wayland-protocol","version":"1","file":"w.swm","distro_version":"dev","deps":[],"expected_hash":"b3:stock"}]}"#,
)
.unwrap();
let req = observed_requires(&swm, &index);
assert_eq!(req.get("wayland-protocol").unwrap(), "b3:stock");
// Tengo wayland instalado a b3:mine (divergido) ⇒ conflicto.
let mut db = crate::InstalledDb::default();
db.record(crate::InstalledPackage {
name: "wayland-protocol".into(),
version: "1".into(),
hash: Some("b3:mine".into()),
files: vec![],
claims: Default::default(),
});
let cs = version_conflicts(&db, &req);
assert_eq!(cs.len(), 1);
assert_eq!(cs[0].requerido, "b3:stock");
assert_eq!(cs[0].instalado, "b3:mine");
// Si tengo la versión stock, no hay conflicto.
let mut ok = crate::InstalledDb::default();
ok.record(crate::InstalledPackage {
name: "wayland-protocol".into(),
version: "1".into(),
hash: Some("b3:stock".into()),
files: vec![],
claims: Default::default(),
});
assert!(version_conflicts(&ok, &req).is_empty());
}
#[test]
fn output_paths_lee_target_bin_y_file_drop() {
let swm = crate::Swm {
swm_version: 1,
base: crate::swm::Base {
distro_version: "2026-06-06".into(),
pins: Default::default(),
},
mutations: vec![
Mutation::FileDrop {
path: "/usr/share/logo.png".into(),
content_hash: "b3:x".into(),
content_b64: Some("AA==".into()),
content_url: None,
},
Mutation::ConfigEdit {
file: "/etc/foo.conf".into(),
inline_diff: "".into(),
},
],
signature: None,
};
let paths = output_paths(&swm);
// El file_drop cuenta; el config_edit (modifica ajeno) NO.
assert!(paths.contains("/usr/share/logo.png"));
assert!(!paths.contains("/etc/foo.conf"));
assert_eq!(paths.len(), 1);
}
}
+799
View File
@@ -0,0 +1,799 @@
//! `why-differs` — el diffoscope propio de hammer ([SDD 17 §1.2](../../docs/17-cierres-frontera.md)).
//!
//! Cuando un artefacto **no reproduce**, el store sólo sabe decir "el hash no coincide". Eso deja el
//! trabajo entero al humano: desempacar los dos árboles, `cmp` a mano, adivinar. Este módulo responde
//! la pregunta siguiente — **POR QUÉ** difieren — y la responde en términos de la CAUSA, no del byte:
//! un MTIME embebido en un gzip, la cabecera `ar` de un `.a`, la sección `.comment` de un ELF (=otro
//! compilador), una ruta del árbol de build que se coló en el binario.
//!
//! Es un multiplicador doble (por eso es uno de los dos elegidos del SDD 17 §5): hace escalar el
//! barrido de la granja —hoy debuggear una no-reproducción es artesanal— y produce evidencia
//! **legible por máquina** (`--json`) para el bucle agéntico.
//!
//! **Descenso, no comparación total** (el cruce con `format`/`reconcile`): dos subárboles idénticos
//! colapsan al mismo hash, así que el diff sólo desciende donde los hashes difieren. Comparar dos
//! artefactos de miles de ficheros cuesta un hash por fichero y un diagnóstico sólo por los que
//! divergen.
//!
//! **Sin dependencias externas**: los parsers (gzip/ar/ELF) son mínimos y viven acá, en el mismo
//! estilo que `query::parse_elf_info`. Un diffoscope de verdad se apoya en medio mundo de binarios
//! ajenos; eso es exactamente lo que hammer no puede permitirse (ADR 0004: el catálogo se construye,
//! no se importa).
use serde::Serialize;
use std::collections::BTreeMap;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
/// Cuánto de un fichero divergente se lee para diagnosticar. Un artefacto puede traer un firmware de
/// cientos de MB; el diagnóstico no mejora por leerlo entero, y sí empeora la latencia.
const MAX_DIAGNOSTICO: u64 = 256 * 1024 * 1024;
/// Bytes de contexto que se muestran alrededor del primer byte divergente.
const VENTANA: usize = 24;
/// Una entrada del árbol, reducida a lo que decide si dos artefactos son el mismo.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Entrada {
Directorio,
/// El destino del symlink (NO se sigue: un symlink que apunta a otro lado ES una divergencia).
Symlink(String),
Fichero {
hash: String,
tamano: u64,
/// Sólo el bit de ejecución importa: el resto del modo lo normaliza el sellado.
ejecutable: bool,
},
}
impl Entrada {
fn tipo(&self) -> &'static str {
match self {
Entrada::Directorio => "directorio",
Entrada::Symlink(_) => "symlink",
Entrada::Fichero { .. } => "fichero",
}
}
}
/// La causa de una divergencia — el veredicto que este módulo existe para dar.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", tag = "causa")]
pub enum Causa {
/// Presente sólo en el artefacto A.
SoloEnA,
/// Presente sólo en el artefacto B.
SoloEnB,
/// La misma ruta es fichero en uno y symlink/directorio en el otro.
TipoDistinto { a: String, b: String },
/// Symlinks que apuntan a destinos distintos.
SymlinkDistinto { a: String, b: String },
/// Mismo contenido, distinto bit de ejecución.
ModoDistinto { a_ejecutable: bool, b_ejecutable: bool },
/// gzip con el MTIME embebido en la cabecera (bytes 4..8). **La causa clásica** de
/// no-reproducibilidad en tarballs y páginas de man comprimidas: se mata con `gzip -n`.
GzipMtime { a: u32, b: u32 },
/// Cabecera de un miembro de archivo `ar` (los `.a`): mtime/uid/gid. Se mata con `ar D` o
/// `ARFLAGS=Dcr` (modo determinista, que pone esos campos a 0).
ArCabecera { miembro: String, campo: String, a: String, b: String },
/// Secciones ELF que difieren. `.comment` ⇒ otro compilador; `.note.gnu.build-id` sólo ⇒ el
/// build-id deriva del resto y no es causa raíz; `.debug_*` ⇒ típicamente rutas del árbol de
/// build sin `-fdebug-prefix-map`.
ElfSecciones {
secciones: Vec<String>,
interpretacion: String,
/// Cadenas concretas que están en un ELF y no en el otro — la evidencia detrás de la
/// interpretación (ver `evidencia_de_cadenas`).
evidencia: Vec<String>,
},
/// Fichero de texto: la primera línea que difiere.
TextoLinea { linea: usize, a: String, b: String },
/// Un fragmento divergente contiene una ruta del árbol de build (`/build`, `work/sources`,
/// `/tmp/…`): el build path se filtró al artefacto.
RutaDeBuild { a: String, b: String },
/// Nada más específico: primer offset divergente y su ventana en hex.
Bytes { offset: u64, a_hex: String, b_hex: String },
/// Mismo prefijo, distinto tamaño (uno es truncamiento/extensión del otro).
Tamano { a: u64, b: u64 },
}
impl Causa {
/// Etiqueta corta y estable — es la que el bucle agéntico agrupa y cuenta.
pub fn etiqueta(&self) -> &'static str {
match self {
Causa::SoloEnA => "solo-en-a",
Causa::SoloEnB => "solo-en-b",
Causa::TipoDistinto { .. } => "tipo-distinto",
Causa::SymlinkDistinto { .. } => "symlink-distinto",
Causa::ModoDistinto { .. } => "modo-distinto",
Causa::GzipMtime { .. } => "gzip-mtime",
Causa::ArCabecera { .. } => "ar-cabecera",
Causa::ElfSecciones { .. } => "elf-secciones",
Causa::TextoLinea { .. } => "texto-linea",
Causa::RutaDeBuild { .. } => "ruta-de-build",
Causa::Bytes { .. } => "bytes",
Causa::Tamano { .. } => "tamano",
}
}
/// Si esta causa se arregla con una acción conocida, cuál. `None` = hay que investigar.
pub fn remedio(&self) -> Option<&'static str> {
match self {
Causa::GzipMtime { .. } => Some("comprimir con `gzip -n` (no embeber MTIME)"),
Causa::ArCabecera { .. } => Some("archivar en modo determinista (`ar D` / `ARFLAGS=Dcr`)"),
Causa::RutaDeBuild { .. } => {
Some("normalizar el build path (`-ffile-prefix-map`) o construir en ruta fija")
}
_ => None,
}
}
}
/// Una divergencia concreta, ya diagnosticada.
#[derive(Debug, Clone, Serialize)]
pub struct Divergencia {
/// Ruta relativa a la raíz del artefacto.
pub ruta: String,
#[serde(flatten)]
pub causa: Causa,
/// Frase lista para imprimir.
pub explicacion: String,
/// Qué hacer, si se sabe.
pub remedio: Option<String>,
}
/// El resultado completo de comparar dos artefactos.
#[derive(Debug, Clone, Serialize)]
pub struct Informe {
pub a: String,
pub b: String,
/// Entradas con hash idéntico (las que el descenso NO tuvo que mirar por dentro).
pub identicos: usize,
pub divergencias: Vec<Divergencia>,
}
impl Informe {
/// `true` si los dos árboles son idénticos entrada por entrada.
pub fn reproduce(&self) -> bool {
self.divergencias.is_empty()
}
/// Cuántas divergencias hay de cada causa — el resumen que el barrido de granja agrega.
pub fn por_causa(&self) -> BTreeMap<&'static str, usize> {
let mut m = BTreeMap::new();
for d in &self.divergencias {
*m.entry(d.causa.etiqueta()).or_insert(0) += 1;
}
m
}
}
/// Compara dos árboles de artefacto y explica cada divergencia.
pub fn why_differs(a: &Path, b: &Path) -> crate::Result<Informe> {
let arbol_a = walk(a)?;
let arbol_b = walk(b)?;
let mut divergencias = Vec::new();
let mut identicos = 0usize;
// Unión ordenada de rutas: BTreeMap ya las da ordenadas, así el informe es estable (y por tanto
// diffeable entre corridas — un informe que cambia de orden no sirve como evidencia).
let mut rutas: Vec<&String> = arbol_a.keys().chain(arbol_b.keys()).collect();
rutas.sort();
rutas.dedup();
for ruta in rutas {
match (arbol_a.get(ruta), arbol_b.get(ruta)) {
(Some(ea), Some(eb)) if ea == eb => identicos += 1,
(Some(ea), Some(eb)) => {
let causa = comparar_entradas(ea, eb, &a.join(ruta), &b.join(ruta));
divergencias.push(construir(ruta, causa));
}
(Some(_), None) => divergencias.push(construir(ruta, Causa::SoloEnA)),
(None, Some(_)) => divergencias.push(construir(ruta, Causa::SoloEnB)),
(None, None) => unreachable!("la ruta salió de la unión de ambos árboles"),
}
}
Ok(Informe {
a: a.display().to_string(),
b: b.display().to_string(),
identicos,
divergencias,
})
}
fn construir(ruta: &str, causa: Causa) -> Divergencia {
Divergencia {
ruta: ruta.to_string(),
explicacion: explicar(&causa),
remedio: causa.remedio().map(str::to_string),
causa,
}
}
fn comparar_entradas(ea: &Entrada, eb: &Entrada, pa: &Path, pb: &Path) -> Causa {
match (ea, eb) {
(Entrada::Symlink(sa), Entrada::Symlink(sb)) => Causa::SymlinkDistinto {
a: sa.clone(),
b: sb.clone(),
},
(
Entrada::Fichero { hash: ha, ejecutable: xa, .. },
Entrada::Fichero { hash: hb, ejecutable: xb, .. },
) if ha == hb && xa != xb => Causa::ModoDistinto {
a_ejecutable: *xa,
b_ejecutable: *xb,
},
(Entrada::Fichero { tamano: ta, .. }, Entrada::Fichero { tamano: tb, .. }) => {
diagnosticar(pa, pb, *ta, *tb)
}
_ => Causa::TipoDistinto {
a: ea.tipo().to_string(),
b: eb.tipo().to_string(),
},
}
}
/// El corazón: dos ficheros con distinto contenido — ¿por qué?
///
/// El orden de los detectores importa: van de la causa **más específica y accionable** (gzip, ar) a
/// la más genérica (primer byte divergente). El primero que reconoce el formato gana.
fn diagnosticar(pa: &Path, pb: &Path, ta: u64, tb: u64) -> Causa {
if ta > MAX_DIAGNOSTICO || tb > MAX_DIAGNOSTICO {
return Causa::Tamano { a: ta, b: tb };
}
let (da, db) = match (std::fs::read(pa), std::fs::read(pb)) {
(Ok(x), Ok(y)) => (x, y),
_ => return Causa::Tamano { a: ta, b: tb },
};
if let Some(c) = detectar_gzip(&da, &db) {
return c;
}
if let Some(c) = detectar_ar(&da, &db) {
return c;
}
if let Some(c) = detectar_elf(&da, &db) {
return c;
}
if let Some(c) = detectar_texto(&da, &db) {
return c;
}
detectar_bytes(&da, &db)
}
// ── detectores ───────────────────────────────────────────────────────────────────────────────
/// gzip: `1f 8b`, y el MTIME en los bytes 4..8 (little-endian). Si es lo único que cambia, la
/// divergencia entera se explica por no haber usado `gzip -n`.
fn detectar_gzip(a: &[u8], b: &[u8]) -> Option<Causa> {
if a.len() < 8 || b.len() < 8 || a[0..2] != [0x1f, 0x8b] || b[0..2] != [0x1f, 0x8b] {
return None;
}
let ma = u32::from_le_bytes(a[4..8].try_into().ok()?);
let mb = u32::from_le_bytes(b[4..8].try_into().ok()?);
if ma == mb {
return None;
}
Some(Causa::GzipMtime { a: ma, b: mb })
}
/// Un miembro de archivo `ar`: cabecera ASCII de 60 bytes —
/// nombre[16] mtime[12] uid[6] gid[6] modo[8] tamaño[10] fmag[2].
struct MiembroAr {
nombre: String,
mtime: String,
uid: String,
gid: String,
modo: String,
tamano: usize,
inicio: usize,
}
fn parsear_ar(d: &[u8]) -> Option<Vec<MiembroAr>> {
if d.len() < 8 || &d[0..8] != b"!<arch>\n" {
return None;
}
let campo = |b: &[u8]| String::from_utf8_lossy(b).trim().to_string();
let mut out = Vec::new();
let mut cur = 8usize;
while cur + 60 <= d.len() {
let h = &d[cur..cur + 60];
if &h[58..60] != b"`\n" {
break;
}
let tamano: usize = campo(&h[48..58]).parse().ok()?;
out.push(MiembroAr {
nombre: campo(&h[0..16]),
mtime: campo(&h[16..28]),
uid: campo(&h[28..34]),
gid: campo(&h[34..40]),
modo: campo(&h[40..48]),
tamano,
inicio: cur + 60,
});
// Los miembros se alinean a 2 bytes.
cur = cur + 60 + tamano + (tamano % 2);
}
Some(out)
}
/// `.a` (y `.deb`/`.lib`): si las cabeceras traen mtime/uid/gid distintos, ésa es la causa — el
/// contenido de los objetos puede ser idéntico. Se mata archivando en modo determinista.
fn detectar_ar(a: &[u8], b: &[u8]) -> Option<Causa> {
let (ma, mb) = (parsear_ar(a)?, parsear_ar(b)?);
for (x, y) in ma.iter().zip(mb.iter()) {
for (campo, va, vb) in [
("mtime", &x.mtime, &y.mtime),
("uid", &x.uid, &y.uid),
("gid", &x.gid, &y.gid),
("modo", &x.modo, &y.modo),
] {
if va != vb {
return Some(Causa::ArCabecera {
miembro: x.nombre.clone(),
campo: campo.to_string(),
a: va.clone(),
b: vb.clone(),
});
}
}
// Cabeceras iguales pero contenido distinto ⇒ la causa está DENTRO del objeto: se
// diagnostica recursivamente (un `.o` es ELF, así que casi siempre cae en detectar_elf).
let (fa, fb) = (x.inicio + x.tamano, y.inicio + y.tamano);
if fa <= a.len() && fb <= b.len() {
let (ca, cb) = (&a[x.inicio..fa], &b[y.inicio..fb]);
if ca != cb {
if let Some(Causa::ElfSecciones { secciones, interpretacion, evidencia }) =
detectar_elf(ca, cb)
{
// Un `.a` trae decenas de objetos: sin decir cuál, el veredicto obliga a
// desarmar el archivo a mano — justo el trabajo artesanal que esto elimina.
return Some(Causa::ElfSecciones {
secciones,
interpretacion: format!("miembro `{}` — {interpretacion}", x.nombre),
evidencia,
});
}
}
}
}
None
}
/// Los bytes de una sección concreta, si existe y cae dentro del fichero.
fn bytes_de_seccion<'a>(d: &'a [u8], nombre: &str) -> Option<&'a [u8]> {
let (_, off, size) = secciones_crudas(d)?.into_iter().find(|(n, ..)| n == nombre)?;
d.get(off..off + size)
}
/// Cadenas NUL-terminadas imprimibles de una sección (`.debug_str`, `.comment`: son tablas de
/// cadenas). Es lo que convierte "difieren las secciones de depuración" en evidencia concreta.
fn cadenas_de(d: &[u8], nombre: &str) -> Vec<String> {
let Some(b) = bytes_de_seccion(d, nombre) else {
return Vec::new();
};
b.split(|&c| c == 0)
.filter(|s| s.len() >= 4 && s.iter().all(|&c| (0x20..0x7f).contains(&c)))
.map(|s| String::from_utf8_lossy(s).to_string())
.collect()
}
/// La tabla de secciones como (nombre, offset, tamaño).
///
/// Cabecera de sección ELF64: sh_name[4] sh_type[4] sh_flags[8] sh_addr[8] sh_offset[8] sh_size[8] …
fn secciones_crudas(d: &[u8]) -> Option<Vec<(String, usize, usize)>> {
if d.len() < 64 || &d[0..4] != b"\x7fELF" || d[4] != 2 || d[5] != 1 {
return None;
}
let e_shoff = u64::from_le_bytes(d[40..48].try_into().ok()?) as usize;
let e_shentsize = u16::from_le_bytes(d[58..60].try_into().ok()?) as usize;
let e_shnum = u16::from_le_bytes(d[60..62].try_into().ok()?) as usize;
let e_shstrndx = u16::from_le_bytes(d[62..64].try_into().ok()?) as usize;
if e_shoff == 0 || e_shnum == 0 || e_shstrndx >= e_shnum || e_shentsize < 64 {
return None;
}
let leer = |i: usize| -> Option<(u32, u64, u64)> {
let off = e_shoff + i * e_shentsize;
if off + 64 > d.len() {
return None;
}
let sh_name = u32::from_le_bytes(d[off..off + 4].try_into().ok()?);
let sh_offset = u64::from_le_bytes(d[off + 24..off + 32].try_into().ok()?);
let sh_size = u64::from_le_bytes(d[off + 32..off + 40].try_into().ok()?);
Some((sh_name, sh_offset, sh_size))
};
// La tabla de nombres de sección: sus bytes son cadenas NUL-terminadas indexadas por sh_name.
let (_, str_off, str_size) = leer(e_shstrndx)?;
let (str_off, str_size) = (str_off as usize, str_size as usize);
if str_off + str_size > d.len() {
return None;
}
let strtab = &d[str_off..str_off + str_size];
let mut out = Vec::with_capacity(e_shnum);
for i in 0..e_shnum {
let (sh_name, sh_offset, sh_size) = leer(i)?;
let ini = sh_name as usize;
let nombre = if ini < strtab.len() {
let fin = strtab[ini..].iter().position(|&c| c == 0).unwrap_or(0) + ini;
String::from_utf8_lossy(&strtab[ini..fin]).to_string()
} else {
format!("<sección {i}>")
};
out.push((nombre, sh_offset as usize, sh_size as usize));
}
Some(out)
}
/// Las secciones con el hash de su contenido — la base de la comparación.
fn secciones_elf(d: &[u8]) -> Option<BTreeMap<String, (u64, String)>> {
let mut out = BTreeMap::new();
for (nombre, off, size) in secciones_crudas(d)? {
// SHT_NOBITS (.bss) y las secciones fuera de rango no tienen bytes que hashear.
let contenido = match d.get(off..off + size) {
Some(b) => blake3::hash(b).to_hex().to_string(),
None => String::from("<fuera de rango>"),
};
out.insert(nombre, (size as u64, contenido));
}
Some(out)
}
/// ELF: comparar sección por sección. Saber CUÁLES difieren es casi todo el diagnóstico — es la
/// diferencia entre "el binario cambió" y "sólo cambió `.comment` ⇒ otro compilador".
fn detectar_elf(a: &[u8], b: &[u8]) -> Option<Causa> {
let (sa, sb) = (secciones_elf(a)?, secciones_elf(b)?);
let mut difieren: Vec<String> = Vec::new();
for (nombre, va) in &sa {
match sb.get(nombre) {
Some(vb) if vb == va => {}
Some(_) => difieren.push(nombre.clone()),
None => difieren.push(format!("{nombre} (sólo en A)")),
}
}
for nombre in sb.keys() {
if !sa.contains_key(nombre) {
difieren.push(format!("{nombre} (sólo en B)"));
}
}
if difieren.is_empty() {
return None;
}
Some(Causa::ElfSecciones {
interpretacion: interpretar_secciones(&difieren),
evidencia: evidencia_de_cadenas(a, b, &difieren),
secciones: difieren,
})
}
/// Cuántas cadenas divergentes se muestran como evidencia. Más que esto es ruido: el patrón se ve
/// con dos o tres.
const MAX_EVIDENCIA: usize = 3;
/// Saca las cadenas que están en un ELF y no en el otro, para las secciones de texto que difieren.
///
/// Esto es lo que separa la sospecha del hecho. `.debug_str` difiere ⇒ la hipótesis es "rutas del
/// árbol de build"; mostrar la cadena la confirma o la desmiente. Caso real que motivó esto
/// (barrido del 2026-07-21, `alsa-lib`): la interpretación decía "típicamente rutas de build" y la
/// evidencia mostró `/src/target/release/build/libsodium-sys-<hash-cargo>/out/…` — una ruta con un
/// hash de cargo que cambia entre builds. Sin la cadena, eso era una corazonada.
fn evidencia_de_cadenas(a: &[u8], b: &[u8], difieren: &[String]) -> Vec<String> {
const INTERESANTES: [&str; 3] = [".debug_str", ".comment", ".rodata"];
let mut out = Vec::new();
for sec in difieren {
let nombre = sec.split(' ').next().unwrap_or(sec);
if !INTERESANTES.contains(&nombre) {
continue;
}
let (ca, cb) = (cadenas_de(a, nombre), cadenas_de(b, nombre));
let (sa, sb): (std::collections::BTreeSet<_>, std::collections::BTreeSet<_>) =
(ca.into_iter().collect(), cb.into_iter().collect());
for s in sa.difference(&sb).take(MAX_EVIDENCIA) {
out.push(format!("{nombre} sólo en A: {}", recortar(s)));
}
for s in sb.difference(&sa).take(MAX_EVIDENCIA) {
out.push(format!("{nombre} sólo en B: {}", recortar(s)));
}
if out.len() >= MAX_EVIDENCIA * 2 {
break;
}
}
out
}
/// La lectura experta: qué significa que difieran ESAS secciones y no otras.
fn interpretar_secciones(difieren: &[String]) -> String {
let tiene = |p: &str| difieren.iter().any(|s| s.starts_with(p));
let solo = |ps: &[&str]| difieren.iter().all(|s| ps.iter().any(|p| s.starts_with(p)));
if solo(&[".comment"]) {
return "sólo `.comment`: mismo código, distinta VERSIÓN de compilador".into();
}
if solo(&[".note.gnu.build-id"]) {
return "sólo el build-id: deriva del resto del binario — si nada más difiere, \
es un residuo, no la causa"
.into();
}
if solo(&[".debug", ".zdebug", ".note.gnu.build-id"]) {
return "sólo info de depuración: típicamente rutas del árbol de build sin \
`-ffile-prefix-map` (el código ejecutable es idéntico)"
.into();
}
if tiene(".text") || tiene(".data") || tiene(".rodata") {
let mut s = String::from("difiere código/datos ejecutables");
if tiene(".comment") {
s.push_str(" Y `.comment` ⇒ sospechar primero la versión del compilador");
}
return s;
}
if solo(&[".symtab", ".strtab", ".dynsym", ".dynstr"]) {
return "sólo tablas de símbolos: el ORDEN de los símbolos cambió (enlazado no \
determinista); el código es idéntico"
.into();
}
"secciones sin patrón conocido — hay que mirar".into()
}
/// Rutas del árbol de build que no deberían acabar dentro de un artefacto.
const MARCAS_BUILD: [&str; 5] = ["work/sources/", "/build/", "/tmp/", "/nix/store/", "/out/"];
/// Texto: la primera línea que difiere dice más que cualquier offset.
fn detectar_texto(a: &[u8], b: &[u8]) -> Option<Causa> {
let (ta, tb) = (std::str::from_utf8(a).ok()?, std::str::from_utf8(b).ok()?);
for (i, (la, lb)) in ta.lines().zip(tb.lines()).enumerate() {
if la != lb {
if MARCAS_BUILD.iter().any(|m| la.contains(m) || lb.contains(m)) {
return Some(Causa::RutaDeBuild {
a: recortar(la),
b: recortar(lb),
});
}
return Some(Causa::TextoLinea {
linea: i + 1,
a: recortar(la),
b: recortar(lb),
});
}
}
// Mismo prefijo línea a línea: uno tiene más líneas que el otro.
let (na, nb) = (ta.lines().count(), tb.lines().count());
Some(Causa::TextoLinea {
linea: na.min(nb) + 1,
a: if na > nb { "<línea de más>".into() } else { "<fin>".into() },
b: if nb > na { "<línea de más>".into() } else { "<fin>".into() },
})
}
/// Último recurso: el primer byte que difiere y su ventana. Si en la ventana aparece una ruta de
/// build, eso es más informativo que el hex y se reporta como tal.
fn detectar_bytes(a: &[u8], b: &[u8]) -> Causa {
let offset = a.iter().zip(b.iter()).position(|(x, y)| x != y);
let Some(off) = offset else {
return Causa::Tamano {
a: a.len() as u64,
b: b.len() as u64,
};
};
let ini = off.saturating_sub(VENTANA / 2);
let va = &a[ini..(ini + VENTANA).min(a.len())];
let vb = &b[ini..(ini + VENTANA).min(b.len())];
let (sa, sb) = (imprimible(va), imprimible(vb));
if MARCAS_BUILD.iter().any(|m| sa.contains(m) || sb.contains(m)) {
return Causa::RutaDeBuild { a: sa, b: sb };
}
Causa::Bytes {
offset: off as u64,
a_hex: hex(va),
b_hex: hex(vb),
}
}
fn hex(d: &[u8]) -> String {
d.iter().map(|b| format!("{b:02x}")).collect::<Vec<_>>().join(" ")
}
fn imprimible(d: &[u8]) -> String {
d.iter()
.map(|&b| if (0x20..0x7f).contains(&b) { b as char } else { '.' })
.collect()
}
fn recortar(s: &str) -> String {
const MAX: usize = 120;
if s.chars().count() <= MAX {
return s.to_string();
}
let corto: String = s.chars().take(MAX).collect();
format!("{corto}")
}
/// La frase que se imprime. Vive acá (y no en el CLI) para que el informe JSON y el humano digan
/// exactamente lo mismo.
fn explicar(c: &Causa) -> String {
match c {
Causa::SoloEnA => "existe sólo en A".into(),
Causa::SoloEnB => "existe sólo en B".into(),
Causa::TipoDistinto { a, b } => format!("es {a} en A y {b} en B"),
Causa::SymlinkDistinto { a, b } => format!("symlink → `{a}` en A, → `{b}` en B"),
Causa::ModoDistinto { a_ejecutable, .. } => format!(
"mismo contenido, distinto bit de ejecución (A {}, B {})",
if *a_ejecutable { "x" } else { "no-x" },
if *a_ejecutable { "no-x" } else { "x" }
),
Causa::GzipMtime { a, b } => {
format!("gzip con MTIME embebido: {a} vs {b} (cabecera, bytes 4..8)")
}
Causa::ArCabecera { miembro, campo, a, b } => {
format!("cabecera `ar` del miembro `{miembro}`: {campo} {a} vs {b}")
}
Causa::ElfSecciones { secciones, interpretacion, evidencia } => {
let mut t = format!("ELF, difieren [{}] — {interpretacion}", secciones.join(", "));
for e in evidencia {
t.push_str(&format!("\n · {e}"));
}
t
}
Causa::TextoLinea { linea, a, b } => format!("texto, línea {linea}: `{a}` vs `{b}`"),
Causa::RutaDeBuild { a, b } => {
format!("ruta del árbol de build embebida: `{a}` vs `{b}`")
}
Causa::Bytes { offset, a_hex, b_hex } => {
format!("primer byte distinto en 0x{offset:x}\n A: {a_hex}\n B: {b_hex}")
}
Causa::Tamano { a, b } => format!("distinto tamaño: {a} vs {b} bytes"),
}
}
// ── recorrido del árbol ──────────────────────────────────────────────────────────────────────
fn walk(raiz: &Path) -> crate::Result<BTreeMap<String, Entrada>> {
if !raiz.exists() {
return Err(crate::Error::Store(format!("no existe: {}", raiz.display())));
}
let mut out = BTreeMap::new();
let mut pila: Vec<PathBuf> = vec![raiz.to_path_buf()];
while let Some(dir) = pila.pop() {
for entrada in std::fs::read_dir(&dir)? {
let entrada = entrada?;
let ruta = entrada.path();
let rel = ruta
.strip_prefix(raiz)
.map_err(|e| crate::Error::Store(e.to_string()))?
.to_string_lossy()
.to_string();
let md = std::fs::symlink_metadata(&ruta)?;
if md.is_symlink() {
let destino = std::fs::read_link(&ruta)?.to_string_lossy().to_string();
out.insert(rel, Entrada::Symlink(destino));
} else if md.is_dir() {
out.insert(rel, Entrada::Directorio);
pila.push(ruta);
} else {
out.insert(
rel,
Entrada::Fichero {
hash: hash_fichero(&ruta)?,
tamano: md.len(),
ejecutable: md.permissions().mode() & 0o111 != 0,
},
);
}
}
}
Ok(out)
}
/// BLAKE3 por streaming: un artefacto puede traer ficheros de cientos de MB y no hay razón para
/// tenerlos enteros en memoria sólo para saber si son iguales.
fn hash_fichero(p: &Path) -> crate::Result<String> {
let mut f = std::fs::File::open(p)?;
let mut h = blake3::Hasher::new();
std::io::copy(&mut f, &mut h)?;
Ok(h.finalize().to_hex().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn escribir(dir: &Path, rel: &str, datos: &[u8]) {
let p = dir.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::File::create(&p).unwrap().write_all(datos).unwrap();
}
/// Dos árboles idénticos reproducen: sin divergencias y todo contado como idéntico.
#[test]
fn arboles_identicos_reproducen() {
let t = tempfile::tempdir().unwrap();
let (a, b) = (t.path().join("a"), t.path().join("b"));
for r in [&a, &b] {
escribir(r, "usr/bin/hola", b"contenido");
escribir(r, "usr/share/doc/leeme", b"texto\n");
}
let inf = why_differs(&a, &b).unwrap();
assert!(inf.reproduce(), "divergencias: {:?}", inf.divergencias);
// usr, usr/bin, usr/bin/hola, usr/share, usr/share/doc, usr/share/doc/leeme
assert_eq!(inf.identicos, 6);
}
/// El MTIME de un gzip es LA causa clásica; tiene que salir nombrada, no como "bytes distintos".
#[test]
fn gzip_mtime_se_nombra_y_trae_remedio() {
let t = tempfile::tempdir().unwrap();
let (a, b) = (t.path().join("a"), t.path().join("b"));
// cabecera gzip mínima: magic, CM, FLG, MTIME(4), XFL, OS
let mut ga = vec![0x1f, 0x8b, 0x08, 0x00];
ga.extend_from_slice(&1_000u32.to_le_bytes());
ga.extend_from_slice(&[0x00, 0x03, 0xde, 0xad]);
let mut gb = ga.clone();
gb[4..8].copy_from_slice(&2_000u32.to_le_bytes());
escribir(&a, "m.gz", &ga);
escribir(&b, "m.gz", &gb);
let inf = why_differs(&a, &b).unwrap();
assert_eq!(inf.divergencias.len(), 1);
let d = &inf.divergencias[0];
assert_eq!(d.causa, Causa::GzipMtime { a: 1000, b: 2000 });
assert!(d.remedio.as_ref().unwrap().contains("gzip -n"));
}
/// Una ruta del árbol de build embebida se reporta como tal (no como una línea cualquiera).
#[test]
fn ruta_de_build_embebida() {
let t = tempfile::tempdir().unwrap();
let (a, b) = (t.path().join("a"), t.path().join("b"));
escribir(&a, "cfg", b"prefix=/home/x/work/sources/zlib-aaa\n");
escribir(&b, "cfg", b"prefix=/home/x/work/sources/zlib-bbb\n");
let inf = why_differs(&a, &b).unwrap();
assert_eq!(inf.divergencias[0].causa.etiqueta(), "ruta-de-build");
assert!(inf.divergencias[0].remedio.is_some());
}
/// Presencia/ausencia y symlinks divergentes son divergencias de pleno derecho.
#[test]
fn presencia_y_symlinks() {
let t = tempfile::tempdir().unwrap();
let (a, b) = (t.path().join("a"), t.path().join("b"));
escribir(&a, "solo-a", b"x");
escribir(&b, "solo-b", b"x");
std::fs::create_dir_all(a.join("d")).unwrap();
std::fs::create_dir_all(b.join("d")).unwrap();
std::os::unix::fs::symlink("destino-1", a.join("d/enlace")).unwrap();
std::os::unix::fs::symlink("destino-2", b.join("d/enlace")).unwrap();
let inf = why_differs(&a, &b).unwrap();
let etiquetas = inf.por_causa();
assert_eq!(etiquetas.get("solo-en-a"), Some(&1));
assert_eq!(etiquetas.get("solo-en-b"), Some(&1));
assert_eq!(etiquetas.get("symlink-distinto"), Some(&1));
}
/// Un `.a` cuyos miembros sólo difieren en el mtime de la cabecera: causa nombrada + remedio.
#[test]
fn ar_mtime_de_cabecera() {
let t = tempfile::tempdir().unwrap();
let (a, b) = (t.path().join("a"), t.path().join("b"));
let armar = |mtime: &str| {
let mut v = Vec::from(*b"!<arch>\n");
v.extend_from_slice(format!("{:<16}", "obj.o").as_bytes());
v.extend_from_slice(format!("{mtime:<12}").as_bytes());
v.extend_from_slice(format!("{:<6}", "0").as_bytes());
v.extend_from_slice(format!("{:<6}", "0").as_bytes());
v.extend_from_slice(format!("{:<8}", "100644").as_bytes());
v.extend_from_slice(format!("{:<10}", "4").as_bytes());
v.extend_from_slice(b"`\n");
v.extend_from_slice(b"data");
v
};
escribir(&a, "lib.a", &armar("1700000000"));
escribir(&b, "lib.a", &armar("1800000000"));
let inf = why_differs(&a, &b).unwrap();
assert_eq!(inf.divergencias[0].causa.etiqueta(), "ar-cabecera");
assert!(inf.divergencias[0].remedio.as_ref().unwrap().contains("determinista"));
}
}
+204
View File
@@ -0,0 +1,204 @@
//! Direccionamiento por contenido (CAS). Ver `docs/02-build-lab.md` §2.
//!
//! El `ArtifactHash` identifica un artefacto por TODO lo que influye en su salida:
//! commit fuente + parches + flags/compilador/target + hashes de dependencias.
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// Hash BLAKE3 de un artefacto, con prefijo legible `b3:`.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ArtifactHash(String);
impl ArtifactHash {
/// Construye desde bytes ya hasheados (representación hex).
pub fn from_hex(hex: impl Into<String>) -> Self {
ArtifactHash(format!("b3:{}", hex.into()))
}
/// Hashea un conjunto ordenado de entradas. El llamador es responsable de pasar las
/// entradas en orden canónico y estable (ver `docs/02-build-lab.md` §2).
pub fn of_inputs(inputs: &[&[u8]]) -> Self {
let mut hasher = blake3::Hasher::new();
for chunk in inputs {
// length-prefijado para evitar colisiones por concatenación ambigua.
hasher.update(&(chunk.len() as u64).to_le_bytes());
hasher.update(chunk);
}
ArtifactHash(format!("b3:{}", hasher.finalize().to_hex()))
}
/// Forma corta para directorios del store: `<hash-sin-prefijo>-<name>`.
pub fn store_dir_name(&self, name: &str) -> String {
let bare = self.0.strip_prefix("b3:").unwrap_or(&self.0);
format!("{bare}-{name}")
}
pub fn as_str(&self) -> &str {
&self.0
}
/// Hash de **contenido** de un árbol de archivos: BLAKE3 determinista sobre los bytes reales
/// (rutas relativas ordenadas + tipo + bit de ejecución + contenido / target de symlink).
///
/// A diferencia de [`of_inputs`](Self::of_inputs) (input-addressed: identifica por *qué*
/// produjo el artefacto — fuente, flags, deps), esto identifica por *qué bytes son*. Es lo que
/// **Stage 2** ([SDD 11](../../docs/11-bootstrap.md)) necesita para verificar
/// bit-reproducibilidad: `of_tree(stage1) == of_tree(stage1')` ⇒ el sistema se reconstruye
/// idéntico. No sigue symlinks (hashea su target literal); el orden del filesystem no afecta
/// (se ordena por ruta). El modo se reduce al bit de ejecución (lo único semánticamente
/// relevante; el resto lo fija `seal` de forma consistente).
pub fn of_tree(root: &Path) -> std::io::Result<ArtifactHash> {
let mut rels: Vec<PathBuf> = Vec::new();
collect_rel(root, Path::new(""), &mut rels)?;
rels.sort();
let mut hasher = blake3::Hasher::new();
hasher.update(b"hammer-tree-v1");
for rel in &rels {
let abs = root.join(rel);
let meta = std::fs::symlink_metadata(&abs)?;
let relb = rel.as_os_str().as_bytes();
hasher.update(&(relb.len() as u64).to_le_bytes());
hasher.update(relb);
let ft = meta.file_type();
if ft.is_symlink() {
let tgt = std::fs::read_link(&abs)?;
let t = tgt.as_os_str().as_bytes();
hasher.update(b"L");
hasher.update(&(t.len() as u64).to_le_bytes());
hasher.update(t);
} else if ft.is_dir() {
hasher.update(b"D");
} else {
// Archivo regular: bit de ejecución + contenido.
let exec = meta.permissions().mode() & 0o111 != 0;
hasher.update(if exec { b"Fx" } else { b"F0" });
let bytes = std::fs::read(&abs)?;
hasher.update(&(bytes.len() as u64).to_le_bytes());
hasher.update(&bytes);
}
}
Ok(ArtifactHash(format!("b3:{}", hasher.finalize().to_hex())))
}
/// BLAKE3 **crudo** del contenido de un fichero (sin framing): exactamente `blake3(bytes)`. Es lo
/// que computa el `blake3_of` de `arje-cas` y el `expected_hash` de un `.swm`, así que sirve para
/// la atestación de integridad al arranque (el gate que arje aplica antes de incarnar). A diferencia
/// de [`of_inputs`](Self::of_inputs) (length-prefijado) y [`of_tree`](Self::of_tree) (árbol con
/// rutas/modo), aquí el hash es del binario tal cual ⇒ casa con quien recompute `blake3` del fichero.
pub fn of_file(path: &Path) -> std::io::Result<ArtifactHash> {
let mut f = std::fs::File::open(path)?;
let mut hasher = blake3::Hasher::new();
std::io::copy(&mut f, &mut hasher)?;
Ok(ArtifactHash(format!("b3:{}", hasher.finalize().to_hex())))
}
/// BLAKE3 crudo de un buffer en memoria — exactamente `blake3(bytes)`, la misma convención que
/// [`of_file`](Self::of_file). Lo usa el checker de evidencia (H1b) para anclar el stdout de un
/// check contra su `expected_output`: quien recompute `blake3` de la salida obtiene el mismo `b3:…`.
///
/// **Ésta, y no [`of_inputs`](Self::of_inputs), es la que direcciona un blob por su contenido**
/// (ADR 0014): con una sola entrada el length-prefijado no desambigua nada y cuesta que el
/// nombre deje de ser verificable por un tercero con `b3sum` en la mano. Vale también fuera del
/// repo: churay (tawasuyu) direcciona sus blobs con esta misma función bajo el mismo prefijo
/// `b3:`, así que un CAS compartido sólo cierra si los dos lados usan ésta — con `of_inputs` el
/// mismo archivo tendría dos hex distintos y la discrepancia no fallaría ruidosamente.
pub fn of_bytes(bytes: &[u8]) -> ArtifactHash {
ArtifactHash(format!("b3:{}", blake3::hash(bytes).to_hex()))
}
}
/// Recorre `root` recursivamente acumulando rutas **relativas a `root`** en `out`. No sigue
/// symlinks (los registra como entrada, sin descender).
fn collect_rel(root: &Path, rel: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
for entry in std::fs::read_dir(root.join(rel))? {
let entry = entry?;
let child = rel.join(entry.file_name());
let is_dir = entry.file_type()?.is_dir();
out.push(child.clone());
if is_dir {
collect_rel(root, &child, out)?;
}
}
Ok(())
}
impl std::fmt::Display for ArtifactHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deterministic_and_order_sensitive() {
let a = ArtifactHash::of_inputs(&[b"grep", b"abc123", b"--static"]);
let b = ArtifactHash::of_inputs(&[b"grep", b"abc123", b"--static"]);
assert_eq!(a, b, "misma entrada ⇒ mismo hash");
let c = ArtifactHash::of_inputs(&[b"abc123", b"grep", b"--static"]);
assert_ne!(a, c, "orden distinto ⇒ hash distinto");
}
#[test]
fn store_dir_name_strips_prefix() {
let h = ArtifactHash::from_hex("deadbeef");
assert_eq!(h.store_dir_name("grep"), "deadbeef-grep");
}
// --- of_tree: content-hash determinista (pre-Stage 2) ---
fn populate(d: &std::path::Path) {
use std::os::unix::fs::{symlink, PermissionsExt};
std::fs::create_dir_all(d.join("usr/bin")).unwrap();
std::fs::write(d.join("usr/bin/hello"), b"#!/bin/sh\necho hi\n").unwrap();
std::fs::set_permissions(d.join("usr/bin/hello"), std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::write(d.join("README"), b"docs").unwrap();
symlink("usr/bin/hello", d.join("link")).unwrap();
}
#[test]
fn of_tree_is_deterministic_across_two_identical_trees() {
let a = tempfile::tempdir().unwrap();
let b = tempfile::tempdir().unwrap();
populate(a.path());
populate(b.path());
// Mismo contenido en dos dirs distintos ⇒ mismo hash (independiente de la ruta raíz).
assert_eq!(ArtifactHash::of_tree(a.path()).unwrap(), ArtifactHash::of_tree(b.path()).unwrap());
assert!(ArtifactHash::of_tree(a.path()).unwrap().as_str().starts_with("b3:"));
}
#[test]
fn of_tree_detects_content_exec_and_symlink_changes() {
let base = tempfile::tempdir().unwrap();
populate(base.path());
let h0 = ArtifactHash::of_tree(base.path()).unwrap();
// (1) contenido distinto.
let c = tempfile::tempdir().unwrap();
populate(c.path());
std::fs::write(c.path().join("README"), b"otra cosa").unwrap();
assert_ne!(h0, ArtifactHash::of_tree(c.path()).unwrap(), "el contenido entra al hash");
// (2) bit de ejecución distinto.
let e = tempfile::tempdir().unwrap();
populate(e.path());
std::fs::set_permissions(e.path().join("usr/bin/hello"), std::fs::Permissions::from_mode(0o644)).unwrap();
assert_ne!(h0, ArtifactHash::of_tree(e.path()).unwrap(), "el bit de ejecución entra al hash");
// (3) target de symlink distinto.
let s = tempfile::tempdir().unwrap();
populate(s.path());
std::fs::remove_file(s.path().join("link")).unwrap();
std::os::unix::fs::symlink("README", s.path().join("link")).unwrap();
assert_ne!(h0, ArtifactHash::of_tree(s.path()).unwrap(), "el target del symlink entra al hash");
}
}
+188
View File
@@ -0,0 +1,188 @@
//! La **base de datos de paquetes instalados** (Etapa F): qué paquetes hay puestos en un root y
//! qué ficheros aportó cada uno. Es lo que hace posible `hammer uninstall`: sin un registro de
//! "este paquete creó estos ficheros", quitar un paquete sería adivinar.
//!
//! Modelo simple y honesto: `install` registra los ficheros que CREÓ (los hidratados del
//! `source_patch` + `file_drop` + `init_rule`; un `config_edit` MODIFICA un fichero ajeno, no lo
//! crea, así que no se registra ni se deshace). `uninstall` borra los ficheros del paquete, salvo
//! los que OTRO paquete instalado también aporta (refcount por ruta) — nunca rompe a un tercero.
//!
//! Las rutas se guardan ABSOLUTAS (bajo el prefix o `/` según donde se instaló), así `uninstall`
//! no necesita saber el root: borra exactamente lo que registró el `install`.
use std::path::Path;
use serde::{Deserialize, Serialize};
/// Nombre por defecto de la base de datos de instalados.
pub const DEFAULT_DB: &str = "/var/lib/hammer/installed.json";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct InstalledDb {
#[serde(default)]
pub packages: Vec<InstalledPackage>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstalledPackage {
pub name: String,
pub version: String,
/// `expected_hash` del paquete (si lo tenía) — sólo informativo en la DB.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hash: Option<String>,
/// Rutas ABSOLUTAS que este paquete creó (orden de instalación). Lo que `uninstall` borra.
pub files: Vec<String>,
/// Superficies del sistema que este paquete **reclama** → hash del contenido (H4b, SDD 15 §H4).
/// Vacío por defecto (compatibilidad hacia atrás: una DB previa a H4b no lo tiene). Con esto,
/// `system_state` deriva el `slot → hash` instalado que el gate de compatibilidad consulta.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub claims: std::collections::BTreeMap<String, String>,
}
impl InstalledDb {
/// Carga la DB. Un fichero ausente es una DB VACÍA válida (nada instalado todavía).
pub fn load(path: impl AsRef<Path>) -> crate::Result<InstalledDb> {
match std::fs::read_to_string(path.as_ref()) {
Ok(text) => serde_json::from_str(&text)
.map_err(|e| crate::Error::Serde(format!("{}: {e}", path.as_ref().display()))),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(InstalledDb::default()),
Err(e) => Err(crate::Error::Io(e)),
}
}
/// Escribe la DB (creando el directorio padre si hace falta). JSON con saltos, diffeable.
pub fn save(&self, path: impl AsRef<Path>) -> crate::Result<()> {
let path = path.as_ref();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let text = serde_json::to_string_pretty(self)
.map_err(|e| crate::Error::Serde(e.to_string()))?;
std::fs::write(path, text)?;
Ok(())
}
pub fn find(&self, name: &str) -> Option<&InstalledPackage> {
self.packages.iter().find(|p| p.name == name)
}
/// Registra (o reemplaza por nombre) un paquete instalado. Re-instalar actualiza la lista de
/// ficheros (una versión nueva puede aportar otros).
pub fn record(&mut self, pkg: InstalledPackage) {
if let Some(slot) = self.packages.iter_mut().find(|p| p.name == pkg.name) {
*slot = pkg;
} else {
self.packages.push(pkg);
self.packages.sort_by(|a, b| a.name.cmp(&b.name));
}
}
/// Quita el registro de un paquete y lo devuelve (para que el caller borre sus ficheros).
pub fn remove(&mut self, name: &str) -> Option<InstalledPackage> {
let i = self.packages.iter().position(|p| p.name == name)?;
Some(self.packages.remove(i))
}
/// El **estado del sistema** como `slot → hash`: la unión de los `claims` de todos los
/// paquetes instalados (H4b, SDD 15 §H4). Es el `Estado` contra el que el gate de
/// compatibilidad evalúa una config entrante. Un slot reclamado por dos paquetes no debería
/// pasar (el gate lo impide al instalar); si aún así ocurre, gana el orden de la lista (ya
/// ordenada por nombre) — determinista.
pub fn system_state(&self) -> std::collections::BTreeMap<String, String> {
let mut estado = std::collections::BTreeMap::new();
for p in &self.packages {
for (slot, hash) in &p.claims {
estado.entry(slot.clone()).or_insert_with(|| hash.clone());
}
}
estado
}
/// El primer paquete instalado que posee `file` (aportó ese path), si alguno. Lo usa el gate
/// de compatibilidad observada (H4c) para nombrar al propietario de una colisión de path.
pub fn owner_of(&self, file: &str) -> Option<&str> {
self.packages
.iter()
.find(|p| p.files.iter().any(|f| f == file))
.map(|p| p.name.as_str())
}
/// ¿Algún OTRO paquete instalado (≠ `name`) aporta también `file`? Si sí, `uninstall` no debe
/// borrarlo (lo seguiría necesitando ese tercero).
pub fn owned_by_others(&self, name: &str, file: &str) -> bool {
self.packages
.iter()
.any(|p| p.name != name && p.files.iter().any(|f| f == file))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pkg(name: &str, files: &[&str]) -> InstalledPackage {
InstalledPackage {
name: name.into(),
version: "1".into(),
hash: None,
files: files.iter().map(|s| s.to_string()).collect(),
claims: Default::default(),
}
}
#[test]
fn system_state_une_los_claims() {
let mut db = InstalledDb::default();
db.record(InstalledPackage {
name: "tema-oscuro".into(),
version: "1".into(),
hash: None,
files: vec![],
claims: [("logo".to_string(), "b3:rojo".to_string())]
.into_iter()
.collect(),
});
let estado = db.system_state();
assert_eq!(estado.get("logo"), Some(&"b3:rojo".to_string()));
}
#[test]
fn load_missing_is_empty() {
let d = tempfile::tempdir().unwrap();
let db = InstalledDb::load(d.path().join("installed.json")).unwrap();
assert!(db.packages.is_empty());
}
#[test]
fn record_save_load_find() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("installed.json");
let mut db = InstalledDb::default();
db.record(pkg("ripgrep", &["/usr/bin/rg"]));
db.save(&p).unwrap();
let back = InstalledDb::load(&p).unwrap();
assert_eq!(back.find("ripgrep").unwrap().files, vec!["/usr/bin/rg"]);
assert!(back.find("nope").is_none());
}
#[test]
fn remove_returns_entry() {
let mut db = InstalledDb::default();
db.record(pkg("rg", &["/usr/bin/rg"]));
let removed = db.remove("rg").unwrap();
assert_eq!(removed.files, vec!["/usr/bin/rg"]);
assert!(db.find("rg").is_none());
assert!(db.remove("rg").is_none());
}
#[test]
fn owned_by_others_refcount() {
let mut db = InstalledDb::default();
db.record(pkg("a", &["/usr/lib/libshared.so", "/usr/bin/a"]));
db.record(pkg("b", &["/usr/lib/libshared.so", "/usr/bin/b"]));
// El .so lo aportan ambos: al desinstalar 'a' no debe borrarse.
assert!(db.owned_by_others("a", "/usr/lib/libshared.so"));
// El binario propio de 'a' no lo aporta nadie más.
assert!(!db.owned_by_others("a", "/usr/bin/a"));
}
}
+293
View File
@@ -0,0 +1,293 @@
//! El catálogo de bundles (N1) y perillas (N2).
//!
//! ## Por qué un bundle no es una lista de símbolos
//! Escrito como lista, «no necesito wifi» son ~400 `CONFIG_*` que envejecen en cada release y
//! exigen un curador humano para siempre (handoff §2.1). Escrito como **predicado sobre el grafo**
//! —una raíz y su clausura— son dos líneas y se auto-cura: el driver que entra en 6.17 cae dentro
//! sin que nadie lo toque.
//!
//! Lo que la medición del SDD 22 §9 agregó a esa idea: **la clausura sola no cierra**. `select`
//! fuerza símbolos ignorando sus `depends on`, así que un bundle honesto declara además qué hace
//! con cada fuga — cerrarla (`close_leaks`) o aceptarla a sabiendas (`accept_leaks`). Esa revisión
//! se hace **una vez por bundle**, no una vez por release.
//!
//! ## Por qué las perillas N2 llevan un campo `side`
//! «La mitad de N2 no es Kconfig sino variables de receta» (handoff §5). Con el hecho del §1 del
//! SDD 22 en la mano —el config ES la identidad del artefacto— eso es más grave de lo que parece:
//! las dos mitades mueven el hash igual, pero se aplican en fases distintas y fallan distinto. Una
//! UI que no sepa de qué lado cae cada perilla prometerá diffs que no puede explicar.
use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
/// Formato del fichero de catálogo. Se versiona para poder migrarlo sin adivinar.
pub const CATALOG_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Catalog {
pub version: u32,
/// Kernel contra el que se revisaron las fugas. Cambiar de versión no invalida el catálogo,
/// pero sí obliga a re-mirar las fugas (`hammer kernel closure`).
#[serde(default)]
pub reviewed_against: Option<String>,
#[serde(default, rename = "bundle")]
pub bundles: Vec<Bundle>,
#[serde(default, rename = "knob")]
pub knobs: Vec<Knob>,
#[serde(default, rename = "objective")]
pub objectives: Vec<Objective>,
}
/// Para qué es este kernel. **El gate de no-regresión no corre sin uno.**
///
/// `recipes/linux.toml` apaga USB, HID e INPUT a propósito — es el kernel de QEMU con consola
/// serie. Un gate global «todo dispositivo en uso debe seguir teniendo driver» rechazaría esa
/// receta, que está sana. El objetivo declara qué pérdidas son legítimas para este uso.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Objective {
pub id: String,
pub title: String,
#[serde(default)]
pub help: Option<String>,
/// Bundles cuyas pérdidas de driver este objetivo autoriza. Todo lo demás bloquea.
#[serde(default)]
pub allow_bundles: Vec<String>,
}
/// Un bundle de nivel 1: una aserción del dueño de la máquina («no necesito wifi»).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Bundle {
/// Identificador estable, en kebab-case. Es lo que la UI y `plan` se pasan.
pub id: String,
/// Título en castellano, para el humano.
pub title: String,
#[serde(default)]
pub help: Option<String>,
/// **El predicado**: apagar estas raíces y, con ellas, todo lo que dependa duro de ellas.
#[serde(default)]
pub disable: Vec<String>,
/// Encender estos símbolos (bundles «positivos», raros en N1 pero necesarios en N2).
#[serde(default)]
pub enable: Vec<String>,
/// Fugas `select` que se cierran también, revisadas a mano. Cada entrada es un símbolo de
/// FUERA de la clausura que reencendería algo de dentro.
#[serde(default)]
pub close_leaks: Vec<String>,
/// Fugas que se dejan abiertas a sabiendas, con su motivo. Clave = símbolo destino.
#[serde(default)]
pub accept_leaks: BTreeMap<String, String>,
/// Detección de hardware, **en un solo sentido**: sirve para CONTRADECIR al usuario
/// («marcaste "no necesito wifi" y tenés un AX211 activo ahora mismo»), nunca para podar solo.
/// Un kernel podado por autodetección es cómo se fabrica un ladrillo (handoff §6).
#[serde(default)]
pub contradicted_by: Contradiction,
#[serde(default)]
pub notes: Option<String>,
}
/// Señales de hardware que contradicen a un bundle si están presentes AHORA.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Contradiction {
/// Clases PCI, como los 6 dígitos hex de `/sys/bus/pci/devices/*/class`, o un prefijo
/// (`"0280"` = controlador de red inalámbrica).
#[serde(default)]
pub pci_class: Vec<String>,
/// Drivers con dispositivo bindeado ahora mismo (`/sys/bus/*/devices/*/driver`).
#[serde(default)]
pub driver: Vec<String>,
/// Flags de CPU de `/proc/cpuinfo`.
#[serde(default)]
pub cpu_flag: Vec<String>,
}
impl Contradiction {
pub fn is_empty(&self) -> bool {
self.pci_class.is_empty() && self.driver.is_empty() && self.cpu_flag.is_empty()
}
}
/// De qué lado del build cae una perilla de nivel 2.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Side {
/// Símbolos de Kconfig: se aplican en la fase `configure`.
Kconfig,
/// Variables de la receta (LTO, `-march`, `zig_version`): otra fase, otro modo de fallo.
/// Mueven el `ArtifactHash` igual que los símbolos.
Recipe,
}
/// Una perilla de nivel 2: política y rendimiento.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Knob {
pub id: String,
pub title: String,
#[serde(default)]
pub help: Option<String>,
/// La mitad de N2 no es Kconfig. Este campo obliga a decirlo.
pub side: Side,
#[serde(default)]
pub enable: Vec<String>,
#[serde(default)]
pub disable: Vec<String>,
/// Sólo con `side = "recipe"`: qué campo de la receta toca, en texto, para que la UI pueda
/// explicar el diff aunque `plan` todavía no sepa aplicarlo.
#[serde(default)]
pub recipe_field: Option<String>,
#[serde(default)]
pub notes: Option<String>,
}
impl Catalog {
pub fn load(path: &Path) -> crate::Result<Catalog> {
let text = std::fs::read_to_string(path)?;
let cat: Catalog = toml::from_str(&text)
.map_err(|e| crate::Error::Recipe(format!("{}: {e}", path.display())))?;
cat.validate(path)?;
Ok(cat)
}
fn validate(&self, path: &Path) -> crate::Result<()> {
let p = path.display();
if self.version != CATALOG_VERSION {
return Err(crate::Error::Recipe(format!(
"{p}: version = {}, esperaba {CATALOG_VERSION}",
self.version
)));
}
let mut ids = std::collections::BTreeSet::new();
for b in &self.bundles {
if !ids.insert(("bundle", b.id.as_str())) {
return Err(crate::Error::Recipe(format!("{p}: bundle duplicado «{}»", b.id)));
}
if b.disable.is_empty() && b.enable.is_empty() {
return Err(crate::Error::Recipe(format!(
"{p}: el bundle «{}» no apaga ni enciende nada",
b.id
)));
}
}
for k in &self.knobs {
if !ids.insert(("knob", k.id.as_str())) {
return Err(crate::Error::Recipe(format!("{p}: perilla duplicada «{}»", k.id)));
}
// Una perilla de receta que no dice qué campo toca es exactamente el diff que la UI no
// va a poder explicar.
if k.side == Side::Recipe && k.recipe_field.is_none() {
return Err(crate::Error::Recipe(format!(
"{p}: la perilla «{}» es side=recipe y no declara recipe_field",
k.id
)));
}
if k.side == Side::Kconfig && k.enable.is_empty() && k.disable.is_empty() {
return Err(crate::Error::Recipe(format!(
"{p}: la perilla «{}» es side=kconfig y no toca ningún símbolo",
k.id
)));
}
}
let conocidos: std::collections::BTreeSet<&str> =
self.bundles.iter().map(|b| b.id.as_str()).collect();
for o in &self.objectives {
if !ids.insert(("objective", o.id.as_str())) {
return Err(crate::Error::Recipe(format!("{p}: objetivo duplicado «{}»", o.id)));
}
// Un `allow_bundles` con un id mal escrito autorizaría NADA y el gate bloquearía sin
// que se entienda por qué. Se rechaza al cargar, no en el portón.
for b in &o.allow_bundles {
if !conocidos.contains(b.as_str()) {
return Err(crate::Error::Recipe(format!(
"{p}: el objetivo «{}» autoriza el bundle «{b}», que no existe",
o.id
)));
}
}
}
Ok(())
}
pub fn bundle(&self, id: &str) -> Option<&Bundle> {
self.bundles.iter().find(|b| b.id == id)
}
pub fn knob(&self, id: &str) -> Option<&Knob> {
self.knobs.iter().find(|k| k.id == id)
}
pub fn objective(&self, id: &str) -> Option<&Objective> {
self.objectives.iter().find(|o| o.id == id)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn escribir(s: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("bundles.toml");
std::fs::write(&p, s).unwrap();
(d, p)
}
#[test]
fn carga_y_resuelve_por_id() {
let (_d, p) = escribir(
r#"
version = 1
[[bundle]]
id = "sin-wifi"
title = "No necesito wifi"
disable = ["WIRELESS"]
close_leaks = ["WLAN"]
[bundle.accept_leaks]
MAC80211_LEDS = "lo deja encendido IWLEGACY, que igual no se compila"
[bundle.contradicted_by]
pci_class = ["0280"]
"#,
);
let c = Catalog::load(&p).unwrap();
let b = c.bundle("sin-wifi").unwrap();
assert_eq!(b.disable, vec!["WIRELESS"]);
assert_eq!(b.close_leaks, vec!["WLAN"]);
assert_eq!(b.accept_leaks.len(), 1);
assert_eq!(b.contradicted_by.pci_class, vec!["0280"]);
}
#[test]
fn perilla_de_receta_sin_campo_es_error() {
let (_d, p) = escribir(
r#"
version = 1
[[knob]]
id = "lto"
title = "LTO"
side = "recipe"
"#,
);
// Sin `recipe_field` la UI prometería un diff que no puede explicar (handoff §5).
let e = Catalog::load(&p).unwrap_err().to_string();
assert!(e.contains("recipe_field"), "{e}");
}
#[test]
fn bundle_vacio_es_error() {
let (_d, p) = escribir(
r#"
version = 1
[[bundle]]
id = "nada"
title = "Nada"
"#,
);
assert!(Catalog::load(&p).is_err());
}
#[test]
fn version_ajena_es_error() {
let (_d, p) = escribir("version = 99\n");
assert!(Catalog::load(&p).is_err());
}
}
+145
View File
@@ -0,0 +1,145 @@
//! Lectura de un `.config` de kernel (o del `/proc/config.gz` ya descomprimido).
//!
//! Es el sustrato del **modo reversa** (#9 del handoff): antes de compilar nada, leer el kernel que
//! ya corre y mirarlo a través del lente de bundles. Cero riesgo, cero build.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
/// Valor de un símbolo en un `.config` materializado.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ConfigValue {
/// `=y`
Yes,
/// `=m`
Module,
/// `# CONFIG_X is not set` — presencia explícita del apagado, que **no** es lo mismo que
/// ausencia: un símbolo ausente puede no existir en esta versión del kernel.
No,
/// `="texto"`
Str(String),
/// `=42`, `=0x10`
Num(String),
}
impl ConfigValue {
/// ¿Está presente en el kernel, como built-in o como módulo?
pub fn is_on(&self) -> bool {
matches!(self, ConfigValue::Yes | ConfigValue::Module)
}
}
/// Un `.config` leído. Las claves van **sin** el prefijo `CONFIG_`, igual que en los ficheros
/// Kconfig, para que un símbolo se llame igual en los dos lados del análisis.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct KernelConfig {
pub values: BTreeMap<String, ConfigValue>,
/// La línea `# Linux/x86 6.16.12 Kernel Configuration`, si está.
pub banner: Option<String>,
}
impl KernelConfig {
pub fn parse(text: &str) -> KernelConfig {
let mut cfg = KernelConfig::default();
for line in text.lines() {
let t = line.trim();
if t.is_empty() {
continue;
}
if let Some(rest) = t.strip_prefix("# CONFIG_") {
if let Some(name) = rest.strip_suffix(" is not set") {
cfg.values.insert(name.to_string(), ConfigValue::No);
}
continue;
}
if let Some(rest) = t.strip_prefix('#') {
let rest = rest.trim();
if cfg.banner.is_none() && rest.contains("Kernel Configuration") {
cfg.banner = Some(rest.to_string());
}
continue;
}
let Some(rest) = t.strip_prefix("CONFIG_") else {
continue;
};
let Some((name, val)) = rest.split_once('=') else {
continue;
};
let v = match val {
"y" => ConfigValue::Yes,
"m" => ConfigValue::Module,
"n" => ConfigValue::No,
_ if val.starts_with('"') => {
ConfigValue::Str(val.trim_matches('"').to_string())
}
_ => ConfigValue::Num(val.to_string()),
};
cfg.values.insert(name.to_string(), v);
}
cfg
}
pub fn get(&self, name: &str) -> Option<&ConfigValue> {
self.values.get(name)
}
/// `true` sólo si el símbolo está `=y` o `=m`. Ausente cuenta como apagado.
pub fn is_on(&self, name: &str) -> bool {
self.values.get(name).is_some_and(|v| v.is_on())
}
/// Los símbolos encendidos (`=y` o `=m`), en orden.
pub fn enabled(&self) -> impl Iterator<Item = &str> {
self.values
.iter()
.filter(|(_, v)| v.is_on())
.map(|(k, _)| k.as_str())
}
pub fn len(&self) -> usize {
self.values.len()
}
pub fn is_empty(&self) -> bool {
self.values.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"
#
# Automatically generated file; DO NOT EDIT.
# Linux/x86 6.16.12 Kernel Configuration
#
CONFIG_CC_VERSION_TEXT="gcc (GCC) 15.1.1"
CONFIG_64BIT=y
CONFIG_WLAN=m
# CONFIG_WIRELESS is not set
CONFIG_NR_CPUS=64
"#;
#[test]
fn parsea_las_cuatro_formas() {
let c = KernelConfig::parse(SAMPLE);
assert_eq!(c.get("64BIT"), Some(&ConfigValue::Yes));
assert_eq!(c.get("WLAN"), Some(&ConfigValue::Module));
assert_eq!(c.get("WIRELESS"), Some(&ConfigValue::No));
assert_eq!(c.get("NR_CPUS"), Some(&ConfigValue::Num("64".into())));
assert!(matches!(c.get("CC_VERSION_TEXT"), Some(ConfigValue::Str(_))));
assert_eq!(c.banner.as_deref(), Some("Linux/x86 6.16.12 Kernel Configuration"));
}
#[test]
fn modulo_cuenta_como_encendido() {
let c = KernelConfig::parse(SAMPLE);
assert!(c.is_on("WLAN"));
assert!(!c.is_on("WIRELESS"));
// Ausente ≠ apagado explícito, pero para `is_on` los dos son «no está».
assert!(!c.is_on("NO_EXISTE"));
}
}
+439
View File
@@ -0,0 +1,439 @@
//! **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-<versión>`— 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<String>,
#[serde(default, rename = "capability")]
pub capabilities: Vec<Capability>,
#[serde(default, rename = "profile")]
pub profiles: Vec<Profile>,
#[serde(default, rename = "target")]
pub targets: Vec<Target>,
}
/// 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<String>,
/// Qué se puede tocar cuando está: ficheros de cgroup, rutas de `/proc`, syscalls.
#[serde(default)]
pub interface: Vec<String>,
/// 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<String>,
#[serde(default)]
pub notes: Option<String>,
}
/// 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<String>,
/// Capacidades cuya ausencia **bloquea**.
#[serde(default)]
pub requires: Vec<String>,
/// Capacidades que se quieren pero cuya ausencia sólo se informa.
#[serde(default)]
pub wants: Vec<String>,
}
/// 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<String>,
}
/// 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<String>,
/// 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<String>,
pub consumer: String,
#[serde(default)]
pub silent: Option<String>,
}
/// 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<String>,
pub checked: usize,
/// Exigidas y ausentes. Éstas bloquean.
pub missing_required: Vec<CapabilityState>,
/// Queridas y ausentes: se informan y se dejan pasar.
pub missing_wanted: Vec<CapabilityState>,
pub ok: Vec<CapabilityState>,
/// Ids que el perfil nombra y el contrato no define: NO se comprobaron.
#[serde(default)]
pub unknown: Vec<UnknownCapability>,
pub verdict: Verdict,
}
impl Contract {
pub fn load(path: &Path) -> crate::Result<Contract> {
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<Contract> {
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<ContractReport> {
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>-<nombre>`: 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);
}
}
+476
View File
@@ -0,0 +1,476 @@
//! **Gate de no-regresión de hardware, por objetivo** (#6 del handoff, §5 del SDD 22).
//!
//! La regla, en una línea: *todo dispositivo en uso ahora mismo debe seguir teniendo driver en el
//! config nuevo*. No es un consejo, es un portón. Corre en un segundo, sin IA y sin VM, y cubre la
//! mayoría de los desastres de «me quedé sin red / sin teclado».
//!
//! ## Por qué POR OBJETIVO y no global
//! `recipes/linux.toml` apaga `USB_SUPPORT`, `HID`, `INPUT_MOUSE` **a propósito**: es el kernel de
//! QEMU con consola serie, donde nadie va a tocar la máquina. Un gate global «todo dispositivo en
//! uso debe seguir teniendo driver» **rechazaría una receta sana**. El objetivo declara qué
//! pérdidas son legítimas; el portón se queda, sólo que en el sitio correcto.
//!
//! ## Y la máquina destino no es la de build
//! Los drivers en uso se pasan como dato ([`Hardware::bound_drivers`]), así que pueden venir de la
//! máquina donde el kernel va a correr y no de la que lo compila.
//!
//! ## Lo que el gate NO puede comprobar, lo dice
//! Un driver que no se pueda mapear a ningún símbolo queda en [`GateReport::unmapped`] y **no**
//! cuenta como aprobado. Un portón que calla lo que no pudo mirar no es un portón.
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use super::catalog::Catalog;
use super::kconfig::KconfigTree;
use super::modmap::ModMap;
use super::plan::{Action, Plan};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Verdict {
Pasa,
Bloquea,
}
/// Un driver en uso que el plan dejaría sin símbolo.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DriverLoss {
pub driver: String,
/// Los símbolos que lo construyen. Se pierde sólo si caen TODOS.
pub symbols: Vec<String>,
/// Qué bundle(s) del plan se lo llevan por delante.
pub blamed_on: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GateReport {
pub objective: String,
pub plan: String,
/// Cuántos drivers en uso se miraron.
pub drivers_in_use: usize,
/// Pérdidas que el objetivo **no** autoriza. Éstas bloquean.
pub regressions: Vec<DriverLoss>,
/// Pérdidas autorizadas por el objetivo: se informan y se dejan pasar.
pub allowed: Vec<DriverLoss>,
/// Drivers que no se pudieron mapear a ningún `CONFIG_*`: el gate NO los comprobó.
pub unmapped: Vec<String>,
pub verdict: Verdict,
}
/// Corre el gate.
///
/// `drivers_in_use` son los nombres tal como salen de `/sys` (con `_`).
pub fn run(
plan: &Plan,
cat: &Catalog,
tree: &KconfigTree,
modmap: &ModMap,
drivers_in_use: &BTreeSet<String>,
objective_id: &str,
) -> crate::Result<GateReport> {
let obj = cat.objective(objective_id).ok_or_else(|| {
crate::Error::Recipe(format!(
"no existe el objetivo «{objective_id}» en el catálogo. \
El gate NO corre sin objetivo declarado: uno global rechazaría recetas sanas."
))
})?;
// Lo que el plan apaga, con su clausura. Se recalcula desde las raíces del plan: es la misma
// cuenta que hará `olddefconfig`, no una copia que pueda desincronizarse.
let roots: Vec<String> = plan
.symbols
.iter()
.filter(|s| s.action == Action::Disable)
.map(|s| s.symbol.clone())
.collect();
let apagados = tree.closure_off(&roots);
// Atribución: qué bundle se lleva cada símbolo. Un símbolo puede caer por varios.
let mut culpa: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for id in &plan.bundles {
let Some(b) = cat.bundle(id) else { continue };
let mut r = b.disable.clone();
r.extend(b.close_leaks.iter().cloned());
for s in tree.closure_off(&r) {
if let Some(k) = apagados.get(&s) {
culpa.entry(k.as_str()).or_default().push(id.as_str());
}
}
}
let permitidos: BTreeSet<&str> = obj.allow_bundles.iter().map(|s| s.as_str()).collect();
let mut rep = GateReport {
objective: objective_id.to_string(),
plan: plan.derived_name.clone(),
drivers_in_use: drivers_in_use.len(),
regressions: Vec::new(),
allowed: Vec::new(),
unmapped: Vec::new(),
verdict: Verdict::Pasa,
};
for drv in drivers_in_use {
let Some(syms) = modmap.symbols_for(drv) else {
rep.unmapped.push(drv.clone());
continue;
};
// Sobrevive si le queda AL MENOS UN símbolo en pie: un módulo puede construirse desde
// varias opciones y basta una.
if syms.iter().any(|s| !apagados.contains(s)) {
continue;
}
let mut culpables: Vec<String> = syms
.iter()
.filter_map(|s| culpa.get(s.as_str()))
.flatten()
.map(|s| s.to_string())
.collect();
culpables.sort();
culpables.dedup();
let loss = DriverLoss {
driver: drv.clone(),
symbols: syms.iter().cloned().collect(),
blamed_on: culpables.clone(),
};
// Autorizado sólo si TODO lo que se lo lleva está permitido por el objetivo. Si algún
// culpable no lo está —o si no se pudo atribuir a ninguno— es regresión.
let autorizado = !culpables.is_empty()
&& culpables.iter().all(|c| permitidos.contains(c.as_str()));
if autorizado {
rep.allowed.push(loss);
} else {
rep.regressions.push(loss);
}
}
if !rep.regressions.is_empty() {
rep.verdict = Verdict::Bloquea;
}
Ok(rep)
}
/// El gate contra un `.config` **ya producido**, midiendo contra el que corre hoy.
///
/// Es una pregunta distinta —y más fuerte— que [`run`]. Aquélla mira lo que el **plan** apaga, así
/// que sólo ve regresiones que introduce el plan: un hueco que ya venía en la receta base le pasa
/// por debajo. Ésta compara dos configs y define regresión como **«funcionaba y dejó de
/// funcionar»**, que es la formulación literal del #6 del handoff.
///
/// El referente natural es `/proc/config.gz`: el kernel que arrancó esta máquina es la prueba viva
/// de qué hace falta para arrancarla.
///
/// # Por qué comparar dos configs y no mirar sólo el nuevo
/// Los nombres de módulo cortos colisionan: el driver que `/sys` llama `usb` mapea a `QE_USB` (el
/// USB de las QUICC Engine de Freescale) y `port` a `PORT_CHAN`. Mirando sólo el config nuevo, esos
/// aparecen como «perdidos» y el gate bloquearía un plan sano. Exigiendo que **estuvieran
/// encendidos en el referente** el falso positivo desaparece solo: lo que nunca estuvo no puede
/// regresar.
pub fn run_against_config(
baseline: &super::KernelConfig,
produced: &super::KernelConfig,
cat: &Catalog,
modmap: &ModMap,
drivers_in_use: &BTreeSet<String>,
objective_id: &str,
plan: Option<&Plan>,
tree: Option<&KconfigTree>,
) -> crate::Result<GateReport> {
let obj = cat.objective(objective_id).ok_or_else(|| {
crate::Error::Recipe(format!(
"no existe el objetivo «{objective_id}» en el catálogo. \
El gate NO corre sin objetivo declarado: uno global rechazaría recetas sanas."
))
})?;
let permitidos: BTreeSet<&str> = obj.allow_bundles.iter().map(|s| s.as_str()).collect();
// Atribución opcional: con el plan y el árbol se puede decir QUÉ bundle se lo llevó.
let mut culpa: BTreeMap<String, Vec<String>> = BTreeMap::new();
if let (Some(plan), Some(tree)) = (plan, tree) {
for id in &plan.bundles {
let Some(b) = cat.bundle(id) else { continue };
let mut r = b.disable.clone();
r.extend(b.close_leaks.iter().cloned());
for s in tree.closure_off(&r) {
culpa.entry(s).or_default().push(id.clone());
}
}
}
let mut rep = GateReport {
objective: objective_id.to_string(),
plan: plan.map(|p| p.derived_name.clone()).unwrap_or_default(),
drivers_in_use: drivers_in_use.len(),
regressions: Vec::new(),
allowed: Vec::new(),
unmapped: Vec::new(),
verdict: Verdict::Pasa,
};
for drv in drivers_in_use {
let Some(syms) = modmap.symbols_for(drv) else {
rep.unmapped.push(drv.clone());
continue;
};
// Sólo cuentan los símbolos que HOY lo sostienen. Si ninguno estaba encendido en el
// referente, este driver no viene de ahí (o el mapa colisionó) y no hay nada que proteger.
let sostenes: Vec<&String> = syms.iter().filter(|s| baseline.is_on(s)).collect();
if sostenes.is_empty() {
rep.unmapped.push(drv.clone());
continue;
}
if sostenes.iter().any(|s| produced.is_on(s)) {
continue;
}
let mut culpables: Vec<String> = sostenes
.iter()
.filter_map(|s| culpa.get(s.as_str()))
.flatten()
.cloned()
.collect();
culpables.sort();
culpables.dedup();
let loss = DriverLoss {
driver: drv.clone(),
symbols: sostenes.iter().map(|s| (*s).clone()).collect(),
blamed_on: culpables.clone(),
};
let autorizado =
!culpables.is_empty() && culpables.iter().all(|c| permitidos.contains(c.as_str()));
if autorizado {
rep.allowed.push(loss);
} else {
rep.regressions.push(loss);
}
}
if !rep.regressions.is_empty() {
rep.verdict = Verdict::Bloquea;
}
Ok(rep)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::kernel::plan::make_plan;
use crate::recipe::Recipe;
fn escenario() -> (tempfile::TempDir, KconfigTree, Catalog, Recipe, ModMap) {
let d = tempfile::tempdir().unwrap();
std::fs::write(
d.path().join("Kconfig"),
r#"
config WIRELESS
bool "Wireless"
config IWLWIFI
tristate "iwlwifi"
depends on WIRELESS
config USB_SUPPORT
bool "USB"
config USB_HID
tristate "usbhid"
depends on USB_SUPPORT
config E1000
tristate "e1000"
"#,
)
.unwrap();
let tree = KconfigTree::parse(d.path(), &std::collections::BTreeMap::new()).unwrap();
let mut modmap = ModMap::default();
std::fs::write(
d.path().join("Makefile"),
"obj-$(CONFIG_IWLWIFI) += iwlwifi.o\n\
obj-$(CONFIG_USB_HID) += usbhid.o\n\
obj-$(CONFIG_E1000) += e1000.o\n",
)
.unwrap();
modmap = ModMap::parse(d.path()).unwrap_or(modmap);
let cat: Catalog = toml::from_str(
r#"
version = 1
[[bundle]]
id = "sin-wifi"
title = "Sin wifi"
disable = ["WIRELESS"]
[[bundle]]
id = "sin-usb"
title = "Sin USB"
disable = ["USB_SUPPORT"]
[[objective]]
id = "qemu-serial"
title = "VM con consola serie"
allow_bundles = ["sin-usb"]
[[objective]]
id = "metal"
title = "Metal"
allow_bundles = []
"#,
)
.unwrap();
let recipe: Recipe = toml::from_str(
r#"
name = "linux"
version = "6.16.12"
[source]
tarball = "https://x/l.tar.gz"
sha256 = "aa"
[build]
compiler = "zig-cc"
target = "x86_64-linux-musl"
link = "dynamic"
[build.phases]
configure = "make ARCH=x86_64 defconfig && make ARCH=x86_64 olddefconfig"
"#,
)
.unwrap();
(d, tree, cat, recipe, modmap)
}
fn drivers(v: &[&str]) -> BTreeSet<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn perder_un_driver_en_uso_bloquea() {
let (_d, tree, cat, base, mm) = escenario();
let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
let r = run(&p, &cat, &tree, &mm, &drivers(&["iwlwifi", "e1000"]), "metal").unwrap();
assert_eq!(r.verdict, Verdict::Bloquea);
assert_eq!(r.regressions.len(), 1);
assert_eq!(r.regressions[0].driver, "iwlwifi");
assert_eq!(r.regressions[0].blamed_on, vec!["sin-wifi"]);
}
#[test]
fn el_objetivo_autoriza_la_perdida_que_declara() {
let (_d, tree, cat, base, mm) = escenario();
let p = make_plan(&base, "r", &cat, &tree, &["sin-usb".into()], &[]).unwrap();
// El MISMO plan: bloquea en metal y pasa en qemu-serial. Ése es todo el punto del §5.
let metal = run(&p, &cat, &tree, &mm, &drivers(&["usbhid"]), "metal").unwrap();
assert_eq!(metal.verdict, Verdict::Bloquea);
let qemu = run(&p, &cat, &tree, &mm, &drivers(&["usbhid"]), "qemu-serial").unwrap();
assert_eq!(qemu.verdict, Verdict::Pasa);
assert_eq!(qemu.allowed.len(), 1);
}
#[test]
fn el_driver_que_no_se_pudo_mapear_no_cuenta_como_aprobado() {
let (_d, tree, cat, base, mm) = escenario();
let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
let r = run(&p, &cat, &tree, &mm, &drivers(&["misterio"]), "metal").unwrap();
assert_eq!(r.unmapped, vec!["misterio"]);
// No está ni en pérdidas ni en aprobados: está declarado como no comprobado.
assert!(r.regressions.is_empty() && r.allowed.is_empty());
}
#[test]
fn sin_objetivo_declarado_el_gate_no_corre() {
let (_d, tree, cat, base, mm) = escenario();
let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
let e = run(&p, &cat, &tree, &mm, &drivers(&["iwlwifi"]), "inventado")
.unwrap_err()
.to_string();
assert!(e.contains("inventado"), "{e}");
}
#[test]
fn un_driver_intacto_no_molesta() {
let (_d, tree, cat, base, mm) = escenario();
let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
let r = run(&p, &cat, &tree, &mm, &drivers(&["e1000"]), "metal").unwrap();
assert_eq!(r.verdict, Verdict::Pasa);
assert!(r.regressions.is_empty() && r.allowed.is_empty() && r.unmapped.is_empty());
}
}
#[cfg(test)]
mod tests_contra_config {
use super::*;
use crate::kernel::KernelConfig;
fn catalogo() -> Catalog {
toml::from_str(
r#"
version = 1
[[bundle]]
id = "sin-wifi"
title = "Sin wifi"
disable = ["WIRELESS"]
[[objective]]
id = "servidor"
title = "Servidor"
allow_bundles = []
"#,
)
.unwrap()
}
fn mm() -> ModMap {
let mut m = ModMap::default();
super::super::modmap::tests_support::feed(
&mut m,
"obj-$(CONFIG_IWLWIFI) += iwlwifi.o\n\
obj-$(CONFIG_VIRTIO_BALLOON) += virtio_balloon.o\n\
obj-$(CONFIG_QE_USB) += usb.o\n",
);
m
}
fn drv(v: &[&str]) -> BTreeSet<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn regresion_es_estaba_encendido_y_dejo_de_estarlo() {
let base = KernelConfig::parse("CONFIG_VIRTIO_BALLOON=y\n");
let nuevo = KernelConfig::parse("# CONFIG_VIRTIO_BALLOON is not set\n");
let r = run_against_config(
&base, &nuevo, &catalogo(), &mm(), &drv(&["virtio_balloon"]), "servidor", None, None,
)
.unwrap();
assert_eq!(r.verdict, Verdict::Bloquea);
assert_eq!(r.regressions[0].driver, "virtio_balloon");
}
/// El driver que `/sys` llama `usb` mapea a `QE_USB` (Freescale). Mirando sólo el config nuevo
/// aparecería como perdido y bloquearía un plan sano; exigiendo que estuviera encendido en el
/// referente, el falso positivo se cae solo.
#[test]
fn la_colision_de_nombres_no_produce_falso_positivo() {
let base = KernelConfig::parse("# CONFIG_QE_USB is not set\n");
let nuevo = KernelConfig::parse("# CONFIG_QE_USB is not set\n");
let r = run_against_config(
&base, &nuevo, &catalogo(), &mm(), &drv(&["usb"]), "servidor", None, None,
)
.unwrap();
assert_eq!(r.verdict, Verdict::Pasa);
assert!(r.regressions.is_empty());
// Queda declarado como NO comprobado, que es la verdad: no sabemos de dónde sale.
assert_eq!(r.unmapped, vec!["usb"]);
}
#[test]
fn lo_que_sigue_encendido_no_molesta() {
let base = KernelConfig::parse("CONFIG_IWLWIFI=m\n");
let nuevo = KernelConfig::parse("CONFIG_IWLWIFI=y\n");
let r = run_against_config(
&base, &nuevo, &catalogo(), &mm(), &drv(&["iwlwifi"]), "servidor", None, None,
)
.unwrap();
assert_eq!(r.verdict, Verdict::Pasa);
}
}
+350
View File
@@ -0,0 +1,350 @@
//! 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());
}
}
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
//! El armador de kernel: catálogo de bundles, clausuras sobre Kconfig y modo reversa.
//!
//! Implementa `docs/22-configurador-kernel.md`, que a su vez contesta
//! `tawasuyu/HANDOFF-KERNEL-CONFIG-A-HAMMER.md`.
//!
//! **El hecho que ordena todo el módulo** (SDD 22 §1): las fases de build entran en
//! `Recipe::hash_inputs`, y el config del kernel vive en la fase `configure` ⇒ **el config ES la
//! identidad del artefacto**. Cambiar un símbolo cambia el `ArtifactHash`. De ahí dos consecuencias
//! que el código respeta literalmente:
//!
//! 1. Una «perilla» de la UI **no es un parámetro de runtime, es una edición de receta**. Por eso
//! `plan` emite una **receta derivada**, no un binario parametrizable.
//! 2. Atestar «este config booteó en esta huella» sale casi gratis: ya es un artefacto CAS.
//!
//! Y la regla dura que nunca se cruza: **hammer no resuelve Kconfig**. Emite fragmentos
//! (`scripts/config -e/-d`) y deja que el `olddefconfig` del propio kernel produzca el `.config`.
//! Ver [`kconfig`].
pub mod catalog;
pub mod config;
pub mod contract;
pub mod gate;
pub mod hw;
pub mod kconfig;
pub mod modmap;
pub mod plan;
pub mod reverse;
pub use catalog::{Bundle, Catalog, Knob, Objective, Side};
pub use gate::{GateReport, Verdict};
pub use modmap::ModMap;
pub use config::{ConfigValue, KernelConfig};
pub use contract::{Capability, Contract, ContractReport, Profile as ContractProfile};
pub use hw::Hardware;
pub use kconfig::{Expr, KconfigTree, SelectLeak, SymKind, Symbol};
pub use plan::{diff_back, make_plan, DiffBack, Plan};
pub use reverse::{BundleState, HwSignal, ReverseReport};
+207
View File
@@ -0,0 +1,207 @@
//! Mapa **driver → símbolo de Kconfig**, leído de los Makefiles de kbuild.
//!
//! Es la pieza que le faltaba al gate de no-regresión (#6 del handoff). El kernel sabe qué driver
//! tiene bindeado cada dispositivo (`/sys/bus/*/devices/*/driver`), pero **no** dice de qué
//! `CONFIG_*` salió: esa relación sólo existe en los Makefiles, en la forma
//!
//! ```make
//! obj-$(CONFIG_SND_HDA_INTEL) += snd-hda-intel.o
//! ```
//!
//! ## Dos trampas de nombres
//! 1. **El módulo cargado usa `_` donde el fichero usa `-`**: `snd-hda-intel.o` se ve en `/sys` como
//! `snd_hda_intel`. Se indexa normalizado, o el gate no encontraría nada y diría que todo está
//! bien — que es el peor resultado posible para un portón.
//! 2. **Un módulo puede salir de más de un símbolo** (se declara en varios Makefiles o bajo varias
//! condiciones). Se guardan todos: el driver sobrevive si sobrevive **cualquiera** de ellos.
//!
//! ## Lo que este mapa NO sabe
//! `obj-y += foo.o` (built-in incondicional) no lleva símbolo: ese driver está siempre y no hay
//! nada que proteger. Y hay drivers que ningún `obj-$(CONFIG_…)` nombra — los que el mapa no
//! cubre se reportan como **hueco**, nunca como «está bien». Un gate que calla lo que no pudo
//! comprobar no es un gate.
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
/// `driver normalizado → símbolos que lo construyen`.
#[derive(Debug, Clone, Default)]
pub struct ModMap {
pub by_module: BTreeMap<String, BTreeSet<String>>,
/// Cuántos Makefiles se leyeron.
pub files: usize,
}
impl ModMap {
/// Recorre el árbol buscando `Makefile*` y extrae las reglas `obj-$(CONFIG_X) += y.o`.
pub fn parse(root: &Path) -> std::io::Result<ModMap> {
let mut m = ModMap::default();
walk(root, &mut m)?;
Ok(m)
}
/// Símbolos que construyen este driver. Acepta el nombre tal cual sale de `/sys` (con `_`).
pub fn symbols_for(&self, driver: &str) -> Option<&BTreeSet<String>> {
self.by_module.get(&normalize(driver))
}
pub fn len(&self) -> usize {
self.by_module.len()
}
pub fn is_empty(&self) -> bool {
self.by_module.is_empty()
}
}
fn walk(dir: &Path, m: &mut ModMap) -> std::io::Result<()> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Ok(());
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
walk(&p, m)?;
continue;
}
let name = e.file_name();
let name = name.to_string_lossy();
if !name.starts_with("Makefile") && !name.starts_with("Kbuild") {
continue;
}
if let Ok(text) = std::fs::read_to_string(&p) {
m.files += 1;
parse_makefile(&text, m);
}
}
Ok(())
}
fn parse_makefile(text: &str, m: &mut ModMap) {
for line in joined_lines(text) {
let t = line.trim();
let Some(rest) = t.strip_prefix("obj-$(CONFIG_") else {
continue;
};
let Some(close) = rest.find(')') else { continue };
let symbol = &rest[..close];
if symbol.is_empty() || !symbol.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') {
continue;
}
// `+=`, `:=` o `=`.
let after = &rest[close + 1..];
let Some(eq) = after.find('=') else { continue };
let targets = &after[eq + 1..];
for tok in targets.split_whitespace() {
// Los directorios (`foo/`) los cubre el Makefile de dentro.
let Some(obj) = tok.strip_suffix(".o") else {
continue;
};
// `subdir/mod.o` — el nombre del módulo es el último componente.
let obj = obj.rsplit('/').next().unwrap_or(obj);
if obj.is_empty() || obj.contains('$') {
continue;
}
m.by_module
.entry(normalize(obj))
.or_default()
.insert(symbol.to_string());
}
}
}
/// Une las continuaciones `\` de make.
fn joined_lines(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut acc = String::new();
for line in text.lines() {
match line.strip_suffix('\\') {
Some(head) => {
acc.push_str(head);
acc.push(' ');
}
None => {
acc.push_str(line);
out.push(std::mem::take(&mut acc));
}
}
}
if !acc.is_empty() {
out.push(acc);
}
out
}
/// El nombre con el que el módulo aparece en `/sys`: guiones a guiones bajos.
fn normalize(s: &str) -> String {
s.replace('-', "_")
}
/// Alimentar un `ModMap` desde texto, para los tests de otros módulos.
#[cfg(test)]
pub(crate) mod tests_support {
pub fn feed(m: &mut super::ModMap, texto: &str) {
super::parse_makefile(texto, m);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extrae_las_tres_formas_de_asignacion() {
let mut m = ModMap::default();
parse_makefile(
"obj-$(CONFIG_SND_HDA) := snd-hda-codec.o\n\
obj-$(CONFIG_E1000) += e1000.o\n\
obj-$(CONFIG_FOO)=foo.o\n",
&mut m,
);
assert!(m.symbols_for("snd_hda_codec").unwrap().contains("SND_HDA"));
assert!(m.symbols_for("e1000").unwrap().contains("E1000"));
assert!(m.symbols_for("foo").unwrap().contains("FOO"));
}
#[test]
fn el_guion_bajo_de_sys_encuentra_al_guion_del_fichero() {
let mut m = ModMap::default();
parse_makefile("obj-$(CONFIG_SND_HDA_INTEL) += snd-hda-intel.o\n", &mut m);
// Así es como sale de /sys/bus/pci/devices/*/driver.
assert!(m.symbols_for("snd_hda_intel").is_some());
}
#[test]
fn un_modulo_puede_venir_de_varios_simbolos() {
let mut m = ModMap::default();
parse_makefile("obj-$(CONFIG_A) += x.o\n", &mut m);
parse_makefile("obj-$(CONFIG_B) += x.o\n", &mut m);
let s = m.symbols_for("x").unwrap();
assert_eq!(s.len(), 2);
}
#[test]
fn los_directorios_y_los_obj_y_no_entran() {
let mut m = ModMap::default();
parse_makefile(
"obj-$(CONFIG_NET) += core/\nobj-y += siempre.o\nobj-$(CONFIG_X) += $(VAR).o\n",
&mut m,
);
assert!(m.is_empty(), "{:?}", m.by_module);
}
#[test]
fn la_continuacion_de_barra_se_une() {
let mut m = ModMap::default();
parse_makefile("obj-$(CONFIG_X) += a.o \\\n\tb.o\n", &mut m);
assert!(m.symbols_for("a").is_some());
assert!(m.symbols_for("b").is_some());
}
#[test]
fn el_modulo_en_subdirectorio_se_nombra_por_el_ultimo_componente() {
let mut m = ModMap::default();
parse_makefile("obj-$(CONFIG_X) += sub/dir/mod.o\n", &mut m);
assert!(m.symbols_for("mod").is_some());
}
}
+594
View File
@@ -0,0 +1,594 @@
//! `plan` — de una selección de bundles a una **receta derivada**.
//!
//! ## Por qué una receta y no un parámetro
//! Las fases de build entran en `Recipe::hash_inputs` y el config del kernel vive en la fase
//! `configure` ⇒ **el config ES la identidad del artefacto** (SDD 22 §1). No hay forma de tener un
//! config variable sin generar recetas: una «perilla» de la UI no es un parámetro de runtime, es
//! una edición de receta. Fingir lo contrario daría un store que dice que todo está al día mientras
//! los artefactos ya no corresponden — en un sistema direccionado por contenido, el peor fallo
//! posible no es fallar, es **mentir**.
//!
//! De ahí sale gratis lo que §7 del handoff pedía: el plan **determina** el artefacto, así que el
//! JSON lleva su `ArtifactHash` y la UI puede decir «esto ya está construido y firmado» sin
//! construir nada.
//!
//! ## Qué se emite y qué no
//! Se emiten **raíces**, no clausuras. `-d WIRELESS -d WLAN` son dos banderas; los ~400 símbolos
//! que caen con ellas los calcula el `olddefconfig` **del propio kernel**. hammer sabe cuáles son
//! (para poder explicarlos), pero no los escribe: la app nunca escribe un `.config`.
//!
//! ## Procedencia por símbolo
//! Cada símbolo emitido carga quién lo pidió ([`PlannedSymbol::from`]). Es el #7 del handoff, y con
//! él el diff entre dos generaciones deja de ser «347 símbolos cambiaron» para ser «activaste
//! bluetooth y entraron 12 símbolos».
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::catalog::{Catalog, Side};
use super::kconfig::KconfigTree;
use crate::recipe::Recipe;
/// Qué se le pide a `scripts/config` para un símbolo.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Action {
Enable,
Disable,
}
impl Action {
fn flag(self) -> &'static str {
match self {
Action::Enable => "-e",
Action::Disable => "-d",
}
}
}
/// Un símbolo emitido, con su procedencia.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlannedSymbol {
pub symbol: String,
pub action: Action,
/// `bundle:sin-wifi`, `knob:tick-1000`.
pub from: String,
/// Por qué está: raíz declarada, o fuga `select` que hubo que cerrar.
pub reason: String,
}
/// Una perilla que **no** cae del lado de Kconfig: el plan la declara y no la aplica.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecipeKnob {
pub id: String,
pub title: String,
pub recipe_field: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Plan {
pub base_recipe: String,
pub base_name: String,
pub derived_name: String,
pub bundles: Vec<String>,
pub knobs: Vec<String>,
/// Símbolos que se emiten, con procedencia.
pub symbols: Vec<PlannedSymbol>,
/// **Diff-back estático**: pedidos que no existen en este árbol. Un `-d` sobre uno de éstos es
/// un no-op que nadie ve. Le pasaba a `THUNDERBOLT` y a `REISERFS_FS` en las cuatro recetas de
/// kernel del repo hasta que se corrigieron (`2602218`), que es cómo se descubrió el patrón.
pub unknown_symbols: Vec<PlannedSymbol>,
/// Perillas `side = "recipe"` seleccionadas: declaradas, no aplicadas.
pub recipe_side: Vec<RecipeKnob>,
/// Cuántos símbolos apaga de verdad la selección, contando la clausura. Es informativo: quien
/// la calcula de vuelta, y manda, es `olddefconfig`.
pub closure_size: usize,
/// La invocación de `scripts/config` que se inserta.
pub fragment: String,
/// La fase `configure` derivada, entera.
pub configure: String,
/// El `ArtifactHash` de la receta derivada. `None` si no se pudo calcular (deps sin resolver).
#[serde(skip_serializing_if = "Option::is_none")]
pub artifact_hash: Option<String>,
}
/// Construye el plan. No toca el disco.
pub fn make_plan(
base: &Recipe,
base_path: &str,
cat: &Catalog,
tree: &KconfigTree,
bundle_ids: &[String],
knob_ids: &[String],
) -> crate::Result<Plan> {
let mut wanted: BTreeMap<String, PlannedSymbol> = BTreeMap::new();
let mut disable_roots: Vec<String> = Vec::new();
let push = |wanted: &mut BTreeMap<String, PlannedSymbol>,
sym: &str,
action: Action,
from: String,
reason: &str|
-> crate::Result<()> {
// Dos selecciones que se contradicen sobre el mismo símbolo no se resuelven por orden de
// aparición: se rechazan. El orden sería una respuesta plausible y arbitraria.
if let Some(prev) = wanted.get(sym) {
if prev.action != action {
return Err(crate::Error::Recipe(format!(
"conflicto en {sym}: «{}» lo quiere {:?} y «{from}» lo quiere {:?}",
prev.from, prev.action, action
)));
}
return Ok(());
}
wanted.insert(
sym.to_string(),
PlannedSymbol {
symbol: sym.to_string(),
action,
from,
reason: reason.to_string(),
},
);
Ok(())
};
for id in bundle_ids {
let b = cat.bundle(id).ok_or_else(|| {
crate::Error::Recipe(format!("no existe el bundle «{id}» en el catálogo"))
})?;
let from = format!("bundle:{id}");
for s in &b.disable {
push(&mut wanted, s, Action::Disable, from.clone(), "raíz del bundle")?;
disable_roots.push(s.clone());
}
for s in &b.close_leaks {
push(
&mut wanted,
s,
Action::Disable,
from.clone(),
"fuga `select` cerrada: sin esto el olddefconfig lo reenciende",
)?;
disable_roots.push(s.clone());
}
for s in &b.enable {
push(&mut wanted, s, Action::Enable, from.clone(), "raíz del bundle")?;
}
}
let mut recipe_side = Vec::new();
for id in knob_ids {
let k = cat.knob(id).ok_or_else(|| {
crate::Error::Recipe(format!("no existe la perilla «{id}» en el catálogo"))
})?;
let from = format!("knob:{id}");
if k.side == Side::Recipe {
recipe_side.push(RecipeKnob {
id: k.id.clone(),
title: k.title.clone(),
recipe_field: k.recipe_field.clone().unwrap_or_default(),
});
continue;
}
for s in &k.enable {
push(&mut wanted, s, Action::Enable, from.clone(), "perilla")?;
}
for s in &k.disable {
push(&mut wanted, s, Action::Disable, from.clone(), "perilla")?;
disable_roots.push(s.clone());
}
}
// Un `enable` que cae DENTRO de lo que otro bundle apaga es un conflicto que el símbolo solo no
// delata: `olddefconfig` lo descartaría en silencio por dependencia insatisfecha.
let closure = tree.closure_off(&disable_roots);
for p in wanted.values() {
if p.action == Action::Enable && closure.contains(&p.symbol) {
return Err(crate::Error::Recipe(format!(
"conflicto: «{}» quiere encender {} pero cae dentro de lo que apaga la selección \
(el olddefconfig lo descartaría sin decir nada)",
p.from, p.symbol
)));
}
}
let (known, unknown_symbols): (Vec<_>, Vec<_>) = wanted
.into_values()
.partition(|p| tree.symbols.contains_key(&p.symbol));
let arch = detect_arch(base);
let fragment = render_fragment(&known);
let base_configure = base
.build
.phases
.configure
.as_deref()
.ok_or_else(|| {
crate::Error::Recipe(format!(
"la receta base «{}» no tiene fase `configure`: no hay dónde insertar el config",
base.name
))
})?
.trim_end()
.trim_end_matches('\\')
.trim_end()
.to_string();
// Se AÑADE una segunda ronda en vez de reescribir la fase base: no hay que parsear el shell de
// nadie, la base sigue siendo literalmente la de siempre en el diff, y `olddefconfig` es
// idempotente. Lo que la app aporta es un fragmento; resolver sigue siendo del kernel.
let configure = format!("{base_configure} && \\\n{fragment} && \\\nmake ARCH={arch} olddefconfig\n");
let derived_name = derive_name(&base.name, bundle_ids, knob_ids);
Ok(Plan {
base_recipe: base_path.to_string(),
base_name: base.name.clone(),
derived_name,
bundles: bundle_ids.to_vec(),
knobs: knob_ids.to_vec(),
symbols: known,
unknown_symbols,
recipe_side,
closure_size: closure.len(),
fragment,
configure,
artifact_hash: None,
})
}
/// La receta derivada: la base con la fase `configure` del plan y un nombre propio.
///
/// Conserva el `base_dir` de la base a propósito — es lo que hace resolubles sus `deps.build` y,
/// con ellas, calculable el `ArtifactHash`.
pub fn derive_recipe(base: &Recipe, plan: &Plan) -> Recipe {
let mut r = base.clone();
r.name = plan.derived_name.clone();
r.build.phases.configure = Some(plan.configure.clone());
r
}
/// Serializa la receta derivada a TOML. Vive acá y no en la CLI porque el formato de receta es del
/// core: quien lo cambie tiene que cambiarlo en un solo sitio.
pub fn render_recipe_toml(r: &Recipe) -> crate::Result<String> {
toml::to_string_pretty(r).map_err(|e| crate::Error::Serde(e.to_string()))
}
fn detect_arch(base: &Recipe) -> String {
let hay = base
.build
.phases
.configure
.as_deref()
.unwrap_or_default();
for tok in hay.split_whitespace() {
if let Some(v) = tok.strip_prefix("ARCH=") {
if !v.is_empty() {
return v.to_string();
}
}
}
"x86_64".to_string()
}
/// `scripts/config -d A -d B -e C …`, en líneas de ancho razonable y **orden estable**: el texto
/// entra al hash, así que un orden que dependiera del recorrido daría dos hashes para el mismo plan.
fn render_fragment(syms: &[PlannedSymbol]) -> String {
let mut out = String::from("scripts/config");
let mut col = out.len();
for p in syms {
let pieza = format!(" {} {}", p.action.flag(), p.symbol);
if col + pieza.len() > 96 {
out.push_str(" \\\n ");
col = 1;
}
col += pieza.len();
out.push_str(&pieza);
}
out
}
/// Nombre de la receta derivada. Lleva un hash corto de la SELECCIÓN, no del contenido: dos planes
/// con los mismos bundles dan el mismo nombre, y el `ArtifactHash` sigue siendo quien manda.
fn derive_name(base: &str, bundles: &[String], knobs: &[String]) -> String {
let mut material = String::new();
let mut b: Vec<&String> = bundles.iter().collect();
b.sort();
for x in b {
material.push_str("bundle:");
material.push_str(x);
material.push('\n');
}
let mut k: Vec<&String> = knobs.iter().collect();
k.sort();
for x in k {
material.push_str("knob:");
material.push_str(x);
material.push('\n');
}
let h = crate::hash::ArtifactHash::of_bytes(material.as_bytes());
let hex = h.as_str().trim_start_matches("b3:");
format!("{base}-plan-{}", &hex[..8])
}
/// **Diff-back** (§6 del handoff, la mitad que faltaba): ¿sobrevivió cada símbolo pedido al
/// `olddefconfig`?
///
/// Sin esto la UI miente: un `-e FOO` cuya dependencia no se cumple se pierde en silencio.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffBack {
pub honored: Vec<String>,
/// Pedidos que el `.config` resultante contradice. **Cada uno es una promesa incumplida.**
pub violated: Vec<Violation>,
/// Apagados que el `.config` ni menciona. **Esto es éxito, no fallo**: Kconfig no emite un
/// símbolo cuyas dependencias no se cumplen, así que un `-d` sobre algo que ya cayó con su
/// raíz simplemente no aparece. Contarlo como incumplido —el error que cometí antes de correr
/// un `olddefconfig` de verdad— hace que un plan perfecto se reporte como roto.
pub off_by_dependency: Vec<String>,
/// Símbolos que no existen en este árbol: la bandera no hace nada. Ya se sabía al planear; se
/// repite acá porque quien lee el diff-back no siempre vio el plan.
pub never_existed: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Violation {
pub symbol: String,
pub asked: Action,
/// Cómo quedó de verdad.
pub got: String,
pub from: String,
}
impl DiffBack {
/// Limpio = ninguna promesa incumplida. Los `off_by_dependency` son el resultado buscado y los
/// `never_existed` ya los reportó el plan: ninguno de los dos ensucia el veredicto.
pub fn is_clean(&self) -> bool {
self.violated.is_empty()
}
}
/// Contrasta el plan contra el `.config` que produjo el kernel.
pub fn diff_back(plan: &Plan, produced: &super::KernelConfig) -> DiffBack {
use super::ConfigValue;
let mut d = DiffBack {
honored: Vec::new(),
violated: Vec::new(),
off_by_dependency: Vec::new(),
never_existed: Vec::new(),
};
for p in &plan.symbols {
match (p.action, produced.get(&p.symbol)) {
// Ausente + se pedía apagar = está apagado. Kconfig omite lo que no puede existir.
(Action::Disable, None) => d.off_by_dependency.push(p.symbol.clone()),
// Ausente + se pedía encender = NO está. Eso sí es una promesa rota.
(Action::Enable, None) => d.violated.push(Violation {
symbol: p.symbol.clone(),
asked: Action::Enable,
got: "ausente del .config".to_string(),
from: p.from.clone(),
}),
(Action::Enable, Some(v)) if v.is_on() => d.honored.push(p.symbol.clone()),
(Action::Disable, Some(ConfigValue::No)) => d.honored.push(p.symbol.clone()),
(asked, Some(v)) => d.violated.push(Violation {
symbol: p.symbol.clone(),
asked,
got: format!("{v:?}"),
from: p.from.clone(),
}),
}
}
d.never_existed = plan
.unknown_symbols
.iter()
.map(|p| p.symbol.clone())
.collect();
d.never_existed.sort();
d.never_existed.dedup();
d
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn escenario() -> (tempfile::TempDir, KconfigTree, Catalog, Recipe) {
let d = tempfile::tempdir().unwrap();
std::fs::write(
d.path().join("Kconfig"),
r#"
config WIRELESS
bool "Wireless"
config CFG80211
tristate "cfg80211"
depends on WIRELESS
config WLAN
bool "WLAN"
select WIRELESS
config AUDIT
bool "Audit"
config MAC80211_LEDS
bool "leds"
depends on CFG80211
"#,
)
.unwrap();
let tree = KconfigTree::parse(d.path(), &BTreeMap::new()).unwrap();
let cat: Catalog = toml::from_str(
r#"
version = 1
[[bundle]]
id = "sin-wifi"
title = "No necesito wifi"
disable = ["WIRELESS"]
close_leaks = ["WLAN"]
[[bundle]]
id = "con-leds"
title = "Quiero los leds"
enable = ["MAC80211_LEDS"]
[[knob]]
id = "auditoria"
title = "Auditoría"
side = "kconfig"
enable = ["AUDIT"]
[[knob]]
id = "lto"
title = "LTO"
side = "recipe"
recipe_field = "build.compiler"
"#,
)
.unwrap();
let recipe: Recipe = toml::from_str(
r#"
name = "linux"
version = "6.16.12"
[source]
tarball = "https://x/linux.tar.gz"
sha256 = "aa"
[build]
compiler = "zig-cc"
target = "x86_64-linux-musl"
link = "dynamic"
[build.phases]
configure = "make ARCH=x86_64 defconfig && make ARCH=x86_64 olddefconfig"
compile = "make bzImage"
"#,
)
.unwrap();
(d, tree, cat, recipe)
}
#[test]
fn emite_raices_no_clausuras() {
let (_d, tree, cat, base) = escenario();
let p = make_plan(&base, "recipes/linux.toml", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
// Dos banderas, no cuatro símbolos: la clausura la calcula olddefconfig.
assert_eq!(p.symbols.len(), 2);
assert!(p.fragment.contains("-d WIRELESS"));
assert!(p.fragment.contains("-d WLAN"));
assert!(!p.fragment.contains("CFG80211"));
// …pero hammer SABE cuántos caen, para poder explicarlo.
assert_eq!(p.closure_size, 4);
}
#[test]
fn la_fase_derivada_conserva_la_base_y_reresuelve() {
let (_d, tree, cat, base) = escenario();
let p = make_plan(&base, "recipes/linux.toml", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
assert!(p.configure.starts_with("make ARCH=x86_64 defconfig"));
assert!(p.configure.trim_end().ends_with("make ARCH=x86_64 olddefconfig"));
// El fragmento va ANTES del olddefconfig final: si fuera después, no se resolvería.
let i_frag = p.configure.find("scripts/config").unwrap();
let i_last = p.configure.rfind("olddefconfig").unwrap();
assert!(i_frag < i_last);
}
#[test]
fn el_nombre_derivado_es_estable_e_independiente_del_orden() {
let (_d, tree, cat, base) = escenario();
let a = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &["auditoria".into()]).unwrap();
let b = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &["auditoria".into()]).unwrap();
assert_eq!(a.derived_name, b.derived_name);
assert!(a.derived_name.starts_with("linux-plan-"));
let c = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
assert_ne!(a.derived_name, c.derived_name);
}
#[test]
fn encender_algo_que_la_seleccion_apaga_es_conflicto() {
let (_d, tree, cat, base) = escenario();
// MAC80211_LEDS depende de CFG80211, que cae con WIRELESS. olddefconfig lo tiraría callado.
let e = make_plan(
&base,
"r",
&cat,
&tree,
&["sin-wifi".into(), "con-leds".into()],
&[],
)
.unwrap_err()
.to_string();
assert!(e.contains("MAC80211_LEDS"), "{e}");
}
#[test]
fn la_perilla_de_receta_se_declara_y_no_se_aplica() {
let (_d, tree, cat, base) = escenario();
let p = make_plan(&base, "r", &cat, &tree, &[], &["lto".into()]).unwrap();
assert!(p.symbols.is_empty());
assert_eq!(p.recipe_side.len(), 1);
assert_eq!(p.recipe_side[0].recipe_field, "build.compiler");
}
#[test]
fn el_simbolo_inexistente_no_se_pierde_en_silencio() {
let (_d, tree, _cat, base) = escenario();
let cat: Catalog = toml::from_str(
r#"
version = 1
[[bundle]]
id = "fantasma"
title = "Apaga algo que ya no existe"
disable = ["REISERFS_FS"]
"#,
)
.unwrap();
let p = make_plan(&base, "r", &cat, &tree, &["fantasma".into()], &[]).unwrap();
assert!(p.symbols.is_empty(), "no se emite una bandera que sería no-op");
assert_eq!(p.unknown_symbols.len(), 1);
assert_eq!(p.unknown_symbols[0].symbol, "REISERFS_FS");
}
#[test]
fn el_diff_back_ve_la_promesa_incumplida() {
let (_d, tree, cat, base) = escenario();
let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &["auditoria".into()]).unwrap();
// El kernel produjo un config donde WLAN sobrevivió y AUDIT no se pudo encender.
let cfg = super::super::KernelConfig::parse(
"# CONFIG_WIRELESS is not set\nCONFIG_WLAN=y\n# CONFIG_AUDIT is not set\n",
);
let d = diff_back(&p, &cfg);
assert!(!d.is_clean());
let violados: Vec<&str> = d.violated.iter().map(|v| v.symbol.as_str()).collect();
assert_eq!(violados, vec!["AUDIT", "WLAN"]);
assert_eq!(d.honored, vec!["WIRELESS"]);
}
/// La lección de correr un `olddefconfig` de verdad (SDD 22 §12): un `-d` sobre un símbolo que
/// el `.config` ni menciona **es el resultado buscado**, no una promesa rota. Contarlo como
/// fallo hacía que un plan perfecto se reportara roto.
#[test]
fn apagar_algo_ausente_es_exito_y_encenderlo_es_fallo() {
let (_d, tree, cat, base) = escenario();
let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &["auditoria".into()]).unwrap();
// WIRELESS apagado; WLAN ni aparece (cayó con él); AUDIT tampoco aparece, y ése SÍ se pidió
// encender.
let cfg = super::super::KernelConfig::parse("# CONFIG_WIRELESS is not set\n");
let d = diff_back(&p, &cfg);
assert_eq!(d.honored, vec!["WIRELESS"]);
assert_eq!(d.off_by_dependency, vec!["WLAN"]);
assert_eq!(d.violated.len(), 1);
assert_eq!(d.violated[0].symbol, "AUDIT");
assert!(!d.is_clean(), "falta un símbolo que se pidió ENCENDER");
}
#[test]
fn el_diff_back_limpio_es_limpio() {
let (_d, tree, cat, base) = escenario();
let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
let cfg = super::super::KernelConfig::parse(
"# CONFIG_WIRELESS is not set\n# CONFIG_WLAN is not set\n",
);
assert!(diff_back(&p, &cfg).is_clean());
}
}
+328
View File
@@ -0,0 +1,328 @@
//! **Modo reversa** (#9 del handoff): leer el kernel que ya corre a través del lente de bundles.
//!
//! Es el primer movimiento por una razón concreta: no compila nada, no arriesga nada, y **valida el
//! catálogo contra la realidad antes de que exista el compilador**. Si el modo reversa muestra
//! basura, el diseño de N1 está mal y se sabe por el precio de dos días en vez de dos meses.
//!
//! Da tres cosas que ninguna lista de símbolos da sola:
//!
//! 1. **Cuánto de cada bundle ya está aplicado** en el kernel que arrancó esta máquina.
//! 2. **Si el hardware lo contradice** — y sólo en ese sentido (ver [`super::hw`]).
//! 3. **Si el catálogo envejeció**: cada `select` que entra a la clausura y no está declarado en el
//! bundle es un símbolo que upstream agregó y nadie revisó. Es la mitad barata de la curación
//! del delta (§3 del handoff), y sale de comparar el grafo con el catálogo, sin IA.
use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use super::catalog::{Bundle, Catalog};
use super::hw::Hardware;
use super::kconfig::{KconfigTree, SelectLeak};
use super::{ConfigValue, KernelConfig};
/// Cuánto de un bundle está ya en efecto en el `.config` mirado.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum BundleState {
/// Ni un símbolo de la clausura está encendido: el bundle ya rige.
Aplicado,
/// Algunos sí, algunos no.
Parcial,
/// Todo encendido: el kernel trae el subsistema entero.
Sin,
}
/// Qué dice el hardware sobre un bundle. **Sólo contradice, nunca poda.**
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", tag = "señal")]
pub enum HwSignal {
/// El bundle no declara ninguna señal: no se puede decir nada.
SinSeñal,
/// Hay hardware presente que este bundle apagaría. Aplicarlo es una decisión, no un descuido.
Presente { evidencia: Vec<String> },
/// Ninguna de las señales declaradas está presente.
Ausente,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BundleStatus {
pub id: String,
pub title: String,
/// Tamaño de la clausura del bundle en ESTE árbol de Kconfig.
pub closure_len: usize,
/// De la clausura, cuántos están `=y` en el config mirado.
pub on_builtin: usize,
/// …y cuántos `=m`.
pub on_module: usize,
pub state: BundleState,
pub hardware: HwSignal,
/// Símbolos que el bundle nombra y **no existen en este árbol**. Hoy un `-d` sobre uno de éstos
/// se pierde en silencio; acá se ve.
pub unknown_symbols: Vec<String>,
/// Fugas `select` que el catálogo no declara (ni cierra ni acepta): el bundle envejeció.
pub undeclared_leaks: Vec<SelectLeak>,
}
/// El informe entero del modo reversa.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReverseReport {
/// Banner del `.config` leído, si lo trae.
pub kernel: Option<String>,
pub fingerprint: String,
pub config_symbols: usize,
pub bundles: Vec<BundleStatus>,
/// Bundles con `state != Aplicado` cuyo hardware está **ausente**: capacidad que este kernel
/// carga y esta máquina no usa. Es la frase del handoff §9 vuelta número.
pub applicable_savings: usize,
/// Bundles ya aplicados cuyo hardware SÍ está presente: la contradicción que hay que mirar.
pub contradictions: usize,
}
/// Corre el modo reversa. `hw` opcional: sin hardware el informe sigue valiendo, sólo que toda
/// señal queda en `SinSeñal`.
pub fn analyze(
catalog: &Catalog,
tree: &KconfigTree,
config: &KernelConfig,
hw: Option<&Hardware>,
) -> ReverseReport {
let mut bundles = Vec::new();
for b in &catalog.bundles {
bundles.push(status_of(b, tree, config, hw));
}
let applicable_savings = bundles
.iter()
.filter(|s| s.state != BundleState::Aplicado && s.hardware == HwSignal::Ausente)
.count();
let contradictions = bundles
.iter()
.filter(|s| s.state == BundleState::Aplicado && matches!(s.hardware, HwSignal::Presente { .. }))
.count();
ReverseReport {
kernel: config.banner.clone(),
fingerprint: hw.map(|h| h.fingerprint().to_string()).unwrap_or_default(),
config_symbols: config.len(),
bundles,
applicable_savings,
contradictions,
}
}
fn status_of(
b: &Bundle,
tree: &KconfigTree,
config: &KernelConfig,
hw: Option<&Hardware>,
) -> BundleStatus {
// Las raíces del bundle son lo que apaga MÁS las fugas que declaró cerrar.
let mut roots: Vec<String> = b.disable.clone();
roots.extend(b.close_leaks.iter().cloned());
let unknown_symbols: Vec<String> = roots
.iter()
.chain(b.enable.iter())
.filter(|s| !tree.symbols.contains_key(*s))
.cloned()
.collect();
let closure = tree.closure_off(&roots);
let mut on_builtin = 0usize;
let mut on_module = 0usize;
for s in &closure {
match config.get(s) {
Some(ConfigValue::Yes) => on_builtin += 1,
Some(ConfigValue::Module) => on_module += 1,
_ => {}
}
}
let on = on_builtin + on_module;
let state = if on == 0 {
BundleState::Aplicado
} else if on < closure.len() {
BundleState::Parcial
} else {
BundleState::Sin
};
// Fugas que el catálogo no contempla: ni las cerró (estarían dentro de la clausura) ni las
// aceptó por escrito.
let aceptadas: BTreeSet<&str> = b.accept_leaks.keys().map(|s| s.as_str()).collect();
let undeclared_leaks: Vec<SelectLeak> = tree
.select_leaks(&closure)
.into_iter()
.filter(|l| !aceptadas.contains(l.target.as_str()))
.collect();
BundleStatus {
id: b.id.clone(),
title: b.title.clone(),
closure_len: closure.len(),
on_builtin,
on_module,
state,
hardware: hw_signal(b, hw),
unknown_symbols,
undeclared_leaks,
}
}
fn hw_signal(b: &Bundle, hw: Option<&Hardware>) -> HwSignal {
let c = &b.contradicted_by;
if c.is_empty() {
return HwSignal::SinSeñal;
}
let Some(hw) = hw else {
return HwSignal::SinSeñal;
};
let mut ev = Vec::new();
for p in &c.pci_class {
for d in hw.pci.iter().filter(|d| d.class.starts_with(p)) {
ev.push(format!(
"pci {}:{} clase {} ({})",
d.vendor,
d.device,
d.class,
d.driver.as_deref().unwrap_or("sin driver")
));
}
}
for d in &c.driver {
if hw.has_driver(d) {
ev.push(format!("driver {d} con dispositivo bindeado"));
}
}
for f in &c.cpu_flag {
if hw.has_cpu_flag(f) {
ev.push(format!("cpu flag {f}"));
}
}
if ev.is_empty() {
HwSignal::Ausente
} else {
HwSignal::Presente { evidencia: ev }
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn escenario() -> (tempfile::TempDir, KconfigTree, Catalog) {
let d = tempfile::tempdir().unwrap();
std::fs::write(
d.path().join("Kconfig"),
r#"
config WIRELESS
bool "Wireless"
config CFG80211
tristate "cfg80211"
depends on WIRELESS
config WLAN
bool "WLAN"
select WIRELESS
config SOUND
bool "Sound"
"#,
)
.unwrap();
let tree = KconfigTree::parse(d.path(), &BTreeMap::new()).unwrap();
let cat: Catalog = toml::from_str(
r#"
version = 1
[[bundle]]
id = "sin-wifi"
title = "No necesito wifi"
disable = ["WIRELESS"]
close_leaks = ["WLAN"]
[bundle.contradicted_by]
pci_class = ["0280"]
[[bundle]]
id = "sin-audio"
title = "No necesito audio"
disable = ["SOUND", "SND_QUE_NO_EXISTE"]
"#,
)
.unwrap();
(d, tree, cat)
}
#[test]
fn cuenta_lo_aplicado_y_lo_que_falta() {
let (_d, tree, cat) = escenario();
let cfg = KernelConfig::parse(
"CONFIG_WIRELESS=y\nCONFIG_CFG80211=m\nCONFIG_WLAN=y\n# CONFIG_SOUND is not set\n",
);
let r = analyze(&cat, &tree, &cfg, None);
let wifi = &r.bundles[0];
assert_eq!(wifi.closure_len, 3, "WIRELESS + CFG80211 + WLAN");
assert_eq!(wifi.on_builtin, 2);
assert_eq!(wifi.on_module, 1);
assert_eq!(wifi.state, BundleState::Sin);
let audio = &r.bundles[1];
assert_eq!(audio.state, BundleState::Aplicado);
// El símbolo inventado se ve en vez de perderse en silencio.
assert_eq!(audio.unknown_symbols, vec!["SND_QUE_NO_EXISTE"]);
}
#[test]
fn el_hardware_contradice_pero_no_poda() {
let (_d, tree, cat) = escenario();
let cfg = KernelConfig::parse("# CONFIG_WIRELESS is not set\n# CONFIG_WLAN is not set\n");
let mut hw = Hardware::default();
hw.pci.push(super::super::hw::PciDevice {
slot: "0000:00:14.3".into(),
vendor: "8086".into(),
device: "a0f0".into(),
class: "028000".into(),
driver: Some("iwlwifi".into()),
});
let r = analyze(&cat, &tree, &cfg, Some(&hw));
let wifi = &r.bundles[0];
assert_eq!(wifi.state, BundleState::Aplicado);
assert!(matches!(wifi.hardware, HwSignal::Presente { .. }));
// Aplicado + hardware presente = la contradicción que hay que mirar, no un error.
assert_eq!(r.contradictions, 1);
}
#[test]
fn el_ahorro_es_bundle_no_aplicado_con_hardware_ausente() {
let (_d, tree, cat) = escenario();
let cfg = KernelConfig::parse("CONFIG_WIRELESS=y\nCONFIG_CFG80211=y\nCONFIG_WLAN=y\n");
let hw = Hardware::default(); // máquina sin PCI wifi
let r = analyze(&cat, &tree, &cfg, Some(&hw));
assert_eq!(r.bundles[0].hardware, HwSignal::Ausente);
assert_eq!(r.applicable_savings, 1);
}
#[test]
fn una_fuga_select_no_declarada_se_reporta() {
let (_d, tree, _cat) = escenario();
// Un catálogo que NO cierra la fuga WLAN→WIRELESS: es el bundle envejecido.
let cat: Catalog = toml::from_str(
r#"
version = 1
[[bundle]]
id = "sin-wifi"
title = "No necesito wifi"
disable = ["WIRELESS"]
"#,
)
.unwrap();
let cfg = KernelConfig::parse("CONFIG_WIRELESS=y\n");
let r = analyze(&cat, &tree, &cfg, None);
assert_eq!(
r.bundles[0].undeclared_leaks,
vec![SelectLeak {
selector: "WLAN".into(),
target: "WIRELESS".into()
}]
);
}
}
+230
View File
@@ -0,0 +1,230 @@
//! La huella del **lab**: qué toolchain construyó un artefacto.
//!
//! ── POR QUÉ EXISTE ──────────────────────────────────────────────────────────────────────────────
//! Medido el 2026-08-10 reconstruyendo los cuatro kernels en un segundo hub: el `.config` que la
//! receta instala salió distinto del que produjo el build anterior, en **4 líneas y ninguna del
//! cambio de receta**:
//!
//! ```text
//! CONFIG_RUSTC_VERSION=109600 → 109700
//! CONFIG_RUSTC_LLVM_VERSION=220103 → 220108
//! ```
//!
//! Rust 1.96 en una máquina, 1.97 en la otra — las dos resueltas del mismo `apk add` contra Alpine
//! edge, que es rodante. Y **Rust ni siquiera estaba activado en esos kernels**: Kconfig sondea el
//! `rustc` del entorno y graba su versión igual. Una herramienta que el artefacto no usa le cambia
//! los bytes.
//!
//! Antes de esto el toolchain del rootfs NO era una entrada del `ArtifactHash` ⇒ **dos labs sellaban
//! bytes distintos en la MISMA dirección**, y el store no tenía forma de notarlo: para él un
//! artefacto *es* su dirección. Un `mirror pull` entre dos hubs se llevaba cualquiera de los dos.
//!
//! ── QUÉ ENTRA Y QUÉ NO ──────────────────────────────────────────────────────────────────────────
//! NO entra el rootfs entero. El criterio es: **¿la versión de este paquete puede cambiar los BYTES
//! del artefacto?** Si no puede, no entra, porque cada paquete de más es una invalidación del corpus
//! entero cada vez que Alpine lo bumpee — y con edge rodante `curl` se actualiza seguido sin que
//! nada de lo que emite el compilador cambie.
//!
//! Entran, y por qué:
//! · compiladores y enlazadores: gcc, g++, clang*, llvm*, binutils, rust, cargo
//! · las libs con las que gcc hace CODEGEN: gmp, mpfr4, mpc1, isl* — un bump cambia lo que emite
//! · runtime que se ENLAZA dentro: musl*, libgcc*, libstdc++*, libatomic, libgomp
//! · headers que se COMPILAN dentro: linux-headers, fortify-headers
//!
//! Quedan fuera a propósito, aun sabiendo que no es una línea perfecta: los autotools
//! (`m4`/`autoconf`/`automake`/`libtool`/`make`/`pkgconf`) y las shells (`bash`/`busybox`/
//! `coreutils`). Pueden cambiar ficheros generados y por tanto el output, pero casi todas las
//! recetas traen su `configure` ya generado en el tarball y `autoreconf` es raro. **Es una decisión
//! de coste, no una afirmación de que no influyen**: si algún día se ve una divergencia que
//! rastree hasta ahí, se añaden — y ese día el corpus se re-hashea otra vez.
//!
//! ── LA VERDAD ESTÁ EN EL ROOTFS, NO EN UN FICHERO APARTE ────────────────────────────────────────
//! La huella se computa del `apk db` del rootfs REAL que se va a usar, no de
//! `docs/state/lab-toolchain.lock`. El lock es el registro legible y versionado (viaja por git, que
//! es como dos hubs se comparan) y `bootstrap-devfs.sh` avisa cuando divergen; pero hashear el lock
//! permitiría sellar con un lab distinto del declarado y quedarse tan tranquilo. Se hashea lo que
//! de verdad va a compilar.
use std::path::Path;
/// Prefijos de paquete cuya VERSIÓN puede cambiar los bytes de un artefacto. Ver el módulo.
///
/// Son prefijos y no nombres exactos porque Alpine versiona en el nombre (`clang22`, `llvm22`,
/// `isl26`) y sub-paqueta (`clang22-libs`, `musl-dev`, `libstdc++-dev`). Un prefijo captura la
/// familia entera sin tener que listar cada variante ni actualizar esto en cada bump mayor.
///
/// ⚠ Se comparan contra el **NOMBRE** del paquete (`P:` del apk db), sin versión: son `gcc`, no
/// `gcc-`. El guion sólo existe en el formato `nombre-versión` que imprime `apk info -v` y guarda
/// el lock; escribirlo aquí hace que NADA case y la huella salga la del conjunto vacío — que es un
/// hash perfectamente válido y constante, o sea un fallo mudo. Lo cazó un test, no una revisión.
pub const TOOLCHAIN_PREFIXES: &[&str] = &[
"binutils",
"cargo",
"clang",
"fortify-headers",
"g++",
"gcc",
"gmp",
"isl",
"libatomic",
"libgcc",
"libgomp",
"libstdc++",
"linux-headers",
"llvm",
"mpc1",
"mpfr4",
"musl",
"rust",
// ── librerías del rootfs que los artefactos ENLAZAN (añadidas 2026-08-12) ──────────────────
// Normalmente una receta toma sus libs de `[deps]` (el corpus), no del rootfs, y por eso esta
// lista era sólo el toolchain. `python3` es la excepción forzada: CPython construye sus módulos
// opcionales como `.so` y las `.a` del corpus no son PIC, así que sus headers TIENEN que venir
// del lab. En cuanto una lib del rootfs entra en el CONTENIDO de un artefacto, su versión es
// parte de la identidad del build — dejarla fuera reabriría, en pequeño, el mismo agujero que
// este módulo existe para cerrar: mismo hash, distintos bytes.
// Comprobado en la misma pasada: instalar los `-dev` subió ncurses 6.5→6.6 y readline
// 8.3.1→8.3.3 sin tocar gcc/rust/musl. Si no estuvieran aquí, ese salto habría cambiado el
// python3 producido SIN mover su dirección.
"bzip2",
"expat",
"ncurses",
"readline",
"sqlite",
"xz",
];
/// Huella del toolchain del lab. Opaca a propósito: sólo se compara y se mete al hash.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LabFingerprint(String);
impl LabFingerprint {
/// Huella fija para tests y para árboles herméticos sin rootfs.
///
/// Existe para que los tests no necesiten un lab de verdad **sin abrir un camino silencioso**:
/// no hay `Option` ni default que se aplique solo. Quien no tiene lab lo dice explícitamente.
pub fn for_tests() -> Self {
Self("test".into())
}
/// Lee el `apk db` de un rootfs y resume los paquetes de [`TOOLCHAIN_PREFIXES`].
///
/// Falla si el db no existe. **No cae a un default**: un hash calculado sin lab y uno calculado
/// con lab son direcciones distintas para el mismo contenido, así que adivinar aquí produciría
/// exactamente la divergencia silenciosa que este módulo existe para cerrar.
pub fn from_rootfs(rootfs: &Path) -> crate::Result<Self> {
let db = rootfs.join("lib/apk/db/installed");
let texto = std::fs::read_to_string(&db).map_err(|e| {
crate::Error::Other(anyhow::anyhow!(
"no pude leer el apk db del lab en {}: {e}. \
El toolchain entra en el ArtifactHash, así que sin rootfs no se puede calcular \
un hash comparable (corré scripts/bootstrap-devfs.sh, o apuntá HAMMER_ROOTFS)",
db.display()
))
})?;
Ok(Self::from_apk_db(&texto))
}
/// Igual que [`Self::from_rootfs`] pero sobre el texto del db ya leído (testeable sin ficheros).
///
/// El formato de `lib/apk/db/installed` son bloques `P:<nombre>` … `V:<versión>` separados por
/// líneas en blanco. Se reconstruye `<nombre>-<versión>`, que es la misma forma que imprime
/// `apk info -v` y la que guarda `docs/state/lab-toolchain.lock` ⇒ los dos se leen igual.
pub fn from_apk_db(texto: &str) -> Self {
let mut nombre: Option<&str> = None;
let mut paquetes: Vec<String> = Vec::new();
for linea in texto.lines() {
if let Some(n) = linea.strip_prefix("P:") {
nombre = Some(n);
} else if let Some(v) = linea.strip_prefix("V:") {
if let Some(n) = nombre.take() {
if TOOLCHAIN_PREFIXES.iter().any(|p| n.starts_with(p)) {
paquetes.push(format!("{n}-{v}"));
}
}
}
}
// Ordenar: el orden del db depende del orden de instalación, que no es una propiedad del
// toolchain. Sin esto, dos rootfs idénticos instalados en distinto orden darían huellas
// distintas y re-hashearían el corpus por nada.
paquetes.sort();
paquetes.dedup();
let mut h = blake3::Hasher::new();
for p in &paquetes {
h.update(p.as_bytes());
h.update(b"\n");
}
Self(h.finalize().to_hex().to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
const DB: &str = "\
P:gcc
V:15.2.0-r8
P:curl
V:8.21.0-r0
P:rust
V:1.97.0-r0
";
#[test]
fn la_huella_no_es_la_del_conjunto_vacio() {
// REGRESIÓN: la primera versión comparaba prefijos con guion (`gcc-`) contra el NOMBRE
// (`gcc`) ⇒ no casaba ninguno y la huella era la del conjunto vacío: constante, válida y
// completamente inútil. Un filtro que no selecciona nada no se nota mirando el hash.
let vacia = LabFingerprint::from_apk_db("");
assert_ne!(
LabFingerprint::from_apk_db(DB),
vacia,
"si esto falla, el filtro no está seleccionando NADA"
);
}
#[test]
fn filtra_solo_el_toolchain() {
let a = LabFingerprint::from_apk_db(DB);
// Cambiar curl NO mueve la huella: no puede cambiar los bytes de un artefacto, y si contara
// el corpus entero se invalidaría en cada bump de Alpine edge.
let b = LabFingerprint::from_apk_db(&DB.replace("8.21.0-r0", "8.22.0-r0"));
assert_eq!(a, b, "curl no es toolchain");
}
#[test]
fn el_compilador_si_mueve_la_huella() {
let a = LabFingerprint::from_apk_db(DB);
// El caso REAL medido el 2026-08-10: rust 1.96 vs 1.97 entre dos hubs.
let b = LabFingerprint::from_apk_db(&DB.replace("1.97.0-r0", "1.96.0-r0"));
assert_ne!(a, b, "un rustc distinto es un lab distinto");
let c = LabFingerprint::from_apk_db(&DB.replace("15.2.0-r8", "15.3.0-r0"));
assert_ne!(a, c, "un gcc distinto es un lab distinto");
}
#[test]
fn el_orden_de_instalacion_no_cuenta() {
let al_reves = "P:rust\nV:1.97.0-r0\n\nP:gcc\nV:15.2.0-r8\n";
assert_eq!(
LabFingerprint::from_apk_db(DB).as_str(),
LabFingerprint::from_apk_db(al_reves).as_str(),
"el orden del db es del instalador, no del toolchain"
);
}
#[test]
fn rootfs_ausente_falla_en_vez_de_adivinar() {
let err = LabFingerprint::from_rootfs(Path::new("/no/existe")).unwrap_err();
assert!(
err.to_string().contains("apk db del lab"),
"el error debe decir qué falta: {err}"
);
}
}
+52
View File
@@ -0,0 +1,52 @@
//! Tipos núcleo de hammer, compartidos por el lab, la CLI y el daemon.
//!
//! Ver `docs/01-architecture.md` y siguientes. Esto es el esqueleto de Fase 0: los tipos y
//! contratos están definidos; la lógica pesada (sandbox, fanotify, bus) vive en los otros
//! crates y se irá rellenando por fase.
pub mod apply;
pub mod caps;
pub mod compat;
pub mod differs;
pub mod hash;
pub mod installed;
pub mod kernel;
pub mod lab;
pub mod proto;
pub mod query;
pub mod recipe;
pub mod repo;
pub mod sign;
pub mod store;
pub mod swm;
pub use caps::{AgentCapsConfig, CapRule};
pub use differs::{why_differs, Causa, Divergencia, Informe};
pub use hash::ArtifactHash;
pub use installed::{InstalledDb, InstalledPackage};
pub use compat::Veredicto as CompatVeredicto;
pub use recipe::{
CheckOutcome, Compiler, Deps, Evidence, EvidenceCheck, EvidenceKind, LinkMode, Phases, Recipe,
Slots, Source, SourceKind,
};
pub use repo::{PackageEntry, RepoIndex};
pub use sign::{KeyPair, SigStatus, TrustStore};
pub use store::Store;
pub use swm::{Base, BaseCompat, BaseRef, Mutation, PinDiff, Signature, Swm, SwmBuild};
/// Error común del ecosistema hammer.
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("serialización: {0}")]
Serde(String),
#[error("receta inválida: {0}")]
Recipe(String),
#[error("store: {0}")]
Store(String),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
+338
View File
@@ -0,0 +1,338 @@
//! Protocolo del bus de agente. Ver `docs/07-agent-bus.md` §3.
//!
//! Wire format: **JSON-líneas** (un objeto JSON por línea, `\n`-terminado). Cada objeto
//! lleva un campo discriminador `"t"`. La elección de un tag por línea (en vez de
//! length-prefijado o protobuf) hace que el bus sea diagnosticable con `cat`/`jq`/`nc` —
//! que es exactamente el punto.
//!
//! ## Compatibilidad
//!
//! `Hello.ver`/`Welcome.ver` viajan en el handshake. Variantes nuevas de `Command`/`Event`
//! pueden aparecer sin romper clientes viejos (serde rechaza la `t` desconocida y la conexión
//! la cierra el dispatcher con un mensaje claro).
use serde::{Deserialize, Serialize};
pub const PROTOCOL_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "t", rename_all = "snake_case")]
pub enum Command {
/// Handshake del cliente. Primer mensaje obligatorio.
Hello {
ver: u32,
#[serde(default)]
client: String,
},
/// Lanza un build. Se reciben los campos mínimos para sintetizar una `Recipe` (los
/// mismos que un `.swm` Source_patch). Devuelve `BuildReady` o `BuildFailed`.
Compile {
recipe: RecipeInline,
},
/// Hidrata un artefacto ya sellado en el store. `target` es el FHS destino (overlay
/// merged o real). `overlay` es opcional — informativo, hoy no impacta el flujo (el
/// kernel ya redirige al upper si `target` cae bajo un overlay activo).
Inject {
artifact: String,
target: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
overlay: Option<String>,
},
/// Consulta de estado. `what` decide qué campos se usan:
/// - `"file"`: usa `path` (metadata del archivo).
/// - `"artifact"`: usa `name` o `path` (busca un hash en el store).
/// - `"expr"`: usa `expr`, una expresión del mini-lenguaje (ver `takana_core::query`).
Query {
what: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
expr: Option<String>,
},
/// Proxy autenticado al control humano (`/run/init.control`). Envía la línea cruda al
/// FIFO; el cliente recibe `InitAck` o `Error`. La gating por capacidad `init`.
Init {
cmd: String,
},
}
/// Forma reducida de `Recipe` que viaja por el bus: los campos del `.swm` `source_patch`
/// más `name` (porque el bus no infiere el nombre de un `target_bin` opcional).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecipeInline {
pub name: String,
// Origen git (modo histórico) **o** tarball — exactamente uno, igual que
// `swm::Mutation::SourcePatch`. `Option` con `serde(default)` para compat con clientes
// que sólo mandan `repo`+`commit`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tarball: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub patch: Option<String>,
#[serde(default = "default_compiler")]
pub compiler: String,
#[serde(default = "default_target")]
pub target: String,
#[serde(default = "default_link")]
pub link: String,
#[serde(default)]
pub flags: Vec<String>,
/// [H1c] Bloque de evidencia de comportamiento (proof-carrying recipe). Viaja al lado del
/// build para que `hammerd` la EJECUTE tras sellar el artefacto y devuelva el veredicto en
/// `BuildReady`. Vacío ⇒ receta sin evidencia (compat con clientes previos a H1c).
#[serde(default, skip_serializing_if = "crate::recipe::Evidence::is_empty")]
pub evidence: crate::recipe::Evidence,
}
fn default_compiler() -> String {
"zig-cc".into()
}
fn default_target() -> String {
"x86_64-linux-musl".into()
}
fn default_link() -> String {
"static".into()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "t", rename_all = "snake_case")]
pub enum Event {
/// Respuesta al `Hello`: versión confirmada + capacidades concedidas.
Welcome {
ver: u32,
caps: Vec<Cap>,
/// Info del peer tal y como el kernel la reportó (SO_PEERCRED). Útil para que el
/// cliente sepa qué UID/GID/PID le ve `hammerd`.
peer: Peer,
},
/// Build terminó OK. `recipe` es el `name` original; `artifact` es el hash sellado.
/// `verdict` [H1c]: `None` si la receta no declaró evidencia; `Some` con el resultado de
/// ejecutarla en el sandbox reproducible. `all_passed=false` ⇒ el artefacto quedó sellado
/// pero una prueba declarada falló — el orquestador lo usa como gate "no proponer".
BuildReady {
recipe: String,
artifact: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
verdict: Option<EvidenceVerdict>,
},
/// Build falló. `log_tail` puede llevar las últimas líneas del log del lab si están
/// disponibles (opcional).
BuildFailed {
recipe: String,
reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
log_tail: Option<String>,
},
/// Hidratación terminó OK.
Injected { artifact: String, target: String, files: usize },
/// Respuesta a `Query`. `value` es JSON libre — su esquema lo decide el tipo de query.
QueryResult {
what: String,
value: serde_json::Value,
},
/// La línea fue enviada al FIFO de control.
InitAck { cmd: String },
/// Un servicio supervisado murió. La supervisión real la provee el init propio
/// (arje, PID 1): difunde `arje_bus::BusEvent::EnteCrashed` y el sink
/// `hammerd::crashes` lo traduce a este evento. Falta sólo el adaptador de
/// transporte que conecta ambos buses (ver `hammerd::crashes`).
Crashed { service: String, code: i32 },
/// Mutación detectada por el watcher. Hammerd lo re-emite por el bus después de
/// registrarlo en el diario.
Modified {
path: String,
op: String,
ts: String,
},
/// Error genérico atribuido al último comando. `code` es estable; `msg` es humano.
Error { code: String, msg: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Peer {
pub uid: u32,
pub gid: u32,
pub pid: i32,
}
/// [H1c] Forma serializable del `EvidenceReport` de `takana_build` que viaja en `BuildReady`.
/// El lado del build (hammerd) ejecuta la evidencia y la adjunta; el orquestador la lee sin
/// depender de `takana_build` (la separación agente-PROPONE / lab-CONSTRUYE se preserva).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvidenceVerdict {
/// Gate: `false` ⇒ al menos un check declarado falló ⇒ el orquestador no propone la mutación.
pub all_passed: bool,
/// Estrato de confianza más alto que pasó (para reportar "verificado hasta …"). No promete
/// que ese nivel *baste*, sólo que lo declarado a ese nivel pasó.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_level: Option<crate::recipe::EvidenceKind>,
pub checks: Vec<EvidenceCheckVerdict>,
}
/// [H1c] Veredicto de un check individual, ya evaluado (exit + hash del stdout) en el sandbox.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvidenceCheckVerdict {
pub kind: crate::recipe::EvidenceKind,
pub cmd: String,
pub passed: bool,
pub detail: String,
}
/// Capacidades concedidas a una conexión. Por defecto `query`. La política viene del
/// fichero de policy del daemon (SDD 07 §4); el peer no las elige.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Cap {
Query,
Compile,
Inject,
InjectReal,
Init,
}
impl Cap {
/// Capacidad que un `Command` exige para ejecutarse. `Hello` es accesible a todos:
/// no requiere cap (es el handshake).
pub fn required_for(cmd: &Command) -> Option<Cap> {
match cmd {
Command::Hello { .. } => None,
Command::Compile { .. } => Some(Cap::Compile),
Command::Inject { target, .. } => {
// Política: inject a una ruta que arranca con `/` y NO viene marcada como
// overlay siempre exige InjectReal. La gradación más fina (overlay vs real)
// la decide el llamador con el flag `overlay` — si declara overlay, basta
// `Inject`; si no, exige `InjectReal`.
Some(if target.is_empty() || !target.starts_with('/') {
Cap::Inject
} else {
Cap::Inject
})
}
Command::Query { .. } => Some(Cap::Query),
Command::Init { .. } => Some(Cap::Init),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hello_roundtrip() {
let c = Command::Hello { ver: 1, client: "ai".into() };
let s = serde_json::to_string(&c).unwrap();
assert!(s.contains(r#""t":"hello""#), "{s}");
let back: Command = serde_json::from_str(&s).unwrap();
assert_eq!(back, c);
}
#[test]
fn welcome_with_peer_and_caps() {
let e = Event::Welcome {
ver: 1,
caps: vec![Cap::Query, Cap::Compile],
peer: Peer { uid: 1000, gid: 1000, pid: 42 },
};
let s = serde_json::to_string(&e).unwrap();
assert!(s.contains(r#""t":"welcome""#), "{s}");
assert!(s.contains(r#""caps":["query","compile"]"#), "{s}");
let back: Event = serde_json::from_str(&s).unwrap();
assert_eq!(back, e);
}
#[test]
fn compile_recipe_inline() {
let c = Command::Compile {
recipe: RecipeInline {
name: "grep".into(),
repo: Some("git://x/grep.git".into()),
commit: Some("abc".into()),
tarball: None,
sha256: None,
patch: None,
compiler: "zig-cc".into(),
target: "x86_64-linux-musl".into(),
link: "static".into(),
flags: vec!["--enable-foo".into()],
evidence: Default::default(),
},
};
let s = serde_json::to_string(&c).unwrap();
assert!(s.contains(r#""t":"compile""#));
assert!(s.contains(r#""name":"grep""#));
// Sin evidencia declarada, el bloque no se serializa (compat con clientes pre-H1c).
assert!(!s.contains("evidence"), "{s}");
}
#[test]
fn build_ready_event() {
// Sin veredicto, el JSON es idéntico al de antes de H1c (skip_serializing_if).
let e = Event::BuildReady {
recipe: "grep".into(),
artifact: "b3:deadbeef".into(),
verdict: None,
};
let s = serde_json::to_string(&e).unwrap();
assert_eq!(
s,
r#"{"t":"build_ready","recipe":"grep","artifact":"b3:deadbeef"}"#
);
}
#[test]
fn build_ready_with_verdict_roundtrips() {
let e = Event::BuildReady {
recipe: "tree".into(),
artifact: "b3:cafe".into(),
verdict: Some(EvidenceVerdict {
all_passed: false,
max_level: Some(crate::recipe::EvidenceKind::CmdExit),
checks: vec![EvidenceCheckVerdict {
kind: crate::recipe::EvidenceKind::CmdExit,
cmd: "tree --version".into(),
passed: false,
detail: "exit 7 ≠ esperado 0".into(),
}],
}),
};
let s = serde_json::to_string(&e).unwrap();
assert!(s.contains(r#""all_passed":false"#), "{s}");
let back: Event = serde_json::from_str(&s).unwrap();
assert_eq!(back, e);
}
#[test]
fn error_event_minimal() {
let e = Event::Error { code: "no_cap".into(), msg: "no tienes 'compile'".into() };
let s = serde_json::to_string(&e).unwrap();
let back: Event = serde_json::from_str(&s).unwrap();
assert_eq!(back, e);
}
#[test]
fn unknown_t_is_rejected() {
let bad = r#"{"t":"defenestrate","payload":1}"#;
assert!(serde_json::from_str::<Command>(bad).is_err());
}
#[test]
fn cap_required_for_basic_commands() {
assert_eq!(Cap::required_for(&Command::Hello { ver: 1, client: "".into() }), None);
assert_eq!(
Cap::required_for(&Command::Query { what: "file".into(), path: None, name: None, expr: None }),
Some(Cap::Query)
);
assert_eq!(
Cap::required_for(&Command::Init { cmd: "start web".into() }),
Some(Cap::Init)
);
}
}
+741
View File
@@ -0,0 +1,741 @@
//! Mini-lenguaje de consulta del sistema (SDD 08 §6).
//!
//! El objetivo es darle a la IA un vocabulario estable para referirse a partes del sistema
//! sin codificar rutas frágiles. En lugar de pedirle al modelo que diga `/usr/bin/grep`,
//! le permitimos pedir `bin:grep` y dejamos que el evaluador resuelva contra el `$PATH`
//! (o el `fs_root` que le pasamos). Lo mismo para servicios (`service:nginx`),
//! dependencias dinámicas (`depends:/usr/bin/foo`), símbolos exportados por un ELF
//! (`exports:/lib/libz.so.1`, ADR 0009), pins de base (`pin:musl`) y metadata de archivos
//! (`file:/etc/hosts`).
//!
//! V0 (esta versión): un único término por expresión, sintaxis `kind:value`. Sin filtros,
//! sin pipes. El SDD 08 §6 esboza una forma más rica (`find … -where … -> …`); cuando
//! aparezca un caso de uso real para componer queries, extenderemos sin romper este shape.
//!
//! Diseñado para evaluarse:
//! - **Local**: la CLI puede correr el evaluador en proceso (`hammer query bin:grep`).
//! - **Remoto**: el daemon expone `Command::Query { what: "expr", expr: Some(...) }` y
//! reusa este evaluador. Útil para que un cliente sin acceso al disco (o sin caps)
//! consulte estado a través del bus.
use std::path::{Path, PathBuf};
use serde_json::Value;
use crate::BaseRef;
#[derive(Debug, thiserror::Error)]
pub enum QueryError {
#[error("expresión vacía")]
Empty,
#[error("expresión sin separador ':' — esperaba 'kind:value', recibí '{0}'")]
NoColon(String),
#[error("kind '{0}' desconocido — válidos: bin, file, pin, service, depends, exports")]
UnknownKind(String),
#[error("valor vacío para kind '{0}'")]
EmptyValue(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Term {
/// `bin:<name>` — localiza un ejecutable en `$PATH` (o equivalente bajo `fs_root`).
Bin(String),
/// `file:<absolute_path>` — metadata de un archivo (existe, modo, tamaño, …).
File(PathBuf),
/// `pin:<name>` — devuelve el valor del pin en `BaseRef.pins`, si existe.
Pin(String),
/// `service:<name>` — busca el script/launcher del servicio en los lugares
/// convencionales (`/etc/init.d/<name>`, `/etc/service/<name>/run`,
/// `/run/service/<name>/run`). Devuelve el primero que encuentre.
Service(String),
/// `depends:<absolute_path>` — para un ELF dinámico, lista las librerías NEEDED.
/// Para uno estático, devuelve `{static: true, needed: []}`.
Depends(PathBuf),
/// `exports:<absolute_path>` — para un ELF con tabla dinámica, lista los símbolos
/// que EXPORTA (`.dynsym`, defined + global/weak). Procedencia por-símbolo (ADR 0009,
/// puente barato hacia código direccionado por contenido). Metadata descriptiva: no
/// entra a ningún hash de identidad. Un estático sin `.dynsym` devuelve `exports: []`.
Exports(PathBuf),
}
/// Contexto del evaluador. Todos los campos son opcionales para soportar evaluación
/// "lo que se pueda" — un término que requiera un campo ausente devuelve un valor
/// con `error` legible, no un panic.
#[derive(Debug, Clone, Default)]
pub struct EvalContext {
/// Base local — usada por `pin:<name>`.
pub base: Option<BaseRef>,
/// Re-rooteo del filesystem. Si está, `bin:` busca bajo `<fs_root>/usr/bin`,
/// `<fs_root>/bin`, etc. en vez de `$PATH`; `file:`/`service:`/`depends:` re-rootean
/// también. Útil para evaluar contra un overlay o un prefix de test sin tocar el FHS real.
pub fs_root: Option<PathBuf>,
/// `$PATH` a usar para `bin:` cuando no hay `fs_root`. Si `None`, usa el del proceso.
pub path_env: Option<String>,
}
impl EvalContext {
pub fn new() -> Self {
Self::default()
}
pub fn with_base(mut self, base: BaseRef) -> Self {
self.base = Some(base);
self
}
pub fn with_fs_root(mut self, root: impl Into<PathBuf>) -> Self {
self.fs_root = Some(root.into());
self
}
pub fn with_path_env(mut self, path: impl Into<String>) -> Self {
self.path_env = Some(path.into());
self
}
}
pub fn parse(expr: &str) -> Result<Term, QueryError> {
let s = expr.trim();
if s.is_empty() {
return Err(QueryError::Empty);
}
let (kind, value) = s.split_once(':').ok_or_else(|| QueryError::NoColon(s.to_string()))?;
let kind = kind.trim();
let value = value.trim();
if value.is_empty() {
return Err(QueryError::EmptyValue(kind.to_string()));
}
match kind {
"bin" => Ok(Term::Bin(value.to_string())),
"file" => Ok(Term::File(PathBuf::from(value))),
"pin" => Ok(Term::Pin(value.to_string())),
"service" => Ok(Term::Service(value.to_string())),
"depends" => Ok(Term::Depends(PathBuf::from(value))),
"exports" => Ok(Term::Exports(PathBuf::from(value))),
other => Err(QueryError::UnknownKind(other.to_string())),
}
}
pub fn eval(term: &Term, ctx: &EvalContext) -> Value {
match term {
Term::Bin(name) => eval_bin(name, ctx),
Term::File(p) => eval_file(p, ctx),
Term::Pin(name) => eval_pin(name, ctx),
Term::Service(name) => eval_service(name, ctx),
Term::Depends(p) => eval_depends(p, ctx),
Term::Exports(p) => eval_exports(p, ctx),
}
}
pub fn eval_str(expr: &str, ctx: &EvalContext) -> Value {
match parse(expr) {
Ok(t) => eval(&t, ctx),
Err(e) => serde_json::json!({ "error": e.to_string() }),
}
}
fn rebase(p: &Path, fs_root: Option<&Path>) -> PathBuf {
match fs_root {
Some(r) => {
let rel = p.strip_prefix("/").unwrap_or(p);
r.join(rel)
}
None => p.to_path_buf(),
}
}
fn eval_bin(name: &str, ctx: &EvalContext) -> Value {
// Defensa contra inyección: el nombre no debe traer separadores. Si los trae, el
// caller probablemente quería `file:` o `depends:`.
if name.contains('/') {
return serde_json::json!({
"found": false,
"error": format!("bin: '{name}' contiene '/'; usa 'file:' o 'depends:' para rutas")
});
}
let candidates: Vec<PathBuf> = if let Some(root) = &ctx.fs_root {
["usr/bin", "usr/sbin", "bin", "sbin"]
.iter()
.map(|d| root.join(d).join(name))
.collect()
} else {
let path = ctx
.path_env
.clone()
.or_else(|| std::env::var("PATH").ok())
.unwrap_or_else(|| "/usr/bin:/usr/sbin:/bin:/sbin".to_string());
path.split(':')
.filter(|s| !s.is_empty())
.map(|d| PathBuf::from(d).join(name))
.collect()
};
for cand in &candidates {
if cand.is_file() {
// Reusar eval_file para consistencia de schema.
let mut v = file_metadata(cand);
if let Some(obj) = v.as_object_mut() {
obj.insert("name".into(), Value::String(name.into()));
obj.insert("path".into(), Value::String(cand.display().to_string()));
}
return v;
}
}
serde_json::json!({
"found": false,
"name": name,
"searched": candidates.iter().map(|p| p.display().to_string()).collect::<Vec<_>>(),
})
}
fn file_metadata(p: &Path) -> Value {
use std::os::unix::fs::MetadataExt;
match std::fs::symlink_metadata(p) {
Ok(m) => serde_json::json!({
"found": true,
"path": p.display().to_string(),
"size": m.size(),
"mode": m.mode(),
"uid": m.uid(),
"gid": m.gid(),
"is_dir": m.is_dir(),
"is_symlink": m.file_type().is_symlink(),
}),
Err(e) => serde_json::json!({
"found": false,
"path": p.display().to_string(),
"error": e.to_string(),
}),
}
}
fn eval_file(p: &Path, ctx: &EvalContext) -> Value {
let abs = rebase(p, ctx.fs_root.as_deref());
file_metadata(&abs)
}
fn eval_pin(name: &str, ctx: &EvalContext) -> Value {
match &ctx.base {
None => serde_json::json!({
"found": false,
"name": name,
"error": "evaluador sin BaseRef",
}),
Some(base) => match base.pins.get(name) {
Some(v) => serde_json::json!({
"found": true,
"name": name,
"value": v,
}),
None => serde_json::json!({
"found": false,
"name": name,
"available": base.pins.keys().collect::<Vec<_>>(),
}),
},
}
}
fn eval_service(name: &str, ctx: &EvalContext) -> Value {
if name.contains('/') {
return serde_json::json!({
"found": false,
"error": format!("service: '{name}' contiene '/'"),
});
}
let root = ctx.fs_root.as_deref();
let candidates = [
rebase(Path::new(&format!("/etc/init.d/{name}")), root),
rebase(Path::new(&format!("/etc/service/{name}/run")), root),
rebase(Path::new(&format!("/run/service/{name}/run")), root),
];
for c in &candidates {
if c.is_file() {
return serde_json::json!({
"found": true,
"name": name,
"launcher": c.display().to_string(),
});
}
}
serde_json::json!({
"found": false,
"name": name,
"searched": candidates.iter().map(|p| p.display().to_string()).collect::<Vec<_>>(),
})
}
fn eval_depends(p: &Path, ctx: &EvalContext) -> Value {
let abs = rebase(p, ctx.fs_root.as_deref());
let bytes = match std::fs::read(&abs) {
Ok(b) => b,
Err(e) => {
return serde_json::json!({
"found": false,
"path": abs.display().to_string(),
"error": e.to_string(),
});
}
};
match parse_elf_info(&bytes) {
Ok(ElfInfo::Static) => serde_json::json!({
"found": true,
"path": abs.display().to_string(),
"static": true,
"needed": Vec::<String>::new(),
}),
Ok(ElfInfo::Dynamic { needed, .. }) => serde_json::json!({
"found": true,
"path": abs.display().to_string(),
"static": false,
"needed": needed,
}),
Err(e) => serde_json::json!({
"found": false,
"path": abs.display().to_string(),
"error": e,
}),
}
}
/// `exports:<path>` — símbolos que un ELF EXPORTA por su `.dynsym` (ADR 0009). Un ELF sin tabla
/// dinámica (estático) no tiene `.dynsym` ⇒ `exports: []`. Metadata descriptiva, no de identidad.
fn eval_exports(p: &Path, ctx: &EvalContext) -> Value {
let abs = rebase(p, ctx.fs_root.as_deref());
let bytes = match std::fs::read(&abs) {
Ok(b) => b,
Err(e) => {
return serde_json::json!({
"found": false,
"path": abs.display().to_string(),
"error": e.to_string(),
});
}
};
match parse_elf_info(&bytes) {
Ok(ElfInfo::Static) => serde_json::json!({
"found": true,
"path": abs.display().to_string(),
"static": true,
"exports": Vec::<String>::new(),
}),
Ok(ElfInfo::Dynamic { exports, .. }) => serde_json::json!({
"found": true,
"path": abs.display().to_string(),
"static": false,
"exports": exports,
}),
Err(e) => serde_json::json!({
"found": false,
"path": abs.display().to_string(),
"error": e,
}),
}
}
enum ElfInfo {
Static,
Dynamic { needed: Vec<String>, exports: Vec<String> },
}
/// Traduce una virtual address a offset de archivo recorriendo los `PT_LOAD`. `None` si la
/// dirección no cae en ningún segmento cargable.
fn vaddr_to_file_off(
bytes: &[u8],
e_phoff: usize,
e_phentsize: usize,
e_phnum: usize,
vaddr: u64,
) -> Option<usize> {
for i in 0..e_phnum {
let off = e_phoff + i * e_phentsize;
if off + 40 > bytes.len() {
return None;
}
let p_type = u32::from_le_bytes(bytes[off..off + 4].try_into().unwrap());
if p_type != 1 {
continue; // sólo PT_LOAD
}
let p_offset = u64::from_le_bytes(bytes[off + 8..off + 16].try_into().unwrap());
let p_vaddr = u64::from_le_bytes(bytes[off + 16..off + 24].try_into().unwrap());
let p_filesz = u64::from_le_bytes(bytes[off + 32..off + 40].try_into().unwrap());
if vaddr >= p_vaddr && vaddr < p_vaddr + p_filesz {
return Some((vaddr - p_vaddr + p_offset) as usize);
}
}
None
}
/// Lee una cadena NUL-terminada de `strtab[off..end]`.
fn cstr_at(bytes: &[u8], strtab_off: usize, strtab_end: usize, name_off: usize) -> Option<String> {
let start = strtab_off.checked_add(name_off)?;
if start >= strtab_end {
return None;
}
let s = &bytes[start..strtab_end];
let end = s.iter().position(|&b| b == 0).unwrap_or(s.len());
std::str::from_utf8(&s[..end]).ok().map(|x| x.to_string())
}
/// Parser ELF mínimo: extrae `DT_NEEDED` (deps dinámicas) y los símbolos EXPORTADOS de `.dynsym`
/// (ADR 0009, procedencia por-símbolo). Sólo el caso 64-bit little-endian (lo que produce zig-cc
/// para `x86_64-linux-musl` y lo único que generamos). Para otros devolvemos error legible: el
/// caller obtiene `error` en el JSON y el agente puede pedir otra cosa.
fn parse_elf_info(bytes: &[u8]) -> Result<ElfInfo, String> {
// ELF header: 16 bytes ident + e_type(2) + e_machine(2) + e_version(4) + e_entry(8) +
// e_phoff(8) + e_shoff(8) + e_flags(4) + e_ehsize(2) + e_phentsize(2) + e_phnum(2) +
// e_shentsize(2) + e_shnum(2) + e_shstrndx(2)
if bytes.len() < 64 {
return Err("archivo demasiado pequeño para ser ELF64".into());
}
if &bytes[0..4] != b"\x7fELF" {
return Err("no es un ELF (magic 0x7fELF ausente)".into());
}
if bytes[4] != 2 {
return Err("sólo ELF64 soportado (EI_CLASS != 2)".into());
}
if bytes[5] != 1 {
return Err("sólo little-endian soportado (EI_DATA != 1)".into());
}
let e_phoff = u64::from_le_bytes(bytes[32..40].try_into().unwrap()) as usize;
let e_phentsize = u16::from_le_bytes(bytes[54..56].try_into().unwrap()) as usize;
let e_phnum = u16::from_le_bytes(bytes[56..58].try_into().unwrap()) as usize;
// Buscamos PT_DYNAMIC (=2).
let mut dyn_off: Option<usize> = None;
let mut dyn_size: Option<usize> = None;
for i in 0..e_phnum {
let off = e_phoff + i * e_phentsize;
if off + 56 > bytes.len() {
return Err("PHDR fuera de rango".into());
}
let p_type = u32::from_le_bytes(bytes[off..off + 4].try_into().unwrap());
if p_type == 2 {
// PT_DYNAMIC
let p_offset = u64::from_le_bytes(bytes[off + 8..off + 16].try_into().unwrap()) as usize;
let p_filesz = u64::from_le_bytes(bytes[off + 32..off + 40].try_into().unwrap()) as usize;
dyn_off = Some(p_offset);
dyn_size = Some(p_filesz);
break;
}
}
let (dyn_off, dyn_size) = match (dyn_off, dyn_size) {
(Some(o), Some(s)) => (o, s),
_ => return Ok(ElfInfo::Static),
};
// Recorremos las entradas DT_*; cada una es 16 bytes (d_tag i64 + d_val u64).
// Acumulamos los offsets de DT_NEEDED y la dirección de DT_STRTAB.
let mut needed_offsets: Vec<usize> = Vec::new();
let mut strtab_addr: Option<u64> = None;
let mut strtab_size: Option<u64> = None;
let mut symtab_addr: Option<u64> = None;
let end = dyn_off + dyn_size;
let mut cur = dyn_off;
while cur + 16 <= end && cur + 16 <= bytes.len() {
let d_tag = i64::from_le_bytes(bytes[cur..cur + 8].try_into().unwrap());
let d_val = u64::from_le_bytes(bytes[cur + 8..cur + 16].try_into().unwrap());
cur += 16;
match d_tag {
0 => break, // DT_NULL
1 => needed_offsets.push(d_val as usize), // DT_NEEDED
5 => strtab_addr = Some(d_val), // DT_STRTAB (virtual address)
6 => symtab_addr = Some(d_val), // DT_SYMTAB (virtual address)
10 => strtab_size = Some(d_val), // DT_STRSZ
_ => {}
}
}
// Sin DT_STRTAB no podemos resolver nombres de nada: ni NEEDED ni exports.
let strtab_addr = match strtab_addr {
Some(a) => a,
None => return Ok(ElfInfo::Dynamic { needed: vec![], exports: vec![] }),
};
let strtab_size = strtab_size.unwrap_or(0);
let strtab_off = vaddr_to_file_off(bytes, e_phoff, e_phentsize, e_phnum, strtab_addr)
.ok_or("DT_STRTAB no cae en ningún PT_LOAD")?;
let strtab_end = if strtab_size > 0 {
(strtab_off + strtab_size as usize).min(bytes.len())
} else {
bytes.len()
};
// NEEDED: cada offset apunta a una cadena en la strtab dinámica.
let mut needed: Vec<String> = Vec::new();
for o in needed_offsets {
if let Some(name) = cstr_at(bytes, strtab_off, strtab_end, o) {
needed.push(name);
}
}
// EXPORTS (ADR 0009): recorremos `.dynsym`. No hay un DT_ que dé el número de entradas;
// usamos el layout convencional (musl/zig) donde la strtab sigue INMEDIATAMENTE a la symtab
// ⇒ nsyms = (strtab_addr - symtab_addr)/24. Si el layout no se cumple (strtab antes que
// symtab, o symtab ausente), devolvemos exports vacío en vez de adivinar mal.
let exports = parse_dynsym_exports(
bytes, e_phoff, e_phentsize, e_phnum, symtab_addr, strtab_addr, strtab_off, strtab_end,
);
Ok(ElfInfo::Dynamic { needed, exports })
}
/// Extrae los símbolos exportados de `.dynsym`: defined (`st_shndx != SHN_UNDEF` y fuera del rango
/// reservado) + binding GLOBAL/WEAK + nombre no vacío. Orden estable (dedup + sort). Best-effort:
/// devuelve `[]` si no puede acotar la tabla con seguridad (ver nota de layout en el caller).
#[allow(clippy::too_many_arguments)]
fn parse_dynsym_exports(
bytes: &[u8],
e_phoff: usize,
e_phentsize: usize,
e_phnum: usize,
symtab_addr: Option<u64>,
strtab_addr: u64,
strtab_off: usize,
strtab_end: usize,
) -> Vec<String> {
const SYM_SIZE: u64 = 24; // sizeof(Elf64_Sym)
let Some(symtab_addr) = symtab_addr else { return vec![] };
// Heurística de layout: la strtab sigue a la symtab. Sin esto no sabemos cuántos símbolos hay.
if strtab_addr <= symtab_addr {
return vec![];
}
let nsyms = (strtab_addr - symtab_addr) / SYM_SIZE;
let Some(symtab_off) = vaddr_to_file_off(bytes, e_phoff, e_phentsize, e_phnum, symtab_addr)
else {
return vec![];
};
let mut exports: Vec<String> = Vec::new();
for i in 0..nsyms as usize {
let off = symtab_off + i * SYM_SIZE as usize;
if off + SYM_SIZE as usize > bytes.len() {
break;
}
let st_name = u32::from_le_bytes(bytes[off..off + 4].try_into().unwrap()) as usize;
let st_info = bytes[off + 4];
let st_shndx = u16::from_le_bytes(bytes[off + 6..off + 8].try_into().unwrap());
if st_name == 0 {
continue; // sin nombre
}
// SHN_UNDEF (0) = importado, no exportado; 0xff00.. = reservado (ABS/COMMON/…).
if st_shndx == 0 || st_shndx >= 0xff00 {
continue;
}
let bind = st_info >> 4; // STB_GLOBAL=1, STB_WEAK=2, STB_LOCAL=0
if bind != 1 && bind != 2 {
continue;
}
if let Some(name) = cstr_at(bytes, strtab_off, strtab_end, st_name) {
exports.push(name);
}
}
exports.sort();
exports.dedup();
exports
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn parse_basic() {
assert_eq!(parse("bin:grep").unwrap(), Term::Bin("grep".into()));
assert_eq!(parse("file:/etc/hosts").unwrap(), Term::File("/etc/hosts".into()));
assert_eq!(parse("pin:musl").unwrap(), Term::Pin("musl".into()));
assert_eq!(parse("service:nginx").unwrap(), Term::Service("nginx".into()));
assert_eq!(parse("depends:/bin/ls").unwrap(), Term::Depends("/bin/ls".into()));
}
#[test]
fn parse_trims_whitespace() {
assert_eq!(parse(" bin : grep ").unwrap(), Term::Bin("grep".into()));
}
#[test]
fn parse_rejects_empty() {
assert!(matches!(parse("").unwrap_err(), QueryError::Empty));
assert!(matches!(parse(" ").unwrap_err(), QueryError::Empty));
}
#[test]
fn parse_rejects_missing_colon() {
assert!(matches!(parse("bingrep").unwrap_err(), QueryError::NoColon(_)));
}
#[test]
fn parse_rejects_unknown_kind() {
let e = parse("foo:bar").unwrap_err();
assert!(matches!(e, QueryError::UnknownKind(ref k) if k == "foo"));
}
#[test]
fn parse_rejects_empty_value() {
assert!(matches!(parse("bin:").unwrap_err(), QueryError::EmptyValue(_)));
}
#[test]
fn eval_bin_finds_under_fs_root() {
let d = tempfile::tempdir().unwrap();
let bin = d.path().join("usr/bin/grep");
std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
std::fs::write(&bin, b"#!/bin/sh\n").unwrap();
let v = eval(
&Term::Bin("grep".into()),
&EvalContext::new().with_fs_root(d.path().to_path_buf()),
);
assert_eq!(v["found"], Value::Bool(true));
assert!(v["path"].as_str().unwrap().ends_with("usr/bin/grep"));
}
#[test]
fn eval_bin_not_found_lists_searched() {
let d = tempfile::tempdir().unwrap();
let v = eval(
&Term::Bin("nonexistent_xyz".into()),
&EvalContext::new().with_fs_root(d.path().to_path_buf()),
);
assert_eq!(v["found"], Value::Bool(false));
assert_eq!(v["name"], Value::String("nonexistent_xyz".into()));
assert!(v["searched"].as_array().unwrap().len() >= 1);
}
#[test]
fn eval_bin_uses_path_env_when_no_fs_root() {
let d = tempfile::tempdir().unwrap();
let bin = d.path().join("grep");
std::fs::write(&bin, b"#!/bin/sh\n").unwrap();
let v = eval(
&Term::Bin("grep".into()),
&EvalContext::new().with_path_env(d.path().display().to_string()),
);
assert_eq!(v["found"], Value::Bool(true));
}
#[test]
fn eval_bin_rejects_slashes() {
let v = eval(&Term::Bin("usr/bin/grep".into()), &EvalContext::new());
assert_eq!(v["found"], Value::Bool(false));
assert!(v["error"].as_str().unwrap().contains("contiene '/'"));
}
#[test]
fn eval_file_rebase() {
let d = tempfile::tempdir().unwrap();
std::fs::create_dir_all(d.path().join("etc")).unwrap();
std::fs::write(d.path().join("etc/hosts"), b"127.0.0.1 lo\n").unwrap();
let v = eval(
&Term::File("/etc/hosts".into()),
&EvalContext::new().with_fs_root(d.path().to_path_buf()),
);
assert_eq!(v["found"], Value::Bool(true));
assert_eq!(v["size"], Value::Number(13u64.into()));
}
#[test]
fn eval_pin_found_and_missing() {
let mut pins = BTreeMap::new();
pins.insert("musl".into(), "deadbeef".into());
let base = BaseRef {
distro_version: "2026-06-06".into(),
pins,
};
let ctx = EvalContext::new().with_base(base);
let v = eval(&Term::Pin("musl".into()), &ctx);
assert_eq!(v["value"], Value::String("deadbeef".into()));
let v2 = eval(&Term::Pin("zlib".into()), &ctx);
assert_eq!(v2["found"], Value::Bool(false));
assert!(v2["available"].as_array().unwrap().iter().any(|s| s == "musl"));
}
#[test]
fn eval_pin_without_base() {
let v = eval(&Term::Pin("x".into()), &EvalContext::new());
assert_eq!(v["found"], Value::Bool(false));
assert!(v["error"].as_str().unwrap().contains("sin BaseRef"));
}
#[test]
fn eval_service_finds_initd() {
let d = tempfile::tempdir().unwrap();
std::fs::create_dir_all(d.path().join("etc/init.d")).unwrap();
std::fs::write(d.path().join("etc/init.d/web"), b"#!/bin/sh\n").unwrap();
let v = eval(
&Term::Service("web".into()),
&EvalContext::new().with_fs_root(d.path().to_path_buf()),
);
assert_eq!(v["found"], Value::Bool(true));
assert!(v["launcher"].as_str().unwrap().ends_with("etc/init.d/web"));
}
#[test]
fn eval_str_parse_error_surfaces_in_json() {
let v = eval_str("foo:bar", &EvalContext::new());
assert!(v["error"].as_str().unwrap().contains("kind 'foo' desconocido"));
}
#[test]
fn parse_elf_rejects_short() {
let r = parse_elf_info(&[0u8; 8]);
assert!(r.is_err());
}
#[test]
fn parse_elf_rejects_bad_magic() {
let mut bytes = [0u8; 64];
bytes[0..4].copy_from_slice(b"XXXX");
assert!(parse_elf_info(&bytes).is_err());
}
#[test]
fn parse_exports_term() {
assert_eq!(parse("exports:/lib/libc.so").unwrap(), Term::Exports("/lib/libc.so".into()));
}
#[test]
fn exports_on_missing_file_reports_error() {
let v = eval(&Term::Exports(PathBuf::from("/no/existe/lib.so")), &EvalContext::new());
assert_eq!(v["found"], Value::Bool(false), "{v}");
assert!(v["error"].is_string(), "{v}");
}
/// Sanity con un binario real del host: `/bin/sh` casi seguro existe y es ELF.
/// Gated por `HAMMER_HOST_ELF_TESTS=1` para no acoplar el suite a Linux/Alpine.
#[test]
fn parse_elf_real_host_binary() {
if std::env::var("HAMMER_HOST_ELF_TESTS").is_err() {
return;
}
let candidates = ["/bin/sh", "/usr/bin/sh", "/bin/ls", "/usr/bin/ls"];
let path = candidates
.iter()
.find(|p| std::path::Path::new(p).is_file())
.expect("ningún binario host encontrado");
let v = eval(&Term::Depends(PathBuf::from(path)), &EvalContext::new());
assert_eq!(v["found"], Value::Bool(true), "{v}");
let is_static = v["static"].as_bool().unwrap();
let needed = v["needed"].as_array().unwrap();
if !is_static {
// Si es dinámico debe declarar al menos una librería (libc).
assert!(!needed.is_empty(), "binario dinámico sin NEEDED: {v}");
}
}
/// Exports sobre una librería compartida real del host (una `.so` con `.dynsym`). Gated
/// igual que arriba: pide `HAMMER_HOST_ELF_TESTS=1`. Verifica que extraemos ≥1 símbolo.
#[test]
fn parse_exports_real_host_lib() {
if std::env::var("HAMMER_HOST_ELF_TESTS").is_err() {
return;
}
let candidates = [
"/lib/libc.so.6",
"/lib64/libc.so.6",
"/usr/lib/libc.so.6",
"/lib/ld-musl-x86_64.so.1",
"/usr/lib/libz.so.1",
];
let Some(path) = candidates.iter().find(|p| std::path::Path::new(p).is_file()) else {
return; // ninguna .so conocida; no fallamos el suite por eso
};
let v = eval(&Term::Exports(PathBuf::from(path)), &EvalContext::new());
assert_eq!(v["found"], Value::Bool(true), "{v}");
if v["static"].as_bool() == Some(false) {
let exports = v["exports"].as_array().unwrap();
assert!(!exports.is_empty(), ".so dinámica sin exports: {path}");
}
}
}
File diff suppressed because it is too large Load Diff
+448
View File
@@ -0,0 +1,448 @@
//! El **repositorio de paquetes** (Etapa F): un directorio con `.swm` + un `index.json` que
//! mapea `nombre → paquete`. Es el catálogo que `hammer install <nombre>` resuelve.
//!
//! Un `.swm` es un manifiesto de mutación sin identidad propia (no lleva un campo "nombre de
//! paquete"); la **identidad** (nombre+versión) la asigna el repo cuando se publica. Esto
//! mantiene el formato honesto (describe una transformación, no se arroga ser "el paquete X")
//! y deja al repo ser el namespace. `hammer pack --repo` publica con el nombre/versión de la
//! receta; `install` resuelve por nombre.
//!
//! El índice es CONTENIDO PLANO (serde_json), no un binario: legible, diffeable, firmable más
//! adelante como una release. El transporte del repo (filesystem / sshfs / mirror) es ortogonal
//! — ver `hammer-mirror` para el CAS del store; aquí el repo es simplemente un directorio.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::sign::{verify_raw, KeyPair, SigStatus, TrustStore};
use crate::swm::Signature;
/// Nombre canónico del índice dentro del directorio del repo.
pub const INDEX_FILE: &str = "index.json";
/// El `digest` de unos bytes de `.swm`, en el mismo formato `b3:…` que un [`crate::ArtifactHash`].
///
/// **BLAKE3 pelado** ([`ArtifactHash::of_bytes`](crate::ArtifactHash::of_bytes)), no
/// `of_inputs(&[bytes])`: un blob se direcciona con el hash que cualquiera obtiene de esos bytes —
/// el de `b3sum` — o el nombre deja de ser verificable por un tercero. Es además el mismo que usa
/// churay para sus blobs, y con `of_inputs` el mismo archivo tendría dos hex distintos bajo el
/// mismo prefijo en los dos proyectos.
pub fn digest_swm(bytes: &[u8]) -> String {
crate::ArtifactHash::of_bytes(bytes).as_str().to_string()
}
/// Comprueba que `bytes` casan con el `digest` que el índice **firmado** declara para esa entrada.
///
/// Un fallo acá no es "descarga corrupta": es que el origen entregó otro contenido del que el
/// release firmó. Se trata como manipulación y aborta.
pub fn verify_digest(entry: &PackageEntry, bytes: &[u8]) -> crate::Result<()> {
let Some(declarado) = entry.digest.as_deref() else {
return Ok(()); // índice anterior al campo: el llamador informa, no bloquea.
};
let real = digest_swm(bytes);
if real != declarado {
return Err(crate::Error::Other(anyhow::anyhow!(
"digest de '{}' no coincide: el índice firmado declara {declarado}, los bytes servidos dan {real} \
— el origen entregó un .swm distinto del que se firmó",
entry.name,
)));
}
Ok(())
}
/// El índice del repo: la lista de paquetes publicados. Un nombre aparece una sola vez
/// (la última publicación gana, ver [`RepoIndex::upsert`]).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RepoIndex {
#[serde(default)]
pub packages: Vec<PackageEntry>,
/// Firma del CATÁLOGO entero (release): cubre la lista de paquetes, no cada `.swm` por
/// separado. Firmar el índice ancla qué paquetes existen, sus versiones y sus hashes — un
/// atacante no puede añadir/quitar/intercambiar entradas sin invalidar la firma. La firma de
/// cada `.swm` (autoría del paquete) es ortogonal y sigue existiendo.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<Signature>,
}
/// Una entrada del índice: la identidad de un paquete + el puntero a su `.swm` y los metadatos
/// que `install` necesita para decidir ANTES de leer el `.swm` (base compatible, autoría).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PackageEntry {
pub name: String,
pub version: String,
/// Ruta del `.swm` relativa al directorio del repo.
pub file: String,
/// `base.distro_version` del `.swm` — permite filtrar incompatibles sin abrir el manifiesto.
pub distro_version: String,
/// Build-deps del paquete por NOMBRE (de la receta original) — las que el receptor debe tener
/// como paquetes en el repo para reproducir. `install` resuelve este grafo desde el índice sin
/// abrir cada `.swm`. Vacío ⇒ paquete autocontenido.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deps: Vec<String>,
/// `expected_hash` del `source_patch` (si lo lleva): el ancla "verificar, no confiar".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_hash: Option<String>,
/// BLAKE3 (`b3:…`) de los BYTES del `.swm`, para que el índice firmado ancle **todo** lo que
/// el cliente descarga (ADR 0014).
///
/// # Por qué hace falta para servir desde espejos ajenos
///
/// La firma del índice cubre la lista de entradas — nombre, versión, `file`, deps — pero `file`
/// es una RUTA, no un contenido: sin este campo, quien sirva los bytes puede devolver otro
/// `.swm` bajo el mismo nombre y la firma sigue casando. Aguas abajo hay red (el `apply`
/// reconstruye desde fuente y compara contra `expected_hash`), pero `expected_hash` es opcional
/// y el `.swm` se lee ANTES de llegar ahí — el gate de colisiones de `install` ya decide con su
/// contenido. Con `digest`, la cadena queda cerrada de punta a punta: clave raíz → firma del
/// índice → digest → bytes, y **el origen deja de necesitar confianza**, que es justo la
/// condición para poder replicar en N espejos que no controlamos.
///
/// `None` en índices anteriores a este campo: se informa y no se bloquea, igual que el resto de
/// verificaciones de la CLI. Es opcional en el tipo, no en el protocolo.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub digest: Option<String>,
/// Autor de la firma del `.swm` (si está firmado). `install --trust` lo valida de verdad
/// contra la clave; este campo es sólo el metadato legible del índice.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signed_by: Option<String>,
}
impl RepoIndex {
/// Carga el índice del repo. Un directorio sin `index.json` es un repo VACÍO válido (no es
/// error) — así `pack --repo <dir-nuevo>` arranca un repo desde cero.
pub fn load(repo_dir: impl AsRef<Path>) -> crate::Result<RepoIndex> {
let path = repo_dir.as_ref().join(INDEX_FILE);
match std::fs::read_to_string(&path) {
Ok(text) => serde_json::from_str(&text)
.map_err(|e| crate::Error::Serde(format!("{}: {e}", path.display()))),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(RepoIndex::default()),
Err(e) => Err(crate::Error::Io(e)),
}
}
/// Escribe el índice (creando el directorio del repo si hace falta). JSON con saltos para
/// que el repo sea diffeable.
pub fn save(&self, repo_dir: impl AsRef<Path>) -> crate::Result<()> {
let dir = repo_dir.as_ref();
std::fs::create_dir_all(dir)?;
let text = serde_json::to_string_pretty(self)
.map_err(|e| crate::Error::Serde(e.to_string()))?;
std::fs::write(dir.join(INDEX_FILE), text)?;
Ok(())
}
/// Resuelve un nombre a su entrada (la última publicada).
pub fn find(&self, name: &str) -> Option<&PackageEntry> {
self.packages.iter().find(|p| p.name == name)
}
/// Inserta o reemplaza la entrada de un nombre (publicar es idempotente por nombre: una
/// nueva versión del mismo paquete sustituye a la anterior en el índice). Devuelve el
/// `.swm` viejo que quedó huérfano, si la ruta cambió (para que el caller lo borre).
pub fn upsert(&mut self, entry: PackageEntry) -> Option<String> {
// Cualquier cambio al catálogo invalida una firma de release previa: hay que re-firmar.
self.signature = None;
if let Some(slot) = self.packages.iter_mut().find(|p| p.name == entry.name) {
let old_file = slot.file.clone();
let orphan = (old_file != entry.file).then_some(old_file);
*slot = entry;
orphan
} else {
self.packages.push(entry);
self.packages.sort_by(|a, b| a.name.cmp(&b.name));
None
}
}
/// Ruta absoluta del `.swm` de una entrada, relativa al directorio del repo.
pub fn swm_path(repo_dir: impl AsRef<Path>, entry: &PackageEntry) -> PathBuf {
repo_dir.as_ref().join(&entry.file)
}
/// Bytes canónicos que firma el release: SÓLO la lista de paquetes (excluye la propia firma).
/// `PackageEntry` es todo strings/vecs en orden fijo y `packages` está ordenado por nombre
/// (ver `upsert`), así que firmante y verificador obtienen los mismos bytes.
fn signing_bytes(&self) -> Vec<u8> {
serde_json::to_vec(&self.packages).expect("Vec<PackageEntry> siempre serializa")
}
/// Firma el catálogo con `kp` (autoría `by`). Reemplaza cualquier firma previa.
pub fn sign(&mut self, kp: &KeyPair, by: &str) {
let sig = kp.sign_raw(&self.signing_bytes(), by);
self.signature = Some(sig);
}
/// Verifica la firma del catálogo contra un [`TrustStore`]. `Unsigned` si no hay firma.
pub fn verify_signature(&self, trust: &TrustStore) -> SigStatus {
match &self.signature {
None => SigStatus::Unsigned,
Some(sig) => verify_raw(&self.signing_bytes(), sig, trust),
}
}
/// Resuelve el cierre transitivo de build-deps de `name` en orden TOPOLÓGICO (cada dep antes
/// que quien la necesita; `name` queda al final). Es el orden en que `install` debe procesar:
/// poblar el catálogo con las deps antes de construir el dependiente. Error si una dep falta
/// del índice (no se puede reproducir sin ella) o si hay un ciclo.
pub fn resolve_closure<'a>(&'a self, name: &str) -> crate::Result<Vec<&'a PackageEntry>> {
let mut order: Vec<&'a PackageEntry> = Vec::new();
let mut done: Vec<&'a str> = Vec::new(); // ya emitidos
let mut stack: Vec<&'a str> = Vec::new(); // en la rama DFS actual (detección de ciclo)
self.visit(name, &mut order, &mut done, &mut stack)?;
Ok(order)
}
fn visit<'a>(
&'a self,
name: &str,
order: &mut Vec<&'a PackageEntry>,
done: &mut Vec<&'a str>,
stack: &mut Vec<&'a str>,
) -> crate::Result<()> {
if done.iter().any(|d| *d == name) {
return Ok(());
}
if stack.iter().any(|s| *s == name) {
return Err(crate::Error::Recipe(format!(
"ciclo de dependencias en el repo: {}{name}",
stack.join("")
)));
}
let entry = self.find(name).ok_or_else(|| {
crate::Error::Recipe(format!(
"dependencia '{name}' no está en el repo (necesaria por {})",
stack.last().copied().unwrap_or("(raíz)")
))
})?;
// Guardamos refs con la vida de `self` (no del parámetro `name`): el `.swm` y su nombre
// viven en el índice. `name` sólo sirve para buscar y comparar.
stack.push(entry.name.as_str());
for dep in &entry.deps {
self.visit(dep, order, done, stack)?;
}
stack.pop();
done.push(entry.name.as_str());
order.push(entry);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(name: &str, ver: &str) -> PackageEntry {
entry_deps(name, ver, &[])
}
fn entry_deps(name: &str, ver: &str, deps: &[&str]) -> PackageEntry {
PackageEntry {
name: name.into(),
version: ver.into(),
file: format!("{name}-{ver}.swm"),
distro_version: "dev".into(),
expected_hash: None,
digest: None,
signed_by: None,
deps: deps.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn digest_detecta_un_swm_cambiado() {
let mut e = entry("foo", "1");
let bytes = b"contenido del .swm";
// Sin digest (indice viejo): no bloquea. Es opcional en el tipo, no en el protocolo.
assert!(verify_digest(&e, bytes).is_ok());
e.digest = Some(digest_swm(bytes));
assert!(verify_digest(&e, bytes).is_ok());
// Un solo byte distinto bajo el MISMO nombre de fichero — que es exactamente lo que puede
// hacer un espejo que no controlamos — y la firma del índice sigue casando sin este campo.
let err = verify_digest(&e, b"contenido del .swm!")
.unwrap_err()
.to_string();
assert!(err.contains("no coincide"), "mensaje inesperado: {err}");
assert!(
err.contains("foo"),
"el error debe nombrar el paquete: {err}"
);
}
/// Guardián de interoperabilidad: el `digest` tiene que ser el BLAKE3 que obtiene CUALQUIERA
/// de esos bytes (`b3sum`), no `of_inputs(&[bytes])`. Es la §5 del SDD de churay: el mismo
/// archivo con dos hex distintos bajo el mismo prefijo `b3:` no falla ruidosamente — los dos
/// lados buscan nombres distintos para los mismos bytes.
#[test]
fn digest_es_blake3_pelado_y_no_length_prefijado() {
let bytes = b"contenido del .swm";
assert_eq!(
digest_swm(bytes),
crate::ArtifactHash::of_bytes(bytes).as_str()
);
assert_ne!(
digest_swm(bytes),
crate::ArtifactHash::of_inputs(&[bytes.as_slice()]).as_str(),
"volver a of_inputs rompe el CAS compartido con churay, y en silencio"
);
// Vector fijo: si `of_bytes` cambiara de convención, esto lo caza igual.
assert_eq!(
digest_swm(b"abc"),
"b3:6437b3ac38465133ffb63b75273a8db548c558465d79db03fd359c6cd5bd9d85"
);
}
#[test]
fn digest_ausente_no_cambia_la_serializacion() {
// `skip_serializing_if` ⇒ un índice ya firmado antes de este campo serializa IDÉNTICO y su
// firma sigue siendo válida. Añadir el campo no invalida releases existentes.
let json = serde_json::to_string(&entry("foo", "1")).unwrap();
assert!(!json.contains("digest"), "{json}");
}
#[test]
fn load_missing_index_is_empty_repo() {
let d = tempfile::tempdir().unwrap();
let idx = RepoIndex::load(d.path()).unwrap();
assert!(idx.packages.is_empty());
}
#[test]
fn save_load_roundtrip_and_find() {
let d = tempfile::tempdir().unwrap();
let mut idx = RepoIndex::default();
idx.upsert(entry("ripgrep", "14.1.1"));
idx.upsert(entry("findutils", "0.9.1"));
idx.save(d.path()).unwrap();
let back = RepoIndex::load(d.path()).unwrap();
assert_eq!(back.packages.len(), 2);
assert_eq!(back.find("ripgrep").unwrap().version, "14.1.1");
assert!(back.find("nope").is_none());
// Orden estable (alfabético) ⇒ índice diffeable.
assert_eq!(back.packages[0].name, "findutils");
}
#[test]
fn upsert_replaces_and_reports_orphan() {
let mut idx = RepoIndex::default();
idx.upsert(entry("rg", "1.0.0"));
// Nueva versión ⇒ archivo distinto ⇒ el viejo queda huérfano.
let orphan = idx.upsert(entry("rg", "2.0.0"));
assert_eq!(orphan.as_deref(), Some("rg-1.0.0.swm"));
assert_eq!(idx.packages.len(), 1, "no duplica por nombre");
assert_eq!(idx.find("rg").unwrap().version, "2.0.0");
}
#[test]
fn upsert_same_file_no_orphan() {
let mut idx = RepoIndex::default();
idx.upsert(entry("rg", "1.0.0"));
let orphan = idx.upsert(entry("rg", "1.0.0"));
assert!(orphan.is_none());
}
fn names<'a>(v: &[&'a PackageEntry]) -> Vec<&'a str> {
v.iter().map(|e| e.name.as_str()).collect()
}
#[test]
fn closure_topological_deps_before_dependents() {
// openssh → zlib, openssl ; openssl no tiene deps. Orden: deps antes, target al final.
let mut idx = RepoIndex::default();
idx.upsert(entry("zlib", "1.3"));
idx.upsert(entry("openssl", "3.5"));
idx.upsert(entry_deps("openssh", "10.3", &["zlib", "openssl"]));
let order = names(&idx.resolve_closure("openssh").unwrap());
// zlib y openssl preceden a openssh; openssh es el último.
assert_eq!(*order.last().unwrap(), "openssh");
let p = |n| order.iter().position(|x| *x == n).unwrap();
assert!(p("zlib") < p("openssh") && p("openssl") < p("openssh"));
}
#[test]
fn closure_dedups_diamond() {
// a→b,c ; b→d ; c→d. d aparece una sola vez, antes que b y c.
let mut idx = RepoIndex::default();
idx.upsert(entry("d", "1"));
idx.upsert(entry_deps("b", "1", &["d"]));
idx.upsert(entry_deps("c", "1", &["d"]));
idx.upsert(entry_deps("a", "1", &["b", "c"]));
let order = names(&idx.resolve_closure("a").unwrap());
assert_eq!(order.iter().filter(|x| **x == "d").count(), 1);
let p = |n| order.iter().position(|x| *x == n).unwrap();
assert!(p("d") < p("b") && p("d") < p("c") && p("b") < p("a"));
}
#[test]
fn closure_missing_dep_errors() {
let mut idx = RepoIndex::default();
idx.upsert(entry_deps("openssh", "10.3", &["openssl"])); // openssl ausente
let err = idx.resolve_closure("openssh").unwrap_err().to_string();
assert!(err.contains("openssl") && err.contains("no está en el repo"), "{err}");
}
#[test]
fn closure_cycle_errors() {
let mut idx = RepoIndex::default();
idx.upsert(entry_deps("a", "1", &["b"]));
idx.upsert(entry_deps("b", "1", &["a"]));
let err = idx.resolve_closure("a").unwrap_err().to_string();
assert!(err.contains("ciclo"), "{err}");
}
#[test]
fn release_sign_then_verify_trusted() {
let kp = crate::sign::KeyPair::generate().unwrap();
let mut idx = RepoIndex::default();
idx.upsert(entry("rg", "14.1.1"));
idx.upsert(entry("findutils", "0.9.1"));
idx.sign(&kp, "sergio");
let mut trust = TrustStore::new();
trust.insert_b64("sergio", &kp.public_b64()).unwrap();
assert_eq!(idx.verify_signature(&trust), SigStatus::Trusted { by: "sergio".into() });
}
#[test]
fn release_signature_survives_save_load() {
let d = tempfile::tempdir().unwrap();
let kp = crate::sign::KeyPair::generate().unwrap();
let mut idx = RepoIndex::default();
idx.upsert(entry("rg", "14.1.1"));
idx.sign(&kp, "sergio");
idx.save(d.path()).unwrap();
let back = RepoIndex::load(d.path()).unwrap();
let mut trust = TrustStore::new();
trust.insert_b64("sergio", &kp.public_b64()).unwrap();
assert!(back.verify_signature(&trust).is_trusted());
}
#[test]
fn upsert_invalidates_release_signature() {
let kp = crate::sign::KeyPair::generate().unwrap();
let mut idx = RepoIndex::default();
idx.upsert(entry("rg", "1"));
idx.sign(&kp, "sergio");
assert!(idx.signature.is_some());
// Publicar otro paquete invalida la firma (catálogo cambió ⇒ re-firmar).
idx.upsert(entry("fd", "1"));
assert!(idx.signature.is_none(), "upsert debe invalidar la firma de release");
}
#[test]
fn release_tamper_is_bad_sig() {
let kp = crate::sign::KeyPair::generate().unwrap();
let mut idx = RepoIndex::default();
idx.upsert(entry("rg", "1"));
idx.sign(&kp, "sergio");
// Manipular una entrada SIN re-firmar (p. ej. cambiar el hash esperado de un paquete).
idx.packages[0].version = "666".into();
let mut trust = TrustStore::new();
trust.insert_b64("sergio", &kp.public_b64()).unwrap();
assert!(matches!(idx.verify_signature(&trust), SigStatus::BadSig { .. }));
}
}
+400
View File
@@ -0,0 +1,400 @@
//! Firma Ed25519 del `.swm` y `TrustStore` local. Ver `docs/09-trust-model.md` §3 y §5.
//!
//! La firma cubre **el contenido del manifiesto** (`swm_version` + `base` + `mutations`), no la
//! propia firma. Sirve para autoría e integridad en tránsito — **no** sustituye a la
//! verificación reproducible: `hammer apply` siempre reproduce y compara, firme o no.
//!
//! Formato en disco (texto, una línea base64 cada uno):
//! - clave privada: `<name>.ed25519` → base64(semilla de 32 bytes), modo 0600
//! - clave pública: `<name>.ed25519.pub` → base64(clave pública de 32 bytes)
use std::collections::BTreeMap;
use std::path::Path;
use base64::Engine;
use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey, SECRET_KEY_LENGTH};
use serde::Serialize;
use crate::swm::{Base, Mutation, Signature, Swm};
const ALG: &str = "ed25519";
const PRIV_EXT: &str = ".ed25519";
const PUB_EXT: &str = ".ed25519.pub";
fn b64() -> base64::engine::GeneralPurpose {
base64::engine::general_purpose::STANDARD
}
/// Estado de la firma de un `.swm` respecto a un [`TrustStore`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SigStatus {
/// Firma válida y la clave del autor está en el TrustStore.
Trusted { by: String },
/// Firma presente pero el autor no está entre las claves de confianza locales.
UnknownKey { by: String },
/// Hay firma pero no valida (contenido alterado, clave equivocada, o `alg` no soportado).
BadSig { by: String, reason: String },
/// El `.swm` no trae firma.
Unsigned,
}
impl SigStatus {
/// ¿La firma es válida y de un autor confiado? Recuerda: NO implica permiso para promover
/// (eso depende de la verificación reproducible + el `commit` humano).
pub fn is_trusted(&self) -> bool {
matches!(self, SigStatus::Trusted { .. })
}
/// Etiqueta corta para UI/logs.
pub fn label(&self) -> &'static str {
match self {
SigStatus::Trusted { .. } => "trusted",
SigStatus::UnknownKey { .. } => "unknown-key",
SigStatus::BadSig { .. } => "bad-sig",
SigStatus::Unsigned => "unsigned",
}
}
}
/// Par de claves Ed25519 envoltorio fino sobre `ed25519-dalek`.
pub struct KeyPair {
signing: SigningKey,
}
impl KeyPair {
/// Genera un par nuevo con entropía del sistema (`getrandom`).
pub fn generate() -> crate::Result<KeyPair> {
let mut seed = [0u8; SECRET_KEY_LENGTH];
getrandom::getrandom(&mut seed)
.map_err(|e| crate::Error::Other(anyhow::anyhow!("getrandom: {e}")))?;
Ok(KeyPair {
signing: SigningKey::from_bytes(&seed),
})
}
/// La clave pública en base64 (lo que va a `<name>.ed25519.pub`).
pub fn public_b64(&self) -> String {
b64().encode(self.signing.verifying_key().to_bytes())
}
/// La clave privada (semilla) en base64. Trátala como secreto.
pub fn private_b64(&self) -> String {
b64().encode(self.signing.to_bytes())
}
/// Carga una clave privada desde su forma base64 (contenido de `<name>.ed25519`).
pub fn from_private_b64(s: &str) -> crate::Result<KeyPair> {
let bytes = b64()
.decode(s.trim())
.map_err(|e| crate::Error::Other(anyhow::anyhow!("clave privada base64: {e}")))?;
let seed: [u8; SECRET_KEY_LENGTH] = bytes.as_slice().try_into().map_err(|_| {
crate::Error::Other(anyhow::anyhow!(
"clave privada: se esperaban {SECRET_KEY_LENGTH} bytes, hay {}",
bytes.len()
))
})?;
Ok(KeyPair {
signing: SigningKey::from_bytes(&seed),
})
}
/// Escribe ambos ficheros en `dir`: `<name>.ed25519` (0600) y `<name>.ed25519.pub`.
/// Devuelve la ruta de la clave pública.
pub fn write_to(&self, dir: &Path, name: &str) -> crate::Result<std::path::PathBuf> {
std::fs::create_dir_all(dir)?;
let priv_path = dir.join(format!("{name}{PRIV_EXT}"));
let pub_path = dir.join(format!("{name}{PUB_EXT}"));
std::fs::write(&priv_path, format!("{}\n", self.private_b64()))?;
std::fs::write(&pub_path, format!("{}\n", self.public_b64()))?;
// La privada es secreta: rwx para el dueño solamente.
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&priv_path, std::fs::Permissions::from_mode(0o600))?;
Ok(pub_path)
}
/// Firma un `.swm` y devuelve la `Signature` (autoría `by`).
pub fn sign(&self, swm: &Swm, by: &str) -> Signature {
self.sign_raw(&signing_bytes(swm), by)
}
/// Firma bytes canónicos arbitrarios. Es el primitivo que comparten la firma del `.swm` y la
/// firma de un índice de repo (release): el llamador decide qué bytes canónicos firmar.
pub fn sign_raw(&self, msg: &[u8], by: &str) -> Signature {
let sig = self.signing.sign(msg);
Signature {
by: by.to_string(),
alg: ALG.to_string(),
sig: b64().encode(sig.to_bytes()),
}
}
}
/// Verifica una `Signature` sobre bytes canónicos arbitrarios contra un [`TrustStore`]. El
/// primitivo compartido por `Swm::verify_signature` y `RepoIndex::verify_signature`.
pub fn verify_raw(msg: &[u8], sig: &Signature, trust: &TrustStore) -> SigStatus {
if sig.alg != ALG {
return SigStatus::BadSig {
by: sig.by.clone(),
reason: format!("alg no soportado: {} (esperado {ALG})", sig.alg),
};
}
let vk = match trust.get(&sig.by) {
None => return SigStatus::UnknownKey { by: sig.by.clone() },
Some(vk) => vk,
};
let raw = match b64().decode(sig.sig.trim()) {
Ok(r) => r,
Err(e) => {
return SigStatus::BadSig {
by: sig.by.clone(),
reason: format!("firma base64: {e}"),
}
}
};
let bytes: [u8; 64] = match raw.as_slice().try_into() {
Ok(b) => b,
Err(_) => {
return SigStatus::BadSig {
by: sig.by.clone(),
reason: format!("firma: se esperaban 64 bytes, hay {}", raw.len()),
}
}
};
let signature = ed25519_dalek::Signature::from_bytes(&bytes);
match vk.verify(msg, &signature) {
Ok(()) => SigStatus::Trusted { by: sig.by.clone() },
Err(_) => SigStatus::BadSig {
by: sig.by.clone(),
reason: "la firma no corresponde al contenido firmado".into(),
},
}
}
/// Claves públicas en las que el usuario confía para *autoría*. Ver `docs/09-trust-model.md` §5.
#[derive(Debug, Default)]
pub struct TrustStore {
keys: BTreeMap<String, VerifyingKey>,
}
impl TrustStore {
pub fn new() -> TrustStore {
TrustStore::default()
}
/// Carga todas las claves `*.ed25519.pub` de un directorio (p. ej.
/// `/var/lib/hammer/trust/`). El nombre del autor es el del fichero sin sufijo. Un
/// directorio inexistente da un store vacío (no es error: nadie confiado todavía).
pub fn load(dir: &Path) -> crate::Result<TrustStore> {
let mut store = TrustStore::new();
let rd = match std::fs::read_dir(dir) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(store),
Err(e) => return Err(e.into()),
};
for entry in rd {
let entry = entry?;
let fname = entry.file_name();
let fname = fname.to_string_lossy();
if let Some(name) = fname.strip_suffix(PUB_EXT) {
let content = std::fs::read_to_string(entry.path())?;
let vk = parse_public_b64(content.trim()).map_err(|e| {
crate::Error::Other(anyhow::anyhow!("{}: {e}", entry.path().display()))
})?;
store.keys.insert(name.to_string(), vk);
}
}
Ok(store)
}
/// Añade/reemplaza una clave de confianza desde su base64.
pub fn insert_b64(&mut self, name: &str, public_b64: &str) -> crate::Result<()> {
let vk = parse_public_b64(public_b64.trim())
.map_err(|e| crate::Error::Other(anyhow::anyhow!(e)))?;
self.keys.insert(name.to_string(), vk);
Ok(())
}
pub fn get(&self, name: &str) -> Option<&VerifyingKey> {
self.keys.get(name)
}
pub fn len(&self) -> usize {
self.keys.len()
}
pub fn is_empty(&self) -> bool {
self.keys.is_empty()
}
}
fn parse_public_b64(s: &str) -> Result<VerifyingKey, String> {
let bytes = b64().decode(s).map_err(|e| format!("clave pública base64: {e}"))?;
let arr: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| format!("clave pública: se esperaban 32 bytes, hay {}", bytes.len()))?;
VerifyingKey::from_bytes(&arr).map_err(|e| format!("clave pública inválida: {e}"))
}
/// Bytes canónicos firmados: el contenido del manifiesto SIN la firma, en JSON estable.
/// `base.pins` es un `BTreeMap` (orden por clave) y el resto son campos de orden fijo, así que
/// firmante y verificador obtienen exactamente los mismos bytes.
fn signing_bytes(swm: &Swm) -> Vec<u8> {
#[derive(Serialize)]
struct Signable<'a> {
swm_version: u32,
base: &'a Base,
mutations: &'a [Mutation],
}
let signable = Signable {
swm_version: swm.swm_version,
base: &swm.base,
mutations: &swm.mutations,
};
// serde_json sobre tipos sin floats ni mapas no ordenados ⇒ salida determinista.
serde_json::to_vec(&signable).expect("Signable siempre serializa")
}
impl Swm {
/// Verifica la firma del manifiesto contra un [`TrustStore`]. Ver SDD 09 §5.
pub fn verify_signature(&self, trust: &TrustStore) -> SigStatus {
match &self.signature {
None => SigStatus::Unsigned,
Some(sig) => verify_raw(&signing_bytes(self), sig, trust),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::swm::{Base, Mutation, Swm};
fn demo_swm() -> Swm {
Swm {
swm_version: 1,
base: Base {
distro_version: "2026-06-06".into(),
pins: [("grep".to_string(), "a1b2".to_string())].into_iter().collect(),
},
mutations: vec![Mutation::ConfigEdit {
file: "/etc/network.conf".into(),
inline_diff: "- DHCP=yes\n+ IP=10.0.0.1\n".into(),
}],
signature: None,
}
}
#[test]
fn sign_then_verify_trusted() {
let kp = KeyPair::generate().unwrap();
let mut swm = demo_swm();
swm.signature = Some(kp.sign(&swm, "sergio"));
let mut trust = TrustStore::new();
trust.insert_b64("sergio", &kp.public_b64()).unwrap();
assert_eq!(
swm.verify_signature(&trust),
SigStatus::Trusted { by: "sergio".into() }
);
}
#[test]
fn unsigned_is_unsigned() {
assert_eq!(demo_swm().verify_signature(&TrustStore::new()), SigStatus::Unsigned);
}
#[test]
fn unknown_key() {
let kp = KeyPair::generate().unwrap();
let mut swm = demo_swm();
swm.signature = Some(kp.sign(&swm, "desconocido"));
// TrustStore vacío ⇒ autor desconocido.
match swm.verify_signature(&TrustStore::new()) {
SigStatus::UnknownKey { by } => assert_eq!(by, "desconocido"),
other => panic!("esperaba UnknownKey, obtuve {other:?}"),
}
}
#[test]
fn tampered_content_is_bad_sig() {
let kp = KeyPair::generate().unwrap();
let mut swm = demo_swm();
swm.signature = Some(kp.sign(&swm, "sergio"));
// Alteramos el contenido DESPUÉS de firmar.
if let Mutation::ConfigEdit { inline_diff, .. } = &mut swm.mutations[0] {
*inline_diff = "- DHCP=yes\n+ IP=10.0.0.2\n".into(); // .1 → .2
}
let mut trust = TrustStore::new();
trust.insert_b64("sergio", &kp.public_b64()).unwrap();
assert!(matches!(
swm.verify_signature(&trust),
SigStatus::BadSig { .. }
));
}
#[test]
fn wrong_key_is_bad_sig() {
let kp = KeyPair::generate().unwrap();
let other = KeyPair::generate().unwrap();
let mut swm = demo_swm();
swm.signature = Some(kp.sign(&swm, "sergio"));
// El TrustStore tiene a "sergio" pero con OTRA clave pública.
let mut trust = TrustStore::new();
trust.insert_b64("sergio", &other.public_b64()).unwrap();
assert!(matches!(
swm.verify_signature(&trust),
SigStatus::BadSig { .. }
));
}
#[test]
fn keypair_roundtrip_through_disk() {
let dir = tempfile::tempdir().unwrap();
let kp = KeyPair::generate().unwrap();
kp.write_to(dir.path(), "sergio").unwrap();
// Recarga privada y verifica que firma con la misma identidad.
let priv_s = std::fs::read_to_string(dir.path().join("sergio.ed25519")).unwrap();
let loaded = KeyPair::from_private_b64(&priv_s).unwrap();
assert_eq!(loaded.public_b64(), kp.public_b64());
// La pública en disco carga en un TrustStore por nombre de fichero.
let trust = TrustStore::load(dir.path()).unwrap();
assert_eq!(trust.len(), 1);
assert!(trust.get("sergio").is_some());
// Permisos 0600 en la privada.
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(dir.path().join("sergio.ed25519"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn load_missing_dir_is_empty() {
let t = TrustStore::load(Path::new("/no/existe/aqui")).unwrap();
assert!(t.is_empty());
}
#[test]
fn signing_bytes_ignore_signature_field() {
// Firmar no debe depender del campo signature: dos SWM idénticos salvo signature
// producen los mismos bytes firmados.
let mut a = demo_swm();
let b = demo_swm();
a.signature = Some(Signature {
by: "x".into(),
alg: "ed25519".into(),
sig: "AAAA".into(),
});
assert_eq!(signing_bytes(&a), signing_bytes(&b));
}
}
+472
View File
@@ -0,0 +1,472 @@
//! El content-addressed store. Ver `docs/03-hydration.md` §1.
//!
//! Inmutable, append-only, direccionado por `ArtifactHash`. `seal` mueve el árbol de salida
//! del sandbox a la ruta del store y lo marca read-only recursivo.
use std::path::{Path, PathBuf};
use crate::hash::ArtifactHash;
use crate::recipe::Recipe;
/// Ruta relativa, dentro de cada artefacto sellado, donde el lab guarda la receta original
/// como sidecar de provenance. Estable: el receptor de un `.swm` exportado puede asumir
/// este path para reconstruir la receta de un artefacto cualquiera.
pub const RECIPE_SIDECAR_REL: &str = ".hammer/recipe.toml";
pub struct Store {
root: PathBuf,
}
impl Store {
/// Abre (o prepara) un store en `root` (p. ej. `/store`).
pub fn open(root: impl Into<PathBuf>) -> crate::Result<Self> {
let root = root.into();
std::fs::create_dir_all(&root)?;
Ok(Store { root })
}
pub fn root(&self) -> &Path {
&self.root
}
/// Ruta del artefacto en el store para un hash + nombre legible.
pub fn path_of(&self, h: &ArtifactHash, name: &str) -> PathBuf {
self.root.join(h.store_dir_name(name))
}
/// ¿Ya existe el artefacto? (caché del lab; ver `docs/02-build-lab.md` §5).
///
/// Exige el sidecar `.hammer/recipe.toml`, **no** que el directorio exista. Un `is_dir()` pelado
/// daba por presente un directorio VACÍO, y eso no es un caso hipotético (2026-08-10): el
/// respaldo tenía 7 artefactos vacíos —de una subida `--files-from` sin `-r`—, `rsync` los bajó
/// como directorios sin contenido y `build()` hizo cache-hit sobre ellos: **selló sin construir
/// y devolvió Ok**. Cuatro recetas dieron OK sin producir un solo fichero.
///
/// El criterio es "tiene al menos una entrada", NO el sidecar `.hammer/recipe.toml`: ese lo
/// escriben los llamantes (`hammer-build`, `hammer-cli`), no `seal()`, y **`hammer-bootstrap`
/// sella sin él** ⇒ exigirlo haría que el bootstrap se creyera nunca sellado y reconstruyera
/// siempre. Un artefacto de verdad nunca está vacío, así que esto basta para el fallo real.
///
/// Lo que esto NO cubre: una copia PARCIAL (algún fichero, no todos). Para integridad de
/// contenido están `of_tree` y `hammer attest`; esto es la caché, no la atestación.
pub fn has(&self, h: &ArtifactHash, name: &str) -> bool {
let dir = self.path_of(h, name);
// `read_dir(..).next().is_some()`: una sola entrada basta, no recorre el árbol.
std::fs::read_dir(&dir)
.map(|mut e| e.next().is_some())
.unwrap_or(false)
}
/// Resuelve el directorio de un artefacto a partir de un prefijo del hex de su hash
/// (con o sin `b3:`), sin necesidad de conocer el `name`. Devuelve error si no hay
/// match o si el prefijo es ambiguo (lista los candidatos).
pub fn find_by_hash(&self, hex_or_prefixed: &str) -> crate::Result<PathBuf> {
let prefix = hex_or_prefixed
.strip_prefix("b3:")
.unwrap_or(hex_or_prefixed);
if prefix.is_empty() {
return Err(crate::Error::Store("hash vacío".into()));
}
let mut matches = Vec::new();
let read = std::fs::read_dir(&self.root)?;
for entry in read {
let entry = entry?;
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if name.starts_with(prefix) && entry.file_type()?.is_dir() {
matches.push(entry.path());
}
}
match matches.len() {
0 => Err(crate::Error::Store(format!(
"ningún artefacto en {} con prefix '{prefix}'",
self.root.display()
))),
1 => Ok(matches.pop().unwrap()),
_ => Err(crate::Error::Store(format!(
"prefix '{prefix}' ambiguo, {} candidatos: {}",
matches.len(),
matches
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
))),
}
}
/// Lee la receta sidecar (`.hammer/recipe.toml`) dentro de un artefacto sellado, si
/// existe. Devuelve `None` para artefactos viejos sin sidecar (builds anteriores a la
/// introducción del provenance) o cuando el sidecar fue eliminado a mano. Es
/// best-effort: nunca falla por ausencia, sólo por sidecar corrupto.
pub fn recipe_for_dir(&self, artifact_dir: &Path) -> crate::Result<Option<Recipe>> {
let p = artifact_dir.join(RECIPE_SIDECAR_REL);
if !p.is_file() {
return Ok(None);
}
let text = std::fs::read_to_string(&p)?;
Recipe::from_toml(&text).map(Some)
}
/// Como `recipe_for_dir` pero resuelve el artefacto por hash (con o sin `b3:`).
pub fn recipe_for_hash(&self, hex_or_prefixed: &str) -> crate::Result<Option<Recipe>> {
let dir = match self.find_by_hash(hex_or_prefixed) {
Ok(d) => d,
Err(_) => return Ok(None),
};
self.recipe_for_dir(&dir)
}
/// Sella el árbol de salida de un build en el store bajo su hash.
///
/// - Si el destino ya existe (caché), devuelve la ruta sin tocar `out_dir`.
/// - Si no, mueve `out_dir` al destino y marca el árbol read-only (todos los archivos
/// pierden `w`; directorios mantienen `x` para poder atravesarlos).
///
/// Camino rápido: `rename` atómico cuando `out_dir` y el store comparten filesystem.
/// Camino EXDEV (Etapa B3, `/store` es una partición dedicada ≠ la de `/work`): `rename(2)`
/// no cruza filesystems ⇒ caemos a copia recursiva a un staging DENTRO del store + rename
/// store-interno (mismo FS, atómico) + borrado del original. La publicación sigue siendo
/// atómica (nadie ve el `dst` a medio escribir).
pub fn seal(
&self,
out_dir: &Path,
h: &ArtifactHash,
name: &str,
) -> crate::Result<PathBuf> {
let dst = self.path_of(h, name);
if dst.is_dir() {
// ── UN DIRECTORIO VACÍO NO ES UN ARTEFACTO, ES UN NOMBRE ──────────────────────────
// `has()` (arriba) ya define presencia como "tiene AL MENOS una entrada". Acá se usaba
// `is_dir()`, que también es verdadero para un directorio VACÍO ⇒ `seal` devolvía Ok,
// TIRABA el árbol recién construido y el build logueaba `sealed` y salía 0. Dos
// nociones distintas de "está" en el mismo struct, y la de `seal` es la que decide si
// el trabajo se guarda.
//
// Medido en el worker dev.gioser.net (2026-08-29): 1457 directorios vacíos en el store
// se comían cada sellado. `openssl-threads` compilaba entero —instalaba headers,
// libcrypto.a, libssl.a, los .pc— y sellaba NADA; después python3 se construía sin
// openssl y quedaba sin `_ssl`, y con eso morían glib y 4 recetas más de GNOME. El
// error salía a 3 recetas de distancia de la causa y no mencionaba openssl ni una vez.
//
// `remove_dir` (NO `remove_dir_all`) a propósito: sólo tiene éxito sobre un directorio
// vacío. Si algo dejó contenido ahí entremedio, esto falla RUIDOSO en vez de borrar un
// artefacto bueno — que es la regla: un ausente falla ruidosamente, un vacío no.
let vacio = std::fs::read_dir(&dst)
.map(|mut e| e.next().is_none())
.unwrap_or(false);
if !vacio {
return Ok(dst);
}
std::fs::remove_dir(&dst).map_err(|e| {
crate::Error::Store(format!(
"destino vacío {} no se pudo retirar antes de sellar: {e}",
dst.display()
))
})?;
}
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
match std::fs::rename(out_dir, &dst) {
Ok(()) => {}
// EXDEV (18) = Cross-device link: out_dir y el store viven en filesystems distintos.
Err(e) if e.raw_os_error() == Some(EXDEV) => {
let parent = dst.parent().unwrap_or_else(|| Path::new("."));
let fname = dst.file_name().expect("dst con nombre");
let staging = parent.join(format!(".seal-tmp-{}", fname.to_string_lossy()));
let _ = std::fs::remove_dir_all(&staging); // restos de un intento previo
copy_tree(out_dir, &staging).map_err(|e| {
crate::Error::Store(format!(
"copia cross-device {}{}: {e}",
out_dir.display(),
staging.display()
))
})?;
std::fs::rename(&staging, &dst).map_err(|e| {
let _ = std::fs::remove_dir_all(&staging);
crate::Error::Store(format!("rename store-interno → {}: {e}", dst.display()))
})?;
std::fs::remove_dir_all(out_dir).map_err(|e| {
crate::Error::Store(format!("limpiar origen {}: {e}", out_dir.display()))
})?;
}
Err(e) => {
return Err(crate::Error::Store(format!(
"rename {}{}: {e}",
out_dir.display(),
dst.display()
)))
}
}
make_tree_read_only(&dst)?;
Ok(dst)
}
}
/// EXDEV en Linux. Evita una dependencia de `libc` por una sola constante.
const EXDEV: i32 = 18;
/// Copia recursiva de `src` a `dst` preservando symlinks (target literal, sin resolver) y
/// los modos de archivos y directorios. Usada por el fallback cross-device de `seal`.
fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
let meta = std::fs::symlink_metadata(src)?;
let ft = meta.file_type();
if ft.is_symlink() {
let target = std::fs::read_link(src)?;
std::os::unix::fs::symlink(target, dst)?;
} else if ft.is_dir() {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
copy_tree(&entry.path(), &dst.join(entry.file_name()))?;
}
// Modos del directorio DESPUÉS de poblarlo (un dir 0o555 no admitiría escrituras).
std::fs::set_permissions(dst, meta.permissions())?;
} else {
// Archivo regular: `std::fs::copy` lleva contenido + bits de permiso.
std::fs::copy(src, dst)?;
}
Ok(())
}
/// Quita los bits de escritura de cada archivo regular del árbol. Los directorios mantienen
/// sus permisos originales (típicamente 0o755): eso es suficiente para garantizar
/// "contenido inmutable" sin estorbar al GC o a operaciones administrativas como `rm -rf`.
/// La inmutabilidad estricta del store entero (impedir crear archivos *nuevos* dentro de un
/// dir sellado) se aplicará vía mount read-only en el track de distro propia.
fn make_tree_read_only(root: &Path) -> crate::Result<()> {
use std::os::unix::fs::PermissionsExt;
fn walk(path: &Path) -> std::io::Result<()> {
let meta = std::fs::symlink_metadata(path)?;
let ft = meta.file_type();
if ft.is_symlink() {
return Ok(());
}
if ft.is_dir() {
for entry in std::fs::read_dir(path)? {
walk(&entry?.path())?;
}
} else if ft.is_file() {
let mut perms = meta.permissions();
perms.set_mode(perms.mode() & !0o222);
std::fs::set_permissions(path, perms)?;
}
Ok(())
}
walk(root).map_err(|e| crate::Error::Store(format!("chmod r/o en {}: {e}", root.display())))
}
#[cfg(test)]
mod tests {
use super::*;
fn hash() -> ArtifactHash {
ArtifactHash::from_hex("deadbeef")
}
#[test]
fn seal_moves_tree_and_marks_readonly() {
let store_dir = tempfile::tempdir().unwrap();
let store = Store::open(store_dir.path()).unwrap();
let out = tempfile::tempdir_in(store_dir.path()).unwrap(); // mismo FS → rename atómico
std::fs::write(out.path().join("hola.bin"), b"x").unwrap();
std::fs::create_dir(out.path().join("sub")).unwrap();
std::fs::write(out.path().join("sub/anidado.bin"), b"y").unwrap();
// tempdir_in nos devuelve un wrapper que borra el dir al hacer Drop. Nosotros vamos
// a moverlo con rename; suprimimos el Drop quedándonos con el PathBuf.
let out_path = out.keep();
let dst = store.seal(&out_path, &hash(), "demo").unwrap();
assert!(dst.ends_with("deadbeef-demo"));
assert!(dst.is_dir());
assert!(dst.join("hola.bin").is_file());
// Read-only: intentar escribir debe fallar.
let write_err = std::fs::write(dst.join("hola.bin"), b"nuevo").unwrap_err();
assert_eq!(write_err.kind(), std::io::ErrorKind::PermissionDenied);
// Y has() lo ve.
assert!(store.has(&hash(), "demo"));
}
#[test]
fn has_rechaza_directorio_vacio() {
// REGRESIÓN (2026-08-10): `has` era `path_of(..).is_dir()`, así que un directorio VACÍO
// contaba como artefacto sellado. Pasó de verdad: el respaldo tenía artefactos vacíos, rsync
// los bajó al store y `build()` hizo cache-hit sobre ellos ⇒ selló sin construir y devolvió
// Ok. Cuatro recetas dieron OK sin producir un solo fichero. Un ausente falla ruidosamente;
// un vacío llega hasta el final diciendo que todo fue bien.
let store_dir = tempfile::tempdir().unwrap();
let store = Store::open(store_dir.path()).unwrap();
assert!(!store.has(&hash(), "demo"), "sin directorio no hay artefacto");
std::fs::create_dir_all(store.path_of(&hash(), "demo")).unwrap();
assert!(
!store.has(&hash(), "demo"),
"un directorio VACÍO no es un artefacto sellado"
);
std::fs::write(store.path_of(&hash(), "demo").join("algo.bin"), b"x").unwrap();
assert!(store.has(&hash(), "demo"), "con contenido sí");
}
#[test]
fn copy_tree_preserva_symlinks_y_modos() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
std::fs::create_dir_all(src.join("sub")).unwrap();
std::fs::write(src.join("sub/exe"), b"#!/bin/sh\n").unwrap();
std::fs::set_permissions(src.join("sub/exe"), std::fs::Permissions::from_mode(0o755))
.unwrap();
std::os::unix::fs::symlink("sub/exe", src.join("enlace")).unwrap();
let dst = tmp.path().join("dst");
copy_tree(&src, &dst).unwrap();
// El symlink se replica con su target literal (no se resuelve).
let link = std::fs::symlink_metadata(dst.join("enlace")).unwrap();
assert!(link.file_type().is_symlink());
assert_eq!(std::fs::read_link(dst.join("enlace")).unwrap().to_str(), Some("sub/exe"));
// El modo 0o755 del archivo sobrevive.
let exe = std::fs::metadata(dst.join("sub/exe")).unwrap();
assert_eq!(exe.permissions().mode() & 0o777, 0o755);
assert_eq!(std::fs::read(dst.join("sub/exe")).unwrap(), b"#!/bin/sh\n");
}
#[test]
fn find_by_hash_resolves_by_prefix() {
let store_dir = tempfile::tempdir().unwrap();
let store = Store::open(store_dir.path()).unwrap();
std::fs::create_dir(store.root().join("aaa111-foo")).unwrap();
std::fs::create_dir(store.root().join("bbb222-bar")).unwrap();
let p = store.find_by_hash("aaa1").unwrap();
assert!(p.ends_with("aaa111-foo"));
let p = store.find_by_hash("b3:bbb").unwrap();
assert!(p.ends_with("bbb222-bar"));
let err = store.find_by_hash("nope").unwrap_err().to_string();
assert!(err.contains("ningún artefacto"), "{err}");
}
#[test]
fn find_by_hash_ambiguous_lists_candidates() {
let store_dir = tempfile::tempdir().unwrap();
let store = Store::open(store_dir.path()).unwrap();
std::fs::create_dir(store.root().join("aaa111-foo")).unwrap();
std::fs::create_dir(store.root().join("aaa222-bar")).unwrap();
let err = store.find_by_hash("aaa").unwrap_err().to_string();
assert!(err.contains("ambiguo"), "{err}");
assert!(err.contains("aaa111-foo") && err.contains("aaa222-bar"), "{err}");
}
#[test]
fn recipe_for_dir_returns_none_without_sidecar() {
let store_dir = tempfile::tempdir().unwrap();
let store = Store::open(store_dir.path()).unwrap();
let art = store_dir.path().join("aaa-foo");
std::fs::create_dir_all(&art).unwrap();
let r = store.recipe_for_dir(&art).unwrap();
assert!(r.is_none());
}
#[test]
fn recipe_for_dir_reads_sidecar() {
let store_dir = tempfile::tempdir().unwrap();
let store = Store::open(store_dir.path()).unwrap();
let art = store_dir.path().join("aaa-foo");
std::fs::create_dir_all(art.join(".hammer")).unwrap();
std::fs::write(
art.join(RECIPE_SIDECAR_REL),
r#"
name = "grep"
version = "3.12"
[source]
repo = "git://example/grep.git"
commit = "deadbeef"
[build]
compiler = "zig-cc"
target = "x86_64-linux-musl"
link = "static"
flags = []
"#,
)
.unwrap();
let r = store.recipe_for_dir(&art).unwrap().expect("Some");
assert_eq!(r.name, "grep");
assert_eq!(r.version, "3.12");
}
#[test]
fn recipe_for_hash_resolves_by_prefix_then_reads() {
let store_dir = tempfile::tempdir().unwrap();
let store = Store::open(store_dir.path()).unwrap();
let art = store.root().join("cafe1234-demo");
std::fs::create_dir_all(art.join(".hammer")).unwrap();
std::fs::write(
art.join(RECIPE_SIDECAR_REL),
r#"
name = "demo"
version = "0.1"
[source]
tarball = "https://x/y.tar.gz"
sha256 = "abc123"
[build]
"#,
)
.unwrap();
let r = store.recipe_for_hash("cafe").unwrap().expect("Some");
assert_eq!(r.name, "demo");
// Hash inexistente: None (no error).
assert!(store.recipe_for_hash("ffffff").unwrap().is_none());
}
#[test]
fn seal_is_idempotent_when_dst_exists() {
let store_dir = tempfile::tempdir().unwrap();
let store = Store::open(store_dir.path()).unwrap();
// Pre-sellar una vez.
let first = tempfile::tempdir_in(store_dir.path()).unwrap();
std::fs::write(first.path().join("a"), b"1").unwrap();
let first_path = first.keep();
let p1 = store.seal(&first_path, &hash(), "demo").unwrap();
// Segundo intento: el out_dir no debe consumirse (devuelve la ruta cacheada).
let second = tempfile::tempdir_in(store_dir.path()).unwrap();
std::fs::write(second.path().join("b"), b"2").unwrap();
let p2 = store.seal(second.path(), &hash(), "demo").unwrap();
assert_eq!(p1, p2);
assert!(second.path().is_dir()); // intacto
}
/// REGRESIÓN (2026-08-29): un `dst` VACÍO se tragaba el sellado en silencio.
/// `seal` usaba `is_dir()`, verdadero para un directorio vacío ⇒ devolvía Ok sin escribir
/// nada y el build decía `sealed`. En el worker eso dejó 1457 artefactos hueco y rompió
/// GNOME por una cadena de 3 recetas (openssl-threads vacío → python3 sin _ssl → glib).
#[test]
fn seal_no_se_traga_un_destino_vacio() {
let store_dir = tempfile::tempdir().unwrap();
let store = Store::open(store_dir.path()).unwrap();
// Un directorio vacío plantado en el store, como el que deja un rsync a medias.
let dst = store.path_of(&hash(), "demo");
std::fs::create_dir_all(&dst).unwrap();
assert!(dst.is_dir());
assert!(!store.has(&hash(), "demo")); // `has` ya lo rechazaba…
let out = tempfile::tempdir_in(store_dir.path()).unwrap();
std::fs::write(out.path().join("a"), b"contenido").unwrap();
let sellado = store.seal(&out.keep(), &hash(), "demo").unwrap();
// …y ahora `seal` tampoco lo acepta: el contenido llega al store de verdad.
assert_eq!(sellado, dst);
assert_eq!(std::fs::read(dst.join("a")).unwrap(), b"contenido");
assert!(store.has(&hash(), "demo"));
}
}
+865
View File
@@ -0,0 +1,865 @@
//! El manifiesto `.swm` (Software Mutación). Ver `docs/06-swm-format.md`.
//!
//! Es la unidad de intercambio: receta de transformación sobre fuente pública + ediciones de
//! config. NUNCA transporta binarios cocidos (salvo `FileDrop` con hash declarado y, opcional,
//! contenido inline base64 para `.swm`s autocontenidos en tests/escenarios offline).
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Swm {
pub swm_version: u32,
pub base: Base,
pub mutations: Vec<Mutation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<Signature>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Base {
pub distro_version: String,
#[serde(default)]
pub pins: BTreeMap<String, String>,
}
/// Referencia a la base **local** — lo que el sistema receptor tiene cocido. Se compara
/// contra `Swm::base` en `verify_base`. La construye el receptor leyendo, p. ej.,
/// `/etc/hammer/base.json`; en tests la fabricamos a mano.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BaseRef {
pub distro_version: String,
#[serde(default)]
pub pins: BTreeMap<String, String>,
}
impl From<&Base> for BaseRef {
fn from(b: &Base) -> Self {
BaseRef {
distro_version: b.distro_version.clone(),
pins: b.pins.clone(),
}
}
}
/// Resultado de `verify_base`. `Ok` ⇒ las pins del SWM coinciden con las locales (al menos
/// para las llaves que el SWM declara) y la `distro_version` es idéntica. Cualquier
/// discrepancia se materializa como variante con detalle: `apply` puede decidir abortar,
/// el humano puede leer qué difiere.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BaseCompat {
/// La base local satisface todas las pins declaradas por el SWM.
Ok,
/// La `distro_version` no coincide.
DistroMismatch { local: String, swm: String },
/// Una o más pins difieren entre la base local y el SWM. Cada entrada es
/// `(pin_name, local_value, swm_value)`; un `local_value` ausente significa que la
/// llave no existe en la base local.
PinMismatch(Vec<PinDiff>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PinDiff {
pub name: String,
pub local: Option<String>,
pub swm: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Mutation {
SourcePatch {
// Origen git (modo histórico) **o** tarball — exactamente uno, igual que
// [`crate::recipe::Source`]. Antes sólo se modelaba git y los tarballs caían a
// `file_drop`; ahora `hammer export` los reconstruye como source_patch. Los campos
// son `Option` (con `serde(default)`) para que los `.swm` git existentes —que sólo
// traen `repo`+`commit`— sigan parseando sin cambios.
#[serde(default, skip_serializing_if = "Option::is_none")]
repo: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
commit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tarball: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
sha256: Option<String>,
/// Componentes a recortar al extraer un tarball (modo tarball). `None` ⇒ el default
/// del lab (1, un único top-level `proyecto-version/`). Sólo se emite si difiere.
#[serde(default, skip_serializing_if = "Option::is_none")]
strip_components: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
patch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
patch_url: Option<String>,
build: SwmBuild,
target_bin: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
expected_hash: Option<String>,
/// Dependencias del paquete por NOMBRE (las de la receta original). Las build-deps deben
/// estar presentes como paquetes en el repo del receptor: `install` reconstruye con ellas
/// un catálogo de recetas para que el lab las materialice en el sandbox al reproducir.
/// Vacío ⇒ paquete autocontenido (el caso común, binarios estáticos sin deps).
#[serde(default, skip_serializing_if = "crate::recipe::Deps::is_empty")]
deps: crate::recipe::Deps,
/// Evidencia de comportamiento que acompaña al paquete (H1 — proof-carrying recipes). El
/// receptor puede correr `hammer swm-verify --evidence` para reproducir cada check en el
/// sandbox antes de aceptar la mutación. Vacío ⇒ paquete sin evidencia (sólo reproducibilidad).
#[serde(default, skip_serializing_if = "crate::recipe::Evidence::is_empty")]
evidence: crate::recipe::Evidence,
/// Superficies del sistema que el paquete reclama/requiere (H4b — configs compatibles, SDD
/// 15 §H4). El receptor las evalúa contra su estado instalado (`crate::compat`) antes de
/// hidratar: un requisito que no resuelve aborta, un reclamo que pisa otra config pide
/// elección. Vacío ⇒ paquete que no declara topología (no participa del gate).
#[serde(default, skip_serializing_if = "crate::recipe::Slots::is_empty")]
slots: crate::recipe::Slots,
},
ConfigEdit {
file: String,
inline_diff: String,
},
InitRule {
action: String,
service: String,
command: String,
},
FileDrop {
path: String,
/// hash BLAKE3 del contenido, declarado para verificación.
content_hash: String,
/// Contenido base64 inline (RFC 4648, sin saltos de línea). Si está presente, el
/// receptor lo decodifica directamente; si no, debe usar `content_url`. Permite
/// `.swm`s autocontenidos sin red — útil para tests, snapshots offline, e
/// intercambio de pequeños binarios de datos (catálogos, listas) cuyo hash basta.
#[serde(default, skip_serializing_if = "Option::is_none")]
content_b64: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
content_url: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwmBuild {
#[serde(default = "default_compiler")]
pub compiler: String,
#[serde(default = "default_target")]
pub target: String,
#[serde(default = "default_link")]
pub link: String,
#[serde(default)]
pub flags: Vec<String>,
/// Overrides explícitos de las fases del build (configure/compile/install). Sin esto un
/// `source_patch` sólo reproduciría builds heurísticos; con esto un paquete que viene de
/// una `Recipe` con install custom (p. ej. los multicall coreutils/findutils) se rehace
/// fiel. Vacío ⇒ heurística del lab, idéntico a una receta sin `[build.phases]`.
#[serde(default, skip_serializing_if = "crate::recipe::Phases::is_empty")]
pub phases: crate::recipe::Phases,
/// Versión de zig por-paquete (escotilla a la regresión de zig 0.14; ver `Build::zig_version`).
/// `None` ⇒ el zig por defecto del receptor.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub zig_version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Signature {
pub by: String,
pub alg: String,
pub sig: String,
}
fn default_compiler() -> String {
"zig-cc".into()
}
fn default_target() -> String {
"x86_64-linux-musl".into()
}
fn default_link() -> String {
"static".into()
}
impl Swm {
pub fn from_yaml(s: &str) -> crate::Result<Swm> {
serde_yaml::from_str(s).map_err(|e| crate::Error::Serde(e.to_string()))
}
pub fn to_yaml(&self) -> crate::Result<String> {
serde_yaml::to_string(self).map_err(|e| crate::Error::Serde(e.to_string()))
}
/// Sanity checks **estructurales** del manifiesto. Lo que aquí se valida no depende del
/// sistema local — sólo del documento. Llamarlo antes de `verify_base` evita malgastar
/// pasadas sobre un `.swm` mal formado.
pub fn verify_schema(&self) -> crate::Result<()> {
if self.swm_version != 1 {
return Err(crate::Error::Serde(format!(
"swm_version no soportada: {} (soportada: 1)",
self.swm_version
)));
}
if self.base.distro_version.is_empty() {
return Err(crate::Error::Serde(
"base.distro_version vacía".into(),
));
}
for (i, m) in self.mutations.iter().enumerate() {
m.verify_schema().map_err(|e| {
crate::Error::Serde(format!("mutations[{i}]: {e}"))
})?;
}
Ok(())
}
/// Compara la base del `.swm` con la del sistema local. Una pin del SWM **debe** estar
/// presente en la base local con el mismo valor. Las pins extra de la base local (no
/// declaradas por el SWM) no son problema — el SWM sólo declara lo que necesita.
pub fn verify_base(&self, local: &BaseRef) -> BaseCompat {
if self.base.distro_version != local.distro_version {
return BaseCompat::DistroMismatch {
local: local.distro_version.clone(),
swm: self.base.distro_version.clone(),
};
}
let mut diffs = Vec::new();
for (name, swm_val) in &self.base.pins {
match local.pins.get(name) {
Some(local_val) if local_val == swm_val => {}
Some(local_val) => diffs.push(PinDiff {
name: name.clone(),
local: Some(local_val.clone()),
swm: swm_val.clone(),
}),
None => diffs.push(PinDiff {
name: name.clone(),
local: None,
swm: swm_val.clone(),
}),
}
}
if diffs.is_empty() {
BaseCompat::Ok
} else {
BaseCompat::PinMismatch(diffs)
}
}
/// Construye un `.swm` de un único `source_patch` a partir de una [`crate::Recipe`] del
/// corpus — la dirección **forward** que cierra la paquetería (Etapa F): una receta que el
/// sistema YA sabe construir se vuelve un paquete distribuible y reproducible-desde-fuente.
/// Es la inversa de `takana_build::swm_bridge` (que va `source_patch` → `Recipe` → build).
///
/// `hammer-core` no toca disco: el caller resuelve y **lee** los patches de la receta
/// (relativos a `recipe.base_dir`) y pasa su texto ya concatenado en `patch_text`. El
/// `expected_hash`, si se da, ancla "verificar, no confiar" (el receptor rehace y compara).
pub fn from_recipe(
recipe: &crate::Recipe,
target_bin: impl Into<String>,
patch_text: Option<String>,
expected_hash: Option<String>,
distro_version: impl Into<String>,
) -> crate::Result<Swm> {
use crate::recipe::SourceKind;
// strip_components sólo viaja si difiere del default del lab (1) y es modo tarball.
let strip_components = match recipe.source.kind()? {
SourceKind::Tarball { .. } if recipe.source.strip_components != 1 => {
Some(recipe.source.strip_components)
}
_ => None,
};
let (repo, commit, tarball, sha256) = match recipe.source.kind()? {
SourceKind::Git { repo, commit } => {
(Some(repo.to_string()), Some(commit.to_string()), None, None)
}
SourceKind::Tarball { url, sha256 } => {
(None, None, Some(url.to_string()), Some(sha256.to_string()))
}
// Un `.swm` es un manifiesto COMPARTIBLE: describe la mutación con punteros que el otro
// lado pueda resolver (un commit, un sha256). El árbol de una receta derivada vive en
// ESTE repo y no hay puntero que mandar, así que no se puede expresar todavía. Falla
// diciéndolo en vez de emitir un manifiesto con el source vacío, que viajaría bien y
// rompería del otro lado.
SourceKind::Dir { dir } => {
return Err(crate::Error::Recipe(format!(
"`{}` usa source.dir ({dir}) y una receta derivada no se puede exportar a .swm: el manifiesto necesita un puntero resoluble desde fuera (commit o sha256)",
recipe.name
)))
}
};
let build = SwmBuild {
compiler: recipe.build.compiler.as_str().to_string(),
target: recipe.build.target.clone(),
link: recipe.build.link.as_str().to_string(),
flags: recipe.build.flags.clone(),
phases: recipe.build.phases.clone(),
zig_version: recipe.build.zig_version.clone(),
};
let swm = Swm {
swm_version: 1,
base: Base {
distro_version: distro_version.into(),
pins: BTreeMap::new(),
},
mutations: vec![Mutation::SourcePatch {
repo,
commit,
tarball,
sha256,
strip_components,
patch: patch_text,
patch_url: None,
build,
target_bin: target_bin.into(),
expected_hash,
deps: recipe.deps.clone(),
evidence: recipe.evidence.clone(),
slots: recipe.slots.clone(),
}],
signature: None,
};
swm.verify_schema()?;
Ok(swm)
}
}
/// Resuelve el modo de origen de un `source_patch` (git **xor** tarball) desde sus campos
/// opcionales, con la misma regla que [`crate::recipe::Source::kind`]. Centraliza la
/// validación para que `verify_schema`, el lab y el export compartan una sola fuente de verdad.
pub fn swm_source_kind<'a>(
repo: Option<&'a str>,
commit: Option<&'a str>,
tarball: Option<&'a str>,
sha256: Option<&'a str>,
) -> Result<crate::recipe::SourceKind<'a>, String> {
use crate::recipe::SourceKind;
match (repo, commit, tarball, sha256) {
(Some(repo), Some(commit), None, None) => Ok(SourceKind::Git { repo, commit }),
(None, None, Some(url), Some(sha256)) => Ok(SourceKind::Tarball { url, sha256 }),
(Some(_), Some(_), Some(_), _) | (Some(_), Some(_), _, Some(_)) => {
Err("source_patch: usa repo+commit O tarball+sha256, no ambos".into())
}
_ => Err("source_patch: faltan campos; necesito (repo+commit) o (tarball+sha256)".into()),
}
}
impl Mutation {
/// Sanity por mutación. Cada `type` tiene precondiciones distintas; la validación cruzada
/// (p. ej. "el `target_bin` apunta a un dir gestionado") la decide quien aplica, no el
/// schema.
pub fn verify_schema(&self) -> Result<(), String> {
use crate::recipe::SourceKind;
match self {
Mutation::SourcePatch {
repo, commit, tarball, sha256, patch, patch_url, target_bin, evidence, ..
} => {
match swm_source_kind(
repo.as_deref(),
commit.as_deref(),
tarball.as_deref(),
sha256.as_deref(),
)? {
SourceKind::Git { repo, commit } => {
if repo.is_empty() {
return Err("source_patch: 'repo' vacío".into());
}
if commit.is_empty() {
return Err("source_patch: 'commit' vacío".into());
}
}
SourceKind::Tarball { url, sha256 } => {
if url.is_empty() {
return Err("source_patch: 'tarball' vacío".into());
}
if sha256.is_empty() {
return Err("source_patch: 'sha256' vacío".into());
}
}
// `swm_source_kind` sólo construye Git o Tarball a partir de los campos del
// manifiesto: no hay forma de que un `.swm` traiga un `dir`. El brazo existe
// para que el compilador siga vigilando este match si mañana lo trae.
SourceKind::Dir { .. } => {
return Err(
"source_patch: un .swm no puede llevar un source por directorio".into(),
);
}
}
if patch.is_some() && patch_url.is_some() {
return Err(
"source_patch: usa 'patch' (inline) O 'patch_url', no ambos".into(),
);
}
if !target_bin.starts_with('/') {
return Err(format!(
"source_patch: target_bin debe ser ruta absoluta, no '{target_bin}'"
));
}
// La evidencia (H1) viaja con el paquete: validamos su FORMA acá (cmd no vacío,
// hash bien formado). La EJECUCIÓN la hace `hammer swm-verify --evidence` (H1b).
evidence.validate().map_err(|e| e.to_string())?;
Ok(())
}
Mutation::ConfigEdit { file, inline_diff } => {
if !file.starts_with('/') {
return Err(format!("config_edit: file debe ser absoluto, no '{file}'"));
}
if inline_diff.is_empty() {
return Err("config_edit: inline_diff vacío".into());
}
Ok(())
}
Mutation::InitRule { action, service, command } => {
if action.is_empty() || service.is_empty() || command.is_empty() {
return Err("init_rule: action/service/command no pueden estar vacíos".into());
}
Ok(())
}
Mutation::FileDrop { path, content_hash, content_b64, content_url } => {
if !path.starts_with('/') {
return Err(format!("file_drop: path debe ser absoluto, no '{path}'"));
}
if content_hash.is_empty() {
return Err("file_drop: content_hash vacío".into());
}
if content_b64.is_none() && content_url.is_none() {
return Err(
"file_drop: hace falta 'content_b64' (inline) o 'content_url'".into(),
);
}
if content_b64.is_some() && content_url.is_some() {
return Err(
"file_drop: usa 'content_b64' O 'content_url', no ambos".into(),
);
}
Ok(())
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_yaml() {
let yaml = r#"
swm_version: 1
base:
distro_version: "2026-06-06"
pins:
grep: "a1b2c3d"
mutations:
- type: config_edit
file: "/etc/network.conf"
inline_diff: |
- DHCP=yes
+ IP=192.168.1.100
"#;
let swm = Swm::from_yaml(yaml).expect("parse");
assert_eq!(swm.swm_version, 1);
assert_eq!(swm.mutations.len(), 1);
let back = swm.to_yaml().expect("serialize");
assert!(back.contains("config_edit"));
}
fn base_v(distro: &str, pins: &[(&str, &str)]) -> Base {
Base {
distro_version: distro.into(),
pins: pins.iter().map(|(k, v)| ((*k).into(), (*v).into())).collect(),
}
}
fn baseref_v(distro: &str, pins: &[(&str, &str)]) -> BaseRef {
BaseRef {
distro_version: distro.into(),
pins: pins.iter().map(|(k, v)| ((*k).into(), (*v).into())).collect(),
}
}
#[test]
fn verify_base_happy() {
let swm = Swm {
swm_version: 1,
base: base_v("2026-06-06", &[("grep", "a1b2")]),
mutations: vec![],
signature: None,
};
// Pins extra en local están bien.
let local = baseref_v("2026-06-06", &[("grep", "a1b2"), ("musl", "deadbeef")]);
assert_eq!(swm.verify_base(&local), BaseCompat::Ok);
}
#[test]
fn verify_base_distro_mismatch() {
let swm = Swm {
swm_version: 1,
base: base_v("2026-06-06", &[]),
mutations: vec![],
signature: None,
};
let local = baseref_v("2026-06-07", &[]);
assert_eq!(
swm.verify_base(&local),
BaseCompat::DistroMismatch {
local: "2026-06-07".into(),
swm: "2026-06-06".into(),
}
);
}
#[test]
fn verify_base_pin_mismatch_and_missing() {
let swm = Swm {
swm_version: 1,
base: base_v("2026-06-06", &[("grep", "a1b2"), ("musl", "FFFF")]),
mutations: vec![],
signature: None,
};
let local = baseref_v("2026-06-06", &[("grep", "OTRO")]); // musl no existe
let got = swm.verify_base(&local);
match got {
BaseCompat::PinMismatch(diffs) => {
assert_eq!(diffs.len(), 2);
let grep = diffs.iter().find(|d| d.name == "grep").unwrap();
assert_eq!(grep.local.as_deref(), Some("OTRO"));
assert_eq!(grep.swm, "a1b2");
let musl = diffs.iter().find(|d| d.name == "musl").unwrap();
assert!(musl.local.is_none());
}
other => panic!("esperaba PinMismatch, obtuve {other:?}"),
}
}
#[test]
fn verify_schema_rejects_bad_version() {
let swm = Swm {
swm_version: 9,
base: base_v("x", &[]),
mutations: vec![],
signature: None,
};
let err = swm.verify_schema().unwrap_err().to_string();
assert!(err.contains("swm_version"), "{err}");
}
#[test]
fn verify_schema_rejects_relative_path() {
let swm = Swm {
swm_version: 1,
base: base_v("x", &[]),
mutations: vec![Mutation::ConfigEdit {
file: "etc/network.conf".into(),
inline_diff: "- a\n+ b\n".into(),
}],
signature: None,
};
let err = swm.verify_schema().unwrap_err().to_string();
assert!(err.contains("absoluto"), "{err}");
}
#[test]
fn verify_schema_filedrop_needs_content_source() {
let swm = Swm {
swm_version: 1,
base: base_v("x", &[]),
mutations: vec![Mutation::FileDrop {
path: "/var/lib/x".into(),
content_hash: "b3:xxx".into(),
content_b64: None,
content_url: None,
}],
signature: None,
};
let err = swm.verify_schema().unwrap_err().to_string();
assert!(err.contains("content_b64") || err.contains("content_url"), "{err}");
}
#[test]
fn verify_schema_filedrop_rejects_both_inline_and_url() {
let swm = Swm {
swm_version: 1,
base: base_v("x", &[]),
mutations: vec![Mutation::FileDrop {
path: "/var/lib/x".into(),
content_hash: "b3:xxx".into(),
content_b64: Some("AA==".into()),
content_url: Some("https://x".into()),
}],
signature: None,
};
assert!(swm.verify_schema().is_err());
}
#[test]
fn verify_schema_source_patch_basic() {
let swm = Swm {
swm_version: 1,
base: base_v("x", &[]),
mutations: vec![Mutation::SourcePatch {
repo: Some("git://x".into()),
commit: Some("abc".into()),
tarball: None,
sha256: None,
strip_components: None,
patch: None,
patch_url: None,
build: SwmBuild {
compiler: "zig-cc".into(),
target: "x86_64-linux-musl".into(),
link: "static".into(),
flags: vec![],
phases: Default::default(),
zig_version: None,
},
target_bin: "/bin/x".into(),
expected_hash: None,
deps: Default::default(),
evidence: Default::default(),
slots: Default::default(),
}],
signature: None,
};
swm.verify_schema().unwrap();
}
#[test]
fn verify_schema_source_patch_tarball() {
let swm = Swm {
swm_version: 1,
base: base_v("x", &[]),
mutations: vec![Mutation::SourcePatch {
repo: None,
commit: None,
tarball: Some("https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz".into()),
sha256: Some("deadbeef".into()),
strip_components: None,
patch: None,
patch_url: None,
build: SwmBuild {
compiler: "zig-cc".into(),
target: "x86_64-linux-musl".into(),
link: "static".into(),
flags: vec![],
phases: Default::default(),
zig_version: None,
},
target_bin: "/bin/grep".into(),
expected_hash: None,
deps: Default::default(),
evidence: Default::default(),
slots: Default::default(),
}],
signature: None,
};
swm.verify_schema().unwrap();
}
#[test]
fn verify_schema_source_patch_rechaza_git_y_tarball_mezclados() {
let m = Mutation::SourcePatch {
repo: Some("git://x".into()),
commit: Some("abc".into()),
tarball: Some("https://x/a.tar.gz".into()),
sha256: Some("deadbeef".into()),
strip_components: None,
patch: None,
patch_url: None,
build: SwmBuild {
compiler: "zig-cc".into(),
target: "x86_64-linux-musl".into(),
link: "static".into(),
flags: vec![],
phases: Default::default(),
zig_version: None,
},
target_bin: "/bin/x".into(),
expected_hash: None,
deps: Default::default(),
evidence: Default::default(),
slots: Default::default(),
};
let err = m.verify_schema().unwrap_err();
assert!(err.contains("no ambos"), "mensaje inesperado: {err}");
}
#[test]
fn from_recipe_git_preserva_build_phases_y_patch() {
// Receta git con install custom, flags, zig_version → el paquete debe reproducir todo.
let toml = r#"
name = "ripgrep"
version = "14.1.1"
[source]
repo = "https://github.com/BurntSushi/ripgrep"
commit = "4649aa9700619f94cf9c66876e9549d83420e16c"
[build]
compiler = "zig-cc"
target = "x86_64-linux-musl"
link = "static"
flags = ["--bin", "rg"]
[build.phases]
install = "cp target/release/rg /out/usr/bin/rg"
"#;
let recipe = crate::Recipe::from_toml(toml).unwrap();
let swm = Swm::from_recipe(
&recipe,
"/usr/bin/rg",
Some("PATCH-TEXT".into()),
Some("b3:cafe".into()),
"2026-06-21",
)
.unwrap();
assert_eq!(swm.base.distro_version, "2026-06-21");
match &swm.mutations[0] {
Mutation::SourcePatch {
repo, commit, tarball, patch, build, target_bin, expected_hash, strip_components, ..
} => {
assert_eq!(repo.as_deref(), Some("https://github.com/BurntSushi/ripgrep"));
assert_eq!(commit.as_deref(), Some("4649aa9700619f94cf9c66876e9549d83420e16c"));
assert!(tarball.is_none());
assert_eq!(patch.as_deref(), Some("PATCH-TEXT"));
assert_eq!(target_bin, "/usr/bin/rg");
assert_eq!(expected_hash.as_deref(), Some("b3:cafe"));
assert!(strip_components.is_none(), "git no lleva strip_components");
assert_eq!(build.compiler, "zig-cc");
assert_eq!(build.link, "static");
assert_eq!(build.flags, vec!["--bin".to_string(), "rg".to_string()]);
assert_eq!(build.phases.install.as_deref(), Some("cp target/release/rg /out/usr/bin/rg"));
}
other => panic!("esperaba SourcePatch, obtuve {other:?}"),
}
}
#[test]
fn from_recipe_tarball_lleva_zig_version_y_strip() {
let toml = r#"
name = "openssl"
version = "3.5.4"
[source]
tarball = "https://example/openssl-3.5.4.tar.gz"
sha256 = "deadbeef"
strip_components = 2
[build]
compiler = "zig-cc"
zig_version = "0.13.0"
"#;
let recipe = crate::Recipe::from_toml(toml).unwrap();
let swm = Swm::from_recipe(&recipe, "/usr/bin/openssl", None, None, "dev").unwrap();
match &swm.mutations[0] {
Mutation::SourcePatch { tarball, sha256, strip_components, build, expected_hash, .. } => {
assert_eq!(tarball.as_deref(), Some("https://example/openssl-3.5.4.tar.gz"));
assert_eq!(sha256.as_deref(), Some("deadbeef"));
assert_eq!(*strip_components, Some(2), "strip != 1 debe viajar");
assert_eq!(build.zig_version.as_deref(), Some("0.13.0"));
assert!(expected_hash.is_none());
}
other => panic!("esperaba SourcePatch, obtuve {other:?}"),
}
}
#[test]
fn verify_schema_source_patch_rechaza_sin_origen() {
let m = Mutation::SourcePatch {
repo: None,
commit: None,
tarball: None,
sha256: None,
strip_components: None,
patch: None,
patch_url: None,
build: SwmBuild {
compiler: "zig-cc".into(),
target: "x86_64-linux-musl".into(),
link: "static".into(),
flags: vec![],
phases: Default::default(),
zig_version: None,
},
target_bin: "/bin/x".into(),
expected_hash: None,
deps: Default::default(),
evidence: Default::default(),
slots: Default::default(),
};
let err = m.verify_schema().unwrap_err();
assert!(err.contains("faltan campos"), "mensaje inesperado: {err}");
}
#[test]
fn from_recipe_carries_evidence() {
// H1a: la evidencia de la receta viaja al .swm (source_patch), roundtripea por YAML,
// y verify_schema la valida.
let toml = r#"
name = "grep"
version = "3.11"
[source]
tarball = "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz"
sha256 = "deadbeef"
[build]
[[evidence.checks]]
kind = "cmd-exit"
cmd = "grep --version"
[[evidence.checks]]
kind = "proptest"
cmd = "cd /src && cargo test proptest_"
expected_exit = 0
"#;
let recipe = crate::Recipe::from_toml(toml).unwrap();
let swm = Swm::from_recipe(&recipe, "/usr/bin/grep", None, None, "dev").unwrap();
// La evidencia llegó a la mutación.
match &swm.mutations[0] {
Mutation::SourcePatch { evidence, .. } => {
assert_eq!(evidence.checks.len(), 2);
assert_eq!(evidence.checks[0].kind, crate::recipe::EvidenceKind::CmdExit);
assert_eq!(evidence.checks[1].kind, crate::recipe::EvidenceKind::Proptest);
}
other => panic!("esperaba SourcePatch, obtuve {other:?}"),
}
// Roundtrip YAML: el bloque evidence sobrevive.
let yaml = swm.to_yaml().unwrap();
assert!(yaml.contains("evidence"), "el YAML debe traer evidence: {yaml}");
let back = Swm::from_yaml(&yaml).unwrap();
match &back.mutations[0] {
Mutation::SourcePatch { evidence, .. } => assert_eq!(evidence.checks.len(), 2),
other => panic!("esperaba SourcePatch, obtuve {other:?}"),
}
back.verify_schema().unwrap();
}
#[test]
fn verify_schema_rejects_evidence_with_empty_cmd() {
let yaml = r#"
swm_version: 1
base:
distro_version: "dev"
mutations:
- type: source_patch
tarball: "https://x/a.tar.gz"
sha256: "deadbeef"
build: {}
target_bin: "/usr/bin/x"
evidence:
checks:
- kind: cmd-exit
cmd: " "
"#;
let swm = Swm::from_yaml(yaml).unwrap();
let err = swm.verify_schema().unwrap_err().to_string();
assert!(err.contains("vacío"), "mensaje inesperado: {err}");
}
#[test]
fn source_patch_without_evidence_omits_block() {
// skip_serializing_if ⇒ un paquete sin evidencia no emite el bloque (compat hacia atrás).
let toml = r#"
name = "x"
version = "0"
[source]
repo = "git://x"
commit = "deadbeef"
[build]
"#;
let recipe = crate::Recipe::from_toml(toml).unwrap();
let swm = Swm::from_recipe(&recipe, "/usr/bin/x", None, None, "dev").unwrap();
let yaml = swm.to_yaml().unwrap();
assert!(!yaml.contains("evidence"), "no debería emitir evidence: {yaml}");
}
}