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…
⎇ 03e908fa20939db7003e6aeecc78d6d930971d21
crates/takana-build/src/harkaq.rs
Historia
Quién tocó qué
crudo
takana etapa 4: los 10 crates de librería y el CLI pasan a takana-*
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}