diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 7c331af0..be2fe239 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -1666,10 +1666,37 @@ fn run_install( eprintln!("firma: no verificada (pasá --trust 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::>() + .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() diff --git a/crates/hammer-cli/tests/compat_gate.rs b/crates/hammer-cli/tests/compat_gate.rs new file mode 100644 index 00000000..47bac76a --- /dev/null +++ b/crates/hammer-cli/tests/compat_gate.rs @@ -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}" + ); +} diff --git a/crates/hammer-core/src/compat.rs b/crates/hammer-core/src/compat.rs index fb24045c..cf63574d 100644 --- a/crates/hammer-core/src/compat.rs +++ b/crates/hammer-core/src/compat.rs @@ -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 { + 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, +) -> Vec { + 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 = ["/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 = ["/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); + } } diff --git a/crates/hammer-core/src/installed.rs b/crates/hammer-core/src/installed.rs index e98a4565..2e025402 100644 --- a/crates/hammer-core/src/installed.rs +++ b/crates/hammer-core/src/installed.rs @@ -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 { diff --git a/docs/15-frontier-ai-native.md b/docs/15-frontier-ai-native.md index 2ffb4044..93de023a 100644 --- a/docs/15-frontier-ai-native.md +++ b/docs/15-frontier-ai-native.md @@ -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