tawasuyu

sergio/takana

> 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…

TOML 1572Shell 203Rust 109Markdown 993728 Definiciones

Fase 4: patch_url / content_url remotos en .swm

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>

7a6bfb4c4db7b90a94e22d1edac7d41786d265a9 · por Sergio · (hace 4 meses) · padre 6db3e3e30 · ver el árbol en este commit

Delta semántico · 11

crates/hammer-build/src/download.rs

crates/hammer-build/src/lib.rs

crates/hammer-build/src/swm_bridge.rs

crates/hammer-cli/src/main.rs

5 archivos · +118 −20

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