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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
6db3e3e302
commit
7a6bfb4c4d
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
if let Some(text) = patch {
|
||||
let patch_path = recipe_dir.join(format!("{commit}.patch"));
|
||||
if let Some(text) = 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user