> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
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/<commit>.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) <noreply@anthropic.com>
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<Vec<u8>> {
+ 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<String> = 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/<commit>.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/<commit>.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