H4c: superficies OBSERVADAS — colisión de fichero en install sin declarar slots (SDD 15 §H4)

La misma subida de H1 (prometer -> verificar), ahora sobre topología: la superficie más
común de un paquete es el conjunto de paths que escribe, y esos paths ya están declarados
en el .swm (target_bin + file_drop.path), conocidos ANTES de hidratar.

- compat::output_paths(swm) lee esos paths; compat::path_collisions(db, name, paths) detecta
  cuáles ya posee OTRO paquete instalado (reusa InstalledDb.files + owner_of, cero declaración
  nueva). Reinstalar el mismo paquete sobre sus propios paths NO colisiona (upgrade).
- Gate en `install` corre el chequeo observado JUNTO al declarado (H4b): pisar el fichero de
  otro paquete = caso logo a nivel de fichero (elección) -> aborta salvo --force-slots.
- Verificado e2e REAL (tests/compat_gate.rs, shell-ea al binario hammer): dos paquetes
  escriben /share/logo.png; el 2do aborta con "COLISIÓN de fichero" sin escribir nada; con
  --force-slots la elección se respeta y el fichero se escribe.

Un paquete SIN declarar slots ya participa del gate por lo que de verdad toca.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 20:17:17 -04:00
co-authored by Claude Opus 4.8
parent 27066776b3
commit 9be7bdd707
5 changed files with 270 additions and 7 deletions
+31 -4
View File
@@ -1666,10 +1666,37 @@ fn run_install(
eprintln!("firma: no verificada (pasá --trust <dir> para validar la autoría)");
}
// Gate de compatibilidad (H4b — configs compatibles, SDD 15 §H4). Antes de reconstruir o tocar
// nada, evaluamos las superficies que el paquete declara (`slots`) contra el estado INSTALADO.
// Es el chequeo local y reproducible de "cuando instalo, ¿es compatible con lo que ya tengo?".
let entrant_slots = source_patch_slots(&load_swm(swm_path_str)?);
// Gate de compatibilidad (H4b/H4c — configs compatibles, SDD 15 §H4). Antes de reconstruir o
// tocar nada, evaluamos las superficies que el paquete tocará contra el estado INSTALADO. Es el
// chequeo local y reproducible de "cuando instalo, ¿es compatible con lo que ya tengo?".
let entrant_swm = load_swm(swm_path_str)?;
// H4c — vía OBSERVADA: los paths que el paquete escribirá (declarados en el .swm, sin que el
// autor prometa `slots`). Si alguno lo posee OTRO paquete instalado, es una colisión de fichero
// (elección, no error) — salvo --force-slots. Se conoce antes de hidratar ⇒ aborta sin tocar nada.
{
let idb = hammer_core::InstalledDb::load(db_path)?;
let paths = hammer_core::compat::output_paths(&entrant_swm);
let cols = hammer_core::compat::path_collisions(&idb, name, &paths);
if !cols.is_empty() {
let detalle = cols
.iter()
.map(|c| format!("{} (de '{}')", c.path, c.propietario))
.collect::<Vec<_>>()
.join(", ");
if force_slots {
eprintln!("compat: {} colisión(es) de fichero: {detalle}; --force-slots ⇒ sobreescribo", cols.len());
} else {
anyhow::bail!(
"compat: COLISIÓN de fichero — '{name}' pisaría {detalle}. Es una elección: \
pasá --force-slots para sobreescribir el/los fichero(s) del otro paquete."
);
}
}
}
// H4b — vía DECLARADA: las superficies (`slots`) que el autor declara reclamar/requerir.
let entrant_slots = source_patch_slots(&entrant_swm);
if !entrant_slots.is_empty() {
entrant_slots
.validate()
+77
View File
@@ -0,0 +1,77 @@
//! H4c e2e: el gate de **colisión de fichero observada** en `hammer install` real.
//!
//! Reproduce el caso "logo" a nivel de fichero, sin declarar `slots`: dos paquetes escriben el
//! mismo path. El primero ya está instalado (pre-seed de la DB); al instalar el segundo, el gate
//! —que lee los paths que el `.swm` escribirá y los compara con lo instalado— debe ABORTAR antes
//! de tocar nada. Con `--force-slots` la elección se respeta y el fichero se escribe.
use std::process::Command;
use base64::{engine::general_purpose::STANDARD, Engine as _};
use hammer_core::ArtifactHash;
#[test]
fn install_aborta_ante_colision_de_fichero_y_force_slots_la_supera() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
let prefix = tmp.path().join("prefix");
let db = tmp.path().join("installed.json");
// Pre-seed: 'tema-claro' YA posee /share/logo.png.
std::fs::write(
&db,
r#"{"packages":[{"name":"tema-claro","version":"1","files":["/share/logo.png"]}]}"#,
)
.unwrap();
// Paquete entrante 'tema-oscuro' = un file_drop que escribe el MISMO path.
let content = b"logo-oscuro";
// apply_file_drop verifica con `of_inputs` (length-prefixed), no `of_bytes` — mismamos eso.
let hash = ArtifactHash::of_inputs(&[content.as_slice()]);
let b64 = STANDARD.encode(content);
let swm = format!(
"swm_version: 1\nbase:\n distro_version: dev\n pins: {{}}\nmutations:\n\
- type: file_drop\n path: /share/logo.png\n content_hash: {}\n content_b64: {}\n",
hash.as_str(),
b64
);
std::fs::write(repo.join("tema-oscuro-1.swm"), swm).unwrap();
let index = r#"{"packages":[{"name":"tema-oscuro","version":"1","file":"tema-oscuro-1.swm","distro_version":"dev","deps":[]}]}"#;
std::fs::write(repo.join("index.json"), index).unwrap();
let bin = env!("CARGO_BIN_EXE_hammer");
let base_args = [
"install",
"tema-oscuro",
"--repo",
repo.to_str().unwrap(),
"--db",
db.to_str().unwrap(),
"--prefix",
prefix.to_str().unwrap(),
];
// 1) Sin --force-slots: colisión ⇒ aborta, y el fichero NO se escribe (aborta pre-hidratación).
let out = Command::new(bin).args(base_args).output().unwrap();
assert!(!out.status.success(), "debía abortar por colisión de fichero");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("COLISIÓN de fichero"), "stderr inesperado: {err}");
assert!(
!prefix.join("share/logo.png").exists(),
"no debía escribir el fichero al abortar"
);
// 2) Con --force-slots: la elección se respeta y el fichero se escribe.
let out = Command::new(bin)
.args(base_args)
.arg("--force-slots")
.output()
.unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "con --force-slots debía instalar. stderr: {err}");
assert!(
prefix.join("share/logo.png").exists(),
"el fichero debía escribirse con --force-slots. stderr: {err}"
);
}
+132
View File
@@ -21,7 +21,10 @@
//! 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`].
@@ -94,6 +97,72 @@ pub fn evaluar(estado: &Estado, slots: &Slots) -> Veredicto {
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
}
#[cfg(test)]
mod tests {
use super::*;
@@ -154,4 +223,67 @@ mod tests {
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());
}
#[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);
}
}
+9
View File
@@ -98,6 +98,15 @@ impl InstalledDb {
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 {
+21 -3
View File
@@ -320,8 +320,25 @@ La definición dura de compatibilidad de una config `C` contra mi estado `E`:
instalación detecte la colisión). `hammer install --help` expone `--force-slots`.
Frontera que queda: definir el **espacio de slots** del sistema (qué es una "superficie": un
fichero, un módulo wasm de wawa, un componente) — ahí está el diseño real, no en el álgebra
(que ya está). Hoy el productor de recetas declara los slots a mano; falta la **convención**
y, más allá, **derivarlos** de lo que el paquete de verdad toca.
(que ya está).
- **H4c** ✅ — **Superficies OBSERVADAS: "no prometas, observá".** H4b confía en que el AUTOR
declare `slots`; 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 aborta sin tocar nada.
- `compat::output_paths(swm)` lee esos paths; `compat::path_collisions(db, name, paths)`
detecta cuáles ya posee **otro** paquete instalado (reusa `InstalledDb.files` + el nuevo
`owner_of` — cero declaración nueva en la receta). Reinstalar el mismo paquete sobre sus
propios paths NO colisiona (es upgrade).
- `hammer install` corre este chequeo **junto** al declarado: pisar el fichero de otro
paquete es el caso *logo* a nivel de fichero (elección) ⇒ aborta salvo `--force-slots`.
- **Verificado e2e real** (`tests/compat_gate.rs`, shell-ea al binario `hammer`): dos paquetes
escriben `/share/logo.png`; el segundo aborta con "COLISIÓN de fichero" **y no escribe nada**;
con `--force-slots` la elección se respeta y el fichero se escribe.
Con H4c, un paquete **sin declarar slots** ya participa del gate por lo que de verdad toca.
Lo que queda abierto es **derivar la otra mitad** (los `requires` observados: de qué depende
el paquete) y subir el grano del path-slot a superficies más ricas (un módulo wasm de wawa,
un componente), además del **colapso** con el lado proceso.
**Frontera honesta.** H4a demuestra el *álgebra* sobre hashes abstractos; lo que **no**
resuelve es la **granularidad de los slots** — dos configs pueden no colisionar en el slot
@@ -382,7 +399,8 @@ H3a (design-doc) ──► registrar la visión, barato
└► H3c ✅ (linker de contenido: imports por hash, Merkle-DAG intra-función)
└► H4a ✅ (config = conjunto de slots por hash: compatible/completa/segura)
└► H4b ✅ (slots en la receta/.swm real + gate en `hammer install`)
└► [proceso] replay del MonotonicLog ──► plan OS-CRDT (otro agente)
└► H4c ✅ (superficies OBSERVADAS: colisión de fichero, sin declarar slots)
└► [proceso] replay del MonotonicLog ──► plan OS-CRDT (otro agente)
```
Recomendación: **H1 primero** (empuja la frontera que ya tenemos, sin apuestas). **H2a** en