sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
⎇ 9be7bdd70751eec8a1b0b77b7440bd68b89ead65
crates/hammer-core/src/compat.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
H4c: superficies OBSERVADAS — colisión de fichero en `install` sin declarar slots (SDD 15 §H4)
1//! H4b — el gate de **compatibilidad de configs** sobre la receta real (SDD 15 §H4).
2//!
3//! Sube a hammer el modelo de slots que `wawa-memo/src/compat.rs` demostró como
4//! prototipo (host/std, 52 tests verdes): una config/paquete declara qué
5//! superficies del sistema **reclama** (`Slots::claims`) y **requiere**
6//! (`Slots::requires`), ambas por hash. Antes de tocar el sistema, `install`
7//! evalúa la config entrante contra el **estado instalado**
8//! ([`crate::InstalledDb::system_state`]) y decide:
9//!
10//! - **Compatible** — requisitos resueltos y reclamos disjuntos (o coincidentes):
11//! se puede instalar sin fricción.
12//! - **Colisión** — reclama una superficie ya ocupada por OTRO contenido: es una
13//! **elección** del usuario (el caso "ya cambié el logo y bajo otra config que
14//! también lo cambia"), no un error. `install` la respeta salvo `--force-slots`.
15//! - **Incompatible** — un requisito no resuelve contra el estado: **rechazo
16//! duro** (el caso "modifiqué el protocolo de Wayland y bajo algo que depende
17//! del Wayland stock"). Es el análogo de sistema a una referencia colgante.
18//!
19//! El chequeo es **local y reproducible** — el mismo "verificar, no confiar" de
20//! SDD 09, ahora sobre la topología de superficies. "Compatible" (este módulo) es
21//! el eje NUEVO; "completa" y "segura" ya viven en el stack (cierre transitivo /
22//! reproducir el resultado) — ver el prototipo `wawa-memo` para la composición.
23
24use std::collections::BTreeSet;
25
26use crate::recipe::Slots;
27use crate::swm::{Mutation, Swm};
28
29/// El estado instalado como `slot → hash`. Alias de lo que produce
30/// [`crate::InstalledDb::system_state`].
31pub type Estado = std::collections::BTreeMap<String, String>;
32
33/// El veredicto de evaluar unos [`Slots`] contra un [`Estado`]. Tres resultados
34/// **mecánicamente distintos**: la incompatibilidad domina a la colisión.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum Veredicto {
37 /// Requisitos resueltos y reclamos disjuntos (o coincidentes en hash): instalable.
38 Compatible,
39 /// Un slot que la config reclama ya está ocupado por **otro** contenido. NO es
40 /// error: es una **elección** (caso *logo*).
41 Colision {
42 slot: String,
43 /// El hash que el slot tiene ahora en el estado.
44 instalado: String,
45 /// El hash que la config quiere poner.
46 entrante: String,
47 },
48 /// Un requisito **no resuelve** contra el estado (caso *wayland*): rechazo duro.
49 /// `presente = None` si el estado ni ofrece el slot; `Some(otro)` si otra versión.
50 Incompatible {
51 slot: String,
52 /// El hash que la config exige para ese slot.
53 requerido: String,
54 /// Lo que el estado ofrece para ese slot (o `None`).
55 presente: Option<String>,
56 },
57}
58
59impl Veredicto {
60 /// `true` sólo si es [`Veredicto::Compatible`].
61 pub fn es_compatible(&self) -> bool {
62 matches!(self, Veredicto::Compatible)
63 }
64}
65
66/// Evalúa unos `slots` contra el `estado` instalado. Primero **requisitos**
67/// (incompatibilidad dura), luego **reclamos** (colisión = elección). Devuelve la
68/// **primera** objeción en orden determinista (`BTreeMap`), o
69/// [`Veredicto::Compatible`]. Un reclamo que coincide en hash es idempotente.
70pub fn evaluar(estado: &Estado, slots: &Slots) -> Veredicto {
71 // 1. Requisitos: cada superficie requerida debe estar presente al hash exacto
72 // contra el que la config fue construida. Es la referencia colgante de sistema.
73 for (slot, requerido) in &slots.requires {
74 match estado.get(slot) {
75 Some(h) if h == requerido => {}
76 otro => {
77 return Veredicto::Incompatible {
78 slot: slot.clone(),
79 requerido: requerido.clone(),
80 presente: otro.cloned(),
81 };
82 }
83 }
84 }
85 // 2. Reclamos: un slot ya ocupado por otro contenido es colisión (elección).
86 for (slot, entrante) in &slots.claims {
87 if let Some(instalado) = estado.get(slot) {
88 if instalado != entrante {
89 return Veredicto::Colision {
90 slot: slot.clone(),
91 instalado: instalado.clone(),
92 entrante: entrante.clone(),
93 };
94 }
95 }
96 }
97 Veredicto::Compatible
98}
99
100// ─────────────────────────────────────────────────────────────────────────
101// H4c — superficies OBSERVADAS: la vía "no prometas, observá"
102// ─────────────────────────────────────────────────────────────────────────
103//
104// H4b confía en que el AUTOR declare `slots` a mano. La misma subida que hizo H1
105// (de "prometer" a "verificar") aplica acá: la superficie más común y observable
106// de un paquete es **el conjunto de paths que escribe**, y esos paths ya están
107// DECLARADOS en el `.swm` (`target_bin` de cada source_patch, `path` de cada
108// file_drop) — se conocen ANTES de hidratar, así que el gate puede abortar sin
109// tocar nada. El "slot" es el path; la señal de colisión es que OTRO paquete
110// instalado ya lo posea. No hace falta que el autor prometa nada: se observa.
111
112/// Los paths ABSOLUTOS que un paquete **escribirá**, leídos de su `.swm`: el
113/// `target_bin` de cada `source_patch` y el `path` de cada `file_drop`. Es la
114/// superficie observada de H4c — conocida sin construir.
115pub fn output_paths(swm: &Swm) -> BTreeSet<String> {
116 let mut paths = BTreeSet::new();
117 for m in &swm.mutations {
118 match m {
119 Mutation::SourcePatch { target_bin, .. } => {
120 paths.insert(target_bin.clone());
121 }
122 Mutation::FileDrop { path, .. } => {
123 paths.insert(path.clone());
124 }
125 // config_edit MODIFICA un fichero ajeno (no lo crea/posee); init_rule escribe en el
126 // dir de reglas del init. Ninguno "reclama" una superficie propia — se omiten.
127 Mutation::ConfigEdit { .. } | Mutation::InitRule { .. } => {}
128 }
129 }
130 paths
131}
132
133/// Una colisión de path observada: `entrante` escribirá un `path` que YA posee otro
134/// paquete instalado (`propietario`). Sobreescribir el fichero de otro paquete es una
135/// **elección** (el caso *logo* a nivel de fichero), no un error.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct ColisionPath {
138 pub path: String,
139 /// El paquete instalado que ya posee ese path.
140 pub propietario: String,
141}
142
143/// Colisiones de path del `entrante` (por nombre + los paths que escribirá) contra el estado
144/// instalado. Reinstalar el MISMO paquete sobre sus propios paths NO colisiona (es upgrade);
145/// sólo pisar un fichero de OTRO paquete cuenta. Reusa lo que `InstalledDb` ya registra — no
146/// necesita que la receta declare nada (H4c: observar, no prometer).
147pub fn path_collisions(
148 db: &crate::InstalledDb,
149 entrante_name: &str,
150 entrante_paths: &BTreeSet<String>,
151) -> Vec<ColisionPath> {
152 let mut cols = Vec::new();
153 for p in entrante_paths {
154 if let Some(dueno) = db.owner_of(p) {
155 if dueno != entrante_name {
156 cols.push(ColisionPath {
157 path: p.clone(),
158 propietario: dueno.to_string(),
159 });
160 }
161 }
162 }
163 cols
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 fn estado(pares: &[(&str, &str)]) -> Estado {
171 pares.iter().map(|(s, h)| (s.to_string(), h.to_string())).collect()
172 }
173
174 fn slots(claims: &[(&str, &str)], requires: &[(&str, &str)]) -> Slots {
175 Slots {
176 claims: claims.iter().map(|(s, h)| (s.to_string(), h.to_string())).collect(),
177 requires: requires.iter().map(|(s, h)| (s.to_string(), h.to_string())).collect(),
178 }
179 }
180
181 #[test]
182 fn logo_ya_cambiado_es_eleccion() {
183 let mio = estado(&[("logo", "b3:rojo")]);
184 let entrante = slots(&[("logo", "b3:azul")], &[]);
185 match evaluar(&mio, &entrante) {
186 Veredicto::Colision { slot, instalado, entrante } => {
187 assert_eq!(slot, "logo");
188 assert_eq!(instalado, "b3:rojo");
189 assert_eq!(entrante, "b3:azul");
190 }
191 otro => panic!("esperaba Colision, fue {otro:?}"),
192 }
193 }
194
195 #[test]
196 fn reclamar_lo_mismo_es_idempotente() {
197 let mio = estado(&[("logo", "b3:rojo")]);
198 assert!(evaluar(&mio, &slots(&[("logo", "b3:rojo")], &[])).es_compatible());
199 }
200
201 #[test]
202 fn wayland_divergido_rechaza_dependiente() {
203 let mio = estado(&[("wayland-protocol", "b3:mio")]);
204 let descargado = slots(&[("app-foo", "b3:v1")], &[("wayland-protocol", "b3:stock")]);
205 match evaluar(&mio, &descargado) {
206 Veredicto::Incompatible { slot, requerido, presente } => {
207 assert_eq!(slot, "wayland-protocol");
208 assert_eq!(requerido, "b3:stock");
209 assert_eq!(presente, Some("b3:mio".to_string()));
210 }
211 otro => panic!("esperaba Incompatible, fue {otro:?}"),
212 }
213 // Con el wayland stock, la misma descarga es compatible.
214 let stock = estado(&[("wayland-protocol", "b3:stock")]);
215 assert!(evaluar(&stock, &descargado).es_compatible());
216 }
217
218 #[test]
219 fn requisito_ausente_incompatible() {
220 let vacio = estado(&[]);
221 match evaluar(&vacio, &slots(&[], &[("wayland-protocol", "b3:stock")])) {
222 Veredicto::Incompatible { presente, .. } => assert_eq!(presente, None),
223 otro => panic!("esperaba Incompatible/None, fue {otro:?}"),
224 }
225 }
226
227 // ── H4c: superficies observadas (paths escritos, sin declarar slots) ──
228
229 fn pkg_con_files(name: &str, files: &[&str]) -> crate::InstalledPackage {
230 crate::InstalledPackage {
231 name: name.into(),
232 version: "1".into(),
233 hash: None,
234 files: files.iter().map(|s| s.to_string()).collect(),
235 claims: Default::default(),
236 }
237 }
238
239 #[test]
240 fn colision_de_path_observada_contra_otro_paquete() {
241 let mut db = crate::InstalledDb::default();
242 db.record(pkg_con_files("tema-claro", &["/usr/share/logo.png"]));
243
244 // 'tema-oscuro' escribirá el mismo path ⇒ colisión con 'tema-claro'.
245 let entrante: BTreeSet<String> = ["/usr/share/logo.png".to_string()].into_iter().collect();
246 let cols = path_collisions(&db, "tema-oscuro", &entrante);
247 assert_eq!(cols.len(), 1);
248 assert_eq!(cols[0].path, "/usr/share/logo.png");
249 assert_eq!(cols[0].propietario, "tema-claro");
250 }
251
252 #[test]
253 fn reinstalar_el_mismo_paquete_no_colisiona() {
254 let mut db = crate::InstalledDb::default();
255 db.record(pkg_con_files("tema-oscuro", &["/usr/share/logo.png"]));
256 // El mismo paquete sobre sus propios paths = upgrade, no colisión.
257 let paths: BTreeSet<String> = ["/usr/share/logo.png".to_string()].into_iter().collect();
258 assert!(path_collisions(&db, "tema-oscuro", &paths).is_empty());
259 }
260
261 #[test]
262 fn output_paths_lee_target_bin_y_file_drop() {
263 let swm = crate::Swm {
264 swm_version: 1,
265 base: crate::swm::Base {
266 distro_version: "2026-06-06".into(),
267 pins: Default::default(),
268 },
269 mutations: vec![
270 Mutation::FileDrop {
271 path: "/usr/share/logo.png".into(),
272 content_hash: "b3:x".into(),
273 content_b64: Some("AA==".into()),
274 content_url: None,
275 },
276 Mutation::ConfigEdit {
277 file: "/etc/foo.conf".into(),
278 inline_diff: "".into(),
279 },
280 ],
281 signature: None,
282 };
283 let paths = output_paths(&swm);
284 // El file_drop cuenta; el config_edit (modifica ajeno) NO.
285 assert!(paths.contains("/usr/share/logo.png"));
286 assert!(!paths.contains("/etc/foo.conf"));
287 assert_eq!(paths.len(), 1);
288 }
289}