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…
takana: las variables de entorno leen las dos formas, gana la nueva
1//! End-to-end del primer entregable: receta `grep` real → build → hidratación → ejecución
2//! dentro del rootfs Alpine. Es el test que cierra Fase 0 + Fase 1.
3//!
4//! Doblemente gateado para no penalizar `cargo test` por defecto:
5//! - Requiere `.dev-fs/` bootstrapeado (rootfs Alpine + zig + cache).
6//! - Requiere `TAKANA_NETWORK_TESTS=1` (descarga ~3 MB del tarball de GNU).
7//!
8//! Comparte la dev cache (`.dev-fs/cache`) para que la segunda corrida en una máquina dada
9//! reuse los objetos de musl y termine en segundos.
10
11use std::path::PathBuf;
12use std::process::Command;
13
14use takana_build::{build, run_hydrate, BuildConfig};
15use takana_core::{LinkMode, Recipe, Store};
16
17const RECIPE_TOML: &str = r#"
18name = "grep"
19version = "3.12"
20
21[source]
22tarball = "https://ftp.gnu.org/gnu/grep/grep-3.12.tar.gz"
23sha256 = "badda546dfc4b9d97e992e2c35f3b5c7f20522ffcbe2f01ba1e9cdcbe7644cdc"
24
25[build]
26compiler = "zig-cc"
27target = "x86_64-linux-musl"
28link = "static"
29flags = ["--disable-perl-regexp", "--disable-nls"]
30"#;
31
32fn project_root() -> PathBuf {
33 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
34 .parent()
35 .unwrap()
36 .parent()
37 .unwrap()
38 .to_path_buf()
39}
40
41fn skip_with_reason(reason: &str) -> bool {
42 eprintln!("SKIP end_to_end_grep: {reason}");
43 true
44}
45
46fn should_skip(cfg: &BuildConfig) -> bool {
47 if takana_core::env::var("TAKANA_NETWORK_TESTS").as_deref() != Some("1") {
48 return skip_with_reason(
49 "TAKANA_NETWORK_TESTS != 1 (este test descarga ~3 MB de ftp.gnu.org y compila ~30s).",
50 );
51 }
52 if !cfg.rootfs.join("bin/busybox").is_file() {
53 return skip_with_reason("rootfs Alpine no bootstrapped (corre scripts/bootstrap-devfs.sh).");
54 }
55 if !cfg.rootfs.join("usr/bin/autoreconf").is_file()
56 || !cfg.rootfs.join("usr/bin/ld").is_file()
57 {
58 return skip_with_reason(
59 "rootfs sin build tools (autoreconf/ld). Re-corre bootstrap-devfs.sh.",
60 );
61 }
62 if !cfg.zig_dir.join("zig").is_file() {
63 return skip_with_reason("zig no encontrado en .dev-fs/tools/zig.");
64 }
65 false
66}
67
68#[test]
69fn grep_real_build_hydrate_run() {
70 let _ = tracing_subscriber::fmt().with_test_writer().try_init();
71
72 let project = project_root();
73 // El store y la salida hidratada DEBEN vivir en el mismo filesystem (los hardlinks no
74 // cruzan device boundaries). Mantenemos ambos bajo el repo, en un subdir efímero.
75 let scratch = project.join("work").join("test-e2e-grep");
76 let _ = std::fs::remove_dir_all(&scratch);
77 std::fs::create_dir_all(&scratch).unwrap();
78 let store_root = scratch.join("store");
79 let work_root = scratch.join("work");
80 std::fs::create_dir_all(&store_root).unwrap();
81 std::fs::create_dir_all(&work_root).unwrap();
82
83 let cfg = BuildConfig {
84 rootfs: project.join(".dev-fs/alpine"),
85 zig_dir: project.join(".dev-fs/tools/zig"),
86 work_root,
87 cache_root: Some(project.join(".dev-fs/cache")),
88 };
89 if should_skip(&cfg) {
90 return;
91 }
92
93 // 1) Receta
94 let recipe_dir = scratch.join("recipe");
95 std::fs::create_dir_all(&recipe_dir).unwrap();
96 let recipe_path = recipe_dir.join("grep.toml");
97 std::fs::write(&recipe_path, RECIPE_TOML).unwrap();
98 let recipe = Recipe::load_from_path(&recipe_path).expect("load recipe");
99
100 // 2) Build
101 let store = Store::open(&store_root).unwrap();
102 let h = build(&recipe, &cfg, &store).expect("build grep");
103 let sealed = store.path_of(&h, &recipe.name);
104 let grep = sealed.join("usr/bin/grep");
105 assert!(grep.is_file(), "esperaba {}", grep.display());
106
107 // ELF estático: cabecera ELF + ningún DT_NEEDED (chequeo barato con `file`/`readelf` si
108 // existen, fallback al magic byte).
109 let header = std::fs::read(&grep).unwrap();
110 assert_eq!(&header[..4], b"\x7fELF", "grep debe ser ELF");
111
112 // 3) Hidratación al mismo filesystem
113 let fhs = scratch.join("fhs");
114 let report = run_hydrate(&sealed, &fhs, LinkMode::Static, None).expect("hydrate");
115 assert!(report.files.iter().any(|f| f.dst.ends_with("usr/bin/grep")));
116
117 // Compartir inode con el store: la propiedad central de hidratar por hardlink.
118 use std::os::unix::fs::MetadataExt;
119 let src_ino = std::fs::metadata(&grep).unwrap().ino();
120 let dst_ino = std::fs::metadata(fhs.join("usr/bin/grep")).unwrap().ino();
121 assert_eq!(src_ino, dst_ino, "hardlink: inode debe coincidir");
122
123 // 4) Ejecutar dentro del rootfs Alpine bindeando el binario hidratado como /usr/bin/grep.
124 // Ejercitamos un patrón regex y un literal; v3.12 NO debe dar "memory exhausted".
125 let out = Command::new("bwrap")
126 .args([
127 "--overlay-src",
128 cfg.rootfs.to_str().unwrap(),
129 "--tmp-overlay",
130 "/",
131 "--ro-bind",
132 fhs.join("usr/bin/grep").to_str().unwrap(),
133 "/usr/bin/grep",
134 "--proc",
135 "/proc",
136 "--dev",
137 "/dev",
138 "--unshare-all",
139 "/usr/bin/grep",
140 "-oE",
141 "[0-9]+",
142 ])
143 .stdin(std::process::Stdio::piped())
144 .stdout(std::process::Stdio::piped())
145 .stderr(std::process::Stdio::piped())
146 .spawn()
147 .expect("spawn bwrap");
148
149 use std::io::Write;
150 out.stdin
151 .as_ref()
152 .unwrap()
153 .write_all(b"prefijo 12345 sufijo\n")
154 .unwrap();
155 let out = out.wait_with_output().expect("wait bwrap");
156 assert!(
157 out.status.success(),
158 "grep en bwrap falló: stderr={}",
159 String::from_utf8_lossy(&out.stderr)
160 );
161 assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "12345");
162
163 // 5) Cache hit en segunda build: mismo hash.
164 let h2 = build(&recipe, &cfg, &store).expect("rebuild grep");
165 assert_eq!(h, h2, "segunda build debe ser cache hit");
166}