//! harkaq — la jaula del build (SDD 16). Integración con el sandbox de bwrap. //! //! Cablea las tres piezas de `scripts/harkaq/` al `Sandbox` real: deriva la política de la //! clausura declarada (D1), lanza el lector de evidencia, mete `harkaq-exec` como último eslabón //! antes del `sh -c` del build, y recoge el `Verdict`. //! //! **Inerte por defecto.** Sin `HARKAQ=1` no se emite un solo argumento distinto de bwrap, no se //! toca el entorno y no se lanza ningún proceso: el sandbox es byte-por-byte el de siempre. Es un //! requisito duro, no una cortesía — hay 700+ artefactos sellados cuyo hash no puede moverse por //! encender un diagnóstico (ver el patrón de `cargo-recipe-ar-wrapper`: nada que re-hashee). //! //! Variables: //! HARKAQ=1 enciende //! HARKAQ_BIN=DIR dir con harkaq-exec y harkaq-audit (default ~/.cache/harkaq) //! HARKAQ_BASE=F fichero con el runtime base, generado por `harkaq-base-closure.py` //! (§4.3: el runtime base es POR ROOTFS y se deriva; acá se consume ya hecho) use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; /// Config de harkaq para un build. `from_env` devuelve `None` salvo que `HARKAQ=1`. #[derive(Debug, Clone)] pub struct Harkaq { pub bin: PathBuf, pub base: Option, } /// Lo que viaja de vuelta tras una fase enjaulada: el fichero del veredicto crudo y el canario /// con el que se lo interpreta. #[derive(Debug)] pub struct Corrida { pub policy: PathBuf, pub canary: String, pub canary_src: PathBuf, verdict: PathBuf, reader: Child, } impl Harkaq { pub fn from_env() -> Option { if std::env::var("HARKAQ").ok().as_deref() != Some("1") { return None; } let bin = std::env::var("HARKAQ_BIN") .map(PathBuf::from) .unwrap_or_else(|_| { PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/root".into())) .join(".cache/harkaq") }); Some(Harkaq { bin, base: std::env::var("HARKAQ_BASE").ok().map(PathBuf::from), }) } pub fn exec_bin(&self) -> PathBuf { self.bin.join("harkaq-exec") } /// Deriva la política de la clausura declarada (D1: no se escribe, se deriva). /// /// Grano POR FICHERO, no por directorio: el sandbox funde las deps y el rootfs Alpine en el /// mismo `/usr` (`--overlay-src` × N), así que `/usr/include/zlib.h` (dep declarada) y /// `/usr/include/stdio.h` (Alpine) son el mismo directorio y una regla sobre `/usr` concedería /// las dos — la evidencia dejaría de valer (SDD §4.1). En el host sí sabemos qué aporta cada /// dep, así que la clausura se enumera fichero a fichero. fn derivar_politica( &self, deps: &[PathBuf], contratos: &[String], dst: &Path, ) -> takana_core::Result<()> { let mut ro: Vec = Vec::new(); let mut dirs: std::collections::BTreeSet = Default::default(); for dep in deps { let real = std::fs::canonicalize(dep).map_err(|e| { takana_core::Error::Other(anyhow::anyhow!("canonicalize {}: {e}", dep.display())) })?; recorrer(&real, &real, &mut ro, &mut dirs)?; } let mut f = std::fs::File::create(dst).map_err(|e| { takana_core::Error::Other(anyhow::anyhow!("crear política {}: {e}", dst.display())) })?; writeln!(f, "# harkaq: clausura derivada de {} dep(s)", deps.len()).ok(); // Superficies de CONTRATO del sandbox, no de la receta: las monta bwrap, no salen de // Alpine, así que no son deuda (SDD §4.2). Las decide el `Sandbox` —que es quien sabe // cuáles montó— y no harkaq: `/cache` sólo existe si hay `cache_dir`, y `harkaq-exec` // falla duro ante un path inexistente (a propósito: una entrada que no existe es un bug // de la política, y tragárselo la haría más chica que la clausura ⇒ falsos positivos). writeln!(f, "# superficies de contrato del sandbox").ok(); for l in contratos { writeln!(f, "{l}").ok(); } if let Some(base) = &self.base { let txt = std::fs::read_to_string(base).map_err(|e| { takana_core::Error::Other(anyhow::anyhow!("leer HARKAQ_BASE {}: {e}", base.display())) })?; writeln!(f, "# runtime base ({})", base.display()).ok(); f.write_all(txt.as_bytes()).ok(); } // `list` de los ancestros de cada fichero de la clausura: pkgconf y los tests de // configure NECESITAN escanear (p.ej. /usr/lib/pkgconfig), pero listar no es leer. // Landlock separa READ_DIR de READ_FILE ⇒ se concede el listado y se sigue denegando la // lectura de lo no declarado (§4.1). writeln!(f, "# dirs de la clausura (listar, no leer)").ok(); for d in &dirs { writeln!(f, "list {d}").ok(); } writeln!(f, "# clausura ({} ficheros)", ro.len()).ok(); ro.sort(); ro.dedup(); for r in &ro { writeln!(f, "ro {r}").ok(); } Ok(()) } /// Arranca el lector y deja todo listo para la fase. El lector se lanza ANTES del build y no /// se le da salida hasta que confirma que escucha: si el build arrancara antes, sus primeras /// denegaciones —el canario entre ellas— se emitirían sin nadie al otro lado y el veredicto /// sería `SinEvidencia` por una carrera, no por un problema real. pub fn preparar( &self, deps: &[PathBuf], contratos: &[String], id: &str, ) -> takana_core::Result { let tmp = std::env::temp_dir().join(format!("harkaq-{id}-{}", std::process::id())); std::fs::create_dir_all(&tmp).ok(); let policy = tmp.join("policy"); self.derivar_politica(deps, contratos, &policy)?; // El canario va en la RAÍZ, no en /tmp: la política concede `rw /tmp` (es el HOME del // build), así que un canario ahí caería DENTRO de la clausura, se leería sin problema y // no denunciaría nada ⇒ SinEvidencia eterno. Tiene que vivir donde la política no // alcanza, por construcción. La raíz del sandbox es escribible (`--tmp-overlay /`). let canary = format!("/harkaq-canary-{id}-{}", std::process::id()); let canary_src = tmp.join("canary"); std::fs::write(&canary_src, b"harkaq\n").ok(); let verdict = tmp.join("verdict.json"); let vf = std::fs::File::create(&verdict).map_err(|e| { takana_core::Error::Other(anyhow::anyhow!("crear verdict: {e}")) })?; let reader = Command::new(self.bin.join("harkaq-audit")) .arg("--canary") .arg(&canary) .arg("--timeout") .arg(std::env::var("HARKAQ_TIMEOUT").unwrap_or_else(|_| "1800".into())) .stdout(Stdio::from(vf)) .stderr(Stdio::inherit()) .spawn() .map_err(|e| { takana_core::Error::Other(anyhow::anyhow!( "spawn harkaq-audit ({}): {e} — ¿compilado y con `setcap cap_audit_read`?", self.bin.display() )) })?; // El lector necesita estar en el multicast antes de que el build empiece. `--ready-fd` es // la vía limpia; acá basta una pausa corta porque el bwrap que sigue tarda mucho más en // levantar los namespaces que el lector en bindear. std::thread::sleep(std::time::Duration::from_millis(300)); Ok(Corrida { policy, canary, canary_src, verdict, reader, }) } /// Cierra la corrida: despierta al lector y devuelve el veredicto crudo (JSON). pub fn recoger(&self, mut c: Corrida) -> String { // Gracia para que llegue el `deallocated` del dominio (el kernel lo emite con retraso, // vía RCU/workqueue) antes de despertar al lector. std::thread::sleep(std::time::Duration::from_millis(1500)); unsafe { libc::kill(c.reader.id() as i32, libc::SIGTERM); } let _ = c.reader.wait(); std::fs::read_to_string(&c.verdict).unwrap_or_else(|e| { format!("{{\"estado\":\"SinEvidencia\",\"motivo\":\"no pude leer el verdict: {e}\"}}") }) } } /// Recorre el árbol de una dep y traduce cada fichero a su ruta DENTRO del sandbox. La /// traducción es sólo quitar el prefijo del store: el resto del path ya es el del sandbox, /// porque `--overlay-src ` funde `/usr/...` en `/usr/...`. fn recorrer( raiz: &Path, dir: &Path, ro: &mut Vec, dirs: &mut std::collections::BTreeSet, ) -> takana_core::Result<()> { let rd = match std::fs::read_dir(dir) { Ok(r) => r, Err(_) => return Ok(()), }; for e in rd.flatten() { let p = e.path(); // `.hammer/` es metadata del artefacto (recipe.toml), no parte de lo que el build // consume: si el build lo leyera, esa denegación es legítima y la queremos ver. if p.file_name().is_some_and(|n| n == ".hammer") { continue; } let ft = match e.file_type() { Ok(t) => t, Err(_) => continue, }; if ft.is_dir() { recorrer(raiz, &p, ro, dirs)?; } else if let Ok(rel) = p.strip_prefix(raiz) { let dentro = format!("/{}", rel.display()); if let Some(d) = Path::new(&dentro).parent() { let mut d = d.to_path_buf(); while d.as_os_str() != "/" && !d.as_os_str().is_empty() { dirs.insert(d.display().to_string()); d = match d.parent() { Some(p) => p.to_path_buf(), None => break, }; } } ro.push(dentro); } } Ok(()) } #[cfg(test)] mod tests { use super::*; /// Arma un artefacto de store de mentira: `/-zlib/usr/include/zlib.h`, etc. fn dep_falsa(nombre: &str) -> PathBuf { let raiz = std::env::temp_dir().join(format!("harkaq-t-{}-{nombre}", std::process::id())); let _ = std::fs::remove_dir_all(&raiz); for d in ["usr/include", "usr/lib/pkgconfig", ".hammer"] { std::fs::create_dir_all(raiz.join(d)).unwrap(); } std::fs::write(raiz.join("usr/include/zlib.h"), b"h").unwrap(); std::fs::write(raiz.join("usr/lib/libz.a"), b"a").unwrap(); std::fs::write(raiz.join("usr/lib/pkgconfig/zlib.pc"), b"p").unwrap(); std::fs::write(raiz.join(".hammer/recipe.toml"), b"r").unwrap(); raiz } #[test] fn la_clausura_traduce_del_store_al_sandbox() { // El corazón de D1. `--overlay-src ` funde `/usr/...` en `/usr/...`, así que // traducir es quitar el prefijo del store. Puro: se testea sin kernel, en cualquier // máquina, que es justamente lo que el SDD §4 exige de harkaq-policy. let dep = dep_falsa("clausura"); let (mut ro, mut dirs) = (Vec::new(), Default::default()); recorrer(&dep, &dep, &mut ro, &mut dirs).unwrap(); ro.sort(); assert_eq!( ro, vec!["/usr/include/zlib.h", "/usr/lib/libz.a", "/usr/lib/pkgconfig/zlib.pc"] ); let _ = std::fs::remove_dir_all(&dep); } #[test] fn la_metadata_del_artefacto_no_es_clausura() { // `.hammer/recipe.toml` es metadata, no algo que el build consuma. Si el build lo // leyera, esa denegación es legítima y la queremos VER, no conceder. let dep = dep_falsa("metadata"); let (mut ro, mut dirs) = (Vec::new(), Default::default()); recorrer(&dep, &dep, &mut ro, &mut dirs).unwrap(); assert!(!ro.iter().any(|r| r.contains(".hammer")), "ro={ro:?}"); let _ = std::fs::remove_dir_all(&dep); } #[test] fn cada_fichero_deja_listables_sus_ancestros() { // Sin `list` en los ancestros, un `ls /usr/lib/pkgconfig` de pkgconf sería un falso // positivo. Listar no es leer: Landlock separa READ_DIR de READ_FILE (§4.1). let dep = dep_falsa("dirs"); let (mut ro, mut dirs) = (Vec::new(), Default::default()); recorrer(&dep, &dep, &mut ro, &mut dirs).unwrap(); for d in ["/usr", "/usr/lib", "/usr/lib/pkgconfig", "/usr/include"] { assert!(dirs.contains(d), "falta `list {d}` en {dirs:?}"); } let _ = std::fs::remove_dir_all(&dep); } #[test] fn apagado_por_defecto() { // Requisito duro: sin HARKAQ=1 no se enciende nada. 700+ artefactos sellados dependen de // que el sandbox no cambie por añadir un diagnóstico. if std::env::var("HARKAQ").is_err() { assert!(Harkaq::from_env().is_none()); } } }