takana etapa 4: los 10 crates de librería y el CLI pasan a takana-*

hammer-{core,build,bootstrap,overlay,journal,mirror,upgrade,agent,recover,cli}
→ takana-*, con sus deps de workspace, sus identificadores en el fuente y las
referencias -p de los scripts.

VERIFICADO que no mueve nada del corpus: `takana hash recipes/zlib.toml`
devuelve b3:dc363f26… , idéntico a antes del renombre. Los nombres de crate no
entran en hash_inputs, pero eso se comprueba, no se supone. 600 tests en verde.

DOS BINARIOS SE CONGELAN, y no por prolijidad:

- `hammerd` — paquete Y binario. Es componente de Stage 1 de la distro (musl,
  busybox, hammerd, arje-zero), lo supervisa arje-zero en el sistema arrancado,
  `PRESEED=hammerd` lo nombra en selfhost-verify y sus bytes anclan el baseline
  of_tree. El nombre del crate va en los símbolos ⇒ renombrarlo mueve los bytes.

- `hammer-recover` — el PAQUETE se renombra a takana-recover, el BINARIO no.
  hammer-live-install.sh lo copia a /usr/sbin/hammer-recover en sistemas ya
  instalados y hornea un hook de arranque que lo invoca por ese nombre:
  renombrarlo rompe máquinas instaladas, no el repo.

Consecuencia que hay que anotar igual: al renombrar hammer-core, los bytes de
hammerd cambian de todos modos porque linkea contra un crate con otro nombre.
El baseline of_tree del selfhost hay que rehacerlo — es efecto de la etapa 4,
no de un cambio de hammerd.

