//! El **repositorio de paquetes** (Etapa F): un directorio con `.swm` + un `index.json` que //! mapea `nombre → paquete`. Es el catálogo que `hammer install ` resuelve. //! //! Un `.swm` es un manifiesto de mutación sin identidad propia (no lleva un campo "nombre de //! paquete"); la **identidad** (nombre+versión) la asigna el repo cuando se publica. Esto //! mantiene el formato honesto (describe una transformación, no se arroga ser "el paquete X") //! y deja al repo ser el namespace. `hammer pack --repo` publica con el nombre/versión de la //! receta; `install` resuelve por nombre. //! //! El índice es CONTENIDO PLANO (serde_json), no un binario: legible, diffeable, firmable más //! adelante como una release. El transporte del repo (filesystem / sshfs / mirror) es ortogonal //! — ver `hammer-mirror` para el CAS del store; aquí el repo es simplemente un directorio. use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use crate::sign::{verify_raw, KeyPair, SigStatus, TrustStore}; use crate::swm::Signature; /// Nombre canónico del índice dentro del directorio del repo. pub const INDEX_FILE: &str = "index.json"; /// El `digest` de unos bytes de `.swm`, en el mismo formato `b3:…` que un [`crate::ArtifactHash`]. /// /// Length-prefijado como todo lo demás: la función es la misma que sella artefactos, no una segunda /// convención de hash conviviendo con la primera. pub fn digest_swm(bytes: &[u8]) -> String { crate::ArtifactHash::of_inputs(&[bytes]) .as_str() .to_string() } /// Comprueba que `bytes` casan con el `digest` que el índice **firmado** declara para esa entrada. /// /// Un fallo acá no es "descarga corrupta": es que el origen entregó otro contenido del que el /// release firmó. Se trata como manipulación y aborta. pub fn verify_digest(entry: &PackageEntry, bytes: &[u8]) -> crate::Result<()> { let Some(declarado) = entry.digest.as_deref() else { return Ok(()); // índice anterior al campo: el llamador informa, no bloquea. }; let real = digest_swm(bytes); if real != declarado { return Err(crate::Error::Other(anyhow::anyhow!( "digest de '{}' no coincide: el índice firmado declara {declarado}, los bytes servidos dan {real} \ — el origen entregó un .swm distinto del que se firmó", entry.name, ))); } Ok(()) } /// El índice del repo: la lista de paquetes publicados. Un nombre aparece una sola vez /// (la última publicación gana, ver [`RepoIndex::upsert`]). #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct RepoIndex { #[serde(default)] pub packages: Vec, /// Firma del CATÁLOGO entero (release): cubre la lista de paquetes, no cada `.swm` por /// separado. Firmar el índice ancla qué paquetes existen, sus versiones y sus hashes — un /// atacante no puede añadir/quitar/intercambiar entradas sin invalidar la firma. La firma de /// cada `.swm` (autoría del paquete) es ortogonal y sigue existiendo. #[serde(default, skip_serializing_if = "Option::is_none")] pub signature: Option, } /// Una entrada del índice: la identidad de un paquete + el puntero a su `.swm` y los metadatos /// que `install` necesita para decidir ANTES de leer el `.swm` (base compatible, autoría). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PackageEntry { pub name: String, pub version: String, /// Ruta del `.swm` relativa al directorio del repo. pub file: String, /// `base.distro_version` del `.swm` — permite filtrar incompatibles sin abrir el manifiesto. pub distro_version: String, /// Build-deps del paquete por NOMBRE (de la receta original) — las que el receptor debe tener /// como paquetes en el repo para reproducir. `install` resuelve este grafo desde el índice sin /// abrir cada `.swm`. Vacío ⇒ paquete autocontenido. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub deps: Vec, /// `expected_hash` del `source_patch` (si lo lleva): el ancla "verificar, no confiar". #[serde(default, skip_serializing_if = "Option::is_none")] pub expected_hash: Option, /// BLAKE3 (`b3:…`) de los BYTES del `.swm`, para que el índice firmado ancle **todo** lo que /// el cliente descarga (ADR 0014). /// /// # Por qué hace falta para servir desde espejos ajenos /// /// La firma del índice cubre la lista de entradas — nombre, versión, `file`, deps — pero `file` /// es una RUTA, no un contenido: sin este campo, quien sirva los bytes puede devolver otro /// `.swm` bajo el mismo nombre y la firma sigue casando. Aguas abajo hay red (el `apply` /// reconstruye desde fuente y compara contra `expected_hash`), pero `expected_hash` es opcional /// y el `.swm` se lee ANTES de llegar ahí — el gate de colisiones de `install` ya decide con su /// contenido. Con `digest`, la cadena queda cerrada de punta a punta: clave raíz → firma del /// índice → digest → bytes, y **el origen deja de necesitar confianza**, que es justo la /// condición para poder replicar en N espejos que no controlamos. /// /// `None` en índices anteriores a este campo: se informa y no se bloquea, igual que el resto de /// verificaciones de la CLI. Es opcional en el tipo, no en el protocolo. #[serde(default, skip_serializing_if = "Option::is_none")] pub digest: Option, /// Autor de la firma del `.swm` (si está firmado). `install --trust` lo valida de verdad /// contra la clave; este campo es sólo el metadato legible del índice. #[serde(default, skip_serializing_if = "Option::is_none")] pub signed_by: Option, } impl RepoIndex { /// Carga el índice del repo. Un directorio sin `index.json` es un repo VACÍO válido (no es /// error) — así `pack --repo ` arranca un repo desde cero. pub fn load(repo_dir: impl AsRef) -> crate::Result { let path = repo_dir.as_ref().join(INDEX_FILE); match std::fs::read_to_string(&path) { Ok(text) => serde_json::from_str(&text) .map_err(|e| crate::Error::Serde(format!("{}: {e}", path.display()))), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(RepoIndex::default()), Err(e) => Err(crate::Error::Io(e)), } } /// Escribe el índice (creando el directorio del repo si hace falta). JSON con saltos para /// que el repo sea diffeable. pub fn save(&self, repo_dir: impl AsRef) -> crate::Result<()> { let dir = repo_dir.as_ref(); std::fs::create_dir_all(dir)?; let text = serde_json::to_string_pretty(self) .map_err(|e| crate::Error::Serde(e.to_string()))?; std::fs::write(dir.join(INDEX_FILE), text)?; Ok(()) } /// Resuelve un nombre a su entrada (la última publicada). pub fn find(&self, name: &str) -> Option<&PackageEntry> { self.packages.iter().find(|p| p.name == name) } /// Inserta o reemplaza la entrada de un nombre (publicar es idempotente por nombre: una /// nueva versión del mismo paquete sustituye a la anterior en el índice). Devuelve el /// `.swm` viejo que quedó huérfano, si la ruta cambió (para que el caller lo borre). pub fn upsert(&mut self, entry: PackageEntry) -> Option { // Cualquier cambio al catálogo invalida una firma de release previa: hay que re-firmar. self.signature = None; if let Some(slot) = self.packages.iter_mut().find(|p| p.name == entry.name) { let old_file = slot.file.clone(); let orphan = (old_file != entry.file).then_some(old_file); *slot = entry; orphan } else { self.packages.push(entry); self.packages.sort_by(|a, b| a.name.cmp(&b.name)); None } } /// Ruta absoluta del `.swm` de una entrada, relativa al directorio del repo. pub fn swm_path(repo_dir: impl AsRef, entry: &PackageEntry) -> PathBuf { repo_dir.as_ref().join(&entry.file) } /// Bytes canónicos que firma el release: SÓLO la lista de paquetes (excluye la propia firma). /// `PackageEntry` es todo strings/vecs en orden fijo y `packages` está ordenado por nombre /// (ver `upsert`), así que firmante y verificador obtienen los mismos bytes. fn signing_bytes(&self) -> Vec { serde_json::to_vec(&self.packages).expect("Vec siempre serializa") } /// Firma el catálogo con `kp` (autoría `by`). Reemplaza cualquier firma previa. pub fn sign(&mut self, kp: &KeyPair, by: &str) { let sig = kp.sign_raw(&self.signing_bytes(), by); self.signature = Some(sig); } /// Verifica la firma del catálogo contra un [`TrustStore`]. `Unsigned` si no hay firma. pub fn verify_signature(&self, trust: &TrustStore) -> SigStatus { match &self.signature { None => SigStatus::Unsigned, Some(sig) => verify_raw(&self.signing_bytes(), sig, trust), } } /// Resuelve el cierre transitivo de build-deps de `name` en orden TOPOLÓGICO (cada dep antes /// que quien la necesita; `name` queda al final). Es el orden en que `install` debe procesar: /// poblar el catálogo con las deps antes de construir el dependiente. Error si una dep falta /// del índice (no se puede reproducir sin ella) o si hay un ciclo. pub fn resolve_closure<'a>(&'a self, name: &str) -> crate::Result> { let mut order: Vec<&'a PackageEntry> = Vec::new(); let mut done: Vec<&'a str> = Vec::new(); // ya emitidos let mut stack: Vec<&'a str> = Vec::new(); // en la rama DFS actual (detección de ciclo) self.visit(name, &mut order, &mut done, &mut stack)?; Ok(order) } fn visit<'a>( &'a self, name: &str, order: &mut Vec<&'a PackageEntry>, done: &mut Vec<&'a str>, stack: &mut Vec<&'a str>, ) -> crate::Result<()> { if done.iter().any(|d| *d == name) { return Ok(()); } if stack.iter().any(|s| *s == name) { return Err(crate::Error::Recipe(format!( "ciclo de dependencias en el repo: {} → {name}", stack.join(" → ") ))); } let entry = self.find(name).ok_or_else(|| { crate::Error::Recipe(format!( "dependencia '{name}' no está en el repo (necesaria por {})", stack.last().copied().unwrap_or("(raíz)") )) })?; // Guardamos refs con la vida de `self` (no del parámetro `name`): el `.swm` y su nombre // viven en el índice. `name` sólo sirve para buscar y comparar. stack.push(entry.name.as_str()); for dep in &entry.deps { self.visit(dep, order, done, stack)?; } stack.pop(); done.push(entry.name.as_str()); order.push(entry); Ok(()) } } #[cfg(test)] mod tests { use super::*; fn entry(name: &str, ver: &str) -> PackageEntry { entry_deps(name, ver, &[]) } fn entry_deps(name: &str, ver: &str, deps: &[&str]) -> PackageEntry { PackageEntry { name: name.into(), version: ver.into(), file: format!("{name}-{ver}.swm"), distro_version: "dev".into(), expected_hash: None, digest: None, signed_by: None, deps: deps.iter().map(|s| s.to_string()).collect(), } } #[test] fn digest_detecta_un_swm_cambiado() { let mut e = entry("foo", "1"); let bytes = b"contenido del .swm"; // Sin digest (indice viejo): no bloquea. Es opcional en el tipo, no en el protocolo. assert!(verify_digest(&e, bytes).is_ok()); e.digest = Some(digest_swm(bytes)); assert!(verify_digest(&e, bytes).is_ok()); // Un solo byte distinto bajo el MISMO nombre de fichero — que es exactamente lo que puede // hacer un espejo que no controlamos — y la firma del índice sigue casando sin este campo. let err = verify_digest(&e, b"contenido del .swm!") .unwrap_err() .to_string(); assert!(err.contains("no coincide"), "mensaje inesperado: {err}"); assert!( err.contains("foo"), "el error debe nombrar el paquete: {err}" ); } #[test] fn digest_ausente_no_cambia_la_serializacion() { // `skip_serializing_if` ⇒ un índice ya firmado antes de este campo serializa IDÉNTICO y su // firma sigue siendo válida. Añadir el campo no invalida releases existentes. let json = serde_json::to_string(&entry("foo", "1")).unwrap(); assert!(!json.contains("digest"), "{json}"); } #[test] fn load_missing_index_is_empty_repo() { let d = tempfile::tempdir().unwrap(); let idx = RepoIndex::load(d.path()).unwrap(); assert!(idx.packages.is_empty()); } #[test] fn save_load_roundtrip_and_find() { let d = tempfile::tempdir().unwrap(); let mut idx = RepoIndex::default(); idx.upsert(entry("ripgrep", "14.1.1")); idx.upsert(entry("findutils", "0.9.1")); idx.save(d.path()).unwrap(); let back = RepoIndex::load(d.path()).unwrap(); assert_eq!(back.packages.len(), 2); assert_eq!(back.find("ripgrep").unwrap().version, "14.1.1"); assert!(back.find("nope").is_none()); // Orden estable (alfabético) ⇒ índice diffeable. assert_eq!(back.packages[0].name, "findutils"); } #[test] fn upsert_replaces_and_reports_orphan() { let mut idx = RepoIndex::default(); idx.upsert(entry("rg", "1.0.0")); // Nueva versión ⇒ archivo distinto ⇒ el viejo queda huérfano. let orphan = idx.upsert(entry("rg", "2.0.0")); assert_eq!(orphan.as_deref(), Some("rg-1.0.0.swm")); assert_eq!(idx.packages.len(), 1, "no duplica por nombre"); assert_eq!(idx.find("rg").unwrap().version, "2.0.0"); } #[test] fn upsert_same_file_no_orphan() { let mut idx = RepoIndex::default(); idx.upsert(entry("rg", "1.0.0")); let orphan = idx.upsert(entry("rg", "1.0.0")); assert!(orphan.is_none()); } fn names<'a>(v: &[&'a PackageEntry]) -> Vec<&'a str> { v.iter().map(|e| e.name.as_str()).collect() } #[test] fn closure_topological_deps_before_dependents() { // openssh → zlib, openssl ; openssl no tiene deps. Orden: deps antes, target al final. let mut idx = RepoIndex::default(); idx.upsert(entry("zlib", "1.3")); idx.upsert(entry("openssl", "3.5")); idx.upsert(entry_deps("openssh", "10.3", &["zlib", "openssl"])); let order = names(&idx.resolve_closure("openssh").unwrap()); // zlib y openssl preceden a openssh; openssh es el último. assert_eq!(*order.last().unwrap(), "openssh"); let p = |n| order.iter().position(|x| *x == n).unwrap(); assert!(p("zlib") < p("openssh") && p("openssl") < p("openssh")); } #[test] fn closure_dedups_diamond() { // a→b,c ; b→d ; c→d. d aparece una sola vez, antes que b y c. let mut idx = RepoIndex::default(); idx.upsert(entry("d", "1")); idx.upsert(entry_deps("b", "1", &["d"])); idx.upsert(entry_deps("c", "1", &["d"])); idx.upsert(entry_deps("a", "1", &["b", "c"])); let order = names(&idx.resolve_closure("a").unwrap()); assert_eq!(order.iter().filter(|x| **x == "d").count(), 1); let p = |n| order.iter().position(|x| *x == n).unwrap(); assert!(p("d") < p("b") && p("d") < p("c") && p("b") < p("a")); } #[test] fn closure_missing_dep_errors() { let mut idx = RepoIndex::default(); idx.upsert(entry_deps("openssh", "10.3", &["openssl"])); // openssl ausente let err = idx.resolve_closure("openssh").unwrap_err().to_string(); assert!(err.contains("openssl") && err.contains("no está en el repo"), "{err}"); } #[test] fn closure_cycle_errors() { let mut idx = RepoIndex::default(); idx.upsert(entry_deps("a", "1", &["b"])); idx.upsert(entry_deps("b", "1", &["a"])); let err = idx.resolve_closure("a").unwrap_err().to_string(); assert!(err.contains("ciclo"), "{err}"); } #[test] fn release_sign_then_verify_trusted() { let kp = crate::sign::KeyPair::generate().unwrap(); let mut idx = RepoIndex::default(); idx.upsert(entry("rg", "14.1.1")); idx.upsert(entry("findutils", "0.9.1")); idx.sign(&kp, "sergio"); let mut trust = TrustStore::new(); trust.insert_b64("sergio", &kp.public_b64()).unwrap(); assert_eq!(idx.verify_signature(&trust), SigStatus::Trusted { by: "sergio".into() }); } #[test] fn release_signature_survives_save_load() { let d = tempfile::tempdir().unwrap(); let kp = crate::sign::KeyPair::generate().unwrap(); let mut idx = RepoIndex::default(); idx.upsert(entry("rg", "14.1.1")); idx.sign(&kp, "sergio"); idx.save(d.path()).unwrap(); let back = RepoIndex::load(d.path()).unwrap(); let mut trust = TrustStore::new(); trust.insert_b64("sergio", &kp.public_b64()).unwrap(); assert!(back.verify_signature(&trust).is_trusted()); } #[test] fn upsert_invalidates_release_signature() { let kp = crate::sign::KeyPair::generate().unwrap(); let mut idx = RepoIndex::default(); idx.upsert(entry("rg", "1")); idx.sign(&kp, "sergio"); assert!(idx.signature.is_some()); // Publicar otro paquete invalida la firma (catálogo cambió ⇒ re-firmar). idx.upsert(entry("fd", "1")); assert!(idx.signature.is_none(), "upsert debe invalidar la firma de release"); } #[test] fn release_tamper_is_bad_sig() { let kp = crate::sign::KeyPair::generate().unwrap(); let mut idx = RepoIndex::default(); idx.upsert(entry("rg", "1")); idx.sign(&kp, "sergio"); // Manipular una entrada SIN re-firmar (p. ej. cambiar el hash esperado de un paquete). idx.packages[0].version = "666".into(); let mut trust = TrustStore::new(); trust.insert_b64("sergio", &kp.public_b64()).unwrap(); assert!(matches!(idx.verify_signature(&trust), SigStatus::BadSig { .. })); } }