tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Definitions

⎇ main crates/takana-build/src/harkaq.rs Rust · 304 lines · 12.8 KB History Who touched what raw

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

1//! harkaq — la jaula del build (SDD 16). Integración con el sandbox de bwrap.
2//!
3//! Cablea las tres piezas de `scripts/harkaq/` al `Sandbox` real: deriva la política de la
4//! clausura declarada (D1), lanza el lector de evidencia, mete `harkaq-exec` como último eslabón
5//! antes del `sh -c` del build, y recoge el `Verdict`.
6//!
7//! **Inerte por defecto.** Sin `HARKAQ=1` no se emite un solo argumento distinto de bwrap, no se
8//! toca el entorno y no se lanza ningún proceso: el sandbox es byte-por-byte el de siempre. Es un
9//! requisito duro, no una cortesía — hay 700+ artefactos sellados cuyo hash no puede moverse por
10//! encender un diagnóstico (ver el patrón de `cargo-recipe-ar-wrapper`: nada que re-hashee).
11//!
12//! Variables:
13//!   HARKAQ=1        enciende
14//!   HARKAQ_BIN=DIR  dir con harkaq-exec y harkaq-audit (default ~/.cache/harkaq)
15//!   HARKAQ_BASE=F   fichero con el runtime base, generado por `harkaq-base-closure.py`
16//!                   (§4.3: el runtime base es POR ROOTFS y se deriva; acá se consume ya hecho)
17
18use std::io::Write;
19use std::path::{Path, PathBuf};
20use std::process::{Child, Command, Stdio};
21
22/// Config de harkaq para un build. `from_env` devuelve `None` salvo que `HARKAQ=1`.
23#[derive(Debug, Clone)]
24pub struct Harkaq {
25    pub bin: PathBuf,
26    pub base: Option<PathBuf>,
27}
28
29/// Lo que viaja de vuelta tras una fase enjaulada: el fichero del veredicto crudo y el canario
30/// con el que se lo interpreta.
31#[derive(Debug)]
32pub struct Corrida {
33    pub policy: PathBuf,
34    pub canary: String,
35    pub canary_src: PathBuf,
36    verdict: PathBuf,
37    reader: Child,
38}
39
40impl Harkaq {
41    pub fn from_env() -> Option<Self> {
42        if std::env::var("HARKAQ").ok().as_deref() != Some("1") {
43            return None;
44        }
45        let bin = std::env::var("HARKAQ_BIN")
46            .map(PathBuf::from)
47            .unwrap_or_else(|_| {
48                PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/root".into()))
49                    .join(".cache/harkaq")
50            });
51        Some(Harkaq {
52            bin,
53            base: std::env::var("HARKAQ_BASE").ok().map(PathBuf::from),
54        })
55    }
56
57    pub fn exec_bin(&self) -> PathBuf {
58        self.bin.join("harkaq-exec")
59    }
60
61    /// Deriva la política de la clausura declarada (D1: no se escribe, se deriva).
62    ///
63    /// Grano POR FICHERO, no por directorio: el sandbox funde las deps y el rootfs Alpine en el
64    /// mismo `/usr` (`--overlay-src` × N), así que `/usr/include/zlib.h` (dep declarada) y
65    /// `/usr/include/stdio.h` (Alpine) son el mismo directorio y una regla sobre `/usr` concedería
66    /// las dos — la evidencia dejaría de valer (SDD §4.1). En el host sí sabemos qué aporta cada
67    /// dep, así que la clausura se enumera fichero a fichero.
68    fn derivar_politica(
69        &self,
70        deps: &[PathBuf],
71        contratos: &[String],
72        dst: &Path,
73    ) -> takana_core::Result<()> {
74        let mut ro: Vec<String> = Vec::new();
75        let mut dirs: std::collections::BTreeSet<String> = Default::default();
76
77        for dep in deps {
78            let real = std::fs::canonicalize(dep).map_err(|e| {
79                takana_core::Error::Other(anyhow::anyhow!("canonicalize {}: {e}", dep.display()))
80            })?;
81            recorrer(&real, &real, &mut ro, &mut dirs)?;
82        }
83
84        let mut f = std::fs::File::create(dst).map_err(|e| {
85            takana_core::Error::Other(anyhow::anyhow!("crear política {}: {e}", dst.display()))
86        })?;
87        writeln!(f, "# harkaq: clausura derivada de {} dep(s)", deps.len()).ok();
88
89        // Superficies de CONTRATO del sandbox, no de la receta: las monta bwrap, no salen de
90        // Alpine, así que no son deuda (SDD §4.2). Las decide el `Sandbox` —que es quien sabe
91        // cuáles montó— y no harkaq: `/cache` sólo existe si hay `cache_dir`, y `harkaq-exec`
92        // falla duro ante un path inexistente (a propósito: una entrada que no existe es un bug
93        // de la política, y tragárselo la haría más chica que la clausura ⇒ falsos positivos).
94        writeln!(f, "# superficies de contrato del sandbox").ok();
95        for l in contratos {
96            writeln!(f, "{l}").ok();
97        }
98
99        if let Some(base) = &self.base {
100            let txt = std::fs::read_to_string(base).map_err(|e| {
101                takana_core::Error::Other(anyhow::anyhow!("leer HARKAQ_BASE {}: {e}", base.display()))
102            })?;
103            writeln!(f, "# runtime base ({})", base.display()).ok();
104            f.write_all(txt.as_bytes()).ok();
105        }
106
107        // `list` de los ancestros de cada fichero de la clausura: pkgconf y los tests de
108        // configure NECESITAN escanear (p.ej. /usr/lib/pkgconfig), pero listar no es leer.
109        // Landlock separa READ_DIR de READ_FILE ⇒ se concede el listado y se sigue denegando la
110        // lectura de lo no declarado (§4.1).
111        writeln!(f, "# dirs de la clausura (listar, no leer)").ok();
112        for d in &dirs {
113            writeln!(f, "list {d}").ok();
114        }
115        writeln!(f, "# clausura ({} ficheros)", ro.len()).ok();
116        ro.sort();
117        ro.dedup();
118        for r in &ro {
119            writeln!(f, "ro {r}").ok();
120        }
121        Ok(())
122    }
123
124    /// Arranca el lector y deja todo listo para la fase. El lector se lanza ANTES del build y no
125    /// se le da salida hasta que confirma que escucha: si el build arrancara antes, sus primeras
126    /// denegaciones —el canario entre ellas— se emitirían sin nadie al otro lado y el veredicto
127    /// sería `SinEvidencia` por una carrera, no por un problema real.
128    pub fn preparar(
129        &self,
130        deps: &[PathBuf],
131        contratos: &[String],
132        id: &str,
133    ) -> takana_core::Result<Corrida> {
134        let tmp = std::env::temp_dir().join(format!("harkaq-{id}-{}", std::process::id()));
135        std::fs::create_dir_all(&tmp).ok();
136        let policy = tmp.join("policy");
137        self.derivar_politica(deps, contratos, &policy)?;
138
139        // El canario va en la RAÍZ, no en /tmp: la política concede `rw /tmp` (es el HOME del
140        // build), así que un canario ahí caería DENTRO de la clausura, se leería sin problema y
141        // no denunciaría nada ⇒ SinEvidencia eterno. Tiene que vivir donde la política no
142        // alcanza, por construcción. La raíz del sandbox es escribible (`--tmp-overlay /`).
143        let canary = format!("/harkaq-canary-{id}-{}", std::process::id());
144        let canary_src = tmp.join("canary");
145        std::fs::write(&canary_src, b"harkaq\n").ok();
146
147        let verdict = tmp.join("verdict.json");
148        let vf = std::fs::File::create(&verdict).map_err(|e| {
149            takana_core::Error::Other(anyhow::anyhow!("crear verdict: {e}"))
150        })?;
151        let reader = Command::new(self.bin.join("harkaq-audit"))
152            .arg("--canary")
153            .arg(&canary)
154            .arg("--timeout")
155            .arg(std::env::var("HARKAQ_TIMEOUT").unwrap_or_else(|_| "1800".into()))
156            .stdout(Stdio::from(vf))
157            .stderr(Stdio::inherit())
158            .spawn()
159            .map_err(|e| {
160                takana_core::Error::Other(anyhow::anyhow!(
161                    "spawn harkaq-audit ({}): {e} — ¿compilado y con `setcap cap_audit_read`?",
162                    self.bin.display()
163                ))
164            })?;
165        // El lector necesita estar en el multicast antes de que el build empiece. `--ready-fd` es
166        // la vía limpia; acá basta una pausa corta porque el bwrap que sigue tarda mucho más en
167        // levantar los namespaces que el lector en bindear.
168        std::thread::sleep(std::time::Duration::from_millis(300));
169
170        Ok(Corrida {
171            policy,
172            canary,
173            canary_src,
174            verdict,
175            reader,
176        })
177    }
178
179    /// Cierra la corrida: despierta al lector y devuelve el veredicto crudo (JSON).
180    pub fn recoger(&self, mut c: Corrida) -> String {
181        // Gracia para que llegue el `deallocated` del dominio (el kernel lo emite con retraso,
182        // vía RCU/workqueue) antes de despertar al lector.
183        std::thread::sleep(std::time::Duration::from_millis(1500));
184        unsafe {
185            libc::kill(c.reader.id() as i32, libc::SIGTERM);
186        }
187        let _ = c.reader.wait();
188        std::fs::read_to_string(&c.verdict).unwrap_or_else(|e| {
189            format!("{{\"estado\":\"SinEvidencia\",\"motivo\":\"no pude leer el verdict: {e}\"}}")
190        })
191    }
192}
193
194/// Recorre el árbol de una dep y traduce cada fichero a su ruta DENTRO del sandbox. La
195/// traducción es sólo quitar el prefijo del store: el resto del path ya es el del sandbox,
196/// porque `--overlay-src <dep>` funde `<dep>/usr/...` en `/usr/...`.
197fn recorrer(
198    raiz: &Path,
199    dir: &Path,
200    ro: &mut Vec<String>,
201    dirs: &mut std::collections::BTreeSet<String>,
202) -> takana_core::Result<()> {
203    let rd = match std::fs::read_dir(dir) {
204        Ok(r) => r,
205        Err(_) => return Ok(()),
206    };
207    for e in rd.flatten() {
208        let p = e.path();
209        // `.hammer/` es metadata del artefacto (recipe.toml), no parte de lo que el build
210        // consume: si el build lo leyera, esa denegación es legítima y la queremos ver.
211        if p.file_name().is_some_and(|n| n == ".hammer") {
212            continue;
213        }
214        let ft = match e.file_type() {
215            Ok(t) => t,
216            Err(_) => continue,
217        };
218        if ft.is_dir() {
219            recorrer(raiz, &p, ro, dirs)?;
220        } else if let Ok(rel) = p.strip_prefix(raiz) {
221            let dentro = format!("/{}", rel.display());
222            if let Some(d) = Path::new(&dentro).parent() {
223                let mut d = d.to_path_buf();
224                while d.as_os_str() != "/" && !d.as_os_str().is_empty() {
225                    dirs.insert(d.display().to_string());
226                    d = match d.parent() {
227                        Some(p) => p.to_path_buf(),
228                        None => break,
229                    };
230                }
231            }
232            ro.push(dentro);
233        }
234    }
235    Ok(())
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    /// Arma un artefacto de store de mentira: `<tmp>/<hash>-zlib/usr/include/zlib.h`, etc.
243    fn dep_falsa(nombre: &str) -> PathBuf {
244        let raiz = std::env::temp_dir().join(format!("harkaq-t-{}-{nombre}", std::process::id()));
245        let _ = std::fs::remove_dir_all(&raiz);
246        for d in ["usr/include", "usr/lib/pkgconfig", ".hammer"] {
247            std::fs::create_dir_all(raiz.join(d)).unwrap();
248        }
249        std::fs::write(raiz.join("usr/include/zlib.h"), b"h").unwrap();
250        std::fs::write(raiz.join("usr/lib/libz.a"), b"a").unwrap();
251        std::fs::write(raiz.join("usr/lib/pkgconfig/zlib.pc"), b"p").unwrap();
252        std::fs::write(raiz.join(".hammer/recipe.toml"), b"r").unwrap();
253        raiz
254    }
255
256    #[test]
257    fn la_clausura_traduce_del_store_al_sandbox() {
258        // El corazón de D1. `--overlay-src <dep>` funde `<dep>/usr/...` en `/usr/...`, así que
259        // traducir es quitar el prefijo del store. Puro: se testea sin kernel, en cualquier
260        // máquina, que es justamente lo que el SDD §4 exige de harkaq-policy.
261        let dep = dep_falsa("clausura");
262        let (mut ro, mut dirs) = (Vec::new(), Default::default());
263        recorrer(&dep, &dep, &mut ro, &mut dirs).unwrap();
264        ro.sort();
265        assert_eq!(
266            ro,
267            vec!["/usr/include/zlib.h", "/usr/lib/libz.a", "/usr/lib/pkgconfig/zlib.pc"]
268        );
269        let _ = std::fs::remove_dir_all(&dep);
270    }
271
272    #[test]
273    fn la_metadata_del_artefacto_no_es_clausura() {
274        // `.hammer/recipe.toml` es metadata, no algo que el build consuma. Si el build lo
275        // leyera, esa denegación es legítima y la queremos VER, no conceder.
276        let dep = dep_falsa("metadata");
277        let (mut ro, mut dirs) = (Vec::new(), Default::default());
278        recorrer(&dep, &dep, &mut ro, &mut dirs).unwrap();
279        assert!(!ro.iter().any(|r| r.contains(".hammer")), "ro={ro:?}");
280        let _ = std::fs::remove_dir_all(&dep);
281    }
282
283    #[test]
284    fn cada_fichero_deja_listables_sus_ancestros() {
285        // Sin `list` en los ancestros, un `ls /usr/lib/pkgconfig` de pkgconf sería un falso
286        // positivo. Listar no es leer: Landlock separa READ_DIR de READ_FILE (§4.1).
287        let dep = dep_falsa("dirs");
288        let (mut ro, mut dirs) = (Vec::new(), Default::default());
289        recorrer(&dep, &dep, &mut ro, &mut dirs).unwrap();
290        for d in ["/usr", "/usr/lib", "/usr/lib/pkgconfig", "/usr/include"] {
291            assert!(dirs.contains(d), "falta `list {d}` en {dirs:?}");
292        }
293        let _ = std::fs::remove_dir_all(&dep);
294    }
295
296    #[test]
297    fn apagado_por_defecto() {
298        // Requisito duro: sin HARKAQ=1 no se enciende nada. 700+ artefactos sellados dependen de
299        // que el sandbox no cambie por añadir un diagnóstico.
300        if std::env::var("HARKAQ").is_err() {
301            assert!(Harkaq::from_env().is_none());
302        }
303    }
304}