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 de `try`/`commit`/`discard` ejercitado dentro de un user namespace creado por
2//! bwrap. El namespace nos da CAP_SYS_ADMIN local para poder `mount -t overlay` sin sudo.
3//!
4//! Caveat de kernel: por defecto, overlayfs **no se deja montar en un user-ns sin
5//! privilegios reales** salvo que el kernel esté configurado para permitirlo
6//! (`kernel.unprivileged_userns_clone=1` + sysctl `kernel.apparmor_restrict_unprivileged_userns=0`
7//! en distros con AppArmor; algunos kernels también requieren la opción `userxattr` en el
8//! mount). Por eso este test está doblemente gateado:
9//! - Requiere `bwrap` y overlayfs en el kernel.
10//! - Requiere `TAKANA_OVERLAY_TESTS=1` — el dev confirma que su entorno admite el
11//! mount unprivilegiado, o corre el test bajo `sudo`.
12//!
13//! Sin esos requisitos el test salta limpio; los aspectos puros (layout, slug, parsing del
14//! manifiesto, errores de id desconocido) ya quedan cubiertos en los unit tests del crate.
15
16use std::path::{Path, PathBuf};
17use std::process::Command;
18
19use takana_overlay::{commit, discard, status, try_overlay, OverlayId};
20
21fn have_bwrap() -> bool {
22 Command::new("bwrap")
23 .arg("--version")
24 .output()
25 .map(|o| o.status.success())
26 .unwrap_or(false)
27}
28
29fn overlayfs_supported() -> bool {
30 std::fs::read_to_string("/proc/filesystems")
31 .map(|s| s.contains("overlay"))
32 .unwrap_or(false)
33}
34
35/// Reejecuta este binario dentro de bwrap con un namespace de usuario y un FS efímero,
36/// reenviando los args originales. Si ya estamos dentro del sandbox (env marker set),
37/// no re-anidamos.
38fn run_inside_userns_or_skip(phase: &str, scratch: &Path) -> bool {
39 if takana_core::env::var("TAKANA_OVERLAY_IN_SANDBOX").is_some() {
40 // Ya estamos dentro — el caller ejecuta la lógica real.
41 return false;
42 }
43 if takana_core::env::var("TAKANA_OVERLAY_TESTS").as_deref() != Some("1") {
44 eprintln!(
45 "SKIP: TAKANA_OVERLAY_TESTS != 1 (overlayfs en user-ns suele estar denegado por \
46 el kernel; setea la var cuando tu host lo permita o corre con sudo)."
47 );
48 return true;
49 }
50 if !have_bwrap() {
51 eprintln!("SKIP: bwrap no disponible");
52 return true;
53 }
54 if !overlayfs_supported() {
55 eprintln!("SKIP: overlay no listado en /proc/filesystems");
56 return true;
57 }
58
59 let self_exe = std::env::current_exe().expect("current_exe");
60 let st = Command::new("bwrap")
61 .args([
62 "--unshare-user-try",
63 "--unshare-pid",
64 "--unshare-net",
65 "--uid", "0", "--gid", "0",
66 "--die-with-parent",
67 "--bind", "/", "/",
68 "--proc", "/proc",
69 "--dev", "/dev",
70 "--bind",
71 ])
72 .arg(scratch)
73 .arg(scratch)
74 .arg("--setenv")
75 .arg("TAKANA_OVERLAY_IN_SANDBOX")
76 .arg("1")
77 .arg("--setenv")
78 .arg("OVERLAY_TEST_SCRATCH")
79 .arg(scratch)
80 .arg(&self_exe)
81 .arg("--exact")
82 .arg(phase)
83 .arg("--nocapture")
84 .status()
85 .expect("spawn bwrap");
86 if !st.success() {
87 panic!("test interior falló dentro de bwrap (exit {:?})", st.code());
88 }
89 true // ya corrimos la versión sandboxed, el host-side return.
90}
91
92/// Lógica que corre DENTRO del sandbox (con CAP_SYS_ADMIN en el user-ns).
93fn body_try_commit_discard() {
94 let scratch = PathBuf::from(std::env::var("OVERLAY_TEST_SCRATCH").unwrap());
95 let lower = scratch.join("lower");
96 let state = scratch.join("state");
97 std::fs::create_dir_all(&lower).unwrap();
98 std::fs::create_dir_all(&state).unwrap();
99
100 // Lower con un archivo base.
101 std::fs::write(lower.join("base.txt"), b"contenido base\n").unwrap();
102
103 // --- try ---
104 let id = try_overlay(&[lower.clone()], &state).expect("try_overlay");
105 // El base sigue visible en el merged (que es el propio target).
106 assert_eq!(
107 std::fs::read_to_string(lower.join("base.txt")).unwrap(),
108 "contenido base\n"
109 );
110 // Escritura nueva.
111 std::fs::write(lower.join("nuevo.txt"), b"hola desde upper\n").unwrap();
112
113 // status lista 1.
114 let ss = status(&state).expect("status");
115 assert_eq!(ss.len(), 1);
116 assert_eq!(ss[0].id, id);
117
118 // --- commit: la escritura debe persistir tras desmontar ---
119 let report = commit(&id, &state).expect("commit");
120 assert!(report.copied.iter().any(|p| p.ends_with("nuevo.txt")),
121 "commit debe haber promocionado nuevo.txt; copied={:?}", report.copied);
122
123 assert_eq!(
124 std::fs::read_to_string(lower.join("nuevo.txt")).unwrap(),
125 "hola desde upper\n",
126 "commit debe haber escrito el archivo en el lower real"
127 );
128 // status ya no debe listar nada.
129 assert!(status(&state).unwrap().is_empty(), "tras commit el state debe quedar limpio");
130
131 // --- segundo ciclo: try + discard NO debe persistir cambios ---
132 let id2 = try_overlay(&[lower.clone()], &state).expect("try 2");
133 std::fs::write(lower.join("efimero.txt"), b"transitorio\n").unwrap();
134 assert!(lower.join("efimero.txt").exists(), "merged debe ver el archivo");
135 discard(&id2, &state).expect("discard");
136 assert!(!lower.join("efimero.txt").exists(),
137 "discard debe haber descartado la escritura del upper");
138 assert!(lower.join("base.txt").is_file(), "el base sigue ahí");
139 assert!(lower.join("nuevo.txt").is_file(),
140 "el commit anterior sigue persistido");
141
142 // --- whiteout: borrar un archivo del lower desde el upper debería propagarlo en commit ---
143 let id3 = try_overlay(&[lower.clone()], &state).expect("try 3");
144 std::fs::remove_file(lower.join("base.txt")).expect("remove a través del overlay");
145 assert!(!lower.join("base.txt").exists(), "merged no debe verlo");
146 let rep = commit(&id3, &state).expect("commit del whiteout");
147 assert!(
148 rep.removed.iter().any(|p| p.ends_with("base.txt")),
149 "commit debe haber registrado el remove; removed={:?}", rep.removed
150 );
151 assert!(!lower.join("base.txt").exists(),
152 "tras commit el lower real ya no tiene base.txt");
153}
154
155#[test]
156fn overlay_try_commit_discard_inside_userns() {
157 if takana_core::env::var("TAKANA_OVERLAY_IN_SANDBOX").is_some() {
158 body_try_commit_discard();
159 return;
160 }
161 let tmp = tempfile::tempdir().unwrap();
162 if run_inside_userns_or_skip("overlay_try_commit_discard_inside_userns", tmp.path()) {
163 return;
164 }
165 // unreachable salvo cuando run_inside_userns_or_skip devuelve false (sandbox).
166 body_try_commit_discard();
167}
168
169/// Anidación real: dos overlays apilados sobre el mismo target. Prueba que (1) el segundo
170/// `try` ve el upper del primero como su lower (stack por mountpoint), (2) el guard LIFO
171/// rechaza commitear el de abajo mientras el de arriba sigue montado, y (3) commitear de
172/// arriba hacia abajo deja en el lower real la fusión de ambas capas.
173fn body_nested_stack() {
174 let scratch = PathBuf::from(std::env::var("OVERLAY_TEST_SCRATCH").unwrap());
175 let lower = scratch.join("lower");
176 let state = scratch.join("state");
177 std::fs::create_dir_all(&lower).unwrap();
178 std::fs::create_dir_all(&state).unwrap();
179 std::fs::write(lower.join("base.txt"), b"base\n").unwrap();
180
181 // --- capa 1 (abajo) ---
182 let id1 = try_overlay(&[lower.clone()], &state).expect("try 1");
183 std::fs::write(lower.join("uno.txt"), b"de la capa 1\n").unwrap();
184
185 // --- capa 2 (arriba), apilada sobre el mismo target ---
186 let id2 = try_overlay(&[lower.clone()], &state).expect("try 2");
187 assert_ne!(id1, id2, "los ids deben diferir aunque caigan en el mismo segundo");
188 // La capa 2 ve lo que escribió la capa 1: el stack toma el merged previo como lower.
189 assert_eq!(
190 std::fs::read_to_string(lower.join("uno.txt")).unwrap(),
191 "de la capa 1\n",
192 "la capa de arriba debe ver las escrituras de la de abajo"
193 );
194 std::fs::write(lower.join("dos.txt"), b"de la capa 2\n").unwrap();
195
196 assert_eq!(status(&state).unwrap().len(), 2, "dos overlays activos");
197
198 // --- guard LIFO: no se puede commitear la capa de abajo con la de arriba encima ---
199 match commit(&id1, &state) {
200 Err(takana_overlay::Error::Shadowed { id, shadowed_by }) => {
201 assert_eq!(id, id1);
202 assert_eq!(shadowed_by, vec![id2.clone()]);
203 }
204 other => panic!("esperaba Shadowed al commitear la capa de abajo, vino {other:?}"),
205 }
206
207 // --- commit de arriba hacia abajo (LIFO correcto) ---
208 let r2 = commit(&id2, &state).expect("commit capa 2");
209 assert!(r2.copied.iter().any(|p| p.ends_with("dos.txt")));
210 // Ahora id1 es la cima: ya se puede commitear.
211 let r1 = commit(&id1, &state).expect("commit capa 1");
212 assert!(r1.copied.iter().any(|p| p.ends_with("uno.txt")));
213
214 // El lower real tiene la fusión de ambas capas + el base original.
215 assert!(status(&state).unwrap().is_empty(), "state limpio tras ambos commits");
216 assert_eq!(std::fs::read_to_string(lower.join("base.txt")).unwrap(), "base\n");
217 assert_eq!(std::fs::read_to_string(lower.join("uno.txt")).unwrap(), "de la capa 1\n");
218 assert_eq!(std::fs::read_to_string(lower.join("dos.txt")).unwrap(), "de la capa 2\n");
219}
220
221#[test]
222fn overlay_nested_stack_inside_userns() {
223 if takana_core::env::var("TAKANA_OVERLAY_IN_SANDBOX").is_some() {
224 body_nested_stack();
225 return;
226 }
227 let tmp = tempfile::tempdir().unwrap();
228 if run_inside_userns_or_skip("overlay_nested_stack_inside_userns", tmp.path()) {
229 return;
230 }
231 body_nested_stack();
232}
233
234#[test]
235fn discard_unknown_id_errors() {
236 // No requiere sandbox.
237 let d = tempfile::tempdir().unwrap();
238 let err = discard(&OverlayId("inexistente".into()), d.path())
239 .unwrap_err()
240 .to_string();
241 assert!(err.contains("no encuentro overlay"), "msg = {err}");
242}