From 9d1b2d4a762d2bc185908bf908666bd1cb9dc4f3 Mon Sep 17 00:00:00 2001 From: sergio Date: Sun, 21 Jun 2026 07:01:46 -0400 Subject: [PATCH] =?UTF-8?q?Etapa=20F=20paqueter=C3=ADa=20#5:=20DB=20de=20i?= =?UTF-8?q?nstalados=20+=20`hammer=20uninstall`/`installed`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Funcionalidad de gestor de paquetes: rastrear qué hay instalado y poder quitarlo. - hammer-core/installed.rs: `InstalledDb` (name→{version,hash,files}) load/save JSON; record (upsert), remove, `owned_by_others` (refcount por ruta). Rutas absolutas ⇒ uninstall no necesita el root. 4 tests. - hammer-cli: run_apply ahora DEVUELVE los ficheros que CREA (hidratados + file_drop + init_rule; config_edit modifica, no crea ⇒ no se registra ni se deshace). install los registra en la DB (--db, default /var/lib/hammer/installed.json). `uninstall ` borra esos ficheros salvo los que otro paquete instalado aporta (refcount) y quita la entrada. `installed` lista. - Validado E2E REAL: install bwrap (con dep libcap) → registra 2 ficheros → `installed` los lista → `uninstall bwrap` los borra (prefix vacío, DB vacía). 31 suites verde. Co-Authored-By: Claude Opus 4.8 --- crates/hammer-cli/src/main.rs | 109 ++++++++++++++++++++- crates/hammer-core/src/installed.rs | 142 ++++++++++++++++++++++++++++ crates/hammer-core/src/lib.rs | 2 + docs/06-swm-format.md | 4 + 4 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 crates/hammer-core/src/installed.rs diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 7d78b04e..8742d190 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -227,6 +227,23 @@ enum Cmd { /// Raíz de estado para el overlay (modo sin `--prefix`). #[arg(long)] state_root: Option, + /// Base de datos de instalados donde registrar el paquete (para `uninstall`). + #[arg(long, default_value = hammer_core::installed::DEFAULT_DB)] + db: PathBuf, + }, + /// [Etapa F] Desinstala un paquete: borra los ficheros que registró (refcount: respeta los + /// que otro paquete instalado también aporta) y lo quita de la DB de instalados. + Uninstall { + /// Nombre del paquete a desinstalar. + name: String, + /// Base de datos de instalados. + #[arg(long, default_value = hammer_core::installed::DEFAULT_DB)] + db: PathBuf, + }, + /// [Etapa F] Lista los paquetes instalados (de la DB). + Installed { + #[arg(long, default_value = hammer_core::installed::DEFAULT_DB)] + db: PathBuf, }, /// [Etapa F] Inspecciona un repositorio de paquetes. Repo { @@ -773,6 +790,7 @@ fn main() -> anyhow::Result<()> { base_ref, trust, state_root, + db, } => { run_install( &cli.store, @@ -783,8 +801,11 @@ fn main() -> anyhow::Result<()> { base_ref.as_deref(), trust.as_deref(), state_root.as_deref(), + &db, )?; } + Cmd::Uninstall { name, db } => run_uninstall(&name, &db)?, + Cmd::Installed { db } => run_installed_list(&db)?, Cmd::Repo { sub } => match sub { RepoCmd::List { repo } => run_repo_list(&repo)?, RepoCmd::Sign { repo, key, by } => run_repo_sign(&repo, &key, by.as_deref())?, @@ -1339,6 +1360,7 @@ fn run_install( base_ref: Option<&std::path::Path>, trust: Option<&std::path::Path>, state_root: Option<&std::path::Path>, + db_path: &std::path::Path, ) -> anyhow::Result<()> { let index = hammer_core::RepoIndex::load(repo_dir)?; @@ -1432,16 +1454,88 @@ fn run_install( } } + // Identidad del paquete para la DB (antes de mover nada; `entry` vive mientras `index`). + let pkg_name = entry.name.clone(); + let pkg_version = entry.version.clone(); + let pkg_hash = entry.expected_hash.clone(); + // El resto (schema, base, reproduce source_patch CON sus deps del catálogo, hidrata) es // exactamente `apply`: build_source_patch usa el MISMO catalog_dir determinista. - run_apply( + let created = run_apply( store_path, swm_path_str, prefix, skip_source_patch, base_ref, state_root, - ) + )?; + + // Registrar en la DB de instalados (salvo dry-run de schema). Permite `hammer uninstall`. + if !skip_source_patch { + let mut idb = hammer_core::InstalledDb::load(db_path)?; + let files: Vec = created.iter().map(|p| p.to_string_lossy().into_owned()).collect(); + let nfiles = files.len(); + idb.record(hammer_core::InstalledPackage { + name: pkg_name.clone(), + version: pkg_version, + hash: pkg_hash, + files, + }); + idb.save(db_path)?; + eprintln!("registrado {pkg_name} ({nfiles} fichero(s)) en {}", db_path.display()); + } + Ok(()) +} + +/// Lista los paquetes instalados de la DB. +fn run_installed_list(db_path: &std::path::Path) -> anyhow::Result<()> { + let idb = hammer_core::InstalledDb::load(db_path)?; + if idb.packages.is_empty() { + println!("nada instalado (DB {})", db_path.display()); + return Ok(()); + } + println!("{} paquete(s) instalado(s) (DB {}):", idb.packages.len(), db_path.display()); + for p in &idb.packages { + println!(" {:<16} {:<10} {} fichero(s)", p.name, p.version, p.files.len()); + } + Ok(()) +} + +/// Desinstala un paquete: borra los ficheros que registró (salvo los que otro paquete instalado +/// también aporta — refcount) y quita su entrada de la DB. NO deshace `config_edit` (modifican +/// ficheros ajenos, no creados por el paquete). +fn run_uninstall(name: &str, db_path: &std::path::Path) -> anyhow::Result<()> { + let mut idb = hammer_core::InstalledDb::load(db_path)?; + let Some(pkg) = idb.find(name).cloned() else { + let avail: Vec<&str> = idb.packages.iter().map(|p| p.name.as_str()).collect(); + anyhow::bail!( + "'{name}' no está instalado (DB {}). Instalados: {}", + db_path.display(), + if avail.is_empty() { "(ninguno)".into() } else { avail.join(", ") } + ); + }; + + let (mut removed, mut kept) = (0usize, 0usize); + for f in &pkg.files { + // Refcount: no borramos un fichero que otro paquete instalado también aporta. + if idb.owned_by_others(name, f) { + kept += 1; + continue; + } + match std::fs::remove_file(f) { + Ok(()) => removed += 1, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} // ya no estaba + Err(e) => eprintln!(" aviso: no pude borrar {f}: {e}"), + } + } + idb.remove(name); + idb.save(db_path)?; + eprintln!( + "desinstalado {name} {} — {removed} fichero(s) borrado(s){}", + pkg.version, + if kept > 0 { format!(", {kept} conservado(s) (los aporta otro paquete)") } else { String::new() } + ); + Ok(()) } /// Devuelve la primera mutación `source_patch` de un `.swm` empaquetado (el caso de `pack`). @@ -1576,9 +1670,12 @@ fn run_apply( skip_source_patch: bool, base_ref: Option<&std::path::Path>, state_root: Option<&std::path::Path>, -) -> anyhow::Result<()> { +) -> anyhow::Result> { let swm = load_swm(file)?; swm.verify_schema()?; + // Ficheros que este apply CREA (hidratados + file_drop + init_rule). Sirven para registrar el + // paquete en la DB de instalados (config_edit MODIFICA, no crea ⇒ no se registra). + let mut created: Vec = Vec::new(); if let Some(local) = load_local_base(base_ref)? { match swm.verify_base(&local) { @@ -1636,6 +1733,7 @@ fn run_apply( abs.display() ); } + created.extend(report.files.iter().map(|f| f.dst.clone())); } hammer_core::Mutation::ConfigEdit { file: f, inline_diff } => { counts.1 += 1; @@ -1649,6 +1747,7 @@ fn run_apply( hammer_core::apply::rebase_path(hammer_core::apply::INIT_RULES_DIR, prefix); let p = hammer_core::apply::apply_init_rule(&rules_dir, action, service, command)?; eprintln!(" init_rule #{}: {action} {service} → {}", i + 1, p.display()); + created.push(p); } hammer_core::Mutation::FileDrop { path, content_hash, content_b64, content_url } => { counts.3 += 1; @@ -1657,6 +1756,7 @@ fn run_apply( (Some(b64), None) => { hammer_core::apply::apply_file_drop(&target, b64, content_hash)?; eprintln!(" file_drop #{} → {}", i + 1, target.display()); + created.push(target.clone()); } (None, Some(url)) => { // Descarga remota (fuera de cualquier sandbox) + verificación BLAKE3 @@ -1669,6 +1769,7 @@ fn run_apply( bytes.len(), target.display() ); + created.push(target.clone()); } _ => unreachable!("verify_schema lo descarta"), } @@ -1683,7 +1784,7 @@ fn run_apply( println!("{id}"); eprintln!("overlay listo: usa `hammer commit {id}` para promocionar al FHS real."); } - Ok(()) + Ok(created) } /// Exporta el diario actual como `.swm`. Provenance-aware: diff --git a/crates/hammer-core/src/installed.rs b/crates/hammer-core/src/installed.rs new file mode 100644 index 00000000..1d8d240b --- /dev/null +++ b/crates/hammer-core/src/installed.rs @@ -0,0 +1,142 @@ +//! La **base de datos de paquetes instalados** (Etapa F): qué paquetes hay puestos en un root y +//! qué ficheros aportó cada uno. Es lo que hace posible `hammer uninstall`: sin un registro de +//! "este paquete creó estos ficheros", quitar un paquete sería adivinar. +//! +//! Modelo simple y honesto: `install` registra los ficheros que CREÓ (los hidratados del +//! `source_patch` + `file_drop` + `init_rule`; un `config_edit` MODIFICA un fichero ajeno, no lo +//! crea, así que no se registra ni se deshace). `uninstall` borra los ficheros del paquete, salvo +//! los que OTRO paquete instalado también aporta (refcount por ruta) — nunca rompe a un tercero. +//! +//! Las rutas se guardan ABSOLUTAS (bajo el prefix o `/` según donde se instaló), así `uninstall` +//! no necesita saber el root: borra exactamente lo que registró el `install`. + +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +/// Nombre por defecto de la base de datos de instalados. +pub const DEFAULT_DB: &str = "/var/lib/hammer/installed.json"; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct InstalledDb { + #[serde(default)] + pub packages: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InstalledPackage { + pub name: String, + pub version: String, + /// `expected_hash` del paquete (si lo tenía) — sólo informativo en la DB. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hash: Option, + /// Rutas ABSOLUTAS que este paquete creó (orden de instalación). Lo que `uninstall` borra. + pub files: Vec, +} + +impl InstalledDb { + /// Carga la DB. Un fichero ausente es una DB VACÍA válida (nada instalado todavía). + pub fn load(path: impl AsRef) -> crate::Result { + match std::fs::read_to_string(path.as_ref()) { + Ok(text) => serde_json::from_str(&text) + .map_err(|e| crate::Error::Serde(format!("{}: {e}", path.as_ref().display()))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(InstalledDb::default()), + Err(e) => Err(crate::Error::Io(e)), + } + } + + /// Escribe la DB (creando el directorio padre si hace falta). JSON con saltos, diffeable. + pub fn save(&self, path: impl AsRef) -> crate::Result<()> { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let text = serde_json::to_string_pretty(self) + .map_err(|e| crate::Error::Serde(e.to_string()))?; + std::fs::write(path, text)?; + Ok(()) + } + + pub fn find(&self, name: &str) -> Option<&InstalledPackage> { + self.packages.iter().find(|p| p.name == name) + } + + /// Registra (o reemplaza por nombre) un paquete instalado. Re-instalar actualiza la lista de + /// ficheros (una versión nueva puede aportar otros). + pub fn record(&mut self, pkg: InstalledPackage) { + if let Some(slot) = self.packages.iter_mut().find(|p| p.name == pkg.name) { + *slot = pkg; + } else { + self.packages.push(pkg); + self.packages.sort_by(|a, b| a.name.cmp(&b.name)); + } + } + + /// Quita el registro de un paquete y lo devuelve (para que el caller borre sus ficheros). + pub fn remove(&mut self, name: &str) -> Option { + let i = self.packages.iter().position(|p| p.name == name)?; + Some(self.packages.remove(i)) + } + + /// ¿Algún OTRO paquete instalado (≠ `name`) aporta también `file`? Si sí, `uninstall` no debe + /// borrarlo (lo seguiría necesitando ese tercero). + pub fn owned_by_others(&self, name: &str, file: &str) -> bool { + self.packages + .iter() + .any(|p| p.name != name && p.files.iter().any(|f| f == file)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pkg(name: &str, files: &[&str]) -> InstalledPackage { + InstalledPackage { + name: name.into(), + version: "1".into(), + hash: None, + files: files.iter().map(|s| s.to_string()).collect(), + } + } + + #[test] + fn load_missing_is_empty() { + let d = tempfile::tempdir().unwrap(); + let db = InstalledDb::load(d.path().join("installed.json")).unwrap(); + assert!(db.packages.is_empty()); + } + + #[test] + fn record_save_load_find() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("installed.json"); + let mut db = InstalledDb::default(); + db.record(pkg("ripgrep", &["/usr/bin/rg"])); + db.save(&p).unwrap(); + let back = InstalledDb::load(&p).unwrap(); + assert_eq!(back.find("ripgrep").unwrap().files, vec!["/usr/bin/rg"]); + assert!(back.find("nope").is_none()); + } + + #[test] + fn remove_returns_entry() { + let mut db = InstalledDb::default(); + db.record(pkg("rg", &["/usr/bin/rg"])); + let removed = db.remove("rg").unwrap(); + assert_eq!(removed.files, vec!["/usr/bin/rg"]); + assert!(db.find("rg").is_none()); + assert!(db.remove("rg").is_none()); + } + + #[test] + fn owned_by_others_refcount() { + let mut db = InstalledDb::default(); + db.record(pkg("a", &["/usr/lib/libshared.so", "/usr/bin/a"])); + db.record(pkg("b", &["/usr/lib/libshared.so", "/usr/bin/b"])); + // El .so lo aportan ambos: al desinstalar 'a' no debe borrarse. + assert!(db.owned_by_others("a", "/usr/lib/libshared.so")); + // El binario propio de 'a' no lo aporta nadie más. + assert!(!db.owned_by_others("a", "/usr/bin/a")); + } +} diff --git a/crates/hammer-core/src/lib.rs b/crates/hammer-core/src/lib.rs index 2f55000c..2d250192 100644 --- a/crates/hammer-core/src/lib.rs +++ b/crates/hammer-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod apply; pub mod caps; pub mod hash; +pub mod installed; pub mod proto; pub mod query; pub mod recipe; @@ -17,6 +18,7 @@ pub mod swm; pub use caps::{AgentCapsConfig, CapRule}; pub use hash::ArtifactHash; +pub use installed::{InstalledDb, InstalledPackage}; pub use recipe::{Compiler, Deps, LinkMode, Phases, Recipe, Source, SourceKind}; pub use repo::{PackageEntry, RepoIndex}; pub use sign::{KeyPair, SigStatus, TrustStore}; diff --git a/docs/06-swm-format.md b/docs/06-swm-format.md index aa91e47a..7f3d55bb 100644 --- a/docs/06-swm-format.md +++ b/docs/06-swm-format.md @@ -147,6 +147,10 @@ CLI: - `hammer repo sign --repo DIR --key KEY` — firma el ÍNDICE entero (release). Re-firmá tras publicar (cada `pack --repo` invalida la firma del release). - `hammer repo verify --repo DIR [--trust DIR]` — verifica la firma del release. +- `hammer uninstall [--db FILE]` — borra los ficheros que el paquete registró (refcount: + respeta los que otro paquete instalado también aporta) y lo quita de la DB de instalados. +- `hammer installed [--db FILE]` — lista los paquetes instalados. `install` registra cada paquete + (nombre, versión, hash, ficheros creados) en la DB (`/var/lib/hammer/installed.json` por defecto). ## 7. Repositorio de paquetes (Etapa F)