Cierra la simetría de la vía observada: además de observar lo que un paquete escribe (H4c), observa de qué depende A UNA VERSIÓN. Fuente observable sin declaración: el paquete se construyó contra la versión de sus deps que hay en el repo (deps.runtime del .swm + expected_hash de cada dep en el índice); si el usuario tiene esa dep instalada a OTRO hash, la divergió -> rechazo duro. Es el caso wayland DERIVADO (el que H4b captura cuando el autor declara requires, ahora leído del cierre). - compat::observed_requires(swm, index) + version_conflicts(db, req) — reusan deps del .swm, expected_hash del índice e InstalledDb.hash (cero declaración nueva). - Cableado en install (rechazo duro, no lo salva --force-slots) y en `hammer compat`. - Verificado e2e real: `hammer compat` marca app INCOMPATIBLE por su dep wayland-protocol instalada a un hash divergido del repo (read-only, ve el source_patch sin construirlo). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
166 lines
7.5 KiB
Rust
166 lines
7.5 KiB
Rust
//! H4c e2e: el gate de **colisión de fichero observada** en `hammer install` real.
|
|
//!
|
|
//! Reproduce el caso "logo" a nivel de fichero, sin declarar `slots`: dos paquetes escriben el
|
|
//! mismo path. El primero ya está instalado (pre-seed de la DB); al instalar el segundo, el gate
|
|
//! —que lee los paths que el `.swm` escribirá y los compara con lo instalado— debe ABORTAR antes
|
|
//! de tocar nada. Con `--force-slots` la elección se respeta y el fichero se escribe.
|
|
|
|
use std::process::Command;
|
|
|
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
|
use hammer_core::ArtifactHash;
|
|
|
|
#[test]
|
|
fn install_aborta_ante_colision_de_fichero_y_force_slots_la_supera() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path().join("repo");
|
|
std::fs::create_dir_all(&repo).unwrap();
|
|
let prefix = tmp.path().join("prefix");
|
|
let db = tmp.path().join("installed.json");
|
|
|
|
// Pre-seed: 'tema-claro' YA posee /share/logo.png.
|
|
std::fs::write(
|
|
&db,
|
|
r#"{"packages":[{"name":"tema-claro","version":"1","files":["/share/logo.png"]}]}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
// Paquete entrante 'tema-oscuro' = un file_drop que escribe el MISMO path.
|
|
let content = b"logo-oscuro";
|
|
// apply_file_drop verifica con `of_inputs` (length-prefixed), no `of_bytes` — mismamos eso.
|
|
let hash = ArtifactHash::of_inputs(&[content.as_slice()]);
|
|
let b64 = STANDARD.encode(content);
|
|
let swm = format!(
|
|
"swm_version: 1\nbase:\n distro_version: dev\n pins: {{}}\nmutations:\n\
|
|
- type: file_drop\n path: /share/logo.png\n content_hash: {}\n content_b64: {}\n",
|
|
hash.as_str(),
|
|
b64
|
|
);
|
|
std::fs::write(repo.join("tema-oscuro-1.swm"), swm).unwrap();
|
|
let index = r#"{"packages":[{"name":"tema-oscuro","version":"1","file":"tema-oscuro-1.swm","distro_version":"dev","deps":[]}]}"#;
|
|
std::fs::write(repo.join("index.json"), index).unwrap();
|
|
|
|
let bin = env!("CARGO_BIN_EXE_hammer");
|
|
let base_args = [
|
|
"install",
|
|
"tema-oscuro",
|
|
"--repo",
|
|
repo.to_str().unwrap(),
|
|
"--db",
|
|
db.to_str().unwrap(),
|
|
"--prefix",
|
|
prefix.to_str().unwrap(),
|
|
];
|
|
|
|
// 1) Sin --force-slots: colisión ⇒ aborta, y el fichero NO se escribe (aborta pre-hidratación).
|
|
let out = Command::new(bin).args(base_args).output().unwrap();
|
|
assert!(!out.status.success(), "debía abortar por colisión de fichero");
|
|
let err = String::from_utf8_lossy(&out.stderr);
|
|
assert!(err.contains("COLISIÓN de fichero"), "stderr inesperado: {err}");
|
|
assert!(
|
|
!prefix.join("share/logo.png").exists(),
|
|
"no debía escribir el fichero al abortar"
|
|
);
|
|
|
|
// 2) Con --force-slots: la elección se respeta y el fichero se escribe.
|
|
let out = Command::new(bin)
|
|
.args(base_args)
|
|
.arg("--force-slots")
|
|
.output()
|
|
.unwrap();
|
|
let err = String::from_utf8_lossy(&out.stderr);
|
|
assert!(out.status.success(), "con --force-slots debía instalar. stderr: {err}");
|
|
assert!(
|
|
prefix.join("share/logo.png").exists(),
|
|
"el fichero debía escribirse con --force-slots. stderr: {err}"
|
|
);
|
|
}
|
|
|
|
/// H4d e2e: `hammer compat <repo>` particiona un repo contra el estado instalado. La búsqueda
|
|
/// del prototipo `wawa-memo` (`filtrar`), ahora sobre el repo real y read-only.
|
|
#[test]
|
|
fn compat_particiona_el_repo_contra_lo_instalado() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path().join("repo");
|
|
std::fs::create_dir_all(&repo).unwrap();
|
|
let db = tmp.path().join("installed.json");
|
|
|
|
// Instalado: 'tema-claro' posee /share/logo.png.
|
|
std::fs::write(
|
|
&db,
|
|
r#"{"packages":[{"name":"tema-claro","version":"1","files":["/share/logo.png"]}]}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
// Dos paquetes en el repo: uno choca (mismo path que tema-claro), otro es limpio (otro path).
|
|
for (name, path) in [("tema-oscuro", "/share/logo.png"), ("fondo-montaña", "/share/fondo.png")] {
|
|
let content = format!("bytes-{name}");
|
|
let hash = ArtifactHash::of_inputs(&[content.as_bytes()]);
|
|
let b64 = STANDARD.encode(content.as_bytes());
|
|
let swm = format!(
|
|
"swm_version: 1\nbase:\n distro_version: dev\n pins: {{}}\nmutations:\n\
|
|
- type: file_drop\n path: {path}\n content_hash: {}\n content_b64: {}\n",
|
|
hash.as_str(), b64
|
|
);
|
|
std::fs::write(repo.join(format!("{name}-1.swm")), swm).unwrap();
|
|
}
|
|
let index = r#"{"packages":[
|
|
{"name":"tema-oscuro","version":"1","file":"tema-oscuro-1.swm","distro_version":"dev","deps":[]},
|
|
{"name":"fondo-montaña","version":"1","file":"fondo-montaña-1.swm","distro_version":"dev","deps":[]}
|
|
]}"#;
|
|
std::fs::write(repo.join("index.json"), index).unwrap();
|
|
|
|
let out = Command::new(env!("CARGO_BIN_EXE_hammer"))
|
|
.args(["compat", "--repo", repo.to_str().unwrap(), "--db", db.to_str().unwrap()])
|
|
.output()
|
|
.unwrap();
|
|
assert!(out.status.success());
|
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
|
// fondo-montaña es compatible; tema-oscuro requiere elección (colisión de fichero).
|
|
assert!(stdout.contains("compatibles (1)"), "stdout: {stdout}");
|
|
assert!(stdout.contains("✓ fondo-montaña"), "stdout: {stdout}");
|
|
assert!(stdout.contains("requieren elección (1)"), "stdout: {stdout}");
|
|
assert!(stdout.contains("tema-oscuro"), "stdout: {stdout}");
|
|
}
|
|
|
|
/// H4e e2e: `hammer compat` marca INCOMPATIBLE un paquete cuya dep de runtime divergiste
|
|
/// (el caso wayland, DERIVADO — sin que el paquete declare `requires`). Read-only: ve el
|
|
/// source_patch sin construirlo.
|
|
#[test]
|
|
fn compat_marca_incompatible_una_dep_divergida() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path().join("repo");
|
|
std::fs::create_dir_all(&repo).unwrap();
|
|
let db = tmp.path().join("installed.json");
|
|
|
|
// Tengo wayland-protocol instalado a b3:mine (lo diverguí del stock del repo).
|
|
std::fs::write(
|
|
&db,
|
|
r#"{"packages":[{"name":"wayland-protocol","version":"1","hash":"b3:mine","files":[]}]}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
// 'app' es un source_patch que depende (runtime) de wayland-protocol.
|
|
let app_swm = "swm_version: 1\nbase:\n distro_version: dev\n pins: {}\nmutations:\n\
|
|
- type: source_patch\n repo: git://x/app.git\n commit: abc\n target_bin: /usr/bin/app\n\
|
|
\x20 build:\n compiler: zig-cc\n deps:\n runtime:\n - wayland-protocol\n";
|
|
std::fs::write(repo.join("app-1.swm"), app_swm).unwrap();
|
|
// wayland-protocol también está en el repo, a b3:stock (la versión contra la que app se hizo).
|
|
let w_swm = "swm_version: 1\nbase:\n distro_version: dev\n pins: {}\nmutations: []\n";
|
|
std::fs::write(repo.join("w-1.swm"), w_swm).unwrap();
|
|
let index = r#"{"packages":[
|
|
{"name":"app","version":"1","file":"app-1.swm","distro_version":"dev","deps":["wayland-protocol"],"expected_hash":"b3:app"},
|
|
{"name":"wayland-protocol","version":"1","file":"w-1.swm","distro_version":"dev","deps":[],"expected_hash":"b3:stock"}
|
|
]}"#;
|
|
std::fs::write(repo.join("index.json"), index).unwrap();
|
|
|
|
let out = Command::new(env!("CARGO_BIN_EXE_hammer"))
|
|
.args(["compat", "--repo", repo.to_str().unwrap(), "--db", db.to_str().unwrap()])
|
|
.output()
|
|
.unwrap();
|
|
assert!(out.status.success());
|
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
|
assert!(stdout.contains("incompatibles (1)"), "stdout: {stdout}");
|
|
assert!(stdout.contains("app") && stdout.contains("deps divergidas"), "stdout: {stdout}");
|
|
}
|