gioser

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 188Rust 55Shell 30Markdown 281211 Definiciones

⎇ 832ade63b4eddb8282325e69cebd7fa6b65e6c7e crates/hammer-core/src/repo.rs Rust · 424 líneas · 18.3 KB Historia Quién tocó qué crudo

repo: el índice firmado ancla el digest del .swm, y --repo acepta lista de orígenes 832ade63b · Sergio ·

1//! El **repositorio de paquetes** (Etapa F): un directorio con `.swm` + un `index.json` que
2//! mapea `nombre → paquete`. Es el catálogo que `hammer install <nombre>` resuelve.
3//!
4//! Un `.swm` es un manifiesto de mutación sin identidad propia (no lleva un campo "nombre de
5//! paquete"); la **identidad** (nombre+versión) la asigna el repo cuando se publica. Esto
6//! mantiene el formato honesto (describe una transformación, no se arroga ser "el paquete X")
7//! y deja al repo ser el namespace. `hammer pack --repo` publica con el nombre/versión de la
8//! receta; `install` resuelve por nombre.
9//!
10//! El índice es CONTENIDO PLANO (serde_json), no un binario: legible, diffeable, firmable más
11//! adelante como una release. El transporte del repo (filesystem / sshfs / mirror) es ortogonal
12//! — ver `hammer-mirror` para el CAS del store; aquí el repo es simplemente un directorio.
13
14use std::path::{Path, PathBuf};
15
16use serde::{Deserialize, Serialize};
17
18use crate::sign::{verify_raw, KeyPair, SigStatus, TrustStore};
19use crate::swm::Signature;
20
21/// Nombre canónico del índice dentro del directorio del repo.
22pub const INDEX_FILE: &str = "index.json";
23
24/// El `digest` de unos bytes de `.swm`, en el mismo formato `b3:…` que un [`crate::ArtifactHash`].
25///
26/// Length-prefijado como todo lo demás: la función es la misma que sella artefactos, no una segunda
27/// convención de hash conviviendo con la primera.
28pub fn digest_swm(bytes: &[u8]) -> String {
29    crate::ArtifactHash::of_inputs(&[bytes])
30        .as_str()
31        .to_string()
32}
33
34/// Comprueba que `bytes` casan con el `digest` que el índice **firmado** declara para esa entrada.
35///
36/// Un fallo acá no es "descarga corrupta": es que el origen entregó otro contenido del que el
37/// release firmó. Se trata como manipulación y aborta.
38pub fn verify_digest(entry: &PackageEntry, bytes: &[u8]) -> crate::Result<()> {
39    let Some(declarado) = entry.digest.as_deref() else {
40        return Ok(()); // índice anterior al campo: el llamador informa, no bloquea.
41    };
42    let real = digest_swm(bytes);
43    if real != declarado {
44        return Err(crate::Error::Other(anyhow::anyhow!(
45            "digest de '{}' no coincide: el índice firmado declara {declarado}, los bytes servidos dan {real} \
46             — el origen entregó un .swm distinto del que se firmó",
47            entry.name,
48        )));
49    }
50    Ok(())
51}
52
53/// El índice del repo: la lista de paquetes publicados. Un nombre aparece una sola vez
54/// (la última publicación gana, ver [`RepoIndex::upsert`]).
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct RepoIndex {
57    #[serde(default)]
58    pub packages: Vec<PackageEntry>,
59    /// Firma del CATÁLOGO entero (release): cubre la lista de paquetes, no cada `.swm` por
60    /// separado. Firmar el índice ancla qué paquetes existen, sus versiones y sus hashes — un
61    /// atacante no puede añadir/quitar/intercambiar entradas sin invalidar la firma. La firma de
62    /// cada `.swm` (autoría del paquete) es ortogonal y sigue existiendo.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub signature: Option<Signature>,
65}
66
67/// Una entrada del índice: la identidad de un paquete + el puntero a su `.swm` y los metadatos
68/// que `install` necesita para decidir ANTES de leer el `.swm` (base compatible, autoría).
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct PackageEntry {
71    pub name: String,
72    pub version: String,
73    /// Ruta del `.swm` relativa al directorio del repo.
74    pub file: String,
75    /// `base.distro_version` del `.swm` — permite filtrar incompatibles sin abrir el manifiesto.
76    pub distro_version: String,
77    /// Build-deps del paquete por NOMBRE (de la receta original) — las que el receptor debe tener
78    /// como paquetes en el repo para reproducir. `install` resuelve este grafo desde el índice sin
79    /// abrir cada `.swm`. Vacío ⇒ paquete autocontenido.
80    #[serde(default, skip_serializing_if = "Vec::is_empty")]
81    pub deps: Vec<String>,
82    /// `expected_hash` del `source_patch` (si lo lleva): el ancla "verificar, no confiar".
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub expected_hash: Option<String>,
85    /// BLAKE3 (`b3:…`) de los BYTES del `.swm`, para que el índice firmado ancle **todo** lo que
86    /// el cliente descarga (ADR 0014).
87    ///
88    /// # Por qué hace falta para servir desde espejos ajenos
89    ///
90    /// La firma del índice cubre la lista de entradas — nombre, versión, `file`, deps — pero `file`
91    /// es una RUTA, no un contenido: sin este campo, quien sirva los bytes puede devolver otro
92    /// `.swm` bajo el mismo nombre y la firma sigue casando. Aguas abajo hay red (el `apply`
93    /// reconstruye desde fuente y compara contra `expected_hash`), pero `expected_hash` es opcional
94    /// y el `.swm` se lee ANTES de llegar ahí — el gate de colisiones de `install` ya decide con su
95    /// contenido. Con `digest`, la cadena queda cerrada de punta a punta: clave raíz → firma del
96    /// índice → digest → bytes, y **el origen deja de necesitar confianza**, que es justo la
97    /// condición para poder replicar en N espejos que no controlamos.
98    ///
99    /// `None` en índices anteriores a este campo: se informa y no se bloquea, igual que el resto de
100    /// verificaciones de la CLI. Es opcional en el tipo, no en el protocolo.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub digest: Option<String>,
103    /// Autor de la firma del `.swm` (si está firmado). `install --trust` lo valida de verdad
104    /// contra la clave; este campo es sólo el metadato legible del índice.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub signed_by: Option<String>,
107}
108
109impl RepoIndex {
110    /// Carga el índice del repo. Un directorio sin `index.json` es un repo VACÍO válido (no es
111    /// error) — así `pack --repo <dir-nuevo>` arranca un repo desde cero.
112    pub fn load(repo_dir: impl AsRef<Path>) -> crate::Result<RepoIndex> {
113        let path = repo_dir.as_ref().join(INDEX_FILE);
114        match std::fs::read_to_string(&path) {
115            Ok(text) => serde_json::from_str(&text)
116                .map_err(|e| crate::Error::Serde(format!("{}: {e}", path.display()))),
117            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(RepoIndex::default()),
118            Err(e) => Err(crate::Error::Io(e)),
119        }
120    }
121
122    /// Escribe el índice (creando el directorio del repo si hace falta). JSON con saltos para
123    /// que el repo sea diffeable.
124    pub fn save(&self, repo_dir: impl AsRef<Path>) -> crate::Result<()> {
125        let dir = repo_dir.as_ref();
126        std::fs::create_dir_all(dir)?;
127        let text = serde_json::to_string_pretty(self)
128            .map_err(|e| crate::Error::Serde(e.to_string()))?;
129        std::fs::write(dir.join(INDEX_FILE), text)?;
130        Ok(())
131    }
132
133    /// Resuelve un nombre a su entrada (la última publicada).
134    pub fn find(&self, name: &str) -> Option<&PackageEntry> {
135        self.packages.iter().find(|p| p.name == name)
136    }
137
138    /// Inserta o reemplaza la entrada de un nombre (publicar es idempotente por nombre: una
139    /// nueva versión del mismo paquete sustituye a la anterior en el índice). Devuelve el
140    /// `.swm` viejo que quedó huérfano, si la ruta cambió (para que el caller lo borre).
141    pub fn upsert(&mut self, entry: PackageEntry) -> Option<String> {
142        // Cualquier cambio al catálogo invalida una firma de release previa: hay que re-firmar.
143        self.signature = None;
144        if let Some(slot) = self.packages.iter_mut().find(|p| p.name == entry.name) {
145            let old_file = slot.file.clone();
146            let orphan = (old_file != entry.file).then_some(old_file);
147            *slot = entry;
148            orphan
149        } else {
150            self.packages.push(entry);
151            self.packages.sort_by(|a, b| a.name.cmp(&b.name));
152            None
153        }
154    }
155
156    /// Ruta absoluta del `.swm` de una entrada, relativa al directorio del repo.
157    pub fn swm_path(repo_dir: impl AsRef<Path>, entry: &PackageEntry) -> PathBuf {
158        repo_dir.as_ref().join(&entry.file)
159    }
160
161    /// Bytes canónicos que firma el release: SÓLO la lista de paquetes (excluye la propia firma).
162    /// `PackageEntry` es todo strings/vecs en orden fijo y `packages` está ordenado por nombre
163    /// (ver `upsert`), así que firmante y verificador obtienen los mismos bytes.
164    fn signing_bytes(&self) -> Vec<u8> {
165        serde_json::to_vec(&self.packages).expect("Vec<PackageEntry> siempre serializa")
166    }
167
168    /// Firma el catálogo con `kp` (autoría `by`). Reemplaza cualquier firma previa.
169    pub fn sign(&mut self, kp: &KeyPair, by: &str) {
170        let sig = kp.sign_raw(&self.signing_bytes(), by);
171        self.signature = Some(sig);
172    }
173
174    /// Verifica la firma del catálogo contra un [`TrustStore`]. `Unsigned` si no hay firma.
175    pub fn verify_signature(&self, trust: &TrustStore) -> SigStatus {
176        match &self.signature {
177            None => SigStatus::Unsigned,
178            Some(sig) => verify_raw(&self.signing_bytes(), sig, trust),
179        }
180    }
181
182    /// Resuelve el cierre transitivo de build-deps de `name` en orden TOPOLÓGICO (cada dep antes
183    /// que quien la necesita; `name` queda al final). Es el orden en que `install` debe procesar:
184    /// poblar el catálogo con las deps antes de construir el dependiente. Error si una dep falta
185    /// del índice (no se puede reproducir sin ella) o si hay un ciclo.
186    pub fn resolve_closure<'a>(&'a self, name: &str) -> crate::Result<Vec<&'a PackageEntry>> {
187        let mut order: Vec<&'a PackageEntry> = Vec::new();
188        let mut done: Vec<&'a str> = Vec::new(); // ya emitidos
189        let mut stack: Vec<&'a str> = Vec::new(); // en la rama DFS actual (detección de ciclo)
190        self.visit(name, &mut order, &mut done, &mut stack)?;
191        Ok(order)
192    }
193
194    fn visit<'a>(
195        &'a self,
196        name: &str,
197        order: &mut Vec<&'a PackageEntry>,
198        done: &mut Vec<&'a str>,
199        stack: &mut Vec<&'a str>,
200    ) -> crate::Result<()> {
201        if done.iter().any(|d| *d == name) {
202            return Ok(());
203        }
204        if stack.iter().any(|s| *s == name) {
205            return Err(crate::Error::Recipe(format!(
206                "ciclo de dependencias en el repo: {} → {name}",
207                stack.join(" → ")
208            )));
209        }
210        let entry = self.find(name).ok_or_else(|| {
211            crate::Error::Recipe(format!(
212                "dependencia '{name}' no está en el repo (necesaria por {})",
213                stack.last().copied().unwrap_or("(raíz)")
214            ))
215        })?;
216        // Guardamos refs con la vida de `self` (no del parámetro `name`): el `.swm` y su nombre
217        // viven en el índice. `name` sólo sirve para buscar y comparar.
218        stack.push(entry.name.as_str());
219        for dep in &entry.deps {
220            self.visit(dep, order, done, stack)?;
221        }
222        stack.pop();
223        done.push(entry.name.as_str());
224        order.push(entry);
225        Ok(())
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    fn entry(name: &str, ver: &str) -> PackageEntry {
234        entry_deps(name, ver, &[])
235    }
236
237    fn entry_deps(name: &str, ver: &str, deps: &[&str]) -> PackageEntry {
238        PackageEntry {
239            name: name.into(),
240            version: ver.into(),
241            file: format!("{name}-{ver}.swm"),
242            distro_version: "dev".into(),
243            expected_hash: None,
244            digest: None,
245            signed_by: None,
246            deps: deps.iter().map(|s| s.to_string()).collect(),
247        }
248    }
249
250    #[test]
251    fn digest_detecta_un_swm_cambiado() {
252        let mut e = entry("foo", "1");
253        let bytes = b"contenido del .swm";
254        // Sin digest (indice viejo): no bloquea. Es opcional en el tipo, no en el protocolo.
255        assert!(verify_digest(&e, bytes).is_ok());
256
257        e.digest = Some(digest_swm(bytes));
258        assert!(verify_digest(&e, bytes).is_ok());
259
260        // Un solo byte distinto bajo el MISMO nombre de fichero — que es exactamente lo que puede
261        // hacer un espejo que no controlamos — y la firma del índice sigue casando sin este campo.
262        let err = verify_digest(&e, b"contenido del .swm!")
263            .unwrap_err()
264            .to_string();
265        assert!(err.contains("no coincide"), "mensaje inesperado: {err}");
266        assert!(
267            err.contains("foo"),
268            "el error debe nombrar el paquete: {err}"
269        );
270    }
271
272    #[test]
273    fn digest_ausente_no_cambia_la_serializacion() {
274        // `skip_serializing_if` ⇒ un índice ya firmado antes de este campo serializa IDÉNTICO y su
275        // firma sigue siendo válida. Añadir el campo no invalida releases existentes.
276        let json = serde_json::to_string(&entry("foo", "1")).unwrap();
277        assert!(!json.contains("digest"), "{json}");
278    }
279
280    #[test]
281    fn load_missing_index_is_empty_repo() {
282        let d = tempfile::tempdir().unwrap();
283        let idx = RepoIndex::load(d.path()).unwrap();
284        assert!(idx.packages.is_empty());
285    }
286
287    #[test]
288    fn save_load_roundtrip_and_find() {
289        let d = tempfile::tempdir().unwrap();
290        let mut idx = RepoIndex::default();
291        idx.upsert(entry("ripgrep", "14.1.1"));
292        idx.upsert(entry("findutils", "0.9.1"));
293        idx.save(d.path()).unwrap();
294
295        let back = RepoIndex::load(d.path()).unwrap();
296        assert_eq!(back.packages.len(), 2);
297        assert_eq!(back.find("ripgrep").unwrap().version, "14.1.1");
298        assert!(back.find("nope").is_none());
299        // Orden estable (alfabético) ⇒ índice diffeable.
300        assert_eq!(back.packages[0].name, "findutils");
301    }
302
303    #[test]
304    fn upsert_replaces_and_reports_orphan() {
305        let mut idx = RepoIndex::default();
306        idx.upsert(entry("rg", "1.0.0"));
307        // Nueva versión ⇒ archivo distinto ⇒ el viejo queda huérfano.
308        let orphan = idx.upsert(entry("rg", "2.0.0"));
309        assert_eq!(orphan.as_deref(), Some("rg-1.0.0.swm"));
310        assert_eq!(idx.packages.len(), 1, "no duplica por nombre");
311        assert_eq!(idx.find("rg").unwrap().version, "2.0.0");
312    }
313
314    #[test]
315    fn upsert_same_file_no_orphan() {
316        let mut idx = RepoIndex::default();
317        idx.upsert(entry("rg", "1.0.0"));
318        let orphan = idx.upsert(entry("rg", "1.0.0"));
319        assert!(orphan.is_none());
320    }
321
322    fn names<'a>(v: &[&'a PackageEntry]) -> Vec<&'a str> {
323        v.iter().map(|e| e.name.as_str()).collect()
324    }
325
326    #[test]
327    fn closure_topological_deps_before_dependents() {
328        // openssh → zlib, openssl ; openssl no tiene deps. Orden: deps antes, target al final.
329        let mut idx = RepoIndex::default();
330        idx.upsert(entry("zlib", "1.3"));
331        idx.upsert(entry("openssl", "3.5"));
332        idx.upsert(entry_deps("openssh", "10.3", &["zlib", "openssl"]));
333        let order = names(&idx.resolve_closure("openssh").unwrap());
334        // zlib y openssl preceden a openssh; openssh es el último.
335        assert_eq!(*order.last().unwrap(), "openssh");
336        let p = |n| order.iter().position(|x| *x == n).unwrap();
337        assert!(p("zlib") < p("openssh") && p("openssl") < p("openssh"));
338    }
339
340    #[test]
341    fn closure_dedups_diamond() {
342        // a→b,c ; b→d ; c→d. d aparece una sola vez, antes que b y c.
343        let mut idx = RepoIndex::default();
344        idx.upsert(entry("d", "1"));
345        idx.upsert(entry_deps("b", "1", &["d"]));
346        idx.upsert(entry_deps("c", "1", &["d"]));
347        idx.upsert(entry_deps("a", "1", &["b", "c"]));
348        let order = names(&idx.resolve_closure("a").unwrap());
349        assert_eq!(order.iter().filter(|x| **x == "d").count(), 1);
350        let p = |n| order.iter().position(|x| *x == n).unwrap();
351        assert!(p("d") < p("b") && p("d") < p("c") && p("b") < p("a"));
352    }
353
354    #[test]
355    fn closure_missing_dep_errors() {
356        let mut idx = RepoIndex::default();
357        idx.upsert(entry_deps("openssh", "10.3", &["openssl"])); // openssl ausente
358        let err = idx.resolve_closure("openssh").unwrap_err().to_string();
359        assert!(err.contains("openssl") && err.contains("no está en el repo"), "{err}");
360    }
361
362    #[test]
363    fn closure_cycle_errors() {
364        let mut idx = RepoIndex::default();
365        idx.upsert(entry_deps("a", "1", &["b"]));
366        idx.upsert(entry_deps("b", "1", &["a"]));
367        let err = idx.resolve_closure("a").unwrap_err().to_string();
368        assert!(err.contains("ciclo"), "{err}");
369    }
370
371    #[test]
372    fn release_sign_then_verify_trusted() {
373        let kp = crate::sign::KeyPair::generate().unwrap();
374        let mut idx = RepoIndex::default();
375        idx.upsert(entry("rg", "14.1.1"));
376        idx.upsert(entry("findutils", "0.9.1"));
377        idx.sign(&kp, "sergio");
378
379        let mut trust = TrustStore::new();
380        trust.insert_b64("sergio", &kp.public_b64()).unwrap();
381        assert_eq!(idx.verify_signature(&trust), SigStatus::Trusted { by: "sergio".into() });
382    }
383
384    #[test]
385    fn release_signature_survives_save_load() {
386        let d = tempfile::tempdir().unwrap();
387        let kp = crate::sign::KeyPair::generate().unwrap();
388        let mut idx = RepoIndex::default();
389        idx.upsert(entry("rg", "14.1.1"));
390        idx.sign(&kp, "sergio");
391        idx.save(d.path()).unwrap();
392
393        let back = RepoIndex::load(d.path()).unwrap();
394        let mut trust = TrustStore::new();
395        trust.insert_b64("sergio", &kp.public_b64()).unwrap();
396        assert!(back.verify_signature(&trust).is_trusted());
397    }
398
399    #[test]
400    fn upsert_invalidates_release_signature() {
401        let kp = crate::sign::KeyPair::generate().unwrap();
402        let mut idx = RepoIndex::default();
403        idx.upsert(entry("rg", "1"));
404        idx.sign(&kp, "sergio");
405        assert!(idx.signature.is_some());
406        // Publicar otro paquete invalida la firma (catálogo cambió ⇒ re-firmar).
407        idx.upsert(entry("fd", "1"));
408        assert!(idx.signature.is_none(), "upsert debe invalidar la firma de release");
409    }
410
411    #[test]
412    fn release_tamper_is_bad_sig() {
413        let kp = crate::sign::KeyPair::generate().unwrap();
414        let mut idx = RepoIndex::default();
415        idx.upsert(entry("rg", "1"));
416        idx.sign(&kp, "sergio");
417        // Manipular una entrada SIN re-firmar (p. ej. cambiar el hash esperado de un paquete).
418        idx.packages[0].version = "666".into();
419
420        let mut trust = TrustStore::new();
421        trust.insert_b64("sergio", &kp.public_b64()).unwrap();
422        assert!(matches!(idx.verify_signature(&trust), SigStatus::BadSig { .. }));
423    }
424}