From 7a6bfb4c4db7b90a94e22d1edac7d41786d265a9 Mon Sep 17 00:00:00 2001 From: Sergio Date: Wed, 10 Jun 2026 19:57:05 +0000 Subject: [PATCH] Fase 4: patch_url / content_url remotos en .swm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cierra el último hueco de "sólo inline": ahora un .swm puede referenciar el patch y el contenido de un file_drop por URL. - hammer-build/src/download.rs: fetch_url_bytes / fetch_url_to_file (curl, sólo fuera del sandbox; acepta file:// para tests offline). 3 tests. - swm_bridge::build_source_patch: descarga patch_url a swm-recipes/.patch antes de compilar (ya no lo rechaza). Test reescrito con file://. - CLI apply: file_drop con content_url descarga y verifica BLAKE3 ANTES de escribir; hash erróneo ⇒ nada tocado (verificado e2e). Integridad: content_hash (file_drop) y build reproducible + expected_hash (patch). 22 binarios de test verdes; e2e por file:// (apply + caso de hash inválido que no escribe). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hammer-build/src/download.rs | 78 +++++++++++++++++++++++++++ crates/hammer-build/src/lib.rs | 1 + crates/hammer-build/src/swm_bridge.rs | 41 ++++++++------ crates/hammer-cli/src/main.rs | 12 +++-- docs/10-roadmap.md | 6 ++- 5 files changed, 118 insertions(+), 20 deletions(-) create mode 100644 crates/hammer-build/src/download.rs diff --git a/crates/hammer-build/src/download.rs b/crates/hammer-build/src/download.rs new file mode 100644 index 00000000..ef6fbf7d --- /dev/null +++ b/crates/hammer-build/src/download.rs @@ -0,0 +1,78 @@ +//! Descarga remota para `.swm`: `patch_url` (source_patch) y `content_url` (file_drop). +//! Ver `docs/06-swm-format.md` §2. La red sólo se toca aquí y en `fetch` — nunca dentro del +//! sandbox de build. +//! +//! Usa `curl` (ya presente en el rootfs del lab). Acepta cualquier esquema que curl entienda; +//! en tests usamos `file://` para no depender de la red. La integridad la garantiza el +//! llamador: el `file_drop` verifica BLAKE3 (`content_hash`) tras descargar; el `patch_url` +//! se cubre indirectamente por el build reproducible + `expected_hash` del artefacto. + +use std::path::Path; +use std::process::Command; + +/// Descarga `url` y devuelve su contenido en memoria. Falla con el stderr de curl si el +/// código de salida no es 0 (incluye errores HTTP gracias a `-f`). +pub fn fetch_url_bytes(url: &str) -> hammer_core::Result> { + let out = Command::new("curl") + .args([ + "-fsSL", + "--retry", + "2", + "--connect-timeout", + "30", + "--max-time", + "300", + url, + ]) + .output() + .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn curl: {e}")))?; + if !out.status.success() { + return Err(hammer_core::Error::Other(anyhow::anyhow!( + "curl {url} falló (exit {}): {}", + out.status.code().map(|c| c.to_string()).unwrap_or_else(|| "?".into()), + String::from_utf8_lossy(&out.stderr).trim() + ))); + } + Ok(out.stdout) +} + +/// Descarga `url` y lo escribe en `dst` (creando los directorios padre). +pub fn fetch_url_to_file(url: &str, dst: &Path) -> hammer_core::Result<()> { + let bytes = fetch_url_bytes(url)?; + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(dst, &bytes)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fetch_file_url_roundtrip() { + let d = tempfile::tempdir().unwrap(); + let src = d.path().join("payload.txt"); + std::fs::write(&src, b"contenido remoto\n").unwrap(); + let url = format!("file://{}", src.display()); + let got = fetch_url_bytes(&url).unwrap(); + assert_eq!(got, b"contenido remoto\n"); + } + + #[test] + fn fetch_to_file_writes_and_creates_parents() { + let d = tempfile::tempdir().unwrap(); + let src = d.path().join("p.txt"); + std::fs::write(&src, b"abc").unwrap(); + let dst = d.path().join("nested/dir/out.txt"); + fetch_url_to_file(&format!("file://{}", src.display()), &dst).unwrap(); + assert_eq!(std::fs::read(&dst).unwrap(), b"abc"); + } + + #[test] + fn missing_url_is_error() { + let err = fetch_url_bytes("file:///no/existe/aqui.bin").unwrap_err().to_string(); + assert!(err.contains("curl"), "{err}"); + } +} diff --git a/crates/hammer-build/src/lib.rs b/crates/hammer-build/src/lib.rs index f2defe12..ed2b010e 100644 --- a/crates/hammer-build/src/lib.rs +++ b/crates/hammer-build/src/lib.rs @@ -6,6 +6,7 @@ use std::path::{Path, PathBuf}; use hammer_core::{ArtifactHash, LinkMode, Phases, Recipe, Store}; pub mod config; +pub mod download; pub mod fetch; pub mod hydrate; pub mod sandbox; diff --git a/crates/hammer-build/src/swm_bridge.rs b/crates/hammer-build/src/swm_bridge.rs index 82a891fc..f88cf643 100644 --- a/crates/hammer-build/src/swm_bridge.rs +++ b/crates/hammer-build/src/swm_bridge.rs @@ -44,24 +44,23 @@ pub fn build_source_patch( } }; - if patch_url.is_some() { - return Err(hammer_core::Error::Recipe( - "patch_url remoto no soportado todavía: usa 'patch' inline".into(), - )); - } - let scratch = scratch_root.unwrap_or(&cfg.work_root).to_path_buf(); let recipe_dir = scratch.join("swm-recipes"); std::fs::create_dir_all(&recipe_dir)?; - // Si trae patch inline, lo materializamos a disco bajo un nombre derivado del commit - // (estable: si el .swm se reaplica, reusamos el mismo archivo y el hash de la receta no - // depende de aleatoriedad). + // Materializamos el patch a disco bajo un nombre derivado del commit (estable: si el .swm + // se reaplica, reusamos el mismo archivo y el hash de la receta no depende de + // aleatoriedad). Origen: inline (`patch`) o remoto (`patch_url`); el schema garantiza que + // no vengan ambos. let mut patches: Vec = Vec::new(); + let patch_path = recipe_dir.join(format!("{commit}.patch")); if let Some(text) = patch { - let patch_path = recipe_dir.join(format!("{commit}.patch")); std::fs::write(&patch_path, text.as_bytes())?; patches.push(patch_path.to_string_lossy().into_owned()); + } else if let Some(url) = patch_url { + tracing::info!(%url, "fetch: descargando patch remoto"); + crate::download::fetch_url_to_file(url, &patch_path)?; + patches.push(patch_path.to_string_lossy().into_owned()); } let name = derive_name(target_bin); @@ -241,20 +240,30 @@ mod tests { } #[test] - fn rejects_patch_url() { + fn patch_url_is_downloaded_before_build() { + // Servimos el patch por file:// (sin red). build_source_patch debe descargarlo y + // materializarlo en swm-recipes/.patch ANTES de fallar aguas abajo por no + // haber rootfs de build en este entorno. Eso prueba que el patch_url quedó cableado. + let d = tempfile::tempdir().unwrap(); + let patch_src = d.path().join("remote.patch"); + std::fs::write(&patch_src, b"--- a\n+++ b\n").unwrap(); + let commit = "deadbeefcafe"; let m = Mutation::SourcePatch { repo: "git://x".into(), - commit: "abc".into(), + commit: commit.into(), patch: None, - patch_url: Some("https://example/foo.patch".into()), + patch_url: Some(format!("file://{}", patch_src.display())), build: fake_swm_build(), target_bin: "/bin/x".into(), expected_hash: None, }; - let d = tempfile::tempdir().unwrap(); let store = Store::open(d.path().join("store")).unwrap(); let cfg = BuildConfig::defaults_for_store(store.root()); - let err = build_source_patch(&m, &cfg, &store, None).unwrap_err().to_string(); - assert!(err.contains("patch_url"), "{err}"); + let scratch = d.path().join("scratch"); + // Falla (no hay rootfs), pero el patch ya debe estar descargado. + let _ = build_source_patch(&m, &cfg, &store, Some(&scratch)); + let landed = scratch.join("swm-recipes").join(format!("{commit}.patch")); + assert!(landed.is_file(), "el patch remoto debió descargarse a {}", landed.display()); + assert_eq!(std::fs::read(&landed).unwrap(), b"--- a\n+++ b\n"); } } diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 7d945ec8..4fa616e1 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -662,9 +662,15 @@ fn run_apply( eprintln!(" file_drop #{} → {}", i + 1, target.display()); } (None, Some(url)) => { - anyhow::bail!( - "file_drop #{} usa content_url={url}: fetch remoto pendiente", - i + 1 + // 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"), diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md index 3dbf178f..3876f008 100644 --- a/docs/10-roadmap.md +++ b/docs/10-roadmap.md @@ -66,7 +66,11 @@ pre-requisito de validación. - [x] Bridge `Mutation::SourcePatch` → `Recipe` + `build` + hidratación. - [x] CLI: `hammer apply [--prefix DIR] [--base-ref base.json]`, `hammer swm-verify`, `hammer export --journal DIR > out.swm`. -- [ ] `patch_url` / `content_url` remotos (hoy sólo inline). +- [x] `patch_url` / `content_url` remotos. `hammer_build::download` (curl, sólo fuera del + sandbox; acepta `file://` para tests offline). `build_source_patch` descarga `patch_url` + al `swm-recipes/.patch` antes de compilar; `hammer apply` descarga `content_url` + y **verifica BLAKE3 antes de escribir** (hash erróneo ⇒ nada tocado). Su integridad la + cubre `content_hash` (file_drop) y el build reproducible + `expected_hash` (patch). - [x] Provenance en `export`: mapa artefacto→receta vía sidecar `.hammer/recipe.toml` que `hammer-build::build` escribe dentro del artefacto antes de sellar. `hammer export` agrupa eventos por `artifact_hash` y emite UN `source_patch` por grupo cuya receta