From 1180bd91544da05dc53dd7fb23f858cb9becd46f Mon Sep 17 00:00:00 2001 From: sergio Date: Sun, 21 Jun 2026 08:50:14 -0400 Subject: [PATCH] =?UTF-8?q?Etapa=20G=20Fase=202:=20tag=E2=86=92SHA=20?= =?UTF-8?q?=E2=80=94=20`hammer=20pin`=20(sello=20inmutable,=20ADR=200006)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Los imports github salen con commit=tag flotante (v1.1.0); el pin lo resuelve al SHA inmutable ⇒ el laboratorio se vuelve determinista al estilo Nix (origen anclado a un punto fijo). - hammer-cli: `hammer pin ` (in-place o --out). Usa `git ls-remote` (host-agnóstico, sin API ni tokens ni rate-limits), prefiere el commit dereferenciado `^{}` para tags anotados. Reescritura DIRIGIDA de la línea `commit = ""` (preserva comentarios/formato; no toca version u otras que casen). No-op si ya es SHA (idempotente) o tarball (ya anclado por sha256). is_git_sha (40 hex sha1 / 64 hex sha256). +1 test. - scripts/pin-recipes.sh: ancla en lote (recipes/*.toml). - Validado real: sd v1.1.0 → 4a7b216552d6… (git ls-remote), idempotente. 31 suites verde. Co-Authored-By: Claude Opus 4.8 --- crates/hammer-cli/src/main.rs | 107 ++++++++++++++++++++++++++++++++++ scripts/pin-recipes.sh | 22 +++++++ 2 files changed, 129 insertions(+) create mode 100755 scripts/pin-recipes.sh diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 2b76c1e7..595a6b48 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -275,6 +275,16 @@ enum Cmd { #[arg(long, short)] out: Option, }, + /// [Etapa G Fase 2] Ancla el `commit` de una receta git: si es un TAG flotante (v1.0.0), + /// lo resuelve al SHA inmutable vía `git ls-remote` (host-agnóstico, sin API ni tokens) y + /// reescribe la receta. El sello determinista al estilo Nix (ADR 0006). No-op si ya es un SHA. + Pin { + /// Receta a anclar (`recipes/foo.toml`). + recipe: PathBuf, + /// Salida. Sin esto, reescribe la receta IN-PLACE. + #[arg(long, short)] + out: Option, + }, /// [Etapa F] Inspecciona un repositorio de paquetes. Repo { #[command(subcommand)] @@ -840,6 +850,7 @@ fn main() -> anyhow::Result<()> { Cmd::Installed { db } => run_installed_list(&db)?, Cmd::ImportNix { file, out } => run_import_nix(&file, out.as_deref())?, Cmd::ImportAlpine { file, out } => run_import_alpine(&file, out.as_deref())?, + Cmd::Pin { recipe, out } => run_pin(&recipe, out.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())?, @@ -1633,6 +1644,87 @@ fn run_import_nix(file: &str, out: Option<&std::path::Path>) -> anyhow::Result<( Ok(()) } +/// `true` si `s` ya es un SHA de git (40 hex sha1 o 64 hex sha256) ⇒ ya está anclado. +fn is_git_sha(s: &str) -> bool { + (s.len() == 40 || s.len() == 64) && s.bytes().all(|b| b.is_ascii_hexdigit()) +} + +/// Resuelve un tag/ref a su SHA de commit inmutable vía `git ls-remote` (cualquier host git, sin +/// API ni tokens). Para tags ANOTADOS prefiere el commit dereferenciado (`^{}`); para ligeros, el +/// ref directo. Devuelve error si el tag no existe en el remoto. +fn resolve_git_sha(repo: &str, tag: &str) -> anyhow::Result { + let out = std::process::Command::new("git") + .args([ + "ls-remote", + repo, + &format!("refs/tags/{tag}"), + &format!("refs/tags/{tag}^{{}}"), + tag, + ]) + .output() + .map_err(|e| anyhow::anyhow!("git ls-remote: {e}"))?; + if !out.status.success() { + anyhow::bail!("git ls-remote {repo} {tag}: {}", String::from_utf8_lossy(&out.stderr).trim()); + } + let text = String::from_utf8_lossy(&out.stdout); + // Líneas `\t`. Preferimos la dereferenciada `^{}` (commit real del tag anotado). + let mut plain: Option = None; + for line in text.lines() { + let mut it = line.split('\t'); + let (Some(sha), Some(rf)) = (it.next(), it.next()) else { continue }; + if rf.ends_with("^{}") { + return Ok(sha.to_string()); + } + plain.get_or_insert_with(|| sha.to_string()); + } + plain.ok_or_else(|| anyhow::anyhow!("'{tag}' no existe como tag/ref en {repo}")) +} + +/// Ancla el `commit` de una receta git: tag flotante → SHA inmutable (Etapa G Fase 2). +fn run_pin(recipe_path: &std::path::Path, out: Option<&std::path::Path>) -> anyhow::Result<()> { + let text = std::fs::read_to_string(recipe_path) + .map_err(|e| anyhow::anyhow!("leyendo {}: {e}", recipe_path.display()))?; + let recipe = hammer_core::Recipe::from_toml(&text) + .map_err(|e| anyhow::anyhow!("{}: {e}", recipe_path.display()))?; + + let (repo, commit) = match recipe.source.kind() { + Ok(hammer_core::SourceKind::Git { repo, commit }) => (repo.to_string(), commit.to_string()), + Ok(hammer_core::SourceKind::Tarball { .. }) => { + eprintln!("{}: tarball (ya anclado por sha256) — nada que pinear", recipe.name); + return Ok(()); + } + Err(e) => anyhow::bail!("{}: {e}", recipe.name), + }; + if is_git_sha(&commit) { + eprintln!("{}: commit ya es SHA ({}) — ya anclado", recipe.name, &commit[..commit.len().min(12)]); + return Ok(()); + } + + let sha = resolve_git_sha(&repo, &commit)?; + // Reescritura dirigida: la línea `commit = ""` (no tocamos version u otras que casen). + let mut replaced = false; + let new_text: String = text + .lines() + .map(|l| { + if !replaced && l.trim_start().starts_with("commit") && l.contains(&format!("\"{commit}\"")) { + replaced = true; + l.replace(&format!("\"{commit}\""), &format!("\"{sha}\"")) + } else { + l.to_string() + } + }) + .collect::>() + .join("\n"); + let new_text = if text.ends_with('\n') { format!("{new_text}\n") } else { new_text }; + if !replaced { + anyhow::bail!("no encontré la línea `commit = \"{commit}\"` para reescribir"); + } + let dest = out.unwrap_or(recipe_path); + std::fs::write(dest, new_text)?; + eprintln!("{}: {commit} → {sha} (anclado en {})", recipe.name, dest.display()); + Ok(()) +} + /// Importa una receta desde un APKBUILD de Alpine (Etapa G). fn run_import_alpine(file: &str, out: Option<&std::path::Path>) -> anyhow::Result<()> { let text = if file == "-" { @@ -2566,6 +2658,21 @@ fn parse_swap(s: &str) -> anyhow::Result { }) } +#[cfg(test)] +mod pin_tests { + use super::*; + + #[test] + fn is_git_sha_detecta_sha_vs_tag() { + assert!(is_git_sha("4a7b216552d64134c0fa17a59b9d557d89019f0f")); // sha1 40 hex + assert!(is_git_sha(&"a".repeat(64))); // sha256 64 hex + assert!(!is_git_sha("v1.1.0")); // tag + assert!(!is_git_sha("main")); // branch + assert!(!is_git_sha("4a7b21")); // corto + assert!(!is_git_sha(&"z".repeat(40))); // 40 chars no-hex + } +} + #[cfg(test)] mod swap_tests { use super::*; diff --git a/scripts/pin-recipes.sh b/scripts/pin-recipes.sh new file mode 100755 index 00000000..aa61215c --- /dev/null +++ b/scripts/pin-recipes.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# pin-recipes.sh — Etapa G Fase 2 (sello inmutable): ancla en LOTE el `commit` +# de cada receta git, resolviendo tags flotantes → SHA vía `git ls-remote` (ADR 0006). Los tarball +# (ya anclados por sha256) y las que ya tienen SHA se saltean. El laboratorio se vuelve determinista +# al estilo Nix: cada origen queda fijado a un punto inmutable de la historia. +# +# Ej: scripts/pin-recipes.sh recipes/*.toml | find recipes -name '*.toml' | xargs scripts/pin-recipes.sh +set -eu +HAMMER="${HAMMER:-cargo run -q -p hammer-cli --}" +[ "$#" -gt 0 ] || { echo "uso: pin-recipes.sh " >&2; exit 1; } + +pinned=0; skipped=0; failed="" +for r in "$@"; do + if $HAMMER pin "$r" 2>&1 | grep -q '→'; then + pinned=$((pinned + 1)) + elif $HAMMER pin "$r" >/dev/null 2>&1; then + skipped=$((skipped + 1)) # ya anclado / tarball + else + failed="$failed $(basename "$r")" + fi +done +echo "pin: $pinned anclados, $skipped salteados (ya-SHA/tarball).${failed:+ FALLARON:$failed}"