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…
⎇ 832ade63b4eddb8282325e69cebd7fa6b65e6c7e
crates/hammer-core/src/repo.rs
Historia
Quién tocó qué
crudo
repo: el índice firmado ancla el digest del .swm, y --repo acepta lista de orígenes
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}