> 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…
Etapa F refinamientos: uninstall poda dirs vacíos + `install --require-signed`
Dos bordes ásperos de la paquetería:
- uninstall ahora retira los directorios que quedaron VACÍOS por el borrado (rmdir de abajo
arriba; remove_dir sólo borra dirs vacíos ⇒ se detiene solo al toparse con contenido de otro
paquete). Antes dejaba /usr/bin, etc. huérfanos.
- `install --require-signed`: modo estricto que ABORTA si el release no está firmado por una
clave confiada (Unsigned o UnknownKey ⇒ error). No basta con que el .swm reproduzca: exige
autoría verificada del catálogo. Default off (no rompe flujos sin firma).
Validado E2E: uninstall bwrap poda 4 dirs; --require-signed aborta sin firma y procede con
release trusted. 31 suites verde.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index 478b6cd9..6deef097 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -230,6 +230,10 @@ enum Cmd {
/// Base de datos de instalados donde registrar el paquete (para `uninstall`).
#[arg(long, default_value = hammer_core::installed::DEFAULT_DB)]
db: PathBuf,
+ /// Modo estricto: abortar si el release del repo no está firmado por una clave confiada
+ /// (no basta con que el `.swm` reproduzca — exige autoría del catálogo verificada).
+ #[arg(long)]
+ require_signed: bool,
},
/// [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.
@@ -791,6 +795,7 @@ fn main() -> anyhow::Result<()> {
trust,
state_root,
db,
+ require_signed,
} => {
run_install(
&cli.store,
@@ -802,6 +807,7 @@ fn main() -> anyhow::Result<()> {
trust.as_deref(),
state_root.as_deref(),
&db,
+ require_signed,
)?;
}
Cmd::Uninstall { name, db } => run_uninstall(&name, &db)?,
@@ -1426,6 +1432,7 @@ fn run_install(
trust: Option<&std::path::Path>,
state_root: Option<&std::path::Path>,
db_path: &std::path::Path,
+ require_signed: bool,
) -> anyhow::Result<()> {
let source = RepoSource::parse(repo);
let index = source.read_index()?;
@@ -1439,12 +1446,20 @@ fn run_install(
match index.verify_signature(&store) {
hammer_core::SigStatus::Trusted { by } => eprintln!("release: trusted (by {by})"),
hammer_core::SigStatus::UnknownKey { by } => {
- eprintln!("release: unknown-key (by {by}) — autoría del catálogo no confiada")
+ eprintln!("release: unknown-key (by {by}) — autoría del catálogo no confiada");
+ if require_signed {
+ anyhow::bail!("--require-signed: el release está firmado por '{by}', clave no confiada en {}", trust_dir.display());
+ }
}
hammer_core::SigStatus::BadSig { by, reason } => {
anyhow::bail!("release: BAD-SIG (by {by}): {reason} — el índice fue manipulado")
}
- hammer_core::SigStatus::Unsigned => eprintln!("release: sin firmar"),
+ hammer_core::SigStatus::Unsigned => {
+ eprintln!("release: sin firmar");
+ if require_signed {
+ anyhow::bail!("--require-signed: el repo no tiene firma de release (firmalo con `hammer repo sign`)");
+ }
+ }
}
}
@@ -1592,6 +1607,7 @@ fn run_uninstall(name: &str, db_path: &std::path::Path) -> anyhow::Result<()> {
};
let (mut removed, mut kept) = (0usize, 0usize);
+ let mut touched_dirs: Vec<PathBuf> = Vec::new();
for f in &pkg.files {
// Refcount: no borramos un fichero que otro paquete instalado también aporta.
if idb.owned_by_others(name, f) {
@@ -1599,17 +1615,40 @@ fn run_uninstall(name: &str, db_path: &std::path::Path) -> anyhow::Result<()> {
continue;
}
match std::fs::remove_file(f) {
- Ok(()) => removed += 1,
+ Ok(()) => {
+ removed += 1;
+ if let Some(parent) = std::path::Path::new(f).parent() {
+ touched_dirs.push(parent.to_path_buf());
+ }
+ }
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} // ya no estaba
Err(e) => eprintln!(" aviso: no pude borrar {f}: {e}"),
}
}
+ // Recoge los directorios que quedaron vacíos por el borrado (y sus padres, hacia arriba):
+ // `remove_dir` sólo borra un dir VACÍO ⇒ se detiene solo al toparse con uno que aún tiene
+ // contenido de otros paquetes. Procesamos de más profundo a menos para vaciar de abajo arriba.
+ let mut pruned = 0usize;
+ touched_dirs.sort_by_key(|d| std::cmp::Reverse(d.components().count()));
+ touched_dirs.dedup();
+ for dir in touched_dirs {
+ let mut cur = dir;
+ while std::fs::remove_dir(&cur).is_ok() {
+ pruned += 1;
+ match cur.parent() {
+ Some(p) if !p.as_os_str().is_empty() => cur = p.to_path_buf(),
+ _ => break,
+ }
+ }
+ }
+
idb.remove(name);
idb.save(db_path)?;
eprintln!(
- "desinstalado {name} {} — {removed} fichero(s) borrado(s){}",
+ "desinstalado {name} {} — {removed} fichero(s) borrado(s){}{}",
pkg.version,
- if kept > 0 { format!(", {kept} conservado(s) (los aporta otro paquete)") } else { String::new() }
+ if kept > 0 { format!(", {kept} conservado(s) (los aporta otro paquete)") } else { String::new() },
+ if pruned > 0 { format!(", {pruned} dir(s) vacío(s) retirado(s)") } else { String::new() },
);
Ok(())
}
diff --git a/docs/06-swm-format.md b/docs/06-swm-format.md
index 2d8f1741..78159c01 100644
--- a/docs/06-swm-format.md
+++ b/docs/06-swm-format.md
@@ -145,7 +145,8 @@ CLI:
un binario ajeno. Registra el paquete en la DB de instalados (`--db`). `--repo` admite un
directorio local **o una URL HTTP(S)** (el repo son ficheros estáticos: `index.json` + los
`.swm`; con URL baja el índice + el cierre de deps a un temporal y procede igual).
- `--prefix`/`--skip-source-patch` para staging y dry-run de schema.
+ `--prefix`/`--skip-source-patch` para staging y dry-run de schema. `--require-signed` exige que
+ el release esté firmado por una clave confiada (modo estricto: no basta con reproducir).
- `hammer repo list [--repo DIR]` — lista el catálogo (`<repo>/index.json`).
- `hammer repo sign --repo DIR --key KEY` — firma el ÍNDICE entero (release). Re-firmá tras
publicar (cada `pack --repo` invalida la firma del release).