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…

⎇ main crates/takana-core/src/compat.rs Rust · 428 lines · 18.3 KB History Who touched what raw

paquetería: zsh se puede instalar — los parches múltiples viajaban unidos y el target_bin era una adivinanza 9fe1e010e · Sergio ·

1//! H4b — el gate de **compatibilidad de configs** sobre la receta real (SDD 15 §H4).
2//!
3//! Sube a takana 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// ─────────────────────────────────────────────────────────────────────────
167//  H4e — `requires` OBSERVADOS: la versión contra la que el paquete se construyó
168// ─────────────────────────────────────────────────────────────────────────
169//
170// H4c observó lo que un paquete *escribe*; falta observar de qué *depende a una
171// versión* (el caso wayland automático). La fuente observable: un paquete se
172// construyó contra la versión de sus deps que hay EN EL REPO. Si el usuario tiene
173// esa dep instalada a OTRO hash, la diverguió ⇒ el paquete no encaja. No hace
174// falta que el autor prometa `requires`: se lee del cierre (deps del `.swm` +
175// `expected_hash` de cada dep en el índice). Es el análogo de sistema a un
176// requisito que no resuelve — el mismo rechazo duro de H4b, ahora derivado.
177
178/// [H4e] Los `requires` OBSERVADOS de un paquete: por cada dep de **runtime**
179/// declarada en su `.swm`, la versión (`expected_hash`) que esa dep tiene **en el
180/// índice** — o sea, contra la que el paquete se construyó. Deps sin `expected_hash`
181/// en el índice se omiten (no hay versión observable que exigir).
182pub fn observed_requires(
183    swm: &Swm,
184    index: &crate::RepoIndex,
185) -> std::collections::BTreeMap<String, String> {
186    let mut req = std::collections::BTreeMap::new();
187    for m in &swm.mutations {
188        if let Mutation::SourcePatch { deps, .. } = m {
189            for dep in &deps.runtime {
190                if let Some(h) = index.find(dep).and_then(|e| e.expected_hash.clone()) {
191                    req.insert(dep.clone(), h);
192                }
193            }
194        }
195    }
196    req
197}
198
199/// Un conflicto de versión observado: una dep de runtime está instalada a un hash
200/// **distinto** del que el paquete espera (el que hay en el repo). Es el caso
201/// *wayland* derivado: divergiste una superficie de la que este paquete depende.
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct ConflictoVersion {
204    pub dep: String,
205    /// El hash que el paquete espera (la versión del repo contra la que se construyó).
206    pub requerido: String,
207    /// El hash al que la dep está instalada AHORA (divergido).
208    pub instalado: String,
209}
210
211/// Conflictos de versión del entrante contra el estado instalado: por cada
212/// `requires` observado, si la dep está instalada a otro hash. Una dep que NO está
213/// instalada no cuenta acá (es un asunto de presencia, no de divergencia) — sólo
214/// pisar una versión distinta rechaza. Reusa lo que `InstalledDb` ya registra.
215pub fn version_conflicts(
216    db: &crate::InstalledDb,
217    requires: &std::collections::BTreeMap<String, String>,
218) -> Vec<ConflictoVersion> {
219    let mut out = Vec::new();
220    for (dep, requerido) in requires {
221        if let Some(pkg) = db.find(dep) {
222            if let Some(instalado) = &pkg.hash {
223                if instalado != requerido {
224                    out.push(ConflictoVersion {
225                        dep: dep.clone(),
226                        requerido: requerido.clone(),
227                        instalado: instalado.clone(),
228                    });
229                }
230            }
231        }
232    }
233    out
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    fn estado(pares: &[(&str, &str)]) -> Estado {
241        pares.iter().map(|(s, h)| (s.to_string(), h.to_string())).collect()
242    }
243
244    fn slots(claims: &[(&str, &str)], requires: &[(&str, &str)]) -> Slots {
245        Slots {
246            claims: claims.iter().map(|(s, h)| (s.to_string(), h.to_string())).collect(),
247            requires: requires.iter().map(|(s, h)| (s.to_string(), h.to_string())).collect(),
248        }
249    }
250
251    #[test]
252    fn logo_ya_cambiado_es_eleccion() {
253        let mio = estado(&[("logo", "b3:rojo")]);
254        let entrante = slots(&[("logo", "b3:azul")], &[]);
255        match evaluar(&mio, &entrante) {
256            Veredicto::Colision { slot, instalado, entrante } => {
257                assert_eq!(slot, "logo");
258                assert_eq!(instalado, "b3:rojo");
259                assert_eq!(entrante, "b3:azul");
260            }
261            otro => panic!("esperaba Colision, fue {otro:?}"),
262        }
263    }
264
265    #[test]
266    fn reclamar_lo_mismo_es_idempotente() {
267        let mio = estado(&[("logo", "b3:rojo")]);
268        assert!(evaluar(&mio, &slots(&[("logo", "b3:rojo")], &[])).es_compatible());
269    }
270
271    #[test]
272    fn wayland_divergido_rechaza_dependiente() {
273        let mio = estado(&[("wayland-protocol", "b3:mio")]);
274        let descargado = slots(&[("app-foo", "b3:v1")], &[("wayland-protocol", "b3:stock")]);
275        match evaluar(&mio, &descargado) {
276            Veredicto::Incompatible { slot, requerido, presente } => {
277                assert_eq!(slot, "wayland-protocol");
278                assert_eq!(requerido, "b3:stock");
279                assert_eq!(presente, Some("b3:mio".to_string()));
280            }
281            otro => panic!("esperaba Incompatible, fue {otro:?}"),
282        }
283        // Con el wayland stock, la misma descarga es compatible.
284        let stock = estado(&[("wayland-protocol", "b3:stock")]);
285        assert!(evaluar(&stock, &descargado).es_compatible());
286    }
287
288    #[test]
289    fn requisito_ausente_incompatible() {
290        let vacio = estado(&[]);
291        match evaluar(&vacio, &slots(&[], &[("wayland-protocol", "b3:stock")])) {
292            Veredicto::Incompatible { presente, .. } => assert_eq!(presente, None),
293            otro => panic!("esperaba Incompatible/None, fue {otro:?}"),
294        }
295    }
296
297    // ── H4c: superficies observadas (paths escritos, sin declarar slots) ──
298
299    fn pkg_con_files(name: &str, files: &[&str]) -> crate::InstalledPackage {
300        crate::InstalledPackage {
301            name: name.into(),
302            version: "1".into(),
303            hash: None,
304            files: files.iter().map(|s| s.to_string()).collect(),
305            claims: Default::default(),
306        }
307    }
308
309    #[test]
310    fn colision_de_path_observada_contra_otro_paquete() {
311        let mut db = crate::InstalledDb::default();
312        db.record(pkg_con_files("tema-claro", &["/usr/share/logo.png"]));
313
314        // 'tema-oscuro' escribirá el mismo path ⇒ colisión con 'tema-claro'.
315        let entrante: BTreeSet<String> = ["/usr/share/logo.png".to_string()].into_iter().collect();
316        let cols = path_collisions(&db, "tema-oscuro", &entrante);
317        assert_eq!(cols.len(), 1);
318        assert_eq!(cols[0].path, "/usr/share/logo.png");
319        assert_eq!(cols[0].propietario, "tema-claro");
320    }
321
322    #[test]
323    fn reinstalar_el_mismo_paquete_no_colisiona() {
324        let mut db = crate::InstalledDb::default();
325        db.record(pkg_con_files("tema-oscuro", &["/usr/share/logo.png"]));
326        // El mismo paquete sobre sus propios paths = upgrade, no colisión.
327        let paths: BTreeSet<String> = ["/usr/share/logo.png".to_string()].into_iter().collect();
328        assert!(path_collisions(&db, "tema-oscuro", &paths).is_empty());
329    }
330
331    // ── H4e: requires observados (versión de la dep contra la que se construyó) ──
332
333    #[test]
334    fn wayland_divergido_observado_da_conflicto_de_version() {
335        // El paquete 'app' depende (runtime) de 'wayland-protocol'. En el repo, wayland está a
336        // b3:stock (la versión contra la que app se construyó).
337        let swm = crate::Swm {
338            swm_version: 1,
339            base: crate::swm::Base { distro_version: "dev".into(), pins: Default::default() },
340            mutations: vec![Mutation::SourcePatch {
341                repo: Some("git://x/app.git".into()),
342                commit: Some("abc".into()),
343                tarball: None,
344                sha256: None,
345                strip_components: None,
346                patch: None,
347                patches: Vec::new(),
348                patch_url: None,
349                build: crate::swm::SwmBuild {
350                    compiler: "zig-cc".into(),
351                    target: "x86_64-linux-musl".into(),
352                    link: "static".into(),
353                    flags: vec![],
354                    phases: Default::default(),
355                    zig_version: None,
356                    strip_debug: None,
357                },
358                target_bin: "/usr/bin/app".into(),
359                expected_hash: Some("b3:app".into()),
360                deps: crate::recipe::Deps { build: vec![], runtime: vec!["wayland-protocol".into()] },
361                evidence: Default::default(),
362                slots: Default::default(),
363            }],
364            signature: None,
365        };
366        let index: crate::RepoIndex = serde_json::from_str(
367            r#"{"packages":[{"name":"wayland-protocol","version":"1","file":"w.swm","distro_version":"dev","deps":[],"expected_hash":"b3:stock"}]}"#,
368        )
369        .unwrap();
370
371        let req = observed_requires(&swm, &index);
372        assert_eq!(req.get("wayland-protocol").unwrap(), "b3:stock");
373
374        // Tengo wayland instalado a b3:mine (divergido) ⇒ conflicto.
375        let mut db = crate::InstalledDb::default();
376        db.record(crate::InstalledPackage {
377            name: "wayland-protocol".into(),
378            version: "1".into(),
379            hash: Some("b3:mine".into()),
380            files: vec![],
381            claims: Default::default(),
382        });
383        let cs = version_conflicts(&db, &req);
384        assert_eq!(cs.len(), 1);
385        assert_eq!(cs[0].requerido, "b3:stock");
386        assert_eq!(cs[0].instalado, "b3:mine");
387
388        // Si tengo la versión stock, no hay conflicto.
389        let mut ok = crate::InstalledDb::default();
390        ok.record(crate::InstalledPackage {
391            name: "wayland-protocol".into(),
392            version: "1".into(),
393            hash: Some("b3:stock".into()),
394            files: vec![],
395            claims: Default::default(),
396        });
397        assert!(version_conflicts(&ok, &req).is_empty());
398    }
399
400    #[test]
401    fn output_paths_lee_target_bin_y_file_drop() {
402        let swm = crate::Swm {
403            swm_version: 1,
404            base: crate::swm::Base {
405                distro_version: "2026-06-06".into(),
406                pins: Default::default(),
407            },
408            mutations: vec![
409                Mutation::FileDrop {
410                    path: "/usr/share/logo.png".into(),
411                    content_hash: "b3:x".into(),
412                    content_b64: Some("AA==".into()),
413                    content_url: None,
414                },
415                Mutation::ConfigEdit {
416                    file: "/etc/foo.conf".into(),
417                    inline_diff: "".into(),
418                },
419            ],
420            signature: None,
421        };
422        let paths = output_paths(&swm);
423        // El file_drop cuenta; el config_edit (modifica ajeno) NO.
424        assert!(paths.contains("/usr/share/logo.png"));
425        assert!(!paths.contains("/etc/foo.conf"));
426        assert_eq!(paths.len(), 1);
427    }
428}