Cierra el hueco de seguridad: hoy se firmaba cada .swm (autoría del paquete) pero NO el
catálogo ⇒ un atacante podía añadir/quitar/intercambiar entradas del index.json. Firmar el
release ancla qué paquetes/versiones/hashes existen.
- hammer-core/sign.rs: extraídos `KeyPair::sign_raw` + `verify_raw` genéricos (bytes canónicos
arbitrarios); Swm::{sign,verify_signature} ahora los reusan (DRY, sin cambio de comportamiento).
- hammer-core/repo.rs: `RepoIndex.signature` (Ed25519 sobre la lista de paquetes canónica,
excluye la propia firma) + `sign`/`verify_signature`. `upsert` INVALIDA la firma (cualquier
cambio al catálogo ⇒ re-firmar). 4 tests (sign→verify, survive save/load, upsert-invalida,
tamper→BadSig).
- hammer-cli: `repo sign --key` / `repo verify`; `install` VERIFICA el release antes de resolver
(BadSig ⇒ aborta "el índice fue manipulado"); `repo list` muestra si está firmado.
- Validado E2E host: sin firmar→firmar→trusted→install lo ve; MANIPULAR el índice sin re-firmar
⇒ install ABORTA; re-publicar invalida la firma. 31 suites verde.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2496 lines
103 KiB
Rust
2496 lines
103 KiB
Rust
//! `hammer` — el punto de entrada de todo flujo manual. Ver `docs/`.
|
|
//!
|
|
//! Los subcomandos están mapeados a las fases del roadmap (`docs/10-roadmap.md`). Los que aún
|
|
//! no están implementados devuelven un error claro indicando su fase, para que el esqueleto
|
|
//! sea navegable desde el día uno.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use clap::{Parser, Subcommand};
|
|
|
|
const DEFAULT_STORE: &str = "/store";
|
|
const DEFAULT_REPO: &str = "/var/lib/hammer/repo";
|
|
|
|
#[derive(Parser)]
|
|
#[command(
|
|
name = "hammer",
|
|
version,
|
|
about = "Laboratorio funcional en el sótano, terminal mutable arriba.",
|
|
long_about = "hammer: distro AI-nativa. Ver docs/ para el diseño completo."
|
|
)]
|
|
struct Cli {
|
|
/// Ruta del content-addressed store.
|
|
#[arg(long, default_value = DEFAULT_STORE, global = true)]
|
|
store: String,
|
|
|
|
#[command(subcommand)]
|
|
cmd: Cmd,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Cmd {
|
|
/// [Fase 0] Compila una receta y la sella en el store.
|
|
Build {
|
|
/// Ruta a la receta TOML.
|
|
recipe: String,
|
|
},
|
|
/// [Atestación] Verifica un árbol FHS (p.ej. un product-rootfs hidratado) contra su
|
|
/// `/ente/attest.json`: recomputa el BLAKE3 de cada binario crítico y lo compara. Es el gate de
|
|
/// integridad que arje aplicará al boot, hecho hoy por hammer ("reproducir, no confiar"). Exit≠0
|
|
/// si algún binario diverge o falta.
|
|
Attest {
|
|
/// Directorio raíz del rootfs a verificar (contiene `ente/attest.json`).
|
|
#[arg(long)]
|
|
rootfs: String,
|
|
},
|
|
/// [Fase 1] Proyecta un artefacto del store al FHS (real o de overlay).
|
|
Hydrate {
|
|
/// Hash del artefacto (acepta prefijos cortos; con o sin `b3:`).
|
|
hash: String,
|
|
#[arg(long, default_value = "/")]
|
|
into: String,
|
|
/// Modo de enlazado: `static` (hardlink directo) | `dynamic` (requiere patchelf).
|
|
#[arg(long, default_value = "static")]
|
|
link: String,
|
|
},
|
|
/// [Fase 2] Monta un overlay de experimentación sobre los directorios del sistema.
|
|
/// Sin targets → cubre el FHS clásico (/usr/bin, /bin, /usr/lib, /lib, /etc, …).
|
|
Try {
|
|
/// Directorios a poner bajo overlay. Si vacío, usa la lista por defecto.
|
|
targets: Vec<PathBuf>,
|
|
/// Raíz donde viven los manifiestos y los upper/work. Default `/var/lib/hammer/overlays`.
|
|
#[arg(long)]
|
|
state_root: Option<PathBuf>,
|
|
},
|
|
/// [Fase 2+3] Fusiona un overlay al FHS real y desmonta. Por defecto registra cada
|
|
/// archivo promocionado en el diario; usa `--no-journal` para saltarse el registro.
|
|
Commit {
|
|
id: String,
|
|
#[arg(long)]
|
|
state_root: Option<PathBuf>,
|
|
/// Directorio del diario donde anotar la promoción.
|
|
#[arg(long, default_value = "/var/lib/hammer/journal")]
|
|
journal: PathBuf,
|
|
/// Saltarse el registro en el diario (útil para experimentos efímeros).
|
|
#[arg(long)]
|
|
no_journal: bool,
|
|
},
|
|
/// [Fase 2] Desmonta un overlay y borra su upper. El sistema vuelve al estado base.
|
|
Discard {
|
|
id: String,
|
|
#[arg(long)]
|
|
state_root: Option<PathBuf>,
|
|
},
|
|
/// [Fase 2] Lista los overlays activos según los manifiestos persistidos.
|
|
Status {
|
|
#[arg(long)]
|
|
state_root: Option<PathBuf>,
|
|
},
|
|
/// [Fase 3] Ver/seguir el diario de mutaciones.
|
|
Journal {
|
|
/// Directorio del diario (mismo que `hammerd --journal`).
|
|
#[arg(long, default_value = "/var/lib/hammer/journal")]
|
|
dir: PathBuf,
|
|
/// Muestra los últimos N eventos. Default: 20.
|
|
#[arg(long, short, default_value = "20")]
|
|
tail: usize,
|
|
/// Sigue el archivo: imprime nuevos eventos según aparecen.
|
|
#[arg(long, short)]
|
|
follow: bool,
|
|
/// Formato. `pretty` (humano, default) o `json` (raw passthrough).
|
|
#[arg(long, default_value = "pretty")]
|
|
format: String,
|
|
},
|
|
/// [Fase 4] Aplica un manifiesto .swm. Por defecto abre un overlay sobre el FHS y aplica
|
|
/// las mutaciones ahí; con `--prefix DIR` opera directamente en `DIR` sin overlay
|
|
/// (útil para tests offline o staging en otro filesystem).
|
|
Apply {
|
|
file: String,
|
|
/// Si está, re-rootea todas las rutas absolutas del .swm bajo este prefix y NO abre
|
|
/// overlay. `--prefix /mnt/foo` ⇒ `/etc/network.conf` → `/mnt/foo/etc/network.conf`.
|
|
#[arg(long)]
|
|
prefix: Option<PathBuf>,
|
|
/// Si está, salta source_patch silenciosamente. Útil para probar el flujo de
|
|
/// config_edit/file_drop sin lab disponible.
|
|
#[arg(long)]
|
|
skip_source_patch: bool,
|
|
/// Tras aplicar, intenta verificar pin contra base local de `--base-ref`.
|
|
#[arg(long)]
|
|
base_ref: Option<PathBuf>,
|
|
/// Raíz del overlay (sólo en modo overlay).
|
|
#[arg(long)]
|
|
state_root: Option<PathBuf>,
|
|
},
|
|
/// [Fase 4] Verifica un .swm: schema, base (si se da `--base-ref`), el hash de cada
|
|
/// `file_drop` inline y, con `--trust DIR`, la firma Ed25519 contra el TrustStore local.
|
|
SwmVerify {
|
|
file: String,
|
|
#[arg(long)]
|
|
base_ref: Option<PathBuf>,
|
|
/// Directorio de claves de confianza (`*.ed25519.pub`). Default:
|
|
/// /var/lib/hammer/trust. La firma reporta estado, NO autoriza promover (ver SDD 09).
|
|
#[arg(long)]
|
|
trust: Option<PathBuf>,
|
|
},
|
|
/// [Fase 4] Genera un par de claves Ed25519 para firmar `.swm`. Escribe `<name>.ed25519`
|
|
/// (privada, 0600) y `<name>.ed25519.pub` en `--out` (default: /var/lib/hammer/trust).
|
|
Keygen {
|
|
/// Nombre de la identidad (autoría). Será el `by` de las firmas.
|
|
name: String,
|
|
#[arg(long)]
|
|
out: Option<PathBuf>,
|
|
},
|
|
/// [Fase 4] Firma un `.swm` con una clave privada y escribe el manifiesto firmado.
|
|
SwmSign {
|
|
file: String,
|
|
/// Ruta a la clave privada `<name>.ed25519`.
|
|
#[arg(long)]
|
|
key: PathBuf,
|
|
/// Autoría declarada (`signature.by`). Default: el basename de la clave sin sufijo.
|
|
#[arg(long)]
|
|
by: Option<String>,
|
|
/// Destino. Si se omite, sobrescribe el propio `file` in-place.
|
|
#[arg(long, short)]
|
|
out: Option<PathBuf>,
|
|
},
|
|
/// [Fase 4] Exporta el diario actual como manifiesto .swm a stdout.
|
|
/// Mutaciones trazables ⇒ file_drop con content_b64 y hash. Mutaciones opacas ⇒
|
|
/// mismas pero con `note` indicando el origen (warning legible para el receptor).
|
|
Export {
|
|
/// Archivo JSON con la base local; se inserta como `base` en el .swm. Si no se da,
|
|
/// se emite una base con `distro_version: "unknown"` y se imprime un warning.
|
|
#[arg(long)]
|
|
base_ref: Option<PathBuf>,
|
|
/// Directorio del diario.
|
|
#[arg(long, default_value = "/var/lib/hammer/journal")]
|
|
journal: PathBuf,
|
|
/// Sólo eventos a partir de este timestamp (RFC 3339). Si no se da, exporta todo.
|
|
#[arg(long)]
|
|
since: Option<String>,
|
|
},
|
|
/// [Etapa F] Empaqueta una receta del corpus (`recipes/foo.toml`) como un `.swm` de un
|
|
/// único `source_patch`: la dirección **forward** que vuelve una receta que el sistema ya
|
|
/// sabe construir un paquete distribuible y reproducible-desde-fuente. Inversa de `apply`.
|
|
Pack {
|
|
/// Ruta a la receta a empaquetar (p. ej. `recipes/ripgrep.toml`).
|
|
recipe: PathBuf,
|
|
/// Ancla de sanity: ruta absoluta del binario que debe existir tras hidratar el
|
|
/// artefacto. Default `/usr/bin/<name>`. (apply hidrata el artefacto ENTERO; esto sólo
|
|
/// verifica que algo esperado quedó en su sitio.)
|
|
#[arg(long)]
|
|
target_bin: Option<String>,
|
|
/// Archivo `.swm` de salida. Sin esto (ni `--repo`), se imprime a stdout.
|
|
#[arg(long, short)]
|
|
out: Option<PathBuf>,
|
|
/// Publica el paquete en este repositorio: escribe `<repo>/<name>-<version>.swm` y
|
|
/// actualiza `<repo>/index.json` (upsert por nombre). Compatible con `--out`.
|
|
#[arg(long)]
|
|
repo: Option<PathBuf>,
|
|
/// `distro_version` de la base del paquete (la base local del receptor debe coincidir).
|
|
#[arg(long, default_value = "dev")]
|
|
distro_version: String,
|
|
/// `expected_hash` declarado (`b3:…`) — el ancla de "verificar, no confiar". Si se omite
|
|
/// y no se pasa `--build`, el paquete sale sin ancla (el receptor reproduce igual, pero
|
|
/// no hay hash autor contra el que comparar).
|
|
#[arg(long)]
|
|
expected: Option<String>,
|
|
/// Construir la receta AHORA en el lab para sellar el `expected_hash` real (necesita
|
|
/// store + sandbox). Mutuamente informativo con `--expected` (este último gana si ambos).
|
|
#[arg(long)]
|
|
build: bool,
|
|
/// Firmar el `.swm` resultante con esta clave privada Ed25519 (como `swm-sign`).
|
|
#[arg(long)]
|
|
sign: Option<PathBuf>,
|
|
},
|
|
/// [Etapa F] Instala un paquete por nombre desde un repositorio: resuelve `nombre` en el
|
|
/// índice, verifica la firma (con `--trust`) y la base, reproduce el `source_patch` desde
|
|
/// fuente y lo hidrata. Es `apply` con resolución por nombre — nunca corre un binario ajeno.
|
|
Install {
|
|
/// Nombre del paquete a instalar (clave del índice del repo).
|
|
name: String,
|
|
/// Directorio del repositorio (con `index.json`).
|
|
#[arg(long, default_value = DEFAULT_REPO)]
|
|
repo: PathBuf,
|
|
/// Re-rootea las mutaciones bajo este prefix (tests/staging) en vez de abrir overlay.
|
|
#[arg(long)]
|
|
prefix: Option<PathBuf>,
|
|
/// Verifica el `source_patch` y la base, pero NO construye ni hidrata (dry-run de schema).
|
|
#[arg(long)]
|
|
skip_source_patch: bool,
|
|
/// Base local (JSON) contra la que validar la base del paquete. Sin esto no se chequea.
|
|
#[arg(long)]
|
|
base_ref: Option<PathBuf>,
|
|
/// Directorio de claves de confianza para validar la firma del `.swm`. Sin esto, la
|
|
/// firma se informa pero no bloquea (igual que `swm-verify` sin `--trust`).
|
|
#[arg(long)]
|
|
trust: Option<PathBuf>,
|
|
/// Raíz de estado para el overlay (modo sin `--prefix`).
|
|
#[arg(long)]
|
|
state_root: Option<PathBuf>,
|
|
},
|
|
/// [Etapa F] Inspecciona un repositorio de paquetes.
|
|
Repo {
|
|
#[command(subcommand)]
|
|
sub: RepoCmd,
|
|
},
|
|
/// [Fase 6] Ejecuta el bucle agéntico: traduce una intención NL a un .swm vía catálogo
|
|
/// mock y la aplica a un overlay (o prefix) sin promover al FHS. Imprime el Proposal.
|
|
Ai {
|
|
/// La intención en lenguaje natural. En modo mock (`--catalog`), debe coincidir
|
|
/// exactamente con una entrada del catálogo; en modo `--llm`, es texto libre.
|
|
intent: String,
|
|
/// Catálogo YAML de intent → swm (MockTranslator). Mutuamente excluyente con `--llm`.
|
|
#[arg(long)]
|
|
catalog: Option<PathBuf>,
|
|
/// Activa el traductor LLM real (Claude API). Requiere `ANTHROPIC_API_KEY` en el env
|
|
/// y un binario compilado con `--features llm-claude`. Modelo por defecto:
|
|
/// `claude-opus-4-8` (sobreescribible con `HAMMER_LLM_MODEL`).
|
|
#[arg(long)]
|
|
llm: bool,
|
|
/// Re-rootea las mutaciones bajo este prefix en vez de abrir overlay. Útil para
|
|
/// dev/CI sin root ni overlayfs.
|
|
#[arg(long)]
|
|
prefix: Option<PathBuf>,
|
|
/// Base local (JSON). Si está, el orquestador verifica `verify_base` contra ella.
|
|
#[arg(long)]
|
|
base_ref: Option<PathBuf>,
|
|
/// Socket de hammerd. Si está + el .swm trae source_patch, el orquestador llama
|
|
/// Compile por el bus. Si no, source_patch se omite con warning.
|
|
#[arg(long)]
|
|
bus: Option<PathBuf>,
|
|
/// Raíz de overlays para `try`. Default coincide con `hammer try`.
|
|
#[arg(long)]
|
|
state_root: Option<PathBuf>,
|
|
/// Si está, activa el bucle de auto-reparación: tras aplicar, espera en el bus
|
|
/// de eventos (mismo socket que `--bus` si no se da otro) un evento `Crashed`
|
|
/// durante `--repair-window-ms`. Si llega, traduce `(service, code)` a una nueva
|
|
/// intención y reaplica, hasta este máximo de intentos. Sin `--repair-max-attempts`,
|
|
/// el bucle agéntico corre una sola vez (compatible hacia atrás).
|
|
#[arg(long)]
|
|
repair_max_attempts: Option<u32>,
|
|
/// Ventana de espera por un Crashed tras cada intento. Default: 5000 ms.
|
|
#[arg(long, default_value = "5000")]
|
|
repair_window_ms: u64,
|
|
},
|
|
/// [Fase 5] Envía un comando al FIFO de control humano del init.
|
|
/// Por defecto: /run/init.control. Equivalente a `echo "<line>" > /run/init.control`,
|
|
/// pero con un mensaje de error claro si el FIFO no existe.
|
|
Ctl {
|
|
/// La línea a enviar (p. ej. "start web", "restart network").
|
|
line: String,
|
|
#[arg(long, default_value = "/run/init.control")]
|
|
fifo: PathBuf,
|
|
},
|
|
/// [Fase 6] Evalúa una expresión del mini-lenguaje (SDD 08 §6) contra el sistema local.
|
|
/// Forma: `kind:value`. Kinds soportados: `bin`, `file`, `pin`, `service`, `depends`.
|
|
/// Ejemplos:
|
|
/// hammer query bin:grep
|
|
/// hammer query file:/etc/hosts
|
|
/// hammer query depends:/usr/bin/curl
|
|
/// hammer query pin:musl --base-ref base.json
|
|
Query {
|
|
/// La expresión a evaluar.
|
|
expr: String,
|
|
/// Base local (JSON) para resolver `pin:<name>`. Opcional.
|
|
#[arg(long)]
|
|
base_ref: Option<PathBuf>,
|
|
/// Re-rootea las rutas absolutas bajo este prefix (útil para probar contra un overlay
|
|
/// o un prefix de test sin tocar el FHS real).
|
|
#[arg(long)]
|
|
fs_root: Option<PathBuf>,
|
|
},
|
|
/// [Track posterior] Bootstrap from-scratch (SDD 11). Encadena el toolchain semilla y el
|
|
/// userland mínimo hacia el auto-alojamiento.
|
|
Bootstrap {
|
|
#[command(subcommand)]
|
|
sub: BootstrapCmd,
|
|
},
|
|
/// [Etapa E3] Mirror del store content-addressed: replica artefactos por hash entre máquinas,
|
|
/// verificando `of_tree` (BLAKE3) en recepción. `--store` es el store local.
|
|
Mirror {
|
|
#[command(subcommand)]
|
|
sub: MirrorCmd,
|
|
},
|
|
|
|
/// [Etapa E4] Upgrades atómicos con rollback: aplica un árbol Stage1/producto del store al root
|
|
/// vivo dejando una generación nueva; `rollback` revierte exactamente al árbol anterior.
|
|
Upgrade {
|
|
/// Root del FHS vivo sobre el que se proyecta el árbol (default `/`).
|
|
#[arg(long, default_value = "/", global = true)]
|
|
root: String,
|
|
/// Directorio de estado de generaciones (default `/var/lib/hammer/upgrades`).
|
|
#[arg(long, default_value = hammer_upgrade::DEFAULT_STATE_ROOT, global = true)]
|
|
state_root: String,
|
|
/// Diario donde registrar cada cambio (default `/var/lib/hammer/journal`).
|
|
#[arg(long, default_value = "/var/lib/hammer/journal", global = true)]
|
|
journal: String,
|
|
#[command(subcommand)]
|
|
sub: UpgradeCmd,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum RepoCmd {
|
|
/// Lista los paquetes publicados en el repositorio.
|
|
List {
|
|
/// Directorio del repositorio (con `index.json`).
|
|
#[arg(long, default_value = DEFAULT_REPO)]
|
|
repo: PathBuf,
|
|
},
|
|
/// Firma el ÍNDICE entero (release): ancla qué paquetes/versiones/hashes existen. Re-firmá
|
|
/// tras publicar (cada `pack --repo` invalida la firma previa).
|
|
Sign {
|
|
#[arg(long, default_value = DEFAULT_REPO)]
|
|
repo: PathBuf,
|
|
/// Clave privada Ed25519 con la que firmar el release.
|
|
#[arg(long)]
|
|
key: PathBuf,
|
|
/// Autor de la firma. Default: basename de la clave sin `.ed25519`.
|
|
#[arg(long)]
|
|
by: Option<String>,
|
|
},
|
|
/// Verifica la firma del release del repositorio contra las claves de confianza.
|
|
Verify {
|
|
#[arg(long, default_value = DEFAULT_REPO)]
|
|
repo: PathBuf,
|
|
/// Directorio de claves de confianza. Default `/var/lib/hammer/trust`.
|
|
#[arg(long)]
|
|
trust: Option<PathBuf>,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum UpgradeCmd {
|
|
/// Aplica el árbol `<tree-dir>` (`<hash>-<name>` del store) al root vivo, dejando una generación.
|
|
Apply {
|
|
/// Directorio del artefacto en el store (p.ej. `b3hex…-product-rootfs`).
|
|
tree_dir: String,
|
|
/// `of_tree` esperado (`b3:…`) — de un índice de mirror o release firmada; si se da, se exige.
|
|
#[arg(long)]
|
|
expected: Option<String>,
|
|
},
|
|
/// Revierte la generación viva al árbol anterior (o al estado pre-upgrades).
|
|
Rollback,
|
|
/// Completa (o deshace con --rollback) un apply interrumpido por un corte/reinicio (intento pendiente).
|
|
Recover {
|
|
/// Deshace el apply a medias (vuelve al estado pre-apply) en vez de completarlo.
|
|
#[arg(long)]
|
|
rollback: bool,
|
|
},
|
|
/// Muestra la generación viva y el historial.
|
|
Status,
|
|
/// Borra generaciones que el rollback ya no alcanza (huérfanas tras un rollback). Recupera espacio.
|
|
Prune {
|
|
/// Recorta también la cadena viva a sus N generaciones más nuevas (limita la profundidad de
|
|
/// rollback). Sin flag, conserva toda la cadena y borra sólo las huérfanas.
|
|
#[arg(long)]
|
|
keep: Option<usize>,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum MirrorCmd {
|
|
/// Replica al store REMOTO los artefactos que le faltan (verificados antes de sellar).
|
|
Push {
|
|
/// Ruta del store remoto destino (puede ser un montaje de red/sshfs).
|
|
remote: String,
|
|
},
|
|
/// Trae del store REMOTO los artefactos que le faltan al local (verificados antes de sellar).
|
|
Pull {
|
|
/// Ruta del store remoto origen.
|
|
remote: String,
|
|
},
|
|
/// Muestra el diff entre el store local y uno remoto (qué falta en cada lado, conflictos). No copia.
|
|
Status {
|
|
/// Ruta del store remoto a comparar.
|
|
remote: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum BootstrapCmd {
|
|
/// [Stage 0] Ingiere un toolchain semilla pinned al store. Verifica el sha256 antes de
|
|
/// extraer y lo sella; idempotente si ya está. Imprime el hash del artefacto.
|
|
Stage0 {
|
|
/// URL del tarball del toolchain (cualquier esquema de curl; `file://` para offline).
|
|
#[arg(long)]
|
|
url: String,
|
|
/// sha256 hex del tarball, fijado. Se verifica antes de sellar.
|
|
#[arg(long)]
|
|
sha256: String,
|
|
/// Versión de la semilla (entra en la identidad/hash del artefacto).
|
|
#[arg(long)]
|
|
version: String,
|
|
/// Clase de semilla.
|
|
#[arg(long, default_value = "zig")]
|
|
seed: String,
|
|
},
|
|
/// [Stage 1] Cross-compila el userland mínimo (musl + busybox) con la semilla de Stage 0,
|
|
/// ensambla un rootfs FHS y lo sella. Imprime el hash del rootfs.
|
|
Stage1 {
|
|
/// Hash de la semilla ya sellada por Stage 0 (con o sin prefijo `b3:`).
|
|
#[arg(long)]
|
|
seed_hash: String,
|
|
/// Clase de semilla (debe coincidir con la de Stage 0).
|
|
#[arg(long, default_value = "zig")]
|
|
seed: String,
|
|
/// Directorio de recetas (`musl.toml`, `busybox.toml`).
|
|
#[arg(long, default_value = "recipes")]
|
|
recipes: String,
|
|
},
|
|
/// [Producto] Capa de SERVICIOS: toma el rootfs base verificado del 4/4 (`--rootfs`, el que
|
|
/// imprimió `stage1`) e inyecta por hidratación tardía los componentes de servicio (sshd/openssh
|
|
/// + netup) y sus configs, sellando un `product-rootfs` APARTE. El mecanismo base no se toca (su
|
|
/// `of_tree` sigue blindado para el selfhost-verify). Separación Mecanismo/Política.
|
|
Product {
|
|
/// Hash del rootfs base de Stage 1 (el que imprimió `stage1`; con o sin `b3:`).
|
|
#[arg(long)]
|
|
rootfs: String,
|
|
/// Directorio de recetas (`openssh.toml`, `netup.toml`, …).
|
|
#[arg(long, default_value = "recipes")]
|
|
recipes: String,
|
|
/// Produce además el producto ATESTADO (`product-attested-rootfs`): hidrata el init CON gate
|
|
/// (`arje-zero-attest`) y FIRMA la seed con `arje-packager`. Imprime el hash del atestado.
|
|
#[arg(long)]
|
|
attest: bool,
|
|
/// Política del gate de atestación: `halt` (default) | `degraded` | `warn`. Sólo con `--attest`.
|
|
#[arg(long, default_value = "halt")]
|
|
policy: String,
|
|
/// Fichero con la rootkey de 32 bytes raw para firmar. Sin él se usa la rootkey de desarrollo
|
|
/// determinista (firmas reproducibles). Sólo con `--attest`.
|
|
#[arg(long)]
|
|
rootkey: Option<String>,
|
|
},
|
|
/// [Stage 2] Ancla el content-hash del rootfs de Stage 1 como referencia de reproducibilidad
|
|
/// (anota la línea del manifiesto). Con `--verify <hash>` compara contra un rebuild `stage1'`
|
|
/// (producido en la VM) y emite el veredicto de auto-alojamiento.
|
|
Stage2 {
|
|
/// Hash del rootfs de Stage 1 (el que imprimió `stage1`; con o sin `b3:`).
|
|
#[arg(long)]
|
|
rootfs: String,
|
|
/// Content-hash de un rebuild `stage1'` para comparar. Sin él, sólo ancla la referencia.
|
|
#[arg(long)]
|
|
verify: Option<String>,
|
|
},
|
|
/// [Stage 2 · builder] Ensambla el **builder rootfs** (Stage 1 + toolchain) que, booteado en la
|
|
/// VM, reconstruye `stage1'` con sus propias herramientas (auto-alojamiento, SDD 11 §7). Variante
|
|
/// pragmática: el toolchain entra desde `.dev-fs/alpine`.
|
|
Builder {
|
|
/// Hash del rootfs de Stage 1 (el que imprimió `stage1`; con o sin `b3:`).
|
|
#[arg(long)]
|
|
stage1: String,
|
|
/// Hash de la semilla sellada por Stage 0 (con o sin `b3:`).
|
|
#[arg(long)]
|
|
seed_hash: String,
|
|
/// Clase de semilla (debe coincidir con la de Stage 0).
|
|
#[arg(long, default_value = "zig")]
|
|
seed: String,
|
|
/// Directorio de recetas a embeber.
|
|
#[arg(long, default_value = "recipes")]
|
|
recipes: String,
|
|
/// Binario `hammer` estático a instalar en `/usr/bin/hammer`.
|
|
#[arg(long, default_value = "target/x86_64-unknown-linux-musl/release/hammer")]
|
|
hammer_bin: String,
|
|
/// Toolchain del sandbox de build → `/toolchain` (rootfs Alpine de `.dev-fs`).
|
|
#[arg(long, default_value = ".dev-fs/alpine")]
|
|
toolchain: String,
|
|
/// Etiqueta de identidad del toolchain (entra al hash lógico del builder).
|
|
#[arg(long, default_value = "alpine")]
|
|
toolchain_tag: String,
|
|
/// Content-hash `of_tree(stage1)` (referencia anclada por `stage2`) para que el rebuild se
|
|
/// auto-verifique en la VM. Sin él, el driver sólo imprime el content-hash de `stage1'`.
|
|
#[arg(long)]
|
|
ref_content: Option<String>,
|
|
/// Caché de fuentes (`work/`) a embeber en `/work` para un rebuild offline.
|
|
#[arg(long)]
|
|
work_cache: Option<String>,
|
|
/// Pieza del toolchain construida por hammer desde fuente (variante b, SDD 11 §7.2b) que pisa
|
|
/// la de Alpine en `/toolchain`. Formato `name=hash[:rel_path]`; `rel_path` por defecto
|
|
/// `usr/bin/<name>`. Repetible. Ej.: `--swap make=fbad44ac…`. Cada swap entra al hash lógico.
|
|
#[arg(long = "swap", value_name = "name=hash[:rel_path]")]
|
|
swaps: Vec<String>,
|
|
/// Dónde ensamblar el builder (se reensambla limpio en cada corrida).
|
|
#[arg(long, default_value = "work/builder-rootfs")]
|
|
out: String,
|
|
},
|
|
/// [Stage 0+1+2] Encadena las tres etapas (semilla → rootfs → referencia anclada) en una corrida
|
|
/// y deja el `bootstrap.json` poblado (SDD 11 §5). El veredicto `✓ REPRODUCIBLE` lo sella el
|
|
/// rebuild nativo dentro del rootfs (VM, `scripts/selfhost-verify.sh`), no esta corrida del host.
|
|
All {
|
|
/// URL del tarball del toolchain semilla (cualquier esquema de curl; `file://` para offline).
|
|
#[arg(long)]
|
|
url: String,
|
|
/// sha256 hex del tarball, fijado. Se verifica antes de sellar.
|
|
#[arg(long)]
|
|
sha256: String,
|
|
/// Versión de la semilla (entra en la identidad/hash del artefacto).
|
|
#[arg(long)]
|
|
version: String,
|
|
/// Clase de semilla.
|
|
#[arg(long, default_value = "zig")]
|
|
seed: String,
|
|
/// Directorio de recetas (`musl.toml`, `busybox.toml`, `hammerd.toml`, `arje-zero.toml`).
|
|
#[arg(long, default_value = "recipes")]
|
|
recipes: String,
|
|
},
|
|
/// Imprime el `bootstrap.json` del store: el log de transparencia (SDD 11 §4). Un tercero
|
|
/// reproduce el bootstrap y verifica que sus hashes coinciden con los de estas líneas.
|
|
Manifest,
|
|
}
|
|
|
|
/// Parsea la clase de semilla del flag `--seed`. Único punto de verdad para todas las subcomandos.
|
|
fn parse_seed_kind(s: &str) -> anyhow::Result<hammer_bootstrap::SeedKind> {
|
|
match s {
|
|
"zig" => Ok(hammer_bootstrap::SeedKind::Zig),
|
|
"musl-cross-make" => Ok(hammer_bootstrap::SeedKind::MuslCrossMake),
|
|
other => anyhow::bail!("--seed debe ser 'zig' o 'musl-cross-make', no '{other}'"),
|
|
}
|
|
}
|
|
|
|
fn print_event(ev: &hammer_journal::MutationEvent, format: &str) {
|
|
match format {
|
|
"json" => {
|
|
// Re-serializa. No imprimimos la línea original (no la tenemos a mano), pero el
|
|
// formato es estable porque MutationEvent es la fuente de verdad del schema.
|
|
match serde_json::to_string(ev) {
|
|
Ok(s) => println!("{s}"),
|
|
Err(e) => eprintln!("journal: error serializando: {e}"),
|
|
}
|
|
}
|
|
_ => {
|
|
let actor = match &ev.by.source {
|
|
hammer_journal::Source::HammerHydrate { artifact } => format!("hydrate {artifact}"),
|
|
hammer_journal::Source::HammerCommit { overlay, artifact } => match artifact {
|
|
Some(a) => format!("commit {overlay} ({a})"),
|
|
None => format!("commit {overlay}"),
|
|
},
|
|
hammer_journal::Source::External => {
|
|
let pid = ev.by.pid.map(|p| format!("pid={p}")).unwrap_or_default();
|
|
let uid = ev.by.uid.map(|u| format!("uid={u}")).unwrap_or_default();
|
|
format!("external {pid} {uid}").trim().to_string()
|
|
}
|
|
};
|
|
println!(
|
|
"{ts} {op:<7} {path} ← {actor}",
|
|
ts = ev.ts,
|
|
op = ev.op.as_str(),
|
|
path = ev.path.display(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn main() -> anyhow::Result<()> {
|
|
tracing_subscriber::fmt()
|
|
.with_writer(std::io::stderr)
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| "info".into()),
|
|
)
|
|
.init();
|
|
|
|
let cli = Cli::parse();
|
|
|
|
// Sólo las cmds que de verdad tocan artefactos abren (y posiblemente crean) el store.
|
|
// Las cmds de overlay/diario/apply pueden correr en sistemas sin store local todavía.
|
|
match cli.cmd {
|
|
Cmd::Build { recipe } => {
|
|
let store = hammer_core::Store::open(&cli.store)?;
|
|
let recipe = hammer_core::Recipe::load_from_path(&recipe)?;
|
|
let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
|
|
let hash = hammer_build::build(&recipe, &cfg, &store)?;
|
|
println!("{hash}");
|
|
}
|
|
Cmd::Attest { rootfs } => {
|
|
let report = hammer_bootstrap::verify_attestation(std::path::Path::new(&rootfs))?;
|
|
for p in &report.ok {
|
|
println!(" ✓ {p}");
|
|
}
|
|
for p in &report.missing {
|
|
println!(" ✗ FALTA {p}");
|
|
}
|
|
for p in &report.mismatched {
|
|
println!(" ✗ DIVERGE {p}");
|
|
}
|
|
if report.passed() {
|
|
println!(
|
|
"✓ ATESTACIÓN OK: {} binarios críticos casan su BLAKE3 esperado",
|
|
report.ok.len()
|
|
);
|
|
} else {
|
|
anyhow::bail!(
|
|
"✗ ATESTACIÓN FALLÓ: {} diverge(n), {} falta(n) — integridad comprometida",
|
|
report.mismatched.len(),
|
|
report.missing.len()
|
|
);
|
|
}
|
|
}
|
|
Cmd::Hydrate { hash, into, link } => {
|
|
let store = hammer_core::Store::open(&cli.store)?;
|
|
let mode = match link.as_str() {
|
|
"static" => hammer_core::LinkMode::Static,
|
|
"dynamic" => hammer_core::LinkMode::Dynamic,
|
|
other => anyhow::bail!("--link debe ser 'static' o 'dynamic', no '{other}'"),
|
|
};
|
|
let artifact_dir = store.find_by_hash(&hash)?;
|
|
let report = hammer_build::run_hydrate(
|
|
&artifact_dir,
|
|
std::path::Path::new(&into),
|
|
mode,
|
|
None,
|
|
)?;
|
|
println!(
|
|
"hydrated {} archivo(s) ({} dir(s), {} symlink(s)) → {}",
|
|
report.files.len(),
|
|
report.dirs_created,
|
|
report.symlinks,
|
|
into
|
|
);
|
|
for f in &report.files {
|
|
println!(" {}", f.dst.display());
|
|
}
|
|
}
|
|
Cmd::Try { targets, state_root } => {
|
|
let root = state_root
|
|
.unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
|
|
std::fs::create_dir_all(&root)?;
|
|
let id = hammer_overlay::try_overlay(&targets, &root)?;
|
|
println!("{id}");
|
|
}
|
|
Cmd::Commit { id, state_root, journal, no_journal } => {
|
|
let root = state_root
|
|
.unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
|
|
let id = hammer_overlay::OverlayId(id);
|
|
let report = if no_journal {
|
|
hammer_overlay::commit(&id, &root)?
|
|
} else {
|
|
let j = hammer_journal::Journal::open(&journal)?;
|
|
hammer_overlay::commit_with_journal(&id, &root, &j)?
|
|
};
|
|
println!(
|
|
"commit OK — {} archivo(s) promocionados, {} eliminado(s){}",
|
|
report.copied.len(),
|
|
report.removed.len(),
|
|
if no_journal { "" } else { " (anotado en el diario)" }
|
|
);
|
|
}
|
|
Cmd::Discard { id, state_root } => {
|
|
let root = state_root
|
|
.unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
|
|
hammer_overlay::discard(&hammer_overlay::OverlayId(id), &root)?;
|
|
println!("discard OK");
|
|
}
|
|
Cmd::Status { state_root } => {
|
|
let root = state_root
|
|
.unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
|
|
let overlays = hammer_overlay::status(&root)?;
|
|
if overlays.is_empty() {
|
|
println!("(sin overlays activos)");
|
|
} else {
|
|
for o in overlays {
|
|
println!(
|
|
"{} created={} mounts={}",
|
|
o.id,
|
|
o.created_at,
|
|
o.mounts.len()
|
|
);
|
|
for m in &o.mounts {
|
|
println!(" └─ {} upper={}", m.target.display(), m.upper.display());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Cmd::Journal { dir, tail, follow, format } => {
|
|
let j = hammer_journal::Journal::open(&dir)?;
|
|
let initial = j.tail(tail)?;
|
|
for ev in &initial {
|
|
print_event(ev, &format);
|
|
}
|
|
if follow {
|
|
use std::io::Write;
|
|
let _ = std::io::stdout().flush();
|
|
let mut cursor = j.end_offset()?;
|
|
loop {
|
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
|
let (news, new_cursor) = j.read_from_offset(cursor)?;
|
|
cursor = new_cursor;
|
|
for ev in &news {
|
|
print_event(ev, &format);
|
|
}
|
|
let _ = std::io::stdout().flush();
|
|
}
|
|
}
|
|
}
|
|
Cmd::Apply { file, prefix, skip_source_patch, base_ref, state_root } => {
|
|
run_apply(
|
|
&cli.store,
|
|
&file,
|
|
prefix.as_deref(),
|
|
skip_source_patch,
|
|
base_ref.as_deref(),
|
|
state_root.as_deref(),
|
|
)?;
|
|
}
|
|
Cmd::SwmVerify { file, base_ref, trust } => {
|
|
run_swm_verify(&file, base_ref.as_deref(), trust.as_deref())?;
|
|
}
|
|
Cmd::Keygen { name, out } => {
|
|
run_keygen(&name, out.as_deref())?;
|
|
}
|
|
Cmd::SwmSign { file, key, by, out } => {
|
|
run_swm_sign(&file, &key, by.as_deref(), out.as_deref())?;
|
|
}
|
|
Cmd::Export { base_ref, journal, since } => {
|
|
run_export(base_ref.as_deref(), &journal, since.as_deref(), &cli.store)?;
|
|
}
|
|
Cmd::Pack {
|
|
recipe,
|
|
target_bin,
|
|
out,
|
|
repo,
|
|
distro_version,
|
|
expected,
|
|
build,
|
|
sign,
|
|
} => {
|
|
run_pack(
|
|
&recipe,
|
|
target_bin.as_deref(),
|
|
out.as_deref(),
|
|
repo.as_deref(),
|
|
&distro_version,
|
|
expected.as_deref(),
|
|
build,
|
|
sign.as_deref(),
|
|
&cli.store,
|
|
)?;
|
|
}
|
|
Cmd::Install {
|
|
name,
|
|
repo,
|
|
prefix,
|
|
skip_source_patch,
|
|
base_ref,
|
|
trust,
|
|
state_root,
|
|
} => {
|
|
run_install(
|
|
&cli.store,
|
|
&name,
|
|
&repo,
|
|
prefix.as_deref(),
|
|
skip_source_patch,
|
|
base_ref.as_deref(),
|
|
trust.as_deref(),
|
|
state_root.as_deref(),
|
|
)?;
|
|
}
|
|
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())?,
|
|
RepoCmd::Verify { repo, trust } => run_repo_verify(&repo, trust.as_deref())?,
|
|
},
|
|
Cmd::Ai {
|
|
intent,
|
|
catalog,
|
|
llm,
|
|
prefix,
|
|
base_ref,
|
|
bus,
|
|
state_root,
|
|
repair_max_attempts,
|
|
repair_window_ms,
|
|
} => {
|
|
run_ai(
|
|
&intent,
|
|
catalog.as_deref(),
|
|
llm,
|
|
prefix.as_deref(),
|
|
base_ref.as_deref(),
|
|
bus.as_deref(),
|
|
state_root.as_deref(),
|
|
&cli.store,
|
|
repair_max_attempts,
|
|
repair_window_ms,
|
|
)?;
|
|
}
|
|
Cmd::Ctl { line, fifo } => {
|
|
run_ctl(&line, &fifo)?;
|
|
}
|
|
Cmd::Query { expr, base_ref, fs_root } => {
|
|
run_query(&expr, base_ref.as_deref(), fs_root.as_deref())?;
|
|
}
|
|
Cmd::Bootstrap { sub } => match sub {
|
|
BootstrapCmd::Stage0 { url, sha256, version, seed } => {
|
|
let kind = parse_seed_kind(&seed)?;
|
|
let store = hammer_core::Store::open(&cli.store)?;
|
|
let spec = hammer_bootstrap::SeedSpec { kind, version, url, sha256 };
|
|
let hash = hammer_bootstrap::stage0(&spec, &store)?;
|
|
println!("{hash}");
|
|
}
|
|
BootstrapCmd::Stage1 { seed_hash, seed, recipes } => {
|
|
let seed_kind = parse_seed_kind(&seed)?;
|
|
let store = hammer_core::Store::open(&cli.store)?;
|
|
let base_cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
|
|
let spec = hammer_bootstrap::Stage1Spec {
|
|
seed_hash: hammer_core::ArtifactHash::from_hex(seed_hash.trim_start_matches("b3:")),
|
|
seed_kind,
|
|
recipes_dir: std::path::PathBuf::from(recipes),
|
|
};
|
|
let hash = hammer_bootstrap::stage1(&spec, &base_cfg, &store)?;
|
|
println!("{hash}");
|
|
}
|
|
BootstrapCmd::Product { rootfs, recipes, attest, policy, rootkey } => {
|
|
let store = hammer_core::Store::open(&cli.store)?;
|
|
// El zig del lab (no la semilla): los servicios usan su `zig_version` propia
|
|
// (openssh ⇒ 0.13.0, hermano del default), y si ya están sellados, salen cacheados.
|
|
let base_cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
|
|
let base = hammer_core::ArtifactHash::from_hex(rootfs.trim_start_matches("b3:"));
|
|
let recipes_dir = std::path::Path::new(&recipes);
|
|
let hash = if attest {
|
|
let mut cfg = hammer_bootstrap::AttestConfig { policy, ..Default::default() };
|
|
if let Some(path) = rootkey {
|
|
let bytes = std::fs::read(&path)?;
|
|
cfg.rootkey = bytes.as_slice().try_into().map_err(|_| {
|
|
anyhow::anyhow!("la rootkey {path} debe ser exactamente 32 bytes (son {})", bytes.len())
|
|
})?;
|
|
}
|
|
hammer_bootstrap::product_attested(&base, &base_cfg, recipes_dir, &store, &cfg)?
|
|
} else {
|
|
hammer_bootstrap::product(&base, &base_cfg, recipes_dir, &store)?
|
|
};
|
|
println!("{hash}");
|
|
}
|
|
BootstrapCmd::Stage2 { rootfs, verify } => {
|
|
let store = hammer_core::Store::open(&cli.store)?;
|
|
let rootfs_hash =
|
|
hammer_core::ArtifactHash::from_hex(rootfs.trim_start_matches("b3:"));
|
|
let report = hammer_bootstrap::stage2(&rootfs_hash, &store)?;
|
|
println!("stage1 content-hash: {}", report.stage1_content);
|
|
match verify {
|
|
None => println!("referencia anclada (rebuild nativo de stage1': correr en la VM)"),
|
|
Some(rebuilt) => {
|
|
let rb =
|
|
hammer_core::ArtifactHash::from_hex(rebuilt.trim_start_matches("b3:"));
|
|
match hammer_bootstrap::verify_against(&report, rb) {
|
|
hammer_bootstrap::Reproducibility::Reproducible => {
|
|
println!("✓ REPRODUCIBLE: stage1' == stage1 (auto-alojado bit a bit)");
|
|
}
|
|
hammer_bootstrap::Reproducibility::Divergent { stage1, rebuilt } => {
|
|
anyhow::bail!(
|
|
"✗ DIVERGENTE: stage1={stage1} rebuilt={rebuilt} — hay no-determinismo que cazar (SDD 09 §2)"
|
|
);
|
|
}
|
|
hammer_bootstrap::Reproducibility::RebuildPending => unreachable!(),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
BootstrapCmd::Builder {
|
|
stage1,
|
|
seed_hash,
|
|
seed,
|
|
recipes,
|
|
hammer_bin,
|
|
toolchain,
|
|
toolchain_tag,
|
|
ref_content,
|
|
work_cache,
|
|
swaps,
|
|
out,
|
|
} => {
|
|
let seed_kind = parse_seed_kind(&seed)?;
|
|
let store = hammer_core::Store::open(&cli.store)?;
|
|
let swaps = swaps.iter().map(|s| parse_swap(s)).collect::<anyhow::Result<Vec<_>>>()?;
|
|
let spec = hammer_bootstrap::BuilderSpec {
|
|
stage1_rootfs: hammer_core::ArtifactHash::from_hex(stage1.trim_start_matches("b3:")),
|
|
seed_hash: hammer_core::ArtifactHash::from_hex(seed_hash.trim_start_matches("b3:")),
|
|
seed_kind,
|
|
recipes_dir: std::path::PathBuf::from(recipes),
|
|
hammer_bin: std::path::PathBuf::from(hammer_bin),
|
|
toolchain_src: std::path::PathBuf::from(toolchain),
|
|
toolchain_tag,
|
|
ref_content: ref_content
|
|
.map(|h| hammer_core::ArtifactHash::from_hex(h.trim_start_matches("b3:").to_string())),
|
|
work_cache: work_cache.map(std::path::PathBuf::from),
|
|
swaps,
|
|
};
|
|
let out = std::path::PathBuf::from(out);
|
|
let report = hammer_bootstrap::builder_rootfs(&spec, &store, &out)?;
|
|
println!("builder rootfs: {}", report.builder_hash);
|
|
println!("ensamblado en: {}", report.out_dir.display());
|
|
println!(
|
|
"siguiente: empaquetar como initramfs y bootear en la VM; adentro corre `rebuild-stage1` (runbook §8c)"
|
|
);
|
|
}
|
|
BootstrapCmd::All { url, sha256, version, seed, recipes } => {
|
|
let kind = parse_seed_kind(&seed)?;
|
|
let store = hammer_core::Store::open(&cli.store)?;
|
|
let base_cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
|
|
let spec = hammer_bootstrap::SeedSpec { kind, version, url, sha256 };
|
|
let report = hammer_bootstrap::all(
|
|
&spec,
|
|
std::path::Path::new(&recipes),
|
|
&base_cfg,
|
|
&store,
|
|
)?;
|
|
println!("Stage 0 · semilla {}", report.seed_hash);
|
|
println!("Stage 1 · rootfs {}", report.stage1_rootfs);
|
|
println!("Stage 2 · referencia {}", report.stage2.stage1_content);
|
|
println!();
|
|
println!(
|
|
"bootstrap.json: {} línea(s) anotada(s) (`hammer bootstrap manifest` para verlo)",
|
|
report.manifest.entries.len()
|
|
);
|
|
println!(
|
|
"auto-alojamiento: el rebuild nativo que sella ✓ REPRODUCIBLE corre dentro del rootfs (VM):"
|
|
);
|
|
println!(
|
|
" ./scripts/selfhost-verify.sh # reconstruye stage1' y lo compara con la referencia de arriba"
|
|
);
|
|
}
|
|
BootstrapCmd::Manifest => {
|
|
let store = hammer_core::Store::open(&cli.store)?;
|
|
let manifest = hammer_bootstrap::BootstrapManifest::load(&store)?;
|
|
println!("{}", serde_json::to_string_pretty(&manifest)?);
|
|
}
|
|
},
|
|
Cmd::Mirror { sub } => {
|
|
let local = std::path::PathBuf::from(&cli.store);
|
|
match sub {
|
|
MirrorCmd::Push { remote } => {
|
|
let remote = std::path::PathBuf::from(&remote);
|
|
let r = hammer_mirror::sync(&local, &remote)?;
|
|
print_sync("push", &r);
|
|
if !r.ok() {
|
|
anyhow::bail!("mirror push incompleto: {} fallo(s), {} conflicto(s)", r.failed.len(), r.conflicts.len());
|
|
}
|
|
}
|
|
MirrorCmd::Pull { remote } => {
|
|
let remote = std::path::PathBuf::from(&remote);
|
|
let r = hammer_mirror::sync(&remote, &local)?;
|
|
print_sync("pull", &r);
|
|
if !r.ok() {
|
|
anyhow::bail!("mirror pull incompleto: {} fallo(s), {} conflicto(s)", r.failed.len(), r.conflicts.len());
|
|
}
|
|
}
|
|
MirrorCmd::Status { remote } => {
|
|
let remote = std::path::PathBuf::from(&remote);
|
|
let (missing_remote, missing_local, conflicts) = hammer_mirror::diff(&local, &remote)?;
|
|
println!("faltan en el remoto (push los lleva): {}", missing_remote.len());
|
|
for a in &missing_remote { println!(" → {}", a.dir); }
|
|
println!("faltan en el local (pull los trae): {}", missing_local.len());
|
|
for a in &missing_local { println!(" ← {}", a.dir); }
|
|
println!("conflictos (mismo hash, otro contenido): {}", conflicts.len());
|
|
for d in &conflicts { println!(" ✗ {d}"); }
|
|
}
|
|
}
|
|
}
|
|
Cmd::Upgrade { root, state_root, journal, sub } => {
|
|
let store = std::path::PathBuf::from(&cli.store);
|
|
let root = std::path::PathBuf::from(&root);
|
|
let state_root = std::path::PathBuf::from(&state_root);
|
|
match sub {
|
|
UpgradeCmd::Apply { tree_dir, expected } => {
|
|
let j = hammer_journal::Journal::open(&journal)?;
|
|
let r = hammer_upgrade::apply(
|
|
&store, &tree_dir, expected.as_deref(), &root, &state_root, Some(&j),
|
|
)?;
|
|
if r.already_current {
|
|
println!("generación {} ya viva — árbol idéntico, no-op", r.generation);
|
|
} else {
|
|
println!("✓ generación {} aplicada", r.generation);
|
|
println!(" + {} añadidos, ~ {} pisados, - {} retirados",
|
|
r.added.len(), r.replaced.len(), r.removed.len());
|
|
}
|
|
}
|
|
UpgradeCmd::Rollback => {
|
|
let j = hammer_journal::Journal::open(&journal)?;
|
|
let r = hammer_upgrade::rollback(&root, &state_root, Some(&j))?;
|
|
println!("✓ generación {} revertida", r.reverted);
|
|
match r.now_current {
|
|
Some(id) => println!(" ahora viva: generación {id}"),
|
|
None => println!(" ahora viva: estado pre-upgrades"),
|
|
}
|
|
println!(" {} restaurados, {} borrados", r.restored.len(), r.deleted.len());
|
|
}
|
|
UpgradeCmd::Recover { rollback } => {
|
|
let j = hammer_journal::Journal::open(&journal)?;
|
|
let r = hammer_upgrade::recover(&store, &root, &state_root, Some(&j), rollback)?;
|
|
if !r.recovered {
|
|
println!("no hay apply interrumpido — nada que recuperar");
|
|
} else if r.rolled_forward {
|
|
println!("✓ apply interrumpido COMPLETADO (generación {})", r.generation);
|
|
} else {
|
|
println!("✓ apply interrumpido DESHECHO (generación {} descartada)", r.generation);
|
|
}
|
|
}
|
|
UpgradeCmd::Status => {
|
|
if let Some(p) = hammer_upgrade::pending(&state_root)? {
|
|
println!("⚠ apply interrumpido pendiente (generación {}) — corré `hammer upgrade recover [--rollback]`", p.id);
|
|
}
|
|
let cur = hammer_upgrade::current(&state_root)?;
|
|
match cur {
|
|
Some(id) => println!("generación viva: {id}"),
|
|
None => println!("generación viva: (ninguna — sistema sin upgrades aplicados)"),
|
|
}
|
|
let gens = hammer_upgrade::list(&state_root)?;
|
|
println!("historial: {} generación(es)", gens.len());
|
|
for g in &gens {
|
|
let mark = if Some(g.id) == cur { "*" } else { " " };
|
|
println!(" {mark} {} → {} ({} ficheros)", g.id, g.tree_dir, g.files.len());
|
|
}
|
|
}
|
|
UpgradeCmd::Prune { keep } => {
|
|
let r = hammer_upgrade::prune(&state_root, keep)?;
|
|
println!("✓ prune: {} borradas, {} conservadas", r.removed.len(), r.kept.len());
|
|
for id in &r.removed { println!(" - {id}"); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Imprime el reporte de un `mirror push|pull`.
|
|
fn print_sync(verbo: &str, r: &hammer_mirror::SyncReport) {
|
|
for d in &r.copied {
|
|
println!(" ✓ {d}");
|
|
}
|
|
for (d, why) in &r.failed {
|
|
println!(" ✗ {d}: {why}");
|
|
}
|
|
for d in &r.conflicts {
|
|
println!(" ⚠ conflicto {d} (no sobrescrito)");
|
|
}
|
|
println!(
|
|
"mirror {verbo}: {} copiados, {} ya presentes, {} conflictos, {} fallos",
|
|
r.copied.len(),
|
|
r.skipped_present,
|
|
r.conflicts.len(),
|
|
r.failed.len()
|
|
);
|
|
}
|
|
|
|
/// Lee y deserializa un `.swm` desde disco; falla con mensaje útil si no parsea.
|
|
fn load_swm(file: &str) -> anyhow::Result<hammer_core::Swm> {
|
|
let yaml = std::fs::read_to_string(file)
|
|
.map_err(|e| anyhow::anyhow!("no pude leer {file}: {e}"))?;
|
|
hammer_core::Swm::from_yaml(&yaml)
|
|
.map_err(|e| anyhow::anyhow!("no pude parsear {file}: {e}"))
|
|
}
|
|
|
|
/// Lee una `BaseRef` local desde JSON; usada por `apply` y `swm verify` para verificar la
|
|
/// compatibilidad de pins. Si la ruta no existe, devuelve `Ok(None)` para que el caller
|
|
/// pueda decidir entre fallar o seguir con un warning.
|
|
fn load_local_base(path: Option<&std::path::Path>) -> anyhow::Result<Option<hammer_core::BaseRef>> {
|
|
let Some(p) = path else { return Ok(None) };
|
|
if !p.is_file() {
|
|
return Err(anyhow::anyhow!("base-ref {} no existe", p.display()));
|
|
}
|
|
let bytes = std::fs::read(p)?;
|
|
let base: hammer_core::BaseRef =
|
|
serde_json::from_slice(&bytes).map_err(|e| anyhow::anyhow!("base-ref inválido: {e}"))?;
|
|
Ok(Some(base))
|
|
}
|
|
|
|
fn print_base_compat(c: &hammer_core::BaseCompat) {
|
|
use hammer_core::BaseCompat::*;
|
|
match c {
|
|
Ok => eprintln!("verify_base: OK"),
|
|
DistroMismatch { local, swm } => eprintln!(
|
|
"verify_base: distro_version no coincide — local={local} swm={swm}"
|
|
),
|
|
PinMismatch(diffs) => {
|
|
eprintln!("verify_base: {} pin(s) divergente(s):", diffs.len());
|
|
for d in diffs {
|
|
let local = d.local.as_deref().unwrap_or("(ausente)");
|
|
eprintln!(" {}: local={local} swm={}", d.name, d.swm);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_swm_verify(
|
|
file: &str,
|
|
base_ref: Option<&std::path::Path>,
|
|
trust: Option<&std::path::Path>,
|
|
) -> anyhow::Result<()> {
|
|
let swm = load_swm(file)?;
|
|
swm.verify_schema()
|
|
.map_err(|e| anyhow::anyhow!("schema inválido: {e}"))?;
|
|
println!("schema: OK ({} mutación(es))", swm.mutations.len());
|
|
// Verificación de hash de los file_drop inline (no toca disco).
|
|
for (i, m) in swm.mutations.iter().enumerate() {
|
|
if let hammer_core::Mutation::FileDrop { content_b64: Some(b64), content_hash, path, .. } = m {
|
|
hammer_core::apply::verify_content_hash(b64, content_hash).map_err(|e| {
|
|
anyhow::anyhow!("file_drop #{} en {path}: {e}", i + 1)
|
|
})?;
|
|
}
|
|
}
|
|
println!("file_drop inline: OK");
|
|
if let Some(local) = load_local_base(base_ref)? {
|
|
print_base_compat(&swm.verify_base(&local));
|
|
} else {
|
|
eprintln!("verify_base: omitido (sin --base-ref)");
|
|
}
|
|
print_sig_status(&swm, trust)?;
|
|
Ok(())
|
|
}
|
|
|
|
const DEFAULT_TRUST_DIR: &str = "/var/lib/hammer/trust";
|
|
|
|
/// Carga el TrustStore (de `--trust` o el default) y reporta el estado de la firma. La firma
|
|
/// informa autoría/integridad; NO autoriza promover (eso lo decide la reproducción + commit
|
|
/// humano). Ver `docs/09-trust-model.md`.
|
|
fn print_sig_status(
|
|
swm: &hammer_core::Swm,
|
|
trust: Option<&std::path::Path>,
|
|
) -> anyhow::Result<()> {
|
|
let trust_dir = trust.unwrap_or_else(|| std::path::Path::new(DEFAULT_TRUST_DIR));
|
|
let store = hammer_core::TrustStore::load(trust_dir)?;
|
|
let status = swm.verify_signature(&store);
|
|
match &status {
|
|
hammer_core::SigStatus::Trusted { by } => {
|
|
println!("firma: trusted (by {by})");
|
|
}
|
|
hammer_core::SigStatus::UnknownKey { by } => {
|
|
println!(
|
|
"firma: unknown-key (by {by}) — añade su clave a {} para confiar en la autoría",
|
|
trust_dir.display()
|
|
);
|
|
}
|
|
hammer_core::SigStatus::BadSig { by, reason } => {
|
|
eprintln!("firma: BAD-SIG (by {by}): {reason}");
|
|
anyhow::bail!("la firma del .swm no valida");
|
|
}
|
|
hammer_core::SigStatus::Unsigned => {
|
|
println!("firma: unsigned");
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn run_keygen(name: &str, out: Option<&std::path::Path>) -> anyhow::Result<()> {
|
|
let dir = out.unwrap_or_else(|| std::path::Path::new(DEFAULT_TRUST_DIR));
|
|
let kp = hammer_core::KeyPair::generate()?;
|
|
let pub_path = kp.write_to(dir, name)?;
|
|
println!("clave pública: {}", pub_path.display());
|
|
println!("clave privada: {} (modo 0600 — guárdala)", dir.join(format!("{name}.ed25519")).display());
|
|
println!("pub b64: {}", kp.public_b64());
|
|
Ok(())
|
|
}
|
|
|
|
/// Empaqueta una receta como un `.swm` de un único `source_patch` (Etapa F). Lee la receta,
|
|
/// materializa el texto de sus patches (resueltos contra `base_dir`, concatenados), opcionalmente
|
|
/// construye para sellar el `expected_hash` real, firma si se pide, y emite el manifiesto.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn run_pack(
|
|
recipe_path: &std::path::Path,
|
|
target_bin: Option<&str>,
|
|
out: Option<&std::path::Path>,
|
|
repo: Option<&std::path::Path>,
|
|
distro_version: &str,
|
|
expected: Option<&str>,
|
|
do_build: bool,
|
|
sign: Option<&std::path::Path>,
|
|
store_path: &str,
|
|
) -> anyhow::Result<()> {
|
|
let recipe = hammer_core::Recipe::load_from_path(recipe_path)
|
|
.map_err(|e| anyhow::anyhow!("no pude cargar la receta {}: {e}", recipe_path.display()))?;
|
|
|
|
// Las deps por NOMBRE viajan en el `source_patch` y en el índice; `install` resuelve el cierre
|
|
// desde el repo. Avisamos sólo para recordar que esas deps deben ESTAR publicadas en el mismo
|
|
// repo (si no, `install` fallará al resolver el catálogo).
|
|
if !recipe.deps.build.is_empty() {
|
|
eprintln!(
|
|
"nota: este paquete depende de {:?} — publicá esas deps en el mismo repo para que \
|
|
`install` pueda reproducirlo",
|
|
recipe.deps.build
|
|
);
|
|
}
|
|
|
|
// Texto de los patches: cada uno se resuelve relativo al directorio de la receta (igual que
|
|
// el lab) y se concatena. El `source_patch` del .swm modela un único `patch` inline; un diff
|
|
// unificado multi-fichero concatenado se aplica hunk-a-hunk igual (git apply / patch -p1).
|
|
let patch_text = if recipe.source.patches.is_empty() {
|
|
None
|
|
} else {
|
|
let mut buf = String::new();
|
|
for p in &recipe.source.patches {
|
|
let path = std::path::Path::new(p);
|
|
let abs = if path.is_absolute() {
|
|
path.to_path_buf()
|
|
} else {
|
|
recipe.base_dir.join(path)
|
|
};
|
|
let txt = std::fs::read_to_string(&abs)
|
|
.map_err(|e| anyhow::anyhow!("leyendo patch {}: {e}", abs.display()))?;
|
|
if !buf.is_empty() && !buf.ends_with('\n') {
|
|
buf.push('\n');
|
|
}
|
|
buf.push_str(&txt);
|
|
}
|
|
Some(buf)
|
|
};
|
|
|
|
let target_bin = target_bin
|
|
.map(|s| s.to_string())
|
|
.unwrap_or_else(|| format!("/usr/bin/{}", recipe.name));
|
|
|
|
// expected_hash: `--expected` manda; si no, `--build` lo sella construyendo de verdad en el
|
|
// lab; si ninguno, el paquete sale sin ancla de verificación (válido, sólo más débil).
|
|
let expected_hash: Option<String> = if let Some(h) = expected {
|
|
Some(h.to_string())
|
|
} else if do_build {
|
|
let store = hammer_core::Store::open(store_path)?;
|
|
let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
|
|
let hash = hammer_build::build(&recipe, &cfg, &store)
|
|
.map_err(|e| anyhow::anyhow!("construyendo la receta para sellar expected_hash: {e}"))?;
|
|
eprintln!("construida: {hash}");
|
|
Some(hash.as_str().to_string())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let mut swm = hammer_core::Swm::from_recipe(
|
|
&recipe,
|
|
target_bin,
|
|
patch_text,
|
|
expected_hash.clone(),
|
|
distro_version,
|
|
)?;
|
|
|
|
if let Some(key) = sign {
|
|
let priv_b64 = std::fs::read_to_string(key)
|
|
.map_err(|e| anyhow::anyhow!("no pude leer la clave {}: {e}", key.display()))?;
|
|
let kp = hammer_core::KeyPair::from_private_b64(&priv_b64)?;
|
|
let by = key
|
|
.file_name()
|
|
.map(|f| f.to_string_lossy().trim_end_matches(".ed25519").to_string())
|
|
.unwrap_or_else(|| "anon".into());
|
|
swm.signature = Some(kp.sign(&swm, &by));
|
|
eprintln!("firmado por {by}");
|
|
}
|
|
|
|
let yaml = swm.to_yaml()?;
|
|
let signed = swm.signature.is_some();
|
|
let mut emitted = false;
|
|
|
|
if let Some(dest) = out {
|
|
std::fs::write(dest, &yaml)?;
|
|
eprintln!(
|
|
"empaquetada {} → {} ({} mutación source_patch{})",
|
|
recipe.name,
|
|
dest.display(),
|
|
swm.mutations.len(),
|
|
if signed { ", firmado" } else { "" },
|
|
);
|
|
emitted = true;
|
|
}
|
|
|
|
if let Some(repo_dir) = repo {
|
|
// Publicar: el repo asigna la identidad (nombre+versión de la receta). El `.swm` en sí
|
|
// no lleva nombre — el índice es el namespace. Upsert por nombre (idempotente).
|
|
let file_name = format!("{}-{}.swm", recipe.name, recipe.version);
|
|
std::fs::create_dir_all(repo_dir)?;
|
|
std::fs::write(repo_dir.join(&file_name), &yaml)?;
|
|
let mut index = hammer_core::RepoIndex::load(repo_dir)?;
|
|
let orphan = index.upsert(hammer_core::PackageEntry {
|
|
name: recipe.name.clone(),
|
|
version: recipe.version.clone(),
|
|
file: file_name,
|
|
distro_version: distro_version.to_string(),
|
|
expected_hash: expected_hash.clone(),
|
|
signed_by: swm.signature.as_ref().map(|s| s.by.clone()),
|
|
deps: recipe.deps.build.clone(),
|
|
});
|
|
if let Some(old) = orphan {
|
|
// Una versión nueva deja huérfano el `.swm` viejo: lo retiramos (best-effort).
|
|
let _ = std::fs::remove_file(repo_dir.join(old));
|
|
}
|
|
index.save(repo_dir)?;
|
|
eprintln!(
|
|
"publicado {}-{} en repo {} ({} paquete(s) en el índice)",
|
|
recipe.name,
|
|
recipe.version,
|
|
repo_dir.display(),
|
|
index.packages.len(),
|
|
);
|
|
emitted = true;
|
|
}
|
|
|
|
if !emitted {
|
|
print!("{yaml}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Instala un paquete por nombre desde un repo (Etapa F): resuelve el nombre en el índice,
|
|
/// verifica firma (con `--trust`) y delega en el camino de `apply` (reproduce + hidrata). Nunca
|
|
/// corre un binario ajeno — sólo reconstruye desde fuente y compara.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn run_install(
|
|
store_path: &str,
|
|
name: &str,
|
|
repo_dir: &std::path::Path,
|
|
prefix: Option<&std::path::Path>,
|
|
skip_source_patch: bool,
|
|
base_ref: Option<&std::path::Path>,
|
|
trust: Option<&std::path::Path>,
|
|
state_root: Option<&std::path::Path>,
|
|
) -> anyhow::Result<()> {
|
|
let index = hammer_core::RepoIndex::load(repo_dir)?;
|
|
|
|
// Verificación del RELEASE (firma del catálogo entero): si el índice está firmado, una firma
|
|
// mala = índice manipulado ⇒ abortamos antes de tocar nada. Sin firma se informa; con --trust
|
|
// se valida la autoría. Esto es ortogonal a la firma de cada `.swm`.
|
|
{
|
|
let trust_dir = trust.unwrap_or_else(|| std::path::Path::new(DEFAULT_TRUST_DIR));
|
|
let store = hammer_core::TrustStore::load(trust_dir)?;
|
|
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")
|
|
}
|
|
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"),
|
|
}
|
|
}
|
|
|
|
let entry = index.find(name).ok_or_else(|| {
|
|
let avail: Vec<&str> = index.packages.iter().map(|p| p.name.as_str()).collect();
|
|
anyhow::anyhow!(
|
|
"paquete '{name}' no está en el repo {}. Disponibles: {}",
|
|
repo_dir.display(),
|
|
if avail.is_empty() { "(ninguno)".into() } else { avail.join(", ") }
|
|
)
|
|
})?;
|
|
let swm_path = hammer_core::RepoIndex::swm_path(repo_dir, entry);
|
|
eprintln!(
|
|
"install {} {} ← {}",
|
|
entry.name,
|
|
entry.version,
|
|
swm_path.display()
|
|
);
|
|
let swm_path_str = swm_path
|
|
.to_str()
|
|
.ok_or_else(|| anyhow::anyhow!("ruta de .swm no es UTF-8: {}", swm_path.display()))?;
|
|
|
|
// Verificación de firma ANTES de reproducir nada (sólo con --trust; sin él, se informa
|
|
// que no se verificó, como el resto de la CLI).
|
|
if trust.is_some() {
|
|
let swm = load_swm(swm_path_str)?;
|
|
print_sig_status(&swm, trust)?;
|
|
} else {
|
|
eprintln!("firma: no verificada (pasá --trust <dir> para validar la autoría)");
|
|
}
|
|
|
|
// Dependencias: resolvemos el cierre transitivo de build-deps desde el índice (deps antes
|
|
// que el dependiente) y poblamos el catálogo de recetas que el lab consultará al reproducir
|
|
// el `source_patch`. Sin esto, un paquete con build-deps (openssh→zlib,openssl) no podría
|
|
// reconstruirse: el sandbox no tendría headers/libs de las deps.
|
|
if !entry.deps.is_empty() && !skip_source_patch {
|
|
let store = hammer_core::Store::open(store_path)?;
|
|
let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
|
|
let catalog_dir = hammer_build::swm_bridge::catalog_dir_for(&cfg, None);
|
|
std::fs::create_dir_all(&catalog_dir)?;
|
|
|
|
// El cierre incluye al propio target al final; el catálogo sólo necesita las DEPS (las
|
|
// que el target y sus deps resolverán por nombre). Escribimos un `{dep}.toml` por cada una.
|
|
let closure = index.resolve_closure(name)?;
|
|
let dep_pkgs: Vec<&hammer_core::PackageEntry> =
|
|
closure.into_iter().filter(|p| p.name != name).collect();
|
|
if !dep_pkgs.is_empty() {
|
|
eprintln!(
|
|
"deps: resolviendo catálogo para {} dep(s): {}",
|
|
dep_pkgs.len(),
|
|
dep_pkgs.iter().map(|p| p.name.as_str()).collect::<Vec<_>>().join(", ")
|
|
);
|
|
}
|
|
for dep in dep_pkgs {
|
|
let dep_swm_path = hammer_core::RepoIndex::swm_path(repo_dir, dep);
|
|
let dep_swm = load_swm(
|
|
dep_swm_path
|
|
.to_str()
|
|
.ok_or_else(|| anyhow::anyhow!("ruta no UTF-8: {}", dep_swm_path.display()))?,
|
|
)?;
|
|
let mutation = source_patch_of(&dep_swm).ok_or_else(|| {
|
|
anyhow::anyhow!("la dep '{}' no es un paquete source_patch", dep.name)
|
|
})?;
|
|
// El nombre de la receta DEBE ser el nombre del paquete: así el dependiente la
|
|
// resuelve como `{dep}.toml`.
|
|
let dep_recipe = hammer_build::swm_bridge::recipe_from_source_patch(
|
|
mutation,
|
|
Some(&dep.name),
|
|
&catalog_dir,
|
|
)?;
|
|
let toml = dep_recipe.to_toml()?;
|
|
std::fs::write(catalog_dir.join(format!("{}.toml", dep.name)), toml)?;
|
|
}
|
|
}
|
|
|
|
// 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(
|
|
store_path,
|
|
swm_path_str,
|
|
prefix,
|
|
skip_source_patch,
|
|
base_ref,
|
|
state_root,
|
|
)
|
|
}
|
|
|
|
/// Devuelve la primera mutación `source_patch` de un `.swm` empaquetado (el caso de `pack`).
|
|
fn source_patch_of(swm: &hammer_core::Swm) -> Option<&hammer_core::Mutation> {
|
|
swm.mutations
|
|
.iter()
|
|
.find(|m| matches!(m, hammer_core::Mutation::SourcePatch { .. }))
|
|
}
|
|
|
|
/// Firma el índice de un repo (release). Re-firmá tras publicar.
|
|
fn run_repo_sign(
|
|
repo_dir: &std::path::Path,
|
|
key: &std::path::Path,
|
|
by: Option<&str>,
|
|
) -> anyhow::Result<()> {
|
|
let mut index = hammer_core::RepoIndex::load(repo_dir)?;
|
|
if index.packages.is_empty() {
|
|
anyhow::bail!("repo {} vacío: nada que firmar", repo_dir.display());
|
|
}
|
|
let priv_b64 = std::fs::read_to_string(key)
|
|
.map_err(|e| anyhow::anyhow!("no pude leer la clave {}: {e}", key.display()))?;
|
|
let kp = hammer_core::KeyPair::from_private_b64(&priv_b64)?;
|
|
let by = by.map(|s| s.to_string()).unwrap_or_else(|| {
|
|
key.file_name()
|
|
.map(|f| f.to_string_lossy().trim_end_matches(".ed25519").to_string())
|
|
.unwrap_or_else(|| "anon".into())
|
|
});
|
|
index.sign(&kp, &by);
|
|
index.save(repo_dir)?;
|
|
println!(
|
|
"release firmado por {by} — {} paquete(s) en {}",
|
|
index.packages.len(),
|
|
repo_dir.display()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Verifica la firma del release de un repo contra el TrustStore.
|
|
fn run_repo_verify(
|
|
repo_dir: &std::path::Path,
|
|
trust: Option<&std::path::Path>,
|
|
) -> anyhow::Result<()> {
|
|
let index = hammer_core::RepoIndex::load(repo_dir)?;
|
|
let trust_dir = trust.unwrap_or_else(|| std::path::Path::new(DEFAULT_TRUST_DIR));
|
|
let store = hammer_core::TrustStore::load(trust_dir)?;
|
|
match index.verify_signature(&store) {
|
|
hammer_core::SigStatus::Trusted { by } => {
|
|
println!("release: trusted (by {by}) — {} paquete(s)", index.packages.len());
|
|
Ok(())
|
|
}
|
|
hammer_core::SigStatus::UnknownKey { by } => {
|
|
println!(
|
|
"release: unknown-key (by {by}) — añadí su clave a {} para confiar",
|
|
trust_dir.display()
|
|
);
|
|
Ok(())
|
|
}
|
|
hammer_core::SigStatus::BadSig { by, reason } => {
|
|
anyhow::bail!("release: BAD-SIG (by {by}): {reason} — el índice fue manipulado");
|
|
}
|
|
hammer_core::SigStatus::Unsigned => {
|
|
println!("release: sin firmar (firmá con `hammer repo sign`)");
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Lista los paquetes publicados en un repositorio.
|
|
fn run_repo_list(repo_dir: &std::path::Path) -> anyhow::Result<()> {
|
|
let index = hammer_core::RepoIndex::load(repo_dir)?;
|
|
if index.packages.is_empty() {
|
|
println!("repo {} vacío (sin index.json o sin paquetes)", repo_dir.display());
|
|
return Ok(());
|
|
}
|
|
let rel = match &index.signature {
|
|
Some(s) => format!(" [release firmado por {}]", s.by),
|
|
None => String::new(),
|
|
};
|
|
println!("repo {} — {} paquete(s){}:", repo_dir.display(), index.packages.len(), rel);
|
|
for p in &index.packages {
|
|
let sig = p.signed_by.as_deref().map(|b| format!(" [firmado por {b}]")).unwrap_or_default();
|
|
let anchor = p
|
|
.expected_hash
|
|
.as_deref()
|
|
.map(|h| format!(" expected={h}"))
|
|
.unwrap_or_default();
|
|
println!(
|
|
" {:<16} {:<10} base={} {}{}{}",
|
|
p.name, p.version, p.distro_version, p.file, anchor, sig
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn run_swm_sign(
|
|
file: &str,
|
|
key: &std::path::Path,
|
|
by: Option<&str>,
|
|
out: Option<&std::path::Path>,
|
|
) -> anyhow::Result<()> {
|
|
let mut swm = load_swm(file)?;
|
|
swm.verify_schema()
|
|
.map_err(|e| anyhow::anyhow!("no firmo un .swm con schema inválido: {e}"))?;
|
|
let priv_b64 = std::fs::read_to_string(key)
|
|
.map_err(|e| anyhow::anyhow!("no pude leer la clave {}: {e}", key.display()))?;
|
|
let kp = hammer_core::KeyPair::from_private_b64(&priv_b64)?;
|
|
|
|
// `by` por defecto: basename de la clave sin el sufijo `.ed25519`.
|
|
let by = by.map(|s| s.to_string()).unwrap_or_else(|| {
|
|
key.file_name()
|
|
.map(|f| f.to_string_lossy().trim_end_matches(".ed25519").to_string())
|
|
.unwrap_or_else(|| "anon".into())
|
|
});
|
|
|
|
swm.signature = Some(kp.sign(&swm, &by));
|
|
let yaml = swm.to_yaml()?;
|
|
let dest = out.unwrap_or_else(|| std::path::Path::new(file));
|
|
std::fs::write(dest, yaml)?;
|
|
println!("firmado por {by} → {}", dest.display());
|
|
Ok(())
|
|
}
|
|
|
|
/// Aplica un `.swm`. Dos modos:
|
|
/// - **prefix** (`--prefix DIR`): re-rootea cada path al DIR; no abre overlay. Para tests y
|
|
/// staging.
|
|
/// - **overlay** (default): abre un overlay sobre los targets por defecto y aplica las
|
|
/// mutaciones sobre las rutas reales (que el kernel redirige al upper del overlay).
|
|
fn run_apply(
|
|
store_path: &str,
|
|
file: &str,
|
|
prefix: Option<&std::path::Path>,
|
|
skip_source_patch: bool,
|
|
base_ref: Option<&std::path::Path>,
|
|
state_root: Option<&std::path::Path>,
|
|
) -> anyhow::Result<()> {
|
|
let swm = load_swm(file)?;
|
|
swm.verify_schema()?;
|
|
|
|
if let Some(local) = load_local_base(base_ref)? {
|
|
match swm.verify_base(&local) {
|
|
hammer_core::BaseCompat::Ok => {}
|
|
other => {
|
|
print_base_compat(&other);
|
|
anyhow::bail!("base local no compatible con la base del .swm");
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut overlay_id: Option<hammer_overlay::OverlayId> = None;
|
|
if prefix.is_none() {
|
|
let root = state_root
|
|
.map(|p| p.to_path_buf())
|
|
.unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
|
|
std::fs::create_dir_all(&root)?;
|
|
let id = hammer_overlay::try_overlay(&[], &root)?;
|
|
eprintln!("overlay {id} abierto");
|
|
overlay_id = Some(id);
|
|
}
|
|
|
|
let mut counts = (0usize, 0usize, 0usize, 0usize); // src_patch, cfg, init, drop
|
|
for (i, m) in swm.mutations.iter().enumerate() {
|
|
match m {
|
|
hammer_core::Mutation::SourcePatch { target_bin, .. } => {
|
|
counts.0 += 1;
|
|
if skip_source_patch {
|
|
eprintln!(" [skip] source_patch #{} → {target_bin}", i + 1);
|
|
continue;
|
|
}
|
|
let store = hammer_core::Store::open(store_path)?;
|
|
let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
|
|
let hash = hammer_build::build_source_patch(m, &cfg, &store, None)?;
|
|
let artifact = store.find_by_hash(hash.as_str())?;
|
|
let into = hammer_core::apply::rebase_path("/", prefix);
|
|
let report = hammer_build::run_hydrate(
|
|
&artifact,
|
|
&into,
|
|
hammer_core::LinkMode::Static,
|
|
None,
|
|
)?;
|
|
eprintln!(
|
|
" source_patch #{}: {} → hidratados {} archivo(s) en {}",
|
|
i + 1,
|
|
hash,
|
|
report.files.len(),
|
|
into.display()
|
|
);
|
|
// Sanity: el target_bin declarado debe existir tras la hidratación.
|
|
let abs = hammer_core::apply::rebase_path(target_bin, prefix);
|
|
if !abs.exists() {
|
|
anyhow::bail!(
|
|
"source_patch declara target_bin={target_bin} pero no quedó en {}",
|
|
abs.display()
|
|
);
|
|
}
|
|
}
|
|
hammer_core::Mutation::ConfigEdit { file: f, inline_diff } => {
|
|
counts.1 += 1;
|
|
let target = hammer_core::apply::rebase_path(f, prefix);
|
|
hammer_core::apply::apply_config_edit(&target, inline_diff)?;
|
|
eprintln!(" config_edit #{} → {}", i + 1, target.display());
|
|
}
|
|
hammer_core::Mutation::InitRule { action, service, command } => {
|
|
counts.2 += 1;
|
|
let rules_dir =
|
|
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());
|
|
}
|
|
hammer_core::Mutation::FileDrop { path, content_hash, content_b64, content_url } => {
|
|
counts.3 += 1;
|
|
let target = hammer_core::apply::rebase_path(path, prefix);
|
|
match (content_b64, content_url) {
|
|
(Some(b64), None) => {
|
|
hammer_core::apply::apply_file_drop(&target, b64, content_hash)?;
|
|
eprintln!(" file_drop #{} → {}", i + 1, target.display());
|
|
}
|
|
(None, Some(url)) => {
|
|
// Descarga remota (fuera de cualquier sandbox) + verificación BLAKE3
|
|
// ANTES de escribir nada: nunca tocamos disco con bytes sin verificar.
|
|
let bytes = hammer_build::download::fetch_url_bytes(url)?;
|
|
hammer_core::apply::write_filedrop_bytes(&target, &bytes, content_hash)?;
|
|
eprintln!(
|
|
" file_drop #{} ← {url} ({} bytes) → {}",
|
|
i + 1,
|
|
bytes.len(),
|
|
target.display()
|
|
);
|
|
}
|
|
_ => unreachable!("verify_schema lo descarta"),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
eprintln!(
|
|
"apply OK — source_patch={} config_edit={} init_rule={} file_drop={}",
|
|
counts.0, counts.1, counts.2, counts.3
|
|
);
|
|
if let Some(id) = &overlay_id {
|
|
println!("{id}");
|
|
eprintln!("overlay listo: usa `hammer commit {id}` para promocionar al FHS real.");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Exporta el diario actual como `.swm`. Provenance-aware:
|
|
///
|
|
/// - Eventos cuyo `Source` apunta a un `artifact` con receta sidecar en el store
|
|
/// (`.hammer/recipe.toml`) se agrupan por `artifact_hash` y emiten **un único**
|
|
/// `source_patch` por grupo, con repo/commit/build de la receta original. El receptor
|
|
/// reconstruye el binario desde fuente — provenance real, no un binario opaco.
|
|
/// - Eventos sin provenance trazable (`Source::External`, builds viejos sin sidecar,
|
|
/// `HammerCommit` con `artifact: None`) caen al fallback `file_drop` con `content_b64`
|
|
/// + hash, reproduciendo byte-a-byte pero perdiendo la receta. Se cuenta como warning.
|
|
/// - Eventos `Delete` invalidan estados previos del mismo path y luego se ignoran (un
|
|
/// `.swm` describe el estado final por adición/edición).
|
|
fn run_export(
|
|
base_ref_path: Option<&std::path::Path>,
|
|
journal_dir: &std::path::Path,
|
|
since: Option<&str>,
|
|
store_root: &str,
|
|
) -> anyhow::Result<()> {
|
|
use std::collections::BTreeMap;
|
|
|
|
let j = hammer_journal::Journal::open(journal_dir)?;
|
|
let events = j.read_all()?;
|
|
let filtered: Vec<_> = match since {
|
|
Some(t) => events.into_iter().filter(|e| e.ts.as_str() > t).collect(),
|
|
None => events,
|
|
};
|
|
|
|
let base = match load_local_base(base_ref_path)? {
|
|
Some(local) => hammer_core::Base {
|
|
distro_version: local.distro_version,
|
|
pins: local.pins,
|
|
},
|
|
None => {
|
|
eprintln!("warning: sin --base-ref; emitiendo base con distro_version='unknown'");
|
|
hammer_core::Base {
|
|
distro_version: "unknown".into(),
|
|
pins: BTreeMap::new(),
|
|
}
|
|
}
|
|
};
|
|
|
|
// El store es opcional: si el directorio no existe, seguimos en modo "todo file_drop"
|
|
// (igual que el comportamiento pre-provenance). El receptor no nota la diferencia.
|
|
let store_opt = match hammer_core::Store::open(store_root) {
|
|
Ok(s) => Some(s),
|
|
Err(e) => {
|
|
eprintln!(
|
|
"warning: store {store_root} no accesible ({e}); export sin provenance"
|
|
);
|
|
None
|
|
}
|
|
};
|
|
|
|
let (mutations, stats) = build_export_mutations(&filtered, store_opt.as_ref());
|
|
|
|
let swm = hammer_core::Swm {
|
|
swm_version: 1,
|
|
base,
|
|
mutations,
|
|
signature: None,
|
|
};
|
|
let yaml = swm.to_yaml()?;
|
|
print!("{yaml}");
|
|
eprintln!(
|
|
"export: {} source_patch(es), {} file_drop(s), {} warning(s){}",
|
|
stats.source_patches,
|
|
stats.file_drops,
|
|
stats.warnings,
|
|
if stats.missing_recipe > 0 {
|
|
format!(" ({} artefacto(s) sin sidecar de receta)", stats.missing_recipe)
|
|
} else {
|
|
String::new()
|
|
},
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct ExportStats {
|
|
source_patches: usize,
|
|
file_drops: usize,
|
|
warnings: usize,
|
|
/// Cuántos artefactos referenciados por eventos no traían `.hammer/recipe.toml`
|
|
/// y por tanto cayeron a file_drop. Útil para que el humano sepa que parte del
|
|
/// export perdió provenance (típicamente builds anteriores a Fase 4).
|
|
missing_recipe: usize,
|
|
}
|
|
|
|
/// Construye la lista de mutaciones del `.swm` exportado. Función pura sobre `events` +
|
|
/// el store: testeable sin abrir el journal real ni la CLI.
|
|
///
|
|
/// Estrategia:
|
|
/// 1. Aplicamos las semánticas de Delete (invalida estados anteriores del mismo path) y
|
|
/// dedupeamos por path quedándonos con el último evento.
|
|
/// 2. Particionamos los eventos sobrevivientes en dos: los que tienen un `artifact_hash`
|
|
/// cuyo store contiene la receta sidecar (→ candidates para `source_patch` agrupado),
|
|
/// y el resto (→ `file_drop`).
|
|
/// 3. Por cada grupo `artifact_hash` con receta: emitimos UN `source_patch`. El
|
|
/// `target_bin` es el primer path del grupo en orden alfabético; el resto de paths
|
|
/// queda implícito (el receptor hidrata el artefacto completo).
|
|
/// 4. Por cada evento de fallback: leemos el contenido actual del disco, codificamos
|
|
/// base64 y emitimos `file_drop`. Si la lectura falla, contamos warning y omitimos.
|
|
fn build_export_mutations(
|
|
events: &[hammer_journal::MutationEvent],
|
|
store: Option<&hammer_core::Store>,
|
|
) -> (Vec<hammer_core::Mutation>, ExportStats) {
|
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
|
use std::collections::BTreeMap;
|
|
|
|
// Paso 1: dedup respetando Delete.
|
|
let mut last_by_path: BTreeMap<PathBuf, &hammer_journal::MutationEvent> = BTreeMap::new();
|
|
for ev in events {
|
|
if matches!(ev.op, hammer_journal::MutationOp::Delete) {
|
|
last_by_path.remove(&ev.path);
|
|
continue;
|
|
}
|
|
last_by_path.insert(ev.path.clone(), ev);
|
|
}
|
|
|
|
let mut stats = ExportStats::default();
|
|
|
|
// Paso 2: particionamos. Para que la asignación a `source_patch` requiera la receta,
|
|
// resolvemos el sidecar acá; si no está, el evento cae al fallback.
|
|
//
|
|
// Estructura: artifact_hash → (Recipe, Vec<path>). Los paths se ordenarán antes de
|
|
// emitir, para que el target_bin sea determinista (importante para tests).
|
|
let mut groups: BTreeMap<String, (hammer_core::Recipe, Vec<PathBuf>)> = BTreeMap::new();
|
|
let mut fallback: Vec<(PathBuf, &hammer_journal::MutationEvent)> = Vec::new();
|
|
|
|
let mut artifacts_seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
|
|
let mut artifacts_without_recipe: std::collections::BTreeSet<String> =
|
|
std::collections::BTreeSet::new();
|
|
|
|
for (path, ev) in &last_by_path {
|
|
let artifact = match &ev.by.source {
|
|
hammer_journal::Source::HammerHydrate { artifact } => Some(artifact.clone()),
|
|
hammer_journal::Source::HammerCommit { artifact: Some(a), .. } => Some(a.clone()),
|
|
_ => None,
|
|
};
|
|
let Some(art) = artifact else {
|
|
fallback.push((path.clone(), *ev));
|
|
continue;
|
|
};
|
|
artifacts_seen.insert(art.clone());
|
|
|
|
// Si ya conseguimos la receta para este artifact_hash en una pasada anterior,
|
|
// reusamos sin tocar disco otra vez.
|
|
if let Some((_, paths)) = groups.get_mut(&art) {
|
|
paths.push(path.clone());
|
|
continue;
|
|
}
|
|
if artifacts_without_recipe.contains(&art) {
|
|
fallback.push((path.clone(), *ev));
|
|
continue;
|
|
}
|
|
|
|
let recipe_opt = store.and_then(|s| s.recipe_for_hash(&art).ok().flatten());
|
|
match recipe_opt {
|
|
Some(r) => {
|
|
groups.insert(art, (r, vec![path.clone()]));
|
|
}
|
|
None => {
|
|
artifacts_without_recipe.insert(art);
|
|
fallback.push((path.clone(), *ev));
|
|
}
|
|
}
|
|
}
|
|
|
|
stats.missing_recipe = artifacts_without_recipe.len();
|
|
|
|
let mut mutations: Vec<hammer_core::Mutation> = Vec::new();
|
|
|
|
// Paso 3: source_patches (ordenados por artifact_hash para estabilidad del output).
|
|
for (art_hash, (recipe, mut paths)) in groups {
|
|
paths.sort();
|
|
let target_bin = paths
|
|
.first()
|
|
.map(|p| p.display().to_string())
|
|
.unwrap_or_else(|| "/".into());
|
|
|
|
// Convertimos la Recipe a los campos de origen que SourcePatch necesita
|
|
// (git xor tarball — ambos se modelan ya, sin caer a file_drop).
|
|
let (repo, commit, tarball, sha256) = match recipe.source.kind() {
|
|
Ok(hammer_core::SourceKind::Git { repo, commit }) => {
|
|
(Some(repo.to_string()), Some(commit.to_string()), None, None)
|
|
}
|
|
Ok(hammer_core::SourceKind::Tarball { url, sha256 }) => {
|
|
(None, None, Some(url.to_string()), Some(sha256.to_string()))
|
|
}
|
|
Err(_) => {
|
|
stats.warnings += 1;
|
|
eprintln!(
|
|
"warning: receta de {art_hash} tiene source inválido; cayendo a file_drop"
|
|
);
|
|
for p in &paths {
|
|
if let Some(ev) = last_by_path.get(p) {
|
|
fallback.push((p.clone(), *ev));
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// Patches inline: si la receta tenía patches, los leemos y concatenamos. El
|
|
// hash del artefacto incluye su contenido (ver `Recipe::hash_inputs`), así que
|
|
// el receptor reconstruye byte-a-byte si y sólo si el patch coincide.
|
|
let patch_inline = collect_patches_inline(&recipe);
|
|
|
|
if paths.len() > 1 {
|
|
eprintln!(
|
|
" source_patch {} cubre {} archivo(s); target_bin={} (resto se hidrata al aplicar)",
|
|
art_hash,
|
|
paths.len(),
|
|
target_bin
|
|
);
|
|
}
|
|
|
|
mutations.push(hammer_core::Mutation::SourcePatch {
|
|
repo,
|
|
commit,
|
|
tarball,
|
|
sha256,
|
|
strip_components: (recipe.source.strip_components != 1)
|
|
.then_some(recipe.source.strip_components),
|
|
patch: patch_inline,
|
|
patch_url: None,
|
|
build: hammer_core::SwmBuild {
|
|
compiler: recipe.build.compiler.as_str().to_string(),
|
|
target: recipe.build.target.clone(),
|
|
link: recipe.build.link.as_str().to_string(),
|
|
flags: recipe.build.flags.clone(),
|
|
phases: recipe.build.phases.clone(),
|
|
zig_version: recipe.build.zig_version.clone(),
|
|
},
|
|
target_bin,
|
|
expected_hash: Some(art_hash),
|
|
deps: recipe.deps.clone(),
|
|
});
|
|
stats.source_patches += 1;
|
|
}
|
|
|
|
// Paso 4: file_drops. Dedupeamos por path porque algunos paths pueden haber rebotado
|
|
// del grupo de tarball-fallback al fallback general además del path original.
|
|
let mut seen_paths: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
|
|
fallback.sort_by(|(a, _), (b, _)| a.cmp(b));
|
|
for (path, ev) in fallback {
|
|
if !seen_paths.insert(path.clone()) {
|
|
continue;
|
|
}
|
|
let bytes = match std::fs::read(&path) {
|
|
Ok(b) => b,
|
|
Err(e) => {
|
|
stats.warnings += 1;
|
|
eprintln!(
|
|
"warning: no pude leer {} para exportar (ev ts={}): {e}",
|
|
path.display(),
|
|
ev.ts
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
let h = hammer_core::ArtifactHash::of_inputs(&[bytes.as_slice()]);
|
|
let b64 = STANDARD.encode(&bytes);
|
|
mutations.push(hammer_core::Mutation::FileDrop {
|
|
path: path.display().to_string(),
|
|
content_hash: h.as_str().to_string(),
|
|
content_b64: Some(b64),
|
|
content_url: None,
|
|
});
|
|
stats.file_drops += 1;
|
|
}
|
|
|
|
(mutations, stats)
|
|
}
|
|
|
|
/// Concatena los contenidos de todos los patches declarados por la receta en un único
|
|
/// blob (separado por una línea con `--- /dev/null` falsa). Si la receta no tiene
|
|
/// patches, devuelve `None`. Si algún patch no se puede leer, devuelve `None` y deja un
|
|
/// warning en stderr — el receptor reconstruirá sin patches y verificará con
|
|
/// `expected_hash` que el binario coincide, así que el error es detectable.
|
|
fn collect_patches_inline(recipe: &hammer_core::Recipe) -> Option<String> {
|
|
if recipe.source.patches.is_empty() {
|
|
return None;
|
|
}
|
|
let mut all = String::new();
|
|
for p in &recipe.source.patches {
|
|
let path = if std::path::Path::new(p).is_absolute() {
|
|
std::path::PathBuf::from(p)
|
|
} else {
|
|
recipe.base_dir.join(p)
|
|
};
|
|
match std::fs::read_to_string(&path) {
|
|
Ok(text) => all.push_str(&text),
|
|
Err(e) => {
|
|
eprintln!(
|
|
"warning: no pude leer patch {} para inline: {e}",
|
|
path.display()
|
|
);
|
|
return None;
|
|
}
|
|
}
|
|
}
|
|
Some(all)
|
|
}
|
|
|
|
/// Ejecuta el bucle agéntico de Fase 6 vía `hammer-agent`. Acepta una intención NL +
|
|
/// catálogo YAML que mapea intentos exactos a `.swm`s pre-armados. La salida es un
|
|
/// `Proposal` legible en stdout + el siguiente paso recomendado al humano.
|
|
fn run_ai(
|
|
intent: &str,
|
|
catalog: Option<&std::path::Path>,
|
|
llm: bool,
|
|
prefix: Option<&std::path::Path>,
|
|
base_ref: Option<&std::path::Path>,
|
|
bus: Option<&std::path::Path>,
|
|
state_root: Option<&std::path::Path>,
|
|
store_root: &str,
|
|
repair_max_attempts: Option<u32>,
|
|
repair_window_ms: u64,
|
|
) -> anyhow::Result<()> {
|
|
use hammer_agent::orchestrator::{ApplyTarget, CompileMode, RepairPolicy};
|
|
|
|
if llm && catalog.is_some() {
|
|
anyhow::bail!("--llm y --catalog son mutuamente excluyentes");
|
|
}
|
|
if !llm && catalog.is_none() {
|
|
anyhow::bail!("hace falta --catalog (modo mock) o --llm (modo LLM)");
|
|
}
|
|
|
|
// Calcular BaseRef. En modo catálogo se puede inferir del .swm; en modo LLM hace falta
|
|
// --base-ref explícito porque el modelo no ha producido nada aún.
|
|
let base = if llm {
|
|
match load_local_base(base_ref)? {
|
|
Some(b) => b,
|
|
None => anyhow::bail!(
|
|
"modo --llm requiere --base-ref para que el traductor reciba un SystemContext válido"
|
|
),
|
|
}
|
|
} else {
|
|
infer_base_for_catalog(intent, catalog.unwrap(), base_ref)?
|
|
};
|
|
|
|
let apply = match prefix {
|
|
Some(p) => ApplyTarget::Prefix { prefix: p.to_path_buf() },
|
|
None => ApplyTarget::Overlay {
|
|
state_root: state_root
|
|
.map(|p| p.to_path_buf())
|
|
.unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT)),
|
|
},
|
|
};
|
|
|
|
let compile = match bus {
|
|
Some(s) => CompileMode::ViaBus {
|
|
sock: s.to_path_buf(),
|
|
store: PathBuf::from(store_root),
|
|
timeout: std::time::Duration::from_secs(600),
|
|
},
|
|
None => CompileMode::Skip,
|
|
};
|
|
|
|
let policy_opt = repair_max_attempts.map(|max| RepairPolicy {
|
|
max_attempts: max,
|
|
crash_window: std::time::Duration::from_millis(repair_window_ms),
|
|
event_sock: bus.map(|p| p.to_path_buf()),
|
|
..RepairPolicy::default()
|
|
});
|
|
|
|
let proposal = if llm {
|
|
run_with_llm_translator(intent, base, apply, compile, policy_opt.as_ref())?
|
|
} else {
|
|
run_with_mock_translator(
|
|
intent,
|
|
catalog.unwrap(),
|
|
base,
|
|
apply,
|
|
compile,
|
|
policy_opt.as_ref(),
|
|
)?
|
|
};
|
|
|
|
println!("--- proposal ---");
|
|
println!("intent: {}", proposal.intent);
|
|
println!(
|
|
"applied: source_patch={} config_edit={} init_rule={} file_drop={}",
|
|
proposal.applied.source_patch,
|
|
proposal.applied.config_edit,
|
|
proposal.applied.init_rule,
|
|
proposal.applied.file_drop,
|
|
);
|
|
if proposal.skipped_source_patches > 0 {
|
|
println!(
|
|
"skipped_source_patches: {} (sin --bus)",
|
|
proposal.skipped_source_patches
|
|
);
|
|
}
|
|
println!("checks:");
|
|
for c in &proposal.checks {
|
|
println!(" [{:?}] {}", c.level, c.msg);
|
|
}
|
|
if !proposal.repair_chain.is_empty() {
|
|
println!("\nrepair_chain ({} intento(s)):", proposal.repair_chain.len());
|
|
for r in &proposal.repair_chain {
|
|
match &r.triggered_crash {
|
|
Some(c) => println!(
|
|
" #{:>2} intent={:?} overlay={:?} → crash service={} code={}",
|
|
r.attempt, r.intent, r.overlay_id, c.service, c.code
|
|
),
|
|
None => println!(
|
|
" #{:>2} intent={:?} overlay={:?} → estabilizado",
|
|
r.attempt, r.intent, r.overlay_id
|
|
),
|
|
}
|
|
}
|
|
}
|
|
match (&proposal.overlay_id, &proposal.prefix) {
|
|
(Some(id), _) => {
|
|
println!("\noverlay {id} listo.");
|
|
println!(" Revisa el cambio en caliente y luego:");
|
|
println!(" hammer commit {id} # promueve + diario");
|
|
println!(" hammer discard {id} # descarta");
|
|
}
|
|
(None, Some(p)) => {
|
|
println!("\nprefix {} actualizado (modo test, sin overlay).", p.display());
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// En modo catálogo, intenta deducir un BaseRef si el humano no pasó `--base-ref`:
|
|
/// usa la `base` del propio `.swm` mockeado. Esto facilita el smoke-test offline.
|
|
fn infer_base_for_catalog(
|
|
intent: &str,
|
|
catalog: &std::path::Path,
|
|
base_ref: Option<&std::path::Path>,
|
|
) -> anyhow::Result<hammer_core::BaseRef> {
|
|
if let Some(b) = load_local_base(base_ref)? {
|
|
return Ok(b);
|
|
}
|
|
let cat = hammer_agent::IntentCatalog::load_from_path(catalog)
|
|
.map_err(|e| anyhow::anyhow!("catálogo {}: {e}", catalog.display()))?;
|
|
let base_dir = catalog.parent().unwrap_or_else(|| std::path::Path::new("."));
|
|
let ctx_swm = cat
|
|
.intents
|
|
.iter()
|
|
.find(|e| e.intent == intent)
|
|
.ok_or_else(|| anyhow::anyhow!("intent '{intent}' no está en el catálogo"))?
|
|
.swm_inline
|
|
.clone()
|
|
.or_else(|| {
|
|
cat.intents
|
|
.iter()
|
|
.find(|e| e.intent == intent)
|
|
.and_then(|e| e.swm_path.as_ref())
|
|
.and_then(|p| std::fs::read_to_string(base_dir.join(p)).ok())
|
|
.and_then(|t| hammer_core::Swm::from_yaml(&t).ok())
|
|
});
|
|
match ctx_swm {
|
|
Some(s) => Ok(hammer_core::BaseRef::from(&s.base)),
|
|
None => anyhow::bail!("no pude inferir BaseRef sin --base-ref"),
|
|
}
|
|
}
|
|
|
|
fn run_with_mock_translator(
|
|
intent: &str,
|
|
catalog: &std::path::Path,
|
|
base: hammer_core::BaseRef,
|
|
apply: hammer_agent::orchestrator::ApplyTarget,
|
|
compile: hammer_agent::orchestrator::CompileMode,
|
|
policy: Option<&hammer_agent::orchestrator::RepairPolicy>,
|
|
) -> anyhow::Result<hammer_agent::Proposal> {
|
|
use hammer_agent::{IntentCatalog, MockTranslator, Orchestrator};
|
|
let cat = IntentCatalog::load_from_path(catalog)
|
|
.map_err(|e| anyhow::anyhow!("catálogo {}: {e}", catalog.display()))?;
|
|
let base_dir = catalog.parent().unwrap_or_else(|| std::path::Path::new("."));
|
|
let translator = MockTranslator::from_catalog(&cat, base_dir)
|
|
.map_err(|e| anyhow::anyhow!("catalog: {e}"))?;
|
|
let orch = Orchestrator::new(translator, base, apply, compile);
|
|
match policy {
|
|
Some(p) => orch
|
|
.run_with_repair(intent, p)
|
|
.map_err(|e| anyhow::anyhow!("orchestrator (auto-repair): {e}")),
|
|
None => orch
|
|
.run(intent)
|
|
.map_err(|e| anyhow::anyhow!("orchestrator: {e}")),
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "llm-claude")]
|
|
fn run_with_llm_translator(
|
|
intent: &str,
|
|
base: hammer_core::BaseRef,
|
|
apply: hammer_agent::orchestrator::ApplyTarget,
|
|
compile: hammer_agent::orchestrator::CompileMode,
|
|
policy: Option<&hammer_agent::orchestrator::RepairPolicy>,
|
|
) -> anyhow::Result<hammer_agent::Proposal> {
|
|
use hammer_agent::{ClaudeConfig, ClaudeTranslator, Orchestrator};
|
|
let cfg = ClaudeConfig::from_env()
|
|
.map_err(|e| anyhow::anyhow!("ClaudeConfig::from_env: {e}"))?;
|
|
let translator = ClaudeTranslator::new(cfg);
|
|
let orch = Orchestrator::new(translator, base, apply, compile);
|
|
match policy {
|
|
Some(p) => orch
|
|
.run_with_repair(intent, p)
|
|
.map_err(|e| anyhow::anyhow!("orchestrator (auto-repair): {e}")),
|
|
None => orch
|
|
.run(intent)
|
|
.map_err(|e| anyhow::anyhow!("orchestrator: {e}")),
|
|
}
|
|
}
|
|
|
|
#[cfg(not(feature = "llm-claude"))]
|
|
fn run_with_llm_translator(
|
|
_intent: &str,
|
|
_base: hammer_core::BaseRef,
|
|
_apply: hammer_agent::orchestrator::ApplyTarget,
|
|
_compile: hammer_agent::orchestrator::CompileMode,
|
|
_policy: Option<&hammer_agent::orchestrator::RepairPolicy>,
|
|
) -> anyhow::Result<hammer_agent::Proposal> {
|
|
anyhow::bail!(
|
|
"este binario se compiló sin el feature `llm-claude`. Recompila: \
|
|
`cargo build -p hammer-cli --features llm-claude` (o desactiva --llm)."
|
|
)
|
|
}
|
|
|
|
/// Evalúa una expresión del mini-lenguaje localmente e imprime el JSON resultante.
|
|
/// Espejo en proceso del path remoto vía bus (`AgentClient::query_expr`).
|
|
fn run_query(
|
|
expr: &str,
|
|
base_ref: Option<&std::path::Path>,
|
|
fs_root: Option<&std::path::Path>,
|
|
) -> anyhow::Result<()> {
|
|
let mut ctx = hammer_core::query::EvalContext::new();
|
|
if let Some(b) = load_local_base(base_ref)? {
|
|
ctx = ctx.with_base(b);
|
|
}
|
|
if let Some(r) = fs_root {
|
|
ctx = ctx.with_fs_root(r.to_path_buf());
|
|
}
|
|
let value = hammer_core::query::eval_str(expr, &ctx);
|
|
let json = serde_json::to_string_pretty(&value)?;
|
|
println!("{json}");
|
|
Ok(())
|
|
}
|
|
|
|
/// Envía una línea al FIFO de control humano. No abrimos un proceso intermedio (sería
|
|
/// `sh -c "echo ... > FIFO"` con riesgo de quoting); usamos un `OpenOptions::write` y un
|
|
/// `write_all`. El FIFO bloquea si no hay reader — eso es la garantía de "ack" implícito.
|
|
fn run_ctl(line: &str, fifo: &std::path::Path) -> anyhow::Result<()> {
|
|
use std::io::Write;
|
|
use std::os::unix::fs::FileTypeExt;
|
|
let meta = std::fs::symlink_metadata(fifo).map_err(|e| {
|
|
anyhow::anyhow!(
|
|
"no encuentro el FIFO {} ({e}); ¿está hammerd arrancado?",
|
|
fifo.display()
|
|
)
|
|
})?;
|
|
if !meta.file_type().is_fifo() {
|
|
anyhow::bail!("{} existe pero no es un FIFO", fifo.display());
|
|
}
|
|
let mut f = std::fs::OpenOptions::new().write(true).open(fifo)?;
|
|
f.write_all(line.as_bytes())?;
|
|
if !line.ends_with('\n') {
|
|
f.write_all(b"\n")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Parsea un `--swap name=hash[:rel_path]` (variante b del builder, SDD 11 §7.2b). El `rel_path`
|
|
/// por defecto es `usr/bin/<name>`. Quitamos el prefijo `b3:` del hash ANTES de buscar el `:` del
|
|
/// rel_path opcional — si no, `make=b3:abc` se parsearía como hash=`b3`, rel_path=`abc`.
|
|
fn parse_swap(s: &str) -> anyhow::Result<hammer_bootstrap::ToolchainSwap> {
|
|
let (name, rest) = s
|
|
.split_once('=')
|
|
.ok_or_else(|| anyhow::anyhow!("--swap '{s}': falta '=' (name=hash[:rel_path])"))?;
|
|
let rest = rest.trim_start_matches("b3:");
|
|
let (hash, rel_path) = match rest.split_once(':') {
|
|
Some((h, p)) => (h, p.to_string()),
|
|
None => (rest, format!("usr/bin/{name}")),
|
|
};
|
|
Ok(hammer_bootstrap::ToolchainSwap {
|
|
name: name.to_string(),
|
|
artifact: hammer_core::ArtifactHash::from_hex(hash),
|
|
rel_path,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod swap_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn swap_with_b3_prefix_keeps_full_hash() {
|
|
// Regresión: el `:` del prefijo `b3:` no debe confundirse con el separador del rel_path.
|
|
let s = parse_swap("make=b3:fbad44ac").unwrap();
|
|
assert_eq!(s.name, "make");
|
|
assert_eq!(s.artifact.as_str(), "b3:fbad44ac");
|
|
assert_eq!(s.rel_path, "usr/bin/make", "rel_path por defecto");
|
|
}
|
|
|
|
#[test]
|
|
fn swap_bare_hash_and_explicit_rel_path() {
|
|
let s = parse_swap("bwrap=abcd:usr/bin/bwrap").unwrap();
|
|
assert_eq!(s.name, "bwrap");
|
|
assert_eq!(s.artifact.as_str(), "b3:abcd");
|
|
assert_eq!(s.rel_path, "usr/bin/bwrap");
|
|
}
|
|
|
|
#[test]
|
|
fn swap_b3_prefix_with_explicit_rel_path() {
|
|
let s = parse_swap("make=b3:fbad:usr/local/bin/make").unwrap();
|
|
assert_eq!(s.artifact.as_str(), "b3:fbad");
|
|
assert_eq!(s.rel_path, "usr/local/bin/make");
|
|
}
|
|
|
|
#[test]
|
|
fn swap_missing_equals_errors() {
|
|
let err = parse_swap("makeb3:fbad").unwrap_err().to_string();
|
|
assert!(err.contains("falta '='"), "{err}");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod export_tests {
|
|
use super::*;
|
|
use hammer_journal::{Actor, MutationEvent, MutationOp, Source};
|
|
|
|
fn write_recipe_sidecar(art_dir: &std::path::Path, name: &str, commit: &str) {
|
|
std::fs::create_dir_all(art_dir.join(".hammer")).unwrap();
|
|
let toml = format!(
|
|
r#"
|
|
name = "{name}"
|
|
version = "0.1"
|
|
[source]
|
|
repo = "git://example/{name}.git"
|
|
commit = "{commit}"
|
|
[build]
|
|
compiler = "zig-cc"
|
|
target = "x86_64-linux-musl"
|
|
link = "static"
|
|
flags = ["--enable-foo"]
|
|
"#
|
|
);
|
|
std::fs::write(art_dir.join(hammer_core::store::RECIPE_SIDECAR_REL), toml).unwrap();
|
|
}
|
|
|
|
fn ev_hydrate(path: &str, artifact: &str) -> MutationEvent {
|
|
MutationEvent {
|
|
ts: "2026-06-10T00:00:00Z".into(),
|
|
op: MutationOp::Create,
|
|
path: PathBuf::from(path),
|
|
by: Actor {
|
|
source: Source::HammerHydrate { artifact: artifact.into() },
|
|
pid: None,
|
|
uid: None,
|
|
},
|
|
content_hash: None,
|
|
note: None,
|
|
}
|
|
}
|
|
|
|
fn ev_external(path: &str) -> MutationEvent {
|
|
MutationEvent {
|
|
ts: "2026-06-10T00:00:01Z".into(),
|
|
op: MutationOp::Edit,
|
|
path: PathBuf::from(path),
|
|
by: Actor { source: Source::External, pid: Some(42), uid: Some(1000) },
|
|
content_hash: None,
|
|
note: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn export_without_store_falls_back_to_file_drops() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let target = d.path().join("etc/hosts");
|
|
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
|
|
std::fs::write(&target, b"127.0.0.1 lo\n").unwrap();
|
|
|
|
let events = vec![ev_external(target.to_str().unwrap())];
|
|
let (mutations, stats) = build_export_mutations(&events, None);
|
|
assert_eq!(stats.source_patches, 0);
|
|
assert_eq!(stats.file_drops, 1);
|
|
assert!(matches!(mutations[0], hammer_core::Mutation::FileDrop { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn delete_event_invalidates_previous_state() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let target = d.path().join("a");
|
|
std::fs::write(&target, b"x").unwrap();
|
|
|
|
let mut create = ev_external(target.to_str().unwrap());
|
|
create.op = MutationOp::Create;
|
|
let mut delete = ev_external(target.to_str().unwrap());
|
|
delete.op = MutationOp::Delete;
|
|
let events = vec![create, delete];
|
|
let (mutations, stats) = build_export_mutations(&events, None);
|
|
assert!(mutations.is_empty());
|
|
assert_eq!(stats.file_drops, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn hydrate_with_recipe_sidecar_emits_source_patch() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let store_root = d.path().join("store");
|
|
let store = hammer_core::Store::open(&store_root).unwrap();
|
|
let art_dir = store_root.join("cafe1234-grep");
|
|
write_recipe_sidecar(&art_dir, "grep", "deadbeef");
|
|
|
|
// Dos archivos del MISMO artifact_hash ⇒ deben colapsar en UN source_patch.
|
|
let file_a = d.path().join("usr/bin/grep");
|
|
let file_b = d.path().join("usr/lib/grep-helper.so");
|
|
std::fs::create_dir_all(file_a.parent().unwrap()).unwrap();
|
|
std::fs::create_dir_all(file_b.parent().unwrap()).unwrap();
|
|
std::fs::write(&file_a, b"x").unwrap();
|
|
std::fs::write(&file_b, b"y").unwrap();
|
|
let events = vec![
|
|
ev_hydrate(file_a.to_str().unwrap(), "b3:cafe1234"),
|
|
ev_hydrate(file_b.to_str().unwrap(), "b3:cafe1234"),
|
|
];
|
|
|
|
let (mutations, stats) = build_export_mutations(&events, Some(&store));
|
|
assert_eq!(stats.source_patches, 1, "{:?}", stats);
|
|
assert_eq!(stats.file_drops, 0);
|
|
assert_eq!(stats.missing_recipe, 0);
|
|
match &mutations[0] {
|
|
hammer_core::Mutation::SourcePatch {
|
|
repo,
|
|
commit,
|
|
build,
|
|
target_bin,
|
|
expected_hash,
|
|
..
|
|
} => {
|
|
assert_eq!(repo.as_deref(), Some("git://example/grep.git"));
|
|
assert_eq!(commit.as_deref(), Some("deadbeef"));
|
|
assert_eq!(build.compiler, "zig-cc");
|
|
assert_eq!(build.link, "static");
|
|
assert_eq!(build.flags, vec!["--enable-foo".to_string()]);
|
|
// target_bin es el primer path alfabético del grupo.
|
|
let tb = std::path::PathBuf::from(target_bin);
|
|
assert!(tb.ends_with("usr/bin/grep"), "{target_bin}");
|
|
assert_eq!(expected_hash.as_deref(), Some("b3:cafe1234"));
|
|
}
|
|
other => panic!("esperaba SourcePatch, llegó {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn hydrate_without_recipe_falls_back_to_file_drop() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let store_root = d.path().join("store");
|
|
let store = hammer_core::Store::open(&store_root).unwrap();
|
|
// Creamos el artefacto SIN sidecar.
|
|
std::fs::create_dir_all(store_root.join("badbeef-grep")).unwrap();
|
|
|
|
let target = d.path().join("usr/bin/grep");
|
|
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
|
|
std::fs::write(&target, b"binary").unwrap();
|
|
let events = vec![ev_hydrate(target.to_str().unwrap(), "b3:badbeef")];
|
|
|
|
let (mutations, stats) = build_export_mutations(&events, Some(&store));
|
|
assert_eq!(stats.source_patches, 0);
|
|
assert_eq!(stats.file_drops, 1);
|
|
assert_eq!(stats.missing_recipe, 1, "debe contar el artefacto sin sidecar");
|
|
assert!(matches!(mutations[0], hammer_core::Mutation::FileDrop { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn mixed_traceable_and_external_emit_both_kinds() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let store_root = d.path().join("store");
|
|
let store = hammer_core::Store::open(&store_root).unwrap();
|
|
let art_dir = store_root.join("aaaa1111-grep");
|
|
write_recipe_sidecar(&art_dir, "grep", "feedface");
|
|
|
|
let traced = d.path().join("usr/bin/grep");
|
|
std::fs::create_dir_all(traced.parent().unwrap()).unwrap();
|
|
std::fs::write(&traced, b"x").unwrap();
|
|
let edited = d.path().join("etc/grep.conf");
|
|
std::fs::create_dir_all(edited.parent().unwrap()).unwrap();
|
|
std::fs::write(&edited, b"OPTS=\n").unwrap();
|
|
|
|
let events = vec![
|
|
ev_hydrate(traced.to_str().unwrap(), "b3:aaaa1111"),
|
|
ev_external(edited.to_str().unwrap()),
|
|
];
|
|
let (mutations, stats) = build_export_mutations(&events, Some(&store));
|
|
assert_eq!(stats.source_patches, 1);
|
|
assert_eq!(stats.file_drops, 1);
|
|
// Orden: source_patches primero (BTreeMap por hash), luego file_drops.
|
|
assert!(matches!(mutations[0], hammer_core::Mutation::SourcePatch { .. }));
|
|
assert!(matches!(mutations[1], hammer_core::Mutation::FileDrop { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn unreadable_path_in_fallback_only_warns() {
|
|
// ev_external apuntando a un path inexistente: el evento se cuenta como warning
|
|
// y queda fuera del .swm. La función no falla.
|
|
let events = vec![ev_external("/no/existe/seguramente/x")];
|
|
let (mutations, stats) = build_export_mutations(&events, None);
|
|
assert_eq!(stats.warnings, 1);
|
|
assert_eq!(stats.file_drops, 0);
|
|
assert!(mutations.is_empty());
|
|
}
|
|
|
|
}
|