tawasuyu

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…

TOML 1357Shell 123Rust 79Markdown 712395 Sutichaykuna

⎇ 9be7bdd70751eec8a1b0b77b7440bd68b89ead65 crates/hammer-core/src/compat.rs Rust · 289 siq'ikuna · 12.1 KB Ñawpa willay Pi imata llamkarqan ch'uya

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

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}