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:
@@ -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()
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user