Las referencias en comentarios de recetas y docs (rutas hammer-core/src/…)
quedan para la etapa 5: son texto, no mueven hash.
This commit is contained in:
Sergio
2026-09-09 18:46:41 +00:00
parent d47cafa05d
commit 24bcf1783c
105 changed files with 943 additions and 940 deletions
+52
View File
@@ -0,0 +1,52 @@
[package]
name = "takana-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
description = "El binario `takana`: orquesta build, hydrate, try/commit, apply/export y ctl."
# ADR 0016 — renombre hammer→takana, etapa 2. Los DOS binarios se emiten a
# propósito, en vez de un symlink: la siembra de la granja excluye `/target`, así
# que un symlink hecho en el hub no existiría en el worker, y `cargo clean` lo
# borra. Dos `[[bin]]` sobreviven a las dos cosas. `hammer` se retira en la etapa 6.
[[bin]]
name = "takana"
path = "src/main.rs"
[[bin]]
name = "hammer"
path = "src/main.rs"
[features]
default = []
# Activa el traductor LLM real (Claude API) detrás del flag `hammer ai --llm`.
# Sin esta feature, `--llm` falla con un mensaje claro pidiendo recompilar.
llm-claude = ["takana-agent/llm-claude"]
[dependencies]
takana-core.workspace = true
takana-build.workspace = true
takana-bootstrap.workspace = true
takana-mirror.workspace = true
takana-upgrade.workspace = true
takana-overlay.workspace = true
takana-journal.workspace = true
takana-agent.workspace = true
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
nix.workspace = true
base64.workspace = true
anyhow.workspace = true
clap.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
tempfile.workspace = true
[dev-dependencies]
tempfile.workspace = true
base64.workspace = true
sha2.workspace = true
hex.workspace = true
+391
View File
@@ -0,0 +1,391 @@
//! Importador `Alpine APKBUILD → receta hammer` (Etapa G, fuente #2).
//!
//! **Por qué Alpine y no sólo nix:** Alpine ya porta miles de paquetes a **musl** (y a menudo
//! estático), CON los parches de portabilidad. Eso es justo lo que un import de nixpkgs PIERDE (los
//! parches de nix viven aparte y asumen glibc). Por eso Alpine es la mejor respuesta a "¿qué si el
//! build falla en musl?": su APKBUILD trae el `source=` con los `.patch` que lo hacen compilar.
//!
//! Este importador PARSEA el APKBUILD (no lo ejecuta — es shell; sourcearlo correría código ajeno)
//! y extrae: pkgname/pkgver, la URL de la fuente, **los `.patch` (los carga a `source.patches`)**,
//! makedepends → deps, y `build()/package()` → fases (best-effort: son shell de abuild con CHOST/
//! --shared/etc. ⇒ necesitan adaptación al lab estático de hammer). Es un PUNTO DE PARTIDA, pero
//! uno que YA incluye el trabajo de musl — mucho más cerca de compilar que un import crudo.
use std::collections::BTreeMap;
/// Convierte el texto de un APKBUILD en el TOML de una receta hammer.
pub fn recipe_from_apkbuild(text: &str) -> Result<String, String> {
let vars = parse_vars(text);
let get = |k: &str| vars.get(k).cloned().unwrap_or_default();
let pkgname = get("pkgname");
if pkgname.is_empty() {
return Err("APKBUILD sin pkgname".into());
}
let pkgver = get("pkgver");
let version = if pkgver.is_empty() { "0".into() } else { pkgver };
// source=: tokens separados por espacios/saltos. Cada uno: `[name::]url-o-fichero`.
// El primero con esquema (://) es el tarball; los `.patch` se cargan como parches.
let mut tarball = String::new();
let mut patches: Vec<String> = Vec::new();
for tok in get("source").split_whitespace() {
let (name, url) = match tok.split_once("::") {
Some((n, u)) => (n.to_string(), Some(u.to_string())),
None => {
let base = tok.rsplit('/').next().unwrap_or(tok).to_string();
let url = if tok.contains("://") { Some(tok.to_string()) } else { None };
(base, url)
}
};
if name.ends_with(".patch") || name.ends_with(".diff") {
patches.push(name);
} else if tarball.is_empty() {
if let Some(u) = url {
tarball = u;
}
}
}
let source_block = if tarball.is_empty() {
// Sin tarball (paquetes git-based usan $_commit + snapshot); dejamos un FIXME claro.
"# FIXME: no detecté tarball; fijá `tarball+sha256` o `repo+commit` a mano\ntarball = \"\"\nsha256 = \"\"\n".to_string()
} else {
let sha512 = first_sha(&get("sha512sums"));
format!(
"tarball = \"{tarball}\"\n\
# FIXME sha256: el wrapper lo calcula (Alpine publica sha512). sha512 de Alpine:\n\
# sha512 = \"{sha512}\"\n\
sha256 = \"FIXME-sha256\"\n"
)
};
// build-deps: SÓLO `makedepends`. El `depends` de abuild es RUNTIME (p.ej. gzip depends="less"
// para `zless`) — no hace falta para compilar y rompería `hammer build` (busca recipes/less.toml).
// Lo emitimos como comentario de provenance, no como `[deps] build`. (checkdepends ni se lee: no
// corremos la suite de tests del paquete.)
let self_prefix = format!("{pkgname}-");
let normalize = |src: &str| -> Vec<String> {
let mut v: Vec<String> = Vec::new();
for d in src.split_whitespace() {
let d = d.trim();
// Saltamos: negados (!x), variables sin expandir ($x), pins versionados a subpaquetes
// (`foo=1.2-r3`), y auto-referencias al propio paquete/subpaquetes (`pkgname-...`).
if d.is_empty()
|| d.starts_with('!')
|| d.starts_with('$')
|| d.contains('=')
|| d.starts_with(&self_prefix)
|| d == pkgname
{
continue;
}
// Alpine usa `foo-dev`, `cmd:make`, `pc:libfoo`, `so:libbar.so` — normalizamos lo común.
let name = d
.strip_prefix("cmd:")
.or_else(|| d.strip_prefix("pc:"))
.unwrap_or(d)
.trim_end_matches("-dev")
.to_string();
// El TOOLCHAIN no es un paquete a materializar: lo provee el lab (cargo/rustc = el flujo
// BuildSys::Cargo; make/cmake/meson = los detectores). Una receta que los pusiera en
// `[deps] build` abortaría buscando `cargo.toml`. El path nix ya los filtra; acá igual.
const TOOLCHAIN: &[&str] = &[
"cargo", "cargo-auditable", "rust", "rustc", "go", "make", "cmake", "meson", "ninja",
];
if !name.is_empty()
&& !name.starts_with("so:")
&& !TOOLCHAIN.contains(&name.as_str())
&& !v.contains(&name)
{
v.push(name);
}
}
v
};
let deps = normalize(&get("makedepends"));
let runtime = normalize(&get("depends"));
let mut deps_block = String::new();
if !runtime.is_empty() {
deps_block.push_str(&format!("\n# depends de runtime de Alpine (NO build-deps): {}\n", runtime.join(", ")));
}
if !deps.is_empty() {
let list = deps.iter().map(|d| format!("\"{d}\"")).collect::<Vec<_>>().join(", ");
deps_block.push_str(&format!("\n[deps]\nbuild = [{list}]\n"));
}
// build()/package() → fases (best-effort, comentadas como abuild-shell a adaptar).
let phases_block = {
let compile = func_body(text, "build");
let install = func_body(text, "package");
if compile.is_none() && install.is_none() {
String::new()
} else {
let mut s = String::from("\n[build.phases]\n");
if let Some(b) = compile {
let t = translate_abuild(&b, &pkgname, &version);
s.push_str(&format!("# de build() de Alpine (traducido; el lab provee $CBUILD/$CHOST — Etapa G Fase 3; revisá --shared para estático):\ncompile = {}\n", toml_multiline(&t)));
}
if let Some(p) = install {
let t = translate_abuild(&p, &pkgname, &version);
s.push_str(&format!("# de package() de Alpine (traducido $pkgdir→/out):\ninstall = {}\n", toml_multiline(&t)));
}
s
}
};
let patches_line = if patches.is_empty() {
String::new()
} else {
let list = patches.iter().map(|p| format!("\"{p}\"")).collect::<Vec<_>>().join(", ");
format!("patches = [{list}]\n")
};
let toml = format!(
"# Importada de Alpine aports por `hammer import-alpine` (Etapa G). PUNTO DE PARTIDA — pero\n\
# YA trae los parches de musl de Alpine (lo que un import de nix pierde). Pendiente: el\n\
# sha256 del tarball (el wrapper lo calcula), y adaptar build/install del shell de abuild.\n\
name = \"{pkgname}\"\n\
version = \"{version}\"\n\
\n\
[source]\n\
{source_block}{patches_line}\
\n\
[build]\n\
compiler = \"zig-cc\"\n\
target = \"x86_64-linux-musl\"\n\
link = \"static\"\n\
flags = []\n\
{phases_block}{deps_block}"
);
Ok(toml)
}
/// Recoge asignaciones `clave=valor` y `clave="...multilínea..."` del APKBUILD (ignora funciones y
/// comentarios). Suficiente para los campos de metadatos; expande `$var`/`${var}` conocidas.
fn parse_vars(text: &str) -> BTreeMap<String, String> {
let mut raw: BTreeMap<String, String> = BTreeMap::new();
let bytes: Vec<&str> = text.lines().collect();
let mut i = 0;
while i < bytes.len() {
let line = bytes[i];
let trimmed = line.trim_start();
// Asignación simple `key=...` al inicio de línea (no dentro de función: heurística por '=').
if let Some(eq) = simple_assign(trimmed) {
let key = trimmed[..eq].to_string();
let mut val = trimmed[eq + 1..].to_string();
// Valor multilínea entre comillas dobles: acumular hasta cerrar.
if let Some(rest) = val.strip_prefix('"') {
if let Some(end) = rest.find('"') {
val = rest[..end].to_string();
} else {
let mut acc = rest.to_string();
i += 1;
while i < bytes.len() {
if let Some(end) = bytes[i].find('"') {
acc.push('\n');
acc.push_str(&bytes[i][..end]);
break;
} else {
acc.push('\n');
acc.push_str(bytes[i]);
}
i += 1;
}
val = acc;
}
} else {
// valor sin comillas: hasta fin de línea, sin comentario.
val = val.split('#').next().unwrap_or("").trim().to_string();
val = val.trim_matches('\'').to_string();
}
raw.insert(key, val);
}
i += 1;
}
// Expansión de variables (varias pasadas para anidamiento simple).
let keys: Vec<String> = raw.keys().cloned().collect();
for _ in 0..3 {
for k in &keys {
let mut v = raw[k].clone();
for k2 in &keys {
let val2 = raw[k2].clone();
v = v.replace(&format!("${{{k2}}}"), &val2).replace(&format!("${k2}"), &val2);
}
raw.insert(k.clone(), v);
}
}
raw
}
/// `Some(pos_del_=)` si la línea es una asignación `nombre=...` válida (nombre shell). Evita
/// confundir `foo() {`, comparaciones, o `export X=Y`.
fn simple_assign(line: &str) -> Option<usize> {
let eq = line.find('=')?;
if eq == 0 {
return None;
}
let name = &line[..eq];
if name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && name.chars().next()?.is_ascii_alphabetic() || name.starts_with('_') {
// siguiente char no debe ser '=' (==) ni la asignación es vacía rara.
if line.as_bytes().get(eq + 1) == Some(&b'=') {
return None;
}
return Some(eq);
}
None
}
/// Extrae el cuerpo de una función shell `name() { ... }`: el texto entre la primera `{` y la `}`
/// que la cierra a nivel 0. Best-effort (no parsea shell de verdad; cuenta llaves).
fn func_body(text: &str, name: &str) -> Option<String> {
let pat_a = format!("{name}()");
let start = text.find(&pat_a)?;
let brace = text[start..].find('{')? + start;
let mut depth = 0i32;
let mut end = None;
for (idx, ch) in text[brace..].char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = Some(brace + idx);
break;
}
}
_ => {}
}
}
let body = text[brace + 1..end?].trim().to_string();
if body.is_empty() {
None
} else {
Some(body)
}
}
/// Traduce las variables de abuild a su equivalente en el lab de hammer. Substituciones SEGURAS
/// (inequívocas): `$pkgdir`→`/out` (el DESTDIR del lab), `$pkgname`→nombre, `$pkgver`→versión.
/// NO toca `$CBUILD/$CHOST/$srcdir/$builddir` ni `--shared`. `$CBUILD/$CHOST` los RESUELVE el lab en
/// runtime (env con el triple nativo saneado, Etapa G Fase 3 — `hammer-build`); `$srcdir/$builddir` y
/// `--shared` quedan para el humano. `make DESTDIR="$pkgdir"` → `make DESTDIR="/out"`.
///
/// Además ENVUELVE el cuerpo en una función shell: abuild ejecuta `build()/package()` COMO funciones,
/// donde `local i; …` es válido; el lab corre la fase bajo `sh -c "<cuerpo>"` plano, donde `local`
/// fuera de función es error (rompía gzip al instalar). Envolver restaura el contexto de abuild sin
/// tocar el sandbox ni las fases planas escritas a mano del corpus (sólo se envuelve lo importado).
fn translate_abuild(body: &str, name: &str, version: &str) -> String {
let subst = body
.replace("${pkgdir}", "/out")
.replace("$pkgdir", "/out")
.replace("${pkgname}", name)
.replace("$pkgname", name)
.replace("${pkgver}", version)
.replace("$pkgver", version);
format!("_abuild_phase() {{\n{subst}\n}}\n_abuild_phase")
}
/// Primer hash de un bloque `sha512sums="<hash> <fichero>\n..."`.
fn first_sha(block: &str) -> String {
block
.split_whitespace()
.next()
.unwrap_or("")
.to_string()
}
/// Serializa un cuerpo multilínea como string TOML (literal multi-línea `'''…'''` si tiene saltos).
fn toml_multiline(s: &str) -> String {
if s.contains('\n') {
format!("'''\n{s}\n'''")
} else {
format!("\"{}\"", s.replace('"', "\\\""))
}
}
#[cfg(test)]
mod tests {
use super::*;
const APKBUILD_PATCHED: &str = r#"
pkgname=foo
pkgver=2.5
pkgrel=1
url="https://example.com/foo"
makedepends="zlib-dev openssl-dev linux-headers"
depends="bar"
source="https://example.com/foo-$pkgver.tar.gz
foo-musl-fix.patch
foo-static.patch
"
sha512sums="abc123 foo-2.5.tar.gz"
build() {
./configure --prefix=/usr
make
}
package() {
make DESTDIR="$pkgdir" install
}
"#;
#[test]
fn extracts_source_patches_deps_phases() {
let toml = recipe_from_apkbuild(APKBUILD_PATCHED).unwrap();
let recipe = takana_core::Recipe::from_toml(&toml).expect("recipe válida");
assert_eq!(recipe.name, "foo");
assert_eq!(recipe.version, "2.5");
// tarball con $pkgver expandido:
assert_eq!(recipe.source.tarball.as_deref(), Some("https://example.com/foo-2.5.tar.gz"));
// LOS PARCHES DE MUSL cargados (el punto central):
assert_eq!(recipe.source.patches, vec!["foo-musl-fix.patch", "foo-static.patch"]);
// build-deps: SÓLO makedepends, sin `-dev`. El runtime `depends="bar"` NO entra como
// build-dep (rompería `hammer build`); queda como comentario de provenance.
assert!(recipe.deps.build.contains(&"zlib".to_string()));
assert!(recipe.deps.build.contains(&"openssl".to_string()));
assert!(recipe.deps.build.contains(&"linux-headers".to_string()));
assert!(!recipe.deps.build.contains(&"bar".to_string()), "depends de runtime no es build-dep");
assert!(toml.contains("# depends de runtime de Alpine (NO build-deps): bar"), "provenance: {toml}");
// fases capturadas de build()/package() y TRADUCIDAS: $pkgdir → /out.
assert!(recipe.build.phases.compile.as_deref().unwrap().contains("./configure"));
let install = recipe.build.phases.install.as_deref().unwrap();
assert!(install.contains("DESTDIR=\"/out\""), "instala a /out: {install}");
assert!(!install.contains("$pkgdir"), "no debe quedar $pkgdir: {install}");
}
#[test]
fn translate_abuild_substitutes_vars() {
let body = "make DESTDIR=\"$pkgdir\" install\ncp x ${pkgdir}/usr/share/$pkgname/\necho $pkgver";
let t = translate_abuild(body, "foo", "2.5");
assert!(t.contains("DESTDIR=\"/out\""));
assert!(t.contains("/out/usr/share/foo/"));
assert!(t.contains("echo 2.5"));
assert!(!t.contains("$pkg"));
}
#[test]
fn translate_wraps_body_in_function_for_local() {
// abuild package() usa `local i;` (válido en función); el lab corre la fase plana ⇒ hay que
// envolverla o `local` rompe (gzip). El cuerpo queda DENTRO de la función, invocada al final.
let t = translate_abuild("local i; for i in a b; do mv $i /out/; done", "p", "1");
assert!(t.starts_with("_abuild_phase() {"), "{t}");
assert!(t.trim_end().ends_with("_abuild_phase"), "se invoca al final: {t}");
assert!(t.contains("local i;"), "el `local` se preserva dentro de la función: {t}");
}
#[test]
fn no_patches_is_empty() {
let apk = "pkgname=zlib\npkgver=1.3\nsource=\"https://zlib.net/zlib-$pkgver.tar.gz\"\nsha512sums=\"x z\"\n";
let recipe = takana_core::Recipe::from_toml(&recipe_from_apkbuild(apk).unwrap()).unwrap();
assert_eq!(recipe.name, "zlib");
assert!(recipe.source.patches.is_empty());
assert_eq!(recipe.source.tarball.as_deref(), Some("https://zlib.net/zlib-1.3.tar.gz"));
}
#[test]
fn rejects_no_pkgname() {
assert!(recipe_from_apkbuild("pkgver=1\n").is_err());
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+514
View File
@@ -0,0 +1,514 @@
//! Importador `nix → receta hammer` (Etapa G: poblar el catálogo desde nixpkgs).
//!
//! El problema: a mano, 34 recetas = userland desierto; un distro usable necesita miles de
//! paquetes. nixpkgs es el mayor set de recetas DESDE FUENTE (sources pineados+hash, deps
//! explícitas), así que es la semilla natural del catálogo. Este importador toma la *receta* de
//! nix (qué bajar, flags, deps) y la reexpresa como una `Recipe` de hammer — **nunca el binario**
//! del cache de nix (eso rompería "verificar, no confiar"; hammer reconstruye desde fuente igual).
//!
//! Consume un JSON NORMALIZADO (lo produce `scripts/nix-import.sh` vía `nix eval --apply`), no el
//! `.drv` crudo: así el importador es puro y testeable sin nix, y la fricción de evaluar nixpkgs
//! queda en el wrapper. Lo que el importador NO resuelve (y se documenta en la receta emitida): el
//! build real en el lab de hammer (zig/musl estático) difiere del stdenv de nix (glibc/gcc) ⇒ cada
//! paquete necesita adaptación de toolchain. El importador da el punto de partida, no el binario.
use serde::Deserialize;
/// Paquete nix normalizado (salida de `nix eval --apply` del wrapper). Campos opcionales con
/// defaults para tolerar paquetes que no los declaran.
#[derive(Debug, Deserialize)]
pub struct NixPkg {
pub pname: String,
#[serde(default)]
pub version: String,
pub source: RawSource,
#[serde(default)]
pub configure_flags: Vec<String>,
#[serde(default)]
pub build_inputs: Vec<String>,
#[serde(default)]
pub native_build_inputs: Vec<String>,
/// `true` si nixpkgs lo construye con `rustPlatform.buildRustPackage` (el wrapper lo detecta
/// por `cargoDeps`). Activa la plantilla Cargo de hammer (`--bin` + cp target/release).
#[serde(default)]
pub is_rust: bool,
/// `true` si nixpkgs lo construye con `buildGoModule` (lo detecta por `vendorHash`/`goModules`).
/// Emite `deps.build = ["go"]` y deja las phases a `BuildSys::Go` (go install del main auto).
#[serde(default)]
pub is_go: bool,
/// El binario principal (`meta.mainProgram` de nix; p.ej. ripgrep→"rg"). Para el `--bin`.
#[serde(default)]
pub main_program: String,
}
/// El origen TAL CUAL lo da `nix eval` del `p.src`: la URL fetcheada + el `outputHash`/mode de la
/// derivación fija. `outputHashMode = "flat"` ⇒ el hash es del FICHERO (sha256 de tarball, usable);
/// `"recursive"` ⇒ hash NAR del árbol (no casa con un sha256 de tarball ⇒ usamos git+commit).
#[derive(Debug, Deserialize)]
pub struct RawSource {
pub url: String,
#[serde(default)]
pub output_hash: String,
#[serde(default)]
pub output_hash_mode: String,
}
/// El origen ya clasificado para hammer. `fetchurl` plano → `tarball+sha256`; GitHub (codeload) →
/// `repo+commit` (hammer pinea por commit y reproduce clonando, sin depender del hash NAR de nix).
#[derive(Debug, PartialEq, Eq)]
pub enum NixSource {
Tarball { url: String, sha256: String },
Github { owner: String, repo: String, rev: String },
}
/// Clasifica el origen RAW de nix. Detecta GitHub por la URL de codeload/archive (de
/// `fetchFromGitHub`), si no usa el modo del hash: `flat` ⇒ tarball con su sha256; `recursive`
/// no-github ⇒ no podemos reusar el hash, error pidiendo pin manual.
pub fn classify_source(raw: &RawSource) -> Result<NixSource, String> {
if let Some((owner, repo, rev)) = parse_github_url(&raw.url) {
return Ok(NixSource::Github { owner, repo, rev });
}
match raw.output_hash_mode.as_str() {
"flat" | "" => {
if raw.output_hash.is_empty() {
return Err(format!("source flat sin hash: {}", raw.url));
}
let hex = nix_hash_to_hex(&raw.output_hash)?;
Ok(NixSource::Tarball { url: expand_nix_mirror(&raw.url), sha256: hex })
}
"recursive" => Err(format!(
"source recursivo no-github (hash NAR no reusable): {} — fijá repo+commit a mano",
raw.url
)),
other => Err(format!("outputHashMode desconocido '{other}': {}", raw.url)),
}
}
/// Traduce el esquema `mirror://<sitio>/...` de nix a una URL concreta. nix resuelve estos
/// mirrors en tiempo de eval; un import los deja literales y el fetch de hammer (curl) no los
/// entiende. Mapeamos los más comunes a un mirror real; lo no mapeado se deja igual + el usuario
/// elige espejo. (nixpkgs tiene la lista completa en `pkgs/build-support/fetchurl/mirrors.nix`.)
fn expand_nix_mirror(url: &str) -> String {
let Some(rest) = url.strip_prefix("mirror://") else {
return url.to_string();
};
let Some((site, path)) = rest.split_once('/') else {
return url.to_string();
};
let base = match site {
"gnu" => "https://ftp.gnu.org/gnu/",
"savannah" => "https://download.savannah.gnu.org/releases/",
"kernel" => "https://www.kernel.org/pub/",
"sourceforge" => "https://downloads.sourceforge.net/",
"gnome" => "https://download.gnome.org/",
"apache" => "https://dlcdn.apache.org/",
"xorg" => "https://www.x.org/releases/",
"pypi" => "https://files.pythonhosted.org/packages/",
"cpan" => "https://cpan.metacpan.org/",
"debian" => "https://deb.debian.org/debian/",
_ => return url.to_string(), // desconocido: dejamos el mirror:// (el humano resuelve)
};
format!("{base}{path}")
}
/// Extrae `(owner, repo, rev)` de una URL de descarga de GitHub (las que arma `fetchFromGitHub`):
/// `https://github.com/<owner>/<repo>/archive/<rev>.tar.gz` o
/// `https://codeload.github.com/<owner>/<repo>/tar.gz/<rev>`.
fn parse_github_url(url: &str) -> Option<(String, String, String)> {
if let Some(rest) = url.strip_prefix("https://github.com/") {
// <owner>/<repo>/archive/<rev>.tar.gz (rev puede traer 'refs/tags/..' → tomamos el final)
let parts: Vec<&str> = rest.splitn(4, '/').collect();
if parts.len() == 4 && parts[2] == "archive" {
let rev = parts[3]
.trim_end_matches(".tar.gz")
.trim_end_matches(".zip")
.rsplit('/')
.next()?
.to_string();
return Some((parts[0].to_string(), parts[1].to_string(), rev));
}
}
if let Some(rest) = url.strip_prefix("https://codeload.github.com/") {
// <owner>/<repo>/tar.gz/<rev>
let parts: Vec<&str> = rest.splitn(4, '/').collect();
if parts.len() == 4 {
return Some((parts[0].to_string(), parts[1].to_string(), parts[3].to_string()));
}
}
None
}
/// Convierte una receta nix normalizada en el TOML de una `Recipe` de hammer. El resultado es un
/// PUNTO DE PARTIDA: lleva comentarios marcando lo que falta adaptar (toolchain, nombres de deps).
pub fn to_recipe_toml(pkg: &NixPkg) -> Result<String, String> {
let name = sanitize_name(&pkg.pname);
let version = if pkg.version.is_empty() { "0".to_string() } else { pkg.version.clone() };
let source = classify_source(&pkg.source)
.map_err(|e| format!("source de '{}': {e}", pkg.pname))?;
let source_block = match &source {
NixSource::Tarball { url, sha256 } => {
format!("tarball = \"{url}\"\nsha256 = \"{sha256}\"\n")
}
NixSource::Github { owner, repo, rev } => {
format!("repo = \"https://github.com/{owner}/{repo}\"\ncommit = \"{rev}\"\n")
}
};
// deps: build = nativeBuildInputs ++ buildInputs (por nombre nix; el humano remapea al corpus
// de hammer si difieren — p.ej. nix `pkg-config` ≈ hammer `pkgconf`).
let mut deps: Vec<String> = Vec::new();
for d in pkg.native_build_inputs.iter().chain(pkg.build_inputs.iter()) {
let n = sanitize_name(d);
if !n.is_empty() && !is_nix_noise(&n) && !deps.contains(&n) {
deps.push(n);
}
}
// Rust (buildRustPackage): NO emitir los buildInputs como `[deps]` activas. nix lista el closure
// MAXIMAL (todos los backends C opcionales: zlib, pcre2, openssl, jemalloc…), pero el build Rust
// del lab usa las features DEFAULT de cargo, que suelen traer backend Rust puro (miniz_oxide vs
// zlib, rustls vs openssl) o lo dejan opt-in (pcre2). Cargo resuelve el grafo Rust por vendoring;
// un sys-lib C que SÍ haga falta es adaptación per-paquete (patch/feature, p.ej.
// ripgrep-no-jemalloc), no una dep de corpus. Las recetas Rust validadas (ripgrep/uutils) NO
// declaran `[deps]`; emitirlas rompe `hammer build` (busca recipes/<dep>.toml). Quedan como
// COMENTARIO de provenance (no se pierden: señalan qué C podría necesitarse).
let deps_block = if pkg.is_go {
// Go: la única build-dep es el toolchain `go` (recipes/go.toml). Los buildInputs C de nix
// NO aplican (binario Go estático puro, CGO_ENABLED=0). Quedan como comentario de provenance.
let prov = if deps.is_empty() {
String::new()
} else {
format!("# buildInputs de nix (no usados con CGO off): {}\n", deps.join(", "))
};
format!("\n{prov}[deps]\nbuild = [\"go\"]\n")
} else if deps.is_empty() {
String::new()
} else if pkg.is_rust {
let list = deps.join(", ");
format!("\n# buildInputs de nix (NO deps de hammer — cargo vendorea; backend Rust por\n# defecto). Si algún sys-crate C falla, adaptá per-paquete (patch/feature): {list}\n")
} else {
let list = deps.iter().map(|d| format!("\"{d}\"")).collect::<Vec<_>>().join(", ");
format!("\n[deps]\nbuild = [{list}]\n")
};
// Rust (buildRustPackage): emite la plantilla Cargo de hammer — `--bin <bin>` (cargo exige UN
// target) + install copiando `target/release/<bin>` a /out (mismo patrón que la receta ripgrep).
// El bin = meta.mainProgram de nix (ripgrep→rg), o el pname. Esto deja la receta build-ready.
let (flags_line, rust_install) = if pkg.is_rust {
let bin = if pkg.main_program.is_empty() { name.clone() } else { sanitize_name(&pkg.main_program) };
// `--bin <bin>`: default para el caso común (crate suelto, pname=paquete cargo). Los repos
// con WORKSPACE VIRTUAL en el root (p.ej. sd: el bin vive en el paquete `sd-cli`≠pname)
// necesitan que el humano agregue `-p <paquete-real>` — el import no puede adivinar el nombre.
(
format!("flags = [\"--bin\", \"{bin}\"]\n"),
format!(
"\n[build.phases]\ninstall = \"mkdir -p /out/usr/bin && cp target/release/{bin} /out/usr/bin/{bin}\"\n"
),
)
} else {
("flags = []\n".to_string(), String::new())
};
// configureFlags → una fase configure explícita (si las hay). Si no, se deja a la heurística
// del lab. NOTA: muchos paquetes nix no son autotools (cmake/meson); esto es best-effort.
let phases_block = if pkg.is_go {
// Go: sin phases. `BuildSys::Go` deriva el compile (go install del main auto-detectado).
String::new()
} else if !rust_install.is_empty() {
rust_install
} else if pkg.configure_flags.is_empty() {
String::new()
} else {
let flags = pkg
.configure_flags
.iter()
.map(|f| f.replace('"', "\\\""))
.collect::<Vec<_>>()
.join(" ");
format!("\n[build.phases]\nconfigure = \"./configure --prefix=/usr {flags}\"\n")
};
let toml = format!(
"# Importada de nixpkgs por `hammer import-nix` (Etapa G). PUNTO DE PARTIDA, no final:\n\
# - el build usa el lab de hammer (zig-cc / musl estático), NO el stdenv de nix ⇒ revisá\n\
# compiler/link/phases y adaptá hasta que compile.\n\
# - las deps van con su nombre NIX; remapealas a las recetas del corpus si difieren.\n\
name = \"{name}\"\n\
version = \"{version}\"\n\
\n\
[source]\n\
{source_block}\
\n\
[build]\n\
compiler = \"zig-cc\"\n\
target = \"x86_64-linux-musl\"\n\
link = \"static\"\n\
{flags_line}\
{phases_block}{deps_block}"
);
Ok(toml)
}
/// Filtra el RUIDO de stdenv de nix que no son paquetes reales: setup-hooks, wrappers de
/// compilador, e infraestructura de lenguaje (cargo/rustc-hooks). El humano añade lo que falte;
/// preferimos un punto de partida limpio a arrastrar 10 hooks por receta. Conservador: sólo
/// patrones inequívocamente internos de nix.
fn is_nix_noise(name: &str) -> bool {
name.contains("hook")
|| name.ends_with("-wrapper")
|| name == "install-shell-files"
|| name == "auditable-cargo"
|| name.starts_with("auditable-cargo-")
|| name == "version-check"
// El toolchain Rust ES el lab de hammer (cargo vendorea las crates), NO un paquete dep.
|| name == "rustc"
|| name == "cargo"
|| name == "rust"
}
/// Nombre de receta válido: minúsculas, sólo `[a-z0-9._-]`, sin espacios. nix usa nombres limpios
/// pero por las dudas saneamos (también recorta sufijos de output tipo `-dev`).
fn sanitize_name(s: &str) -> String {
let s = s.trim();
let s = s.strip_suffix("-dev").unwrap_or(s);
s.chars()
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '+') { c.to_ascii_lowercase() } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string()
}
/// Convierte un hash de nix a sha256 HEX (lo que pide `Recipe.source.sha256`). Acepta:
/// - SRI `sha256-<base64>` (formato moderno de nixpkgs),
/// - hex de 64 chars (ya en el formato deseado),
/// - nix-base32 de 52 chars (formato clásico de `outputHash`).
pub fn nix_hash_to_hex(h: &str) -> Result<String, String> {
let h = h.trim();
// SRI: sha256-<base64>
if let Some(b64) = h.strip_prefix("sha256-") {
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| format!("SRI base64 inválido: {e}"))?;
if bytes.len() != 32 {
return Err(format!("SRI sha256: esperaba 32 bytes, hay {}", bytes.len()));
}
return Ok(hex_encode(&bytes));
}
// hex puro (64 chars, todos hex)
if h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit()) {
return Ok(h.to_ascii_lowercase());
}
// nix-base32 (52 chars para sha256)
if h.len() == 52 {
let bytes = nix_base32_decode(h)?;
return Ok(hex_encode(&bytes));
}
Err(format!("formato de hash no reconocido ({} chars): {h}", h.len()))
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
/// Decodifica el base32 PROPIO de nix (alfabeto `0123456789abcdfghijklmnpqrsvwxyz`, sin e/o/u/t;
/// orden LSB-first). Devuelve los 32 bytes del sha256. Port directo de `nixbase32::decode`.
fn nix_base32_decode(s: &str) -> Result<Vec<u8>, String> {
const ALPHABET: &[u8; 32] = b"0123456789abcdfghijklmnpqrsvwxyz";
let chars = s.as_bytes();
let out_len = 32usize; // sha256
let mut out = vec![0u8; out_len];
for (n, &c) in chars.iter().rev().enumerate() {
let digit = ALPHABET
.iter()
.position(|&a| a == c)
.ok_or_else(|| format!("carácter no base32-nix: '{}'", c as char))?
as u32;
let b = n * 5;
let i = b / 8;
let j = (b % 8) as u32;
out[i] |= ((digit << j) & 0xff) as u8;
let carry = digit >> (8 - j);
if carry != 0 {
if i + 1 >= out_len {
return Err("base32-nix: desbordamiento (hash mal formado)".into());
}
out[i + 1] |= carry as u8;
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sri_hash_to_hex() {
// sha256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
// SRI base64 de esos 32 bytes:
let sri = "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=";
let hex = nix_hash_to_hex(sri).unwrap();
assert_eq!(hex, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
}
#[test]
fn hex_passthrough() {
let h = "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855";
assert_eq!(nix_hash_to_hex(h).unwrap(), h.to_ascii_lowercase());
}
#[test]
fn nixbase32_zeros_decode() {
// 52 dígitos '0' (el primer símbolo del alfabeto) ⇒ 32 bytes cero. Caso correcto por
// construcción que ejercita el lookup del alfabeto y el sin-desbordamiento. (El formato
// moderno de nixpkgs es SRI, cubierto arriba; base32 es fallback para drvs clásicos.)
let b32 = "0".repeat(52);
let hex = nix_hash_to_hex(&b32).unwrap();
assert_eq!(hex, "0".repeat(64));
}
#[test]
fn nixbase32_rejects_bad_char() {
// 'e' no está en el alfabeto de nix (omite e/o/u/t).
let b32 = format!("e{}", "0".repeat(51));
assert!(nix_hash_to_hex(&b32).is_err());
}
#[test]
fn classify_github_archive_url() {
let raw = RawSource {
url: "https://github.com/BurntSushi/ripgrep/archive/14.1.1.tar.gz".into(),
output_hash: "sha256-xxx".into(),
output_hash_mode: "recursive".into(),
};
assert_eq!(
classify_source(&raw).unwrap(),
NixSource::Github { owner: "BurntSushi".into(), repo: "ripgrep".into(), rev: "14.1.1".into() }
);
}
#[test]
fn classify_recursive_non_github_errors() {
let raw = RawSource {
url: "https://example.com/foo.tar.gz".into(),
output_hash: "sha256-xxx".into(),
output_hash_mode: "recursive".into(),
};
assert!(classify_source(&raw).is_err());
}
#[test]
fn import_tarball_autotools() {
let json = r#"{
"pname": "hello",
"version": "2.12.1",
"source": { "url": "mirror://gnu/hello/hello-2.12.1.tar.gz",
"output_hash": "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=",
"output_hash_mode": "flat" },
"configure_flags": ["--disable-nls"],
"build_inputs": ["zlib"],
"native_build_inputs": ["pkg-config"]
}"#;
let pkg: NixPkg = serde_json::from_str(json).unwrap();
let toml = to_recipe_toml(&pkg).unwrap();
// Parsea como Recipe válida de hammer.
let recipe = takana_core::Recipe::from_toml(&toml).expect("recipe válida");
assert_eq!(recipe.name, "hello");
assert_eq!(recipe.version, "2.12.1");
// mirror://gnu/ se traduce a una URL concreta que el fetch de hammer entiende.
assert_eq!(recipe.source.tarball.as_deref(), Some("https://ftp.gnu.org/gnu/hello/hello-2.12.1.tar.gz"));
assert_eq!(
recipe.source.sha256.as_deref(),
Some("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
);
assert_eq!(recipe.deps.build, vec!["pkg-config", "zlib"]);
assert!(recipe.build.phases.configure.as_deref().unwrap().contains("--disable-nls"));
}
#[test]
fn rust_package_gets_bin_and_install_template() {
let json = r#"{
"pname": "ripgrep", "version": "14.1.1",
"source": { "url": "https://github.com/BurntSushi/ripgrep/archive/14.1.1.tar.gz", "output_hash_mode": "recursive" },
"is_rust": true, "main_program": "rg"
}"#;
let pkg: NixPkg = serde_json::from_str(json).unwrap();
let recipe = takana_core::Recipe::from_toml(&to_recipe_toml(&pkg).unwrap()).unwrap();
assert_eq!(recipe.build.flags, vec!["--bin", "rg"]);
let inst = recipe.build.phases.install.as_deref().unwrap();
assert!(inst.contains("cp target/release/rg /out/usr/bin/rg"), "{inst}");
}
#[test]
fn rust_buildinputs_are_not_active_deps() {
// nix lista el closure maximal (pcre2/openssl…); para Rust NO deben volverse `[deps]` (cargo
// vendorea, backend Rust por defecto) — romperían `hammer build`. Quedan como comentario.
let json = r#"{
"pname": "rg", "version": "14",
"source": { "url": "https://github.com/o/r/archive/abc.tar.gz", "output_hash_mode": "recursive" },
"is_rust": true, "main_program": "rg",
"build_inputs": ["pcre2", "openssl"]
}"#;
let pkg: NixPkg = serde_json::from_str(json).unwrap();
let toml = to_recipe_toml(&pkg).unwrap();
let recipe = takana_core::Recipe::from_toml(&toml).unwrap();
assert!(recipe.deps.build.is_empty(), "Rust no emite deps activas: {:?}", recipe.deps.build);
assert!(toml.contains("# buildInputs de nix"), "deja provenance comentada: {toml}");
assert!(toml.contains("pcre2"), "lista los nombres en el comentario: {toml}");
}
#[test]
fn filters_nix_stdenv_noise() {
let json = r#"{
"pname": "x", "version": "1",
"source": { "url": "https://github.com/o/r/archive/abc.tar.gz", "output_hash_mode": "recursive" },
"build_inputs": ["pcre2", "cargo-build-hook.sh", "rustc", "pkg-config-wrapper"],
"native_build_inputs": ["install-shell-files", "version-check-hook"]
}"#;
let pkg: NixPkg = serde_json::from_str(json).unwrap();
let recipe = takana_core::Recipe::from_toml(&to_recipe_toml(&pkg).unwrap()).unwrap();
// pcre2 sobrevive (paquete real); hooks/wrappers/shell-files y el toolchain rustc/cargo
// se filtran (rustc/cargo son el lab, no deps).
assert_eq!(recipe.deps.build, vec!["pcre2"]);
}
#[test]
fn expands_common_nix_mirrors() {
assert_eq!(
expand_nix_mirror("mirror://gnu/hello/hello-2.12.1.tar.gz"),
"https://ftp.gnu.org/gnu/hello/hello-2.12.1.tar.gz"
);
assert_eq!(
expand_nix_mirror("mirror://savannah/foo/foo-1.tar.gz"),
"https://download.savannah.gnu.org/releases/foo/foo-1.tar.gz"
);
// desconocido se deja igual (no rompe, el humano resuelve).
assert_eq!(expand_nix_mirror("mirror://raro/x"), "mirror://raro/x");
// url normal pasa sin tocar.
assert_eq!(expand_nix_mirror("https://x/y.tar.gz"), "https://x/y.tar.gz");
}
#[test]
fn import_github_uses_git() {
let json = r#"{
"pname": "ripgrep",
"version": "14.1.1",
"source": { "url": "https://github.com/BurntSushi/ripgrep/archive/4649aa9700619f94cf9c66876e9549d83420e16c.tar.gz",
"output_hash": "sha256-zzz", "output_hash_mode": "recursive" }
}"#;
let pkg: NixPkg = serde_json::from_str(json).unwrap();
let toml = to_recipe_toml(&pkg).unwrap();
let recipe = takana_core::Recipe::from_toml(&toml).unwrap();
assert_eq!(recipe.source.repo.as_deref(), Some("https://github.com/BurntSushi/ripgrep"));
assert_eq!(
recipe.source.commit.as_deref(),
Some("4649aa9700619f94cf9c66876e9549d83420e16c")
);
assert!(recipe.source.sha256.is_none(), "github ⇒ pin por commit, sin sha256");
}
}
File diff suppressed because it is too large Load Diff
+458
View File
@@ -0,0 +1,458 @@
//! Proxy filtrante de Wayland para qorpa — [ADR 0015](../../../docs/adr/0015-imagenes-ajenas.md)
//! §NO-resuelve 1 y §Orden de trabajo 8.
//!
//! ── EL AGUJERO QUE CIERRA ───────────────────────────────────────────────────────────────────────
//! Pasar el socket de Wayland crudo **no es abrir un caño: es un borde de privilegio**. El
//! compositor anuncia TODOS sus globals, y entre ellos hay protocolos que hacen cosas que nadie
//! concedió: `zwlr_screencopy_manager_v1` captura la pantalla, `zwp_virtual_keyboard_manager_v1`
//! sintetiza teclas, `zwlr_data_control_manager_v1` lee el portapapeles sin que nadie copie,
//! `zwlr_layer_shell_v1` dibuja encima de todo. Una instancia ajena con el socket crudo puede las
//! cuatro. Flatpak no pasa el socket: pasa un proxy que filtra. Esto es ese proxy.
//!
//! ── POR QUÉ LISTA BLANCA Y NO NEGRA ─────────────────────────────────────────────────────────────
//! Al revés que la denylist de syscalls de harkaq, acá manda la lista blanca, y la asimetría tiene
//! razón: el conjunto PELIGROSO de Wayland **crece** —cada compositor inventa protocolos
//! privilegiados y aparecen versiones nuevas—, mientras que el conjunto NECESARIO para dibujar una
//! ventana es corto y estable. Una denylist estaría desactualizada el día que alguien actualice el
//! compositor, y sin un solo error visible.
//!
//! ── CÓMO FILTRA, Y POR QUÉ ALCANZA CON MIRAR TAN POCO ───────────────────────────────────────────
//! El proxy relaya bytes salvo dos mensajes:
//! · `wl_registry.global` (evento del compositor): si el interface no está en la lista, **no se
//! reenvía**. El cliente nunca se entera de que existe.
//! · `wl_registry.bind` (petición del cliente): un cliente malicioso puede intentar bindear un
//! `name` numérico que nunca se le anunció. Se corta con un `wl_display.error` y se cierra.
//! Nada más hace falta inspeccionar, y eso es lo que hace esto viable en un fichero: **los mensajes
//! que se descartan no llevan descriptores**, así que los fds se pueden relayar en orden de llegada
//! sin asociarlos a su mensaje.
use std::collections::HashSet;
use std::io;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use nix::sys::socket::{recvmsg, sendmsg, ControlMessage, ControlMessageOwned, MsgFlags};
/// Lo que una app necesita para dibujar, decorarse, recibir entrada y presentar frames.
///
/// Incluye a propósito lo que los JUEGOS piden y suele olvidarse: `pointer_constraints` y
/// `relative_pointer` (mirar con el mouse sin que el cursor se escape), `tearing_control`,
/// `idle_inhibit` (que no se apague la pantalla a media partida).
pub const GLOBALS_PERMITIDOS: &[&str] = &[
"wl_compositor", "wl_subcompositor", "wl_shm", "wl_seat", "wl_output",
"wl_data_device_manager", "wl_drm",
"xdg_wm_base", "zxdg_decoration_manager_v1", "xdg_activation_v1",
"wp_viewporter", "wp_presentation", "wp_single_pixel_buffer_manager_v1",
"wp_fractional_scale_manager_v1", "wp_cursor_shape_manager_v1",
"wp_tearing_control_manager_v1", "wp_content_type_manager_v1",
"zwp_linux_dmabuf_v1", "zwp_relative_pointer_manager_v1",
"zwp_pointer_constraints_v1", "zwp_pointer_gestures_v1",
"zwp_idle_inhibit_manager_v1", "zwp_text_input_manager_v3",
"zwp_primary_selection_device_manager_v1", "zwp_tablet_manager_v2",
"xdg_toplevel_icon_manager_v1",
];
const OBJ_DISPLAY: u32 = 1;
const OP_DISPLAY_GET_REGISTRY: u16 = 1;
const OP_DISPLAY_ERROR: u16 = 0;
const OP_REGISTRY_GLOBAL: u16 = 0;
const OP_REGISTRY_BIND: u16 = 0;
/// `wl_display.error` con `code = 1` es «objeto/global inválido» — el más cercano a la verdad:
/// para este cliente ese global no existe.
const ERR_INVALID_OBJECT: u32 = 1;
/// Estado compartido por las dos direcciones de una conexión.
#[derive(Default)]
struct Estado {
/// Los `wl_registry` que este cliente creó. Puede haber más de uno.
registries: HashSet<u32>,
/// Los `name` numéricos que le ocultamos. Bindearlos es un intento de saltarse el filtro.
ocultos: HashSet<u32>,
}
pub struct Proxy {
/// El socket que se bindea DENTRO de la instancia, en vez del real.
pub socket: PathBuf,
_dir: PathBuf,
}
/// Arranca el proxy en segundo plano. Devuelve el socket a bindear en la jaula.
///
/// Los hilos son detached a propósito: viven lo que viva el proceso, que es exactamente lo que dura
/// la instancia (`hammer qorpa run` bloquea en bwrap).
pub fn arrancar(dir: &Path, socket_real: &Path, permitidos: HashSet<String>) -> io::Result<Proxy> {
std::fs::create_dir_all(dir)?;
let socket = dir.join("wayland-0");
let _ = std::fs::remove_file(&socket);
let escucha = UnixListener::bind(&socket)?;
let real = socket_real.to_path_buf();
let permitidos = Arc::new(permitidos);
std::thread::spawn(move || {
for c in escucha.incoming() {
let Ok(cliente) = c else { continue };
let real = real.clone();
let permitidos = permitidos.clone();
std::thread::spawn(move || {
if let Err(e) = atender(cliente, &real, permitidos) {
eprintln!("[qorpa-wl] conexión terminada: {e}");
}
});
}
});
Ok(Proxy { socket, _dir: dir.to_path_buf() })
}
fn atender(cliente: UnixStream, real: &Path, permitidos: Arc<HashSet<String>>) -> io::Result<()> {
let servidor = UnixStream::connect(real)?;
let estado = Arc::new(Mutex::new(Estado::default()));
let (c2, s2, e2, p2) = (cliente.try_clone()?, servidor.try_clone()?, estado.clone(), permitidos.clone());
let subida = std::thread::spawn(move || bombear(&c2, &s2, Sentido::HaciaServidor, e2, p2));
let r = bombear(&servidor, &cliente, Sentido::HaciaCliente, estado, permitidos);
let _ = cliente.shutdown(std::net::Shutdown::Both);
let _ = servidor.shutdown(std::net::Shutdown::Both);
let _ = subida.join();
r
}
#[derive(PartialEq, Clone, Copy)]
enum Sentido { HaciaServidor, HaciaCliente }
fn bombear(
de: &UnixStream, a: &UnixStream, sentido: Sentido,
estado: Arc<Mutex<Estado>>, permitidos: Arc<HashSet<String>>,
) -> io::Result<()> {
let mut pendiente: Vec<u8> = Vec::new();
loop {
let (datos, fds) = leer(de)?;
if datos.is_empty() && fds.is_empty() {
return Ok(()); // el otro extremo cerró
}
pendiente.extend_from_slice(&datos);
let mut salida: Vec<u8> = Vec::new();
let mut consumido = 0usize;
while let Some((obj, op, tam)) = cabecera(&pendiente[consumido..]) {
let msg = &pendiente[consumido..consumido + tam];
if filtrar(msg, obj, op, sentido, &estado, &permitidos, a)? {
salida.extend_from_slice(msg);
}
consumido += tam;
}
pendiente.drain(..consumido);
// Los fds van con lo que haya: ningún mensaje que descartamos lleva descriptores, así que
// el orden se conserva sin necesidad de saber a qué mensaje pertenece cada uno.
if !salida.is_empty() || !fds.is_empty() {
escribir(a, &salida, &fds)?;
}
}
}
/// Devuelve `true` si el mensaje se reenvía.
fn filtrar(
msg: &[u8], obj: u32, op: u16, sentido: Sentido,
estado: &Arc<Mutex<Estado>>, permitidos: &HashSet<String>, otro: &UnixStream,
) -> io::Result<bool> {
let cuerpo = &msg[8..];
match sentido {
Sentido::HaciaServidor => {
if obj == OBJ_DISPLAY && op == OP_DISPLAY_GET_REGISTRY {
if let Some(id) = leer_u32(cuerpo, 0) {
estado.lock().unwrap().registries.insert(id);
}
return Ok(true);
}
let es_registry = estado.lock().unwrap().registries.contains(&obj);
if es_registry && op == OP_REGISTRY_BIND {
if let Some(name) = leer_u32(cuerpo, 0) {
if estado.lock().unwrap().ocultos.contains(&name) {
// Bindear un global que nunca se anunció es un intento de saltarse el
// filtro, no un accidente: se corta la conexión diciendo por qué.
let _ = escribir(otro, &error_display(obj, ERR_INVALID_OBJECT,
"qorpa: ese global no existe para esta instancia"), &[]);
return Err(io::Error::other("bind a un global filtrado"));
}
}
}
Ok(true)
}
Sentido::HaciaCliente => {
let es_registry = estado.lock().unwrap().registries.contains(&obj);
if es_registry && op == OP_REGISTRY_GLOBAL {
let name = leer_u32(cuerpo, 0);
let interface = leer_str(cuerpo, 4);
if let (Some(name), Some(iface)) = (name, interface) {
if !permitidos.contains(&iface) {
estado.lock().unwrap().ocultos.insert(name);
return Ok(false); // el cliente nunca se entera
}
}
}
Ok(true)
}
}
}
// ── el cable ────────────────────────────────────────────────────────────────────────────────────
/// Cabecera de un mensaje de Wayland: `object_id:u32`, luego `u32` con el tamaño en los 16 bits
/// altos y el opcode en los bajos. Devuelve `None` si todavía no llegó el mensaje entero.
fn cabecera(b: &[u8]) -> Option<(u32, u16, usize)> {
if b.len() < 8 {
return None;
}
let obj = u32::from_ne_bytes(b[0..4].try_into().ok()?);
let w = u32::from_ne_bytes(b[4..8].try_into().ok()?);
let tam = (w >> 16) as usize;
let op = (w & 0xffff) as u16;
// Un tamaño menor que la cabecera es un stream corrupto; tratarlo como "falta" colgaría el
// proxy en un bucle, así que se corta.
if tam < 8 || b.len() < tam {
return None;
}
Some((obj, op, tam))
}
fn leer_u32(cuerpo: &[u8], off: usize) -> Option<u32> {
cuerpo.get(off..off + 4).map(|s| u32::from_ne_bytes(s.try_into().unwrap()))
}
/// Un string de Wayland: `len:u32` INCLUYENDO el NUL, y luego el texto alineado a 4 bytes.
fn leer_str(cuerpo: &[u8], off: usize) -> Option<String> {
let len = leer_u32(cuerpo, off)? as usize;
if len == 0 {
return Some(String::new());
}
let ini = off + 4;
let txt = cuerpo.get(ini..ini + len - 1)?;
Some(String::from_utf8_lossy(txt).into_owned())
}
fn escribir_str(out: &mut Vec<u8>, s: &str) {
let bytes = s.as_bytes();
let len = bytes.len() + 1;
out.extend_from_slice(&(len as u32).to_ne_bytes());
out.extend_from_slice(bytes);
out.push(0);
while out.len() % 4 != 0 {
out.push(0);
}
}
fn error_display(obj: u32, code: u32, msg: &str) -> Vec<u8> {
let mut cuerpo = Vec::new();
cuerpo.extend_from_slice(&obj.to_ne_bytes());
cuerpo.extend_from_slice(&code.to_ne_bytes());
escribir_str(&mut cuerpo, msg);
let tam = 8 + cuerpo.len();
let mut out = Vec::with_capacity(tam);
out.extend_from_slice(&OBJ_DISPLAY.to_ne_bytes());
out.extend_from_slice(&(((tam as u32) << 16) | OP_DISPLAY_ERROR as u32).to_ne_bytes());
out.extend_from_slice(&cuerpo);
out
}
fn leer(s: &UnixStream) -> io::Result<(Vec<u8>, Vec<OwnedFd>)> {
let mut buf = [0u8; 8192];
let mut espacio = nix::cmsg_space!([RawFd; 32]);
let mut iov = [io::IoSliceMut::new(&mut buf)];
let r = recvmsg::<()>(s.as_raw_fd(), &mut iov, Some(&mut espacio), MsgFlags::empty())
.map_err(io::Error::from)?;
let mut fds = Vec::new();
for c in r.cmsgs().map_err(io::Error::from)? {
if let ControlMessageOwned::ScmRights(rs) = c {
for fd in rs {
fds.push(unsafe { OwnedFd::from_raw_fd(fd) });
}
}
}
let n = r.bytes;
Ok((buf[..n].to_vec(), fds))
}
fn escribir(s: &UnixStream, datos: &[u8], fds: &[OwnedFd]) -> io::Result<()> {
let crudos: Vec<RawFd> = fds.iter().map(|f| f.as_raw_fd()).collect();
let cmsgs: Vec<ControlMessage> = if crudos.is_empty() {
vec![]
} else {
vec![ControlMessage::ScmRights(&crudos)]
};
// Un socket de dominio unix puede aceptar menos bytes de los que se le dan: hay que insistir,
// y los fds viajan sólo con el primer envío para no duplicarlos.
let mut off = 0;
loop {
let resto = &datos[off..];
let iov = [io::IoSlice::new(if resto.is_empty() { b"" } else { resto })];
let usar = if off == 0 { &cmsgs[..] } else { &[][..] };
let n = sendmsg::<()>(s.as_raw_fd(), &iov, usar, MsgFlags::empty(), None)
.map_err(io::Error::from)?;
off += n;
if off >= datos.len() {
return Ok(());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
fn msg(obj: u32, op: u16, cuerpo: &[u8]) -> Vec<u8> {
let tam = 8 + cuerpo.len();
let mut v = Vec::with_capacity(tam);
v.extend_from_slice(&obj.to_ne_bytes());
v.extend_from_slice(&(((tam as u32) << 16) | op as u32).to_ne_bytes());
v.extend_from_slice(cuerpo);
v
}
fn global(registry: u32, name: u32, iface: &str, version: u32) -> Vec<u8> {
let mut c = Vec::new();
c.extend_from_slice(&name.to_ne_bytes());
escribir_str(&mut c, iface);
c.extend_from_slice(&version.to_ne_bytes());
msg(registry, OP_REGISTRY_GLOBAL, &c)
}
/// Un compositor de mentira: espera el `get_registry`, y anuncia lo que se le diga.
fn compositor_falso(sock: &Path, anuncios: Vec<(u32, String)>) -> std::thread::JoinHandle<()> {
let l = UnixListener::bind(sock).unwrap();
std::thread::spawn(move || {
let (mut c, _) = l.accept().unwrap();
let mut buf = [0u8; 4096];
let n = c.read(&mut buf).unwrap();
// Lo único que se parsea: el id de registry que pidió el cliente.
let (obj, op, _) = cabecera(&buf[..n]).unwrap();
assert_eq!((obj, op), (OBJ_DISPLAY, OP_DISPLAY_GET_REGISTRY));
let reg = leer_u32(&buf[8..n], 0).unwrap();
let mut out = Vec::new();
for (name, iface) in &anuncios {
out.extend_from_slice(&global(reg, *name, iface, 1));
}
c.write_all(&out).unwrap();
// Se queda viva para que el proxy no vea EOF antes de tiempo.
let _ = c.read(&mut buf);
})
}
fn interfaces_vistas(datos: &[u8], reg: u32) -> Vec<String> {
let mut v = Vec::new();
let mut off = 0;
while let Some((obj, op, tam)) = cabecera(&datos[off..]) {
if obj == reg && op == OP_REGISTRY_GLOBAL {
if let Some(i) = leer_str(&datos[off + 8..off + tam], 4) {
v.push(i);
}
}
off += tam;
}
v
}
#[test]
fn el_cliente_no_se_entera_de_los_globals_peligrosos() {
let d = tempfile::tempdir().unwrap();
let real = d.path().join("compositor.sock");
let h = compositor_falso(&real, vec![
(1, "wl_compositor".into()),
(2, "zwlr_screencopy_manager_v1".into()), // captura de pantalla
(3, "wl_seat".into()),
(4, "zwp_virtual_keyboard_manager_v1".into()), // sintetiza teclas
(5, "zwlr_data_control_manager_v1".into()), // lee el portapapeles
(6, "xdg_wm_base".into()),
(7, "zwlr_layer_shell_v1".into()), // dibuja encima de todo
]);
let permitidos: HashSet<String> =
GLOBALS_PERMITIDOS.iter().map(|s| s.to_string()).collect();
let p = arrancar(&d.path().join("wl"), &real, permitidos).unwrap();
let mut c = UnixStream::connect(&p.socket).unwrap();
let mut cuerpo = Vec::new();
cuerpo.extend_from_slice(&2u32.to_ne_bytes()); // new_id del registry
c.write_all(&msg(OBJ_DISPLAY, OP_DISPLAY_GET_REGISTRY, &cuerpo)).unwrap();
c.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
let mut buf = [0u8; 8192];
let n = c.read(&mut buf).unwrap();
let vistas = interfaces_vistas(&buf[..n], 2);
assert!(vistas.contains(&"wl_compositor".to_string()), "{vistas:?}");
assert!(vistas.contains(&"wl_seat".to_string()), "{vistas:?}");
assert!(vistas.contains(&"xdg_wm_base".to_string()), "{vistas:?}");
for peligroso in ["zwlr_screencopy_manager_v1", "zwp_virtual_keyboard_manager_v1",
"zwlr_data_control_manager_v1", "zwlr_layer_shell_v1"] {
assert!(!vistas.iter().any(|v| v == peligroso), "se coló {peligroso}: {vistas:?}");
}
assert_eq!(vistas.len(), 3, "ni más ni menos que lo permitido: {vistas:?}");
// NO se hace `join` del compositor falso: sigue bloqueado leyendo, y la otra dirección del
// proxy tampoco se entera hasta que intente escribir. Es un hilo de test, muere con él.
drop(c);
drop(h);
}
#[test]
fn bindear_un_global_oculto_corta_la_conexion() {
let d = tempfile::tempdir().unwrap();
let real = d.path().join("c.sock");
let h = compositor_falso(&real, vec![
(1, "wl_compositor".into()),
(99, "zwlr_screencopy_manager_v1".into()),
]);
let permitidos: HashSet<String> = GLOBALS_PERMITIDOS.iter().map(|s| s.to_string()).collect();
let p = arrancar(&d.path().join("wl"), &real, permitidos).unwrap();
let mut c = UnixStream::connect(&p.socket).unwrap();
let mut cuerpo = Vec::new();
cuerpo.extend_from_slice(&2u32.to_ne_bytes());
c.write_all(&msg(OBJ_DISPLAY, OP_DISPLAY_GET_REGISTRY, &cuerpo)).unwrap();
c.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
let mut buf = [0u8; 8192];
let _ = c.read(&mut buf).unwrap();
// Un cliente malicioso adivina el `name` que nunca se le anunció.
let mut b = Vec::new();
b.extend_from_slice(&99u32.to_ne_bytes());
escribir_str(&mut b, "zwlr_screencopy_manager_v1");
b.extend_from_slice(&1u32.to_ne_bytes());
b.extend_from_slice(&3u32.to_ne_bytes());
c.write_all(&msg(2, OP_REGISTRY_BIND, &b)).unwrap();
// Tiene que llegar un wl_display.error y cerrarse. Ocultar el global sin cortar el bind
// dejaría el filtro en decorativo: el `name` es sólo un número que se puede adivinar.
let n = c.read(&mut buf).unwrap_or(0);
let hubo_error = n > 0 && cabecera(&buf[..n]).map(|(o, op, _)| (o, op))
== Some((OBJ_DISPLAY, OP_DISPLAY_ERROR));
let cerro = { let n2 = c.read(&mut buf).unwrap_or(0); n2 == 0 };
assert!(hubo_error || cerro, "el bind al global oculto no fue cortado");
// NO se hace `join` del compositor falso: sigue bloqueado leyendo, y la otra dirección del
// proxy tampoco se entera hasta que intente escribir. Es un hilo de test, muere con él.
drop(c);
drop(h);
}
#[test]
fn la_lista_es_BLANCA_no_negra() {
// Un protocolo privilegiado que todavía no existe tampoco pasa: es la propiedad que una
// denylist no puede dar, y la razón de elegir lista blanca acá y denylist en las syscalls.
let permitidos: HashSet<String> = GLOBALS_PERMITIDOS.iter().map(|s| s.to_string()).collect();
assert!(!permitidos.contains("zwlr_screencopy_manager_v1"));
assert!(!permitidos.contains("protocolo_privilegiado_del_futuro_v9"));
assert!(permitidos.contains("wl_compositor"));
}
#[test]
fn el_parser_no_se_cuelga_con_un_stream_corrupto() {
// Un `size` menor que la cabecera haría avanzar 0 bytes ⇒ bucle infinito dentro del proxy.
let mut malo = Vec::new();
malo.extend_from_slice(&1u32.to_ne_bytes());
malo.extend_from_slice(&((4u32 << 16) | 1).to_ne_bytes()); // size=4, imposible
assert!(cabecera(&malo).is_none());
assert!(cabecera(&[0u8; 3]).is_none()); // mensaje incompleto
}
}
+139
View File
@@ -0,0 +1,139 @@
//! E2E del orquestador `hammer boot menu` (ADR 0010, glue de arranque): emite el grafo, lanza el
//! compositor (mirada) y activa el nodo elegido. Probamos el GLUE headless con stubs de compositor —
//! el pivot de generaciones ya lo cubren los tests de `boot_graph::activate`. Tres caminos:
//! A) sin compositor instalado (servidor headless) → emite el grafo y sigue el arranque (exit 0).
//! B) el compositor no elige nada → "sin selección", sigue el arranque (exit 0).
//! C) el compositor escribe un id en boot-select → menu lo consume y se lo pasa a `activate`.
use std::path::Path;
use std::process::Command;
const BIN: &str = env!("CARGO_BIN_EXE_hammer");
fn menu(state: &Path, work: &Path, compositor: &str) -> std::process::Output {
Command::new(BIN)
.args(["boot", "--state-root"])
.arg(state)
.arg("--journal")
.arg(work.join("journal"))
.arg("menu")
.arg("--compositor")
.arg(compositor)
.arg("--out")
.arg(work.join("graph.json"))
.arg("--select")
.arg(work.join("boot-select"))
.output()
.expect("ejecutar hammer boot menu")
}
fn write_stub(path: &Path, body: &str) -> String {
std::fs::write(path, body).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
path.display().to_string()
}
#[test]
fn a_sin_compositor_emite_grafo_y_sigue() {
let tmp = tempfile::tempdir().unwrap();
let state = tmp.path().join("state");
std::fs::create_dir_all(&state).unwrap();
let out = menu(&state, tmp.path(), "no-existe-compositor-xyz --arranque");
assert!(out.status.success(), "graceful sin compositor debe exit 0: {out:?}");
// el grafo se publica igual (otros lectores lo usan)
assert!(tmp.path().join("graph.json").exists(), "el grafo debe emitirse aun sin compositor");
let so = String::from_utf8_lossy(&out.stdout);
assert!(so.contains("sin compositor"), "debe reportar la ausencia del compositor: {so}");
}
#[test]
fn b_compositor_sin_seleccion_sigue_arranque() {
let tmp = tempfile::tempdir().unwrap();
let state = tmp.path().join("state");
std::fs::create_dir_all(&state).unwrap();
let stub = write_stub(&tmp.path().join("noop.sh"), "#!/bin/sh\nexit 0\n");
let out = menu(&state, tmp.path(), &stub);
assert!(out.status.success(), "sin selección debe exit 0: {out:?}");
let so = String::from_utf8_lossy(&out.stdout);
assert!(so.contains("sin selección"), "debe reportar que no hubo elección: {so}");
}
#[test]
fn c_seleccion_viaja_de_boot_select_a_activate() {
let tmp = tempfile::tempdir().unwrap();
let state = tmp.path().join("state");
std::fs::create_dir_all(&state).unwrap();
// stub que "elige" un id escribiéndolo en el canal de vuelta (el path que menu le pasa por --select).
let sel = tmp.path().join("boot-select");
let stub = write_stub(
&tmp.path().join("pick.sh"),
&format!("#!/bin/sh\necho cafe1234deadbeef > '{}'\n", sel.display()),
);
let out = menu(&state, tmp.path(), &stub);
// El state_root de prueba está vacío (sin generación viva) ⇒ activate falla; PERO eso prueba que el
// id viajó de boot-select → activate (el glue). El pivot exitoso lo cubren los tests de boot_graph.
let err = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success(), "con un id inválido y grafo vacío, activate debe fallar");
assert!(
err.contains("generación viva") || err.contains("cafe1234") || err.contains("grafo"),
"el fallo debe venir de activate (el id llegó): stderr={err}"
);
}
// --- `hammer boot activate --from-select` idempotente (contrato PLAN-KIKIN §9) ---------------------
// arje-zero lo invoca INCONDICIONAL en todo apagado: sin selección debe ser un no-op limpio (exit 0),
// y el fichero sólo se consume si la activación tuvo éxito (si falla, queda para diagnóstico).
fn activate_from_select(state: &Path, work: &Path, select: &Path) -> std::process::Output {
Command::new(BIN)
.args(["boot", "--state-root"])
.arg(state)
.arg("--journal")
.arg(work.join("journal"))
.args(["activate", "--from-select", "--select"])
.arg(select)
.output()
.expect("ejecutar hammer boot activate")
}
#[test]
fn d_activate_from_select_sin_fichero_es_noop() {
let tmp = tempfile::tempdir().unwrap();
let state = tmp.path().join("state");
std::fs::create_dir_all(&state).unwrap();
let out = activate_from_select(&state, tmp.path(), &tmp.path().join("boot-select"));
assert!(out.status.success(), "sin fichero debe ser no-op exit 0: {out:?}");
let so = String::from_utf8_lossy(&out.stdout);
assert!(so.contains("no-op"), "debe reportar el no-op: {so}");
}
#[test]
fn e_activate_from_select_vacio_es_noop() {
let tmp = tempfile::tempdir().unwrap();
let state = tmp.path().join("state");
std::fs::create_dir_all(&state).unwrap();
let sel = tmp.path().join("boot-select");
std::fs::write(&sel, "\n").unwrap();
let out = activate_from_select(&state, tmp.path(), &sel);
assert!(out.status.success(), "fichero vacío debe ser no-op exit 0: {out:?}");
let so = String::from_utf8_lossy(&out.stdout);
assert!(so.contains("no-op"), "debe reportar el no-op: {so}");
}
#[test]
fn f_activate_fallido_no_consume_el_select() {
let tmp = tempfile::tempdir().unwrap();
let state = tmp.path().join("state");
std::fs::create_dir_all(&state).unwrap();
let sel = tmp.path().join("boot-select");
std::fs::write(&sel, "cafe1234deadbeef\n").unwrap();
// state_root vacío ⇒ no hay generación viva ⇒ activate falla…
let out = activate_from_select(&state, tmp.path(), &sel);
assert!(!out.status.success(), "con grafo vacío activate debe fallar: {out:?}");
// …y el canal queda intacto para diagnóstico (sólo se consume en éxito).
assert!(sel.exists(), "el boot-select NO debe consumirse cuando activate falla");
}
+76
View File
@@ -0,0 +1,76 @@
//! E2E del CLI `hammer bootstrap stage0` (track posterior, SDD 11). Construye un tarball de
//! semilla falso, lo ingiere vía `file://` (sin red) y verifica el camino completo:
//! sella + imprime el hash, es idempotente, y un sha256 erróneo falla sin sellar.
use std::path::Path;
use std::process::Command;
const BIN: &str = env!("CARGO_BIN_EXE_hammer");
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(bytes);
hex::encode(h.finalize())
}
/// Crea un tarball con `bin/zig` dentro y devuelve `(url file://, sha256 hex)`.
fn make_seed_tarball(dir: &Path) -> (String, String) {
let src = dir.join("src");
std::fs::create_dir_all(src.join("bin")).unwrap();
std::fs::write(src.join("bin/zig"), b"#!/bin/sh\necho fake zig\n").unwrap();
let tarball = dir.join("seed.tar.gz");
let st = Command::new("tar")
.arg("-czf")
.arg(&tarball)
.arg("-C")
.arg(&src)
.arg(".")
.status()
.unwrap();
assert!(st.success());
let sha = sha256_hex(&std::fs::read(&tarball).unwrap());
(format!("file://{}", tarball.display()), sha)
}
fn stage0(store: &Path, url: &str, sha256: &str, version: &str) -> std::process::Output {
Command::new(BIN)
.arg("--store")
.arg(store)
.args(["bootstrap", "stage0", "--url"])
.arg(url)
.args(["--sha256", sha256, "--version", version])
.output()
.unwrap()
}
#[test]
fn stage0_seals_idempotent_and_rejects_bad_sha() {
let tmp = tempfile::tempdir().unwrap();
let (url, sha) = make_seed_tarball(tmp.path());
let store = tmp.path().join("store");
// --- primera ingestión: sella e imprime el hash ---
let out = stage0(&store, &url, &sha, "0.13.0");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let hash = String::from_utf8(out.stdout).unwrap().trim().to_string();
assert!(hash.starts_with("b3:"), "hash inesperado: {hash}");
// El artefacto está en el store con el contenido extraído.
let zig = store.join(format!("{}-seed-zig", hash.trim_start_matches("b3:"))).join("bin/zig");
assert!(zig.is_file(), "bin/zig debe existir en el artefacto sellado");
// --- idempotente: mismo hash, sin re-sellar ---
let out2 = stage0(&store, &url, &sha, "0.13.0");
assert!(out2.status.success());
assert_eq!(String::from_utf8(out2.stdout).unwrap().trim(), hash);
// --- sha erróneo: falla y no sella un artefacto nuevo ---
let bad = stage0(&store, &url, &"0".repeat(64), "bad");
assert!(!bad.status.success(), "un sha erróneo debe fallar");
assert!(
String::from_utf8_lossy(&bad.stderr).contains("sha256 mismatch"),
"stderr: {}",
String::from_utf8_lossy(&bad.stderr)
);
}
+165
View File
@@ -0,0 +1,165 @@
//! 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 takana_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}");
}
+163
View File
@@ -0,0 +1,163 @@
//! Roundtrip e2e de Fase 4: construimos un `.swm` a mano (los tipos viven en hammer-core),
//! lo aplicamos a un prefix temporal, leemos el resultado y verificamos que los archivos
//! coinciden con lo esperado.
//!
//! NO ejercitamos `source_patch` porque arrastra todo el lab (red + sandbox); para esos
//! casos existen los tests gated en `HAMMER_NETWORK_TESTS`. Aquí cubrimos los caminos puros
//! de hammer-core: `verify_schema`, `verify_base`, `apply_config_edit`, `apply_file_drop`.
use std::collections::BTreeMap;
use std::path::Path;
use base64::{engine::general_purpose::STANDARD, Engine as _};
use takana_core::apply::{apply_config_edit, apply_file_drop, rebase_path, verify_content_hash};
use takana_core::swm::{Base, BaseCompat, BaseRef, Mutation, Swm};
use takana_core::ArtifactHash;
fn write(p: &Path, content: &str) {
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(p, content).unwrap();
}
fn base(distro: &str, pins: &[(&str, &str)]) -> Base {
Base {
distro_version: distro.into(),
pins: pins.iter().map(|(k, v)| ((*k).into(), (*v).into())).collect(),
}
}
fn baseref(distro: &str, pins: &[(&str, &str)]) -> BaseRef {
BaseRef {
distro_version: distro.into(),
pins: pins.iter().map(|(k, v)| ((*k).into(), (*v).into())).collect(),
}
}
fn mk_swm(mutations: Vec<Mutation>) -> Swm {
Swm {
swm_version: 1,
base: base("2026-06-06", &[("musl", "deadbeef")]),
mutations,
signature: None,
}
}
/// Verifica que un `.swm` pasa schema y base, y luego aplica cada mutación bajo un prefix.
/// Este es el camino que la CLI ejercita; aquí lo reproducimos como librería para que el
/// test se mantenga aunque la CLI cambie superficialmente.
fn apply_swm_under_prefix(swm: &Swm, prefix: &Path) {
swm.verify_schema().expect("schema válido");
// Base compatible (idéntica a la del .swm).
let local = BaseRef::from(&swm.base);
assert_eq!(swm.verify_base(&local), BaseCompat::Ok);
for m in &swm.mutations {
match m {
Mutation::ConfigEdit { file, inline_diff } => {
let target = rebase_path(file, Some(prefix));
apply_config_edit(&target, inline_diff).unwrap();
}
Mutation::FileDrop { path, content_hash, content_b64, .. } => {
let target = rebase_path(path, Some(prefix));
let b64 = content_b64.as_ref().expect("inline para tests");
apply_file_drop(&target, b64, content_hash).unwrap();
}
_ => panic!("este test sólo cubre config_edit/file_drop"),
}
}
}
#[test]
fn roundtrip_yaml_apply_reproduces_files() {
let payload = b"hola hammer\n";
let payload_hash = ArtifactHash::of_inputs(&[payload.as_slice()]).as_str().to_string();
let payload_b64 = STANDARD.encode(payload);
let swm = mk_swm(vec![
Mutation::ConfigEdit {
file: "/etc/network.conf".into(),
inline_diff: "- DHCP=yes\n+ IP=192.168.1.100\n".into(),
},
Mutation::FileDrop {
path: "/var/lib/hammer/saludo.txt".into(),
content_hash: payload_hash.clone(),
content_b64: Some(payload_b64),
content_url: None,
},
]);
// Roundtrip YAML completo: serializa, deserializa y reusa.
let yaml = swm.to_yaml().unwrap();
let swm2 = Swm::from_yaml(&yaml).unwrap();
assert_eq!(swm2.mutations.len(), 2);
let prefix_dir = tempfile::tempdir().unwrap();
let prefix = prefix_dir.path();
// El archivo objetivo del config_edit debe existir antes (config_edit no crea archivos).
write(
&prefix.join("etc/network.conf"),
"USE=eth0\nDHCP=yes\nMTU=1500\n",
);
apply_swm_under_prefix(&swm2, prefix);
let got_net = std::fs::read_to_string(prefix.join("etc/network.conf")).unwrap();
assert_eq!(got_net, "USE=eth0\nIP=192.168.1.100\nMTU=1500\n");
let got_drop = std::fs::read(prefix.join("var/lib/hammer/saludo.txt")).unwrap();
assert_eq!(got_drop, payload);
}
#[test]
fn verify_base_distinguishes_compatible_and_not() {
let swm = mk_swm(vec![Mutation::ConfigEdit {
file: "/etc/x".into(),
inline_diff: "- a\n+ b\n".into(),
}]);
// distro distinta
let other = baseref("2026-09-09", &[("musl", "deadbeef")]);
assert!(matches!(
swm.verify_base(&other),
BaseCompat::DistroMismatch { .. }
));
// pin distinta
let bad_pin = baseref("2026-06-06", &[("musl", "ZZZZ")]);
assert!(matches!(swm.verify_base(&bad_pin), BaseCompat::PinMismatch(_)));
// pins extras locales no son problema
let mut more = BTreeMap::new();
more.insert("musl".into(), "deadbeef".into());
more.insert("kernel".into(), "abcdef".into());
let ok = BaseRef { distro_version: "2026-06-06".into(), pins: more };
assert_eq!(swm.verify_base(&ok), BaseCompat::Ok);
}
#[test]
fn verify_content_hash_catches_payload_tampering() {
let payload = b"datos integros\n";
let b64 = STANDARD.encode(payload);
let h = ArtifactHash::of_inputs(&[payload.as_slice()]).as_str().to_string();
verify_content_hash(&b64, &h).expect("hash ok");
// Mutar un byte del payload sin actualizar el hash ⇒ falla.
let mut bad = payload.to_vec();
bad[0] ^= 0x01;
let bad_b64 = STANDARD.encode(&bad);
let err = verify_content_hash(&bad_b64, &h).unwrap_err().to_string();
assert!(err.contains("no coincide"), "{err}");
}
#[test]
fn yaml_includes_only_inline_field_when_url_is_none() {
// Un .swm autocontenido (lo que produce export) no debe acabar emitiendo `content_url`.
let swm = mk_swm(vec![Mutation::FileDrop {
path: "/var/lib/x".into(),
content_hash: "b3:00".into(),
content_b64: Some("AA==".into()),
content_url: None,
}]);
let y = swm.to_yaml().unwrap();
assert!(y.contains("content_b64"));
assert!(!y.contains("content_url"), "skip_serializing_if=None debe omitir el campo");
}