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…
⎇ 9bc518899e80b19fb156e950ac5899c57fd0f4a2
crates/takana-core/src/compat.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
swm: `strip_debug` no viajaba en el paquete — y es ENTRADA DE HASH, así que 40 recetas no instalaban
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 patch_url: None,
348 build: crate::swm::SwmBuild {
349 compiler: "zig-cc".into(),
350 target: "x86_64-linux-musl".into(),
351 link: "static".into(),
352 flags: vec![],
353 phases: Default::default(),
354 zig_version: None,
355 strip_debug: None,
356 },
357 target_bin: "/usr/bin/app".into(),
358 expected_hash: Some("b3:app".into()),
359 deps: crate::recipe::Deps { build: vec![], runtime: vec!["wayland-protocol".into()] },
360 evidence: Default::default(),
361 slots: Default::default(),
362 }],
363 signature: None,
364 };
365 let index: crate::RepoIndex = serde_json::from_str(
366 r#"{"packages":[{"name":"wayland-protocol","version":"1","file":"w.swm","distro_version":"dev","deps":[],"expected_hash":"b3:stock"}]}"#,
367 )
368 .unwrap();
369
370 let req = observed_requires(&swm, &index);
371 assert_eq!(req.get("wayland-protocol").unwrap(), "b3:stock");
372
373 // Tengo wayland instalado a b3:mine (divergido) ⇒ conflicto.
374 let mut db = crate::InstalledDb::default();
375 db.record(crate::InstalledPackage {
376 name: "wayland-protocol".into(),
377 version: "1".into(),
378 hash: Some("b3:mine".into()),
379 files: vec![],
380 claims: Default::default(),
381 });
382 let cs = version_conflicts(&db, &req);
383 assert_eq!(cs.len(), 1);
384 assert_eq!(cs[0].requerido, "b3:stock");
385 assert_eq!(cs[0].instalado, "b3:mine");
386
387 // Si tengo la versión stock, no hay conflicto.
388 let mut ok = crate::InstalledDb::default();
389 ok.record(crate::InstalledPackage {
390 name: "wayland-protocol".into(),
391 version: "1".into(),
392 hash: Some("b3:stock".into()),
393 files: vec![],
394 claims: Default::default(),
395 });
396 assert!(version_conflicts(&ok, &req).is_empty());
397 }
398
399 #[test]
400 fn output_paths_lee_target_bin_y_file_drop() {
401 let swm = crate::Swm {
402 swm_version: 1,
403 base: crate::swm::Base {
404 distro_version: "2026-06-06".into(),
405 pins: Default::default(),
406 },
407 mutations: vec![
408 Mutation::FileDrop {
409 path: "/usr/share/logo.png".into(),
410 content_hash: "b3:x".into(),
411 content_b64: Some("AA==".into()),
412 content_url: None,
413 },
414 Mutation::ConfigEdit {
415 file: "/etc/foo.conf".into(),
416 inline_diff: "".into(),
417 },
418 ],
419 signature: None,
420 };
421 let paths = output_paths(&swm);
422 // El file_drop cuenta; el config_edit (modifica ajeno) NO.
423 assert!(paths.contains("/usr/share/logo.png"));
424 assert!(!paths.contains("/etc/foo.conf"));
425 assert_eq!(paths.len(), 1);
426 }
427}