Etapa F paquetería #5: DB de instalados + hammer uninstall/installed

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 <nombre>` 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 07:01:46 -04:00
co-authored by Claude Opus 4.8
parent 977f07ca6c
commit 9d1b2d4a76
4 changed files with 253 additions and 4 deletions
+105 -4
View File
@@ -227,6 +227,23 @@ enum Cmd {
/// Raíz de estado para el overlay (modo sin `--prefix`).
#[arg(long)]
state_root: Option<PathBuf>,
/// 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<String> = 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<Vec<PathBuf>> {
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<PathBuf> = 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:
+142
View File
@@ -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<InstalledPackage>,
}
#[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<String>,
/// Rutas ABSOLUTAS que este paquete creó (orden de instalación). Lo que `uninstall` borra.
pub files: Vec<String>,
}
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<Path>) -> crate::Result<InstalledDb> {
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<Path>) -> 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<InstalledPackage> {
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"));
}
}
+2
View File
@@ -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};
+4
View File
@@ -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 <nombre> [--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)