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…
⎇ b580c5bfc5b134c64a571136441f585c025deaaf
crates/hammer-core/src/apply.rs
History
Who touched what
raw
feat(swm): init_rule se materializa a /etc/hammer/init.d/{service}.rule
1//! Primitivas para aplicar mutaciones de un `.swm` a disco. Ver `docs/06-swm-format.md` §3.
2//!
3//! Estas funciones son **puras** sobre el sistema de archivos: reciben paths absolutos (ya
4//! re-rooteados al overlay o a un prefix de test) y aplican un único `Mutation` cada una.
5//! La orquestación (montar overlay, iterar mutaciones, build de source_patch) vive en
6//! `hammer-cli`, no aquí — hammer-core no depende de hammer-build ni del overlay.
7//!
8//! ## `config_edit`
9//!
10//! El `inline_diff` se parsea como una secuencia de hunks. Un hunk es un bloque contiguo de
11//! líneas `- ` (a eliminar) seguido de un bloque contiguo de líneas `+ ` (a insertar). Las
12//! líneas que no empiezan por `- ` ni `+ ` son separadores entre hunks (líneas vacías,
13//! contexto humano) y se ignoran.
14//!
15//! Aplicación: para cada hunk, se busca el bloque "removed" **exacto** en el archivo y se
16//! sustituye por el "added". Si aparece varias veces ⇒ error (ambiguo). Si no aparece ⇒
17//! error. No hay 3-way ni fuzz por ahora; un `.swm` que pinche dos veces falla pronto.
18//!
19//! ## `file_drop`
20//!
21//! Decodifica el base64, verifica que el BLAKE3 del contenido coincide con el
22//! `content_hash` declarado, y escribe el archivo (creando los directorios padre).
23//! `content_url` se rechaza aquí — el fetch externo lo decide la capa CLI.
24
25use std::path::{Path, PathBuf};
26
27use base64::{engine::general_purpose::STANDARD, Engine as _};
28use serde::Serialize;
29
30use crate::hash::ArtifactHash;
31
32/// Directorio canónico donde `init_rule` materializa una regla por servicio. El init
33/// (arje, PID 1) lee este árbol para saber qué supervisar. Es el contrato on-disk entre
34/// hammer (declara) y el init (ejecuta) — análogo a `/etc/systemd/system` pero nativo.
35pub const INIT_RULES_DIR: &str = "/etc/hammer/init.d";
36
37#[derive(Debug, thiserror::Error)]
38pub enum ApplyError {
39 #[error("io: {0}")]
40 Io(#[from] std::io::Error),
41 #[error("config_edit: {0}")]
42 ConfigEdit(String),
43 #[error("file_drop: {0}")]
44 FileDrop(String),
45 #[error("init_rule: {0}")]
46 InitRule(String),
47}
48
49pub type ApplyResult<T> = std::result::Result<T, ApplyError>;
50
51/// Aplica un `config_edit` re-rooteado: `file` ya es la ruta final en disco (el caller
52/// re-rootea al upper del overlay o a un prefix de test antes de llamar).
53pub fn apply_config_edit(file: &Path, inline_diff: &str) -> ApplyResult<()> {
54 let original = std::fs::read_to_string(file).map_err(|e| {
55 ApplyError::ConfigEdit(format!("leyendo {}: {e}", file.display()))
56 })?;
57 let hunks = parse_hunks(inline_diff)?;
58 if hunks.is_empty() {
59 return Err(ApplyError::ConfigEdit(
60 "inline_diff no contiene hunks (líneas con '- ' o '+ ')".into(),
61 ));
62 }
63 let mut content = original;
64 for (i, hunk) in hunks.iter().enumerate() {
65 content = apply_hunk(&content, hunk).map_err(|e| {
66 ApplyError::ConfigEdit(format!(
67 "hunk #{} en {}: {e}",
68 i + 1,
69 file.display()
70 ))
71 })?;
72 }
73 // Escribimos con `cp -a`-equivalente: para preservar permisos, usamos write directo y
74 // confiamos en que el archivo destino ya existe (config_edit nunca crea archivos).
75 std::fs::write(file, content)?;
76 Ok(())
77}
78
79/// Aplica un `file_drop`: decodifica `content_b64`, verifica el hash, escribe `path`.
80/// El `content_hash` admite el prefijo `b3:` (es el formato canónico) o el hex puro.
81pub fn apply_file_drop(
82 path: &Path,
83 content_b64: &str,
84 content_hash: &str,
85) -> ApplyResult<()> {
86 let bytes = STANDARD
87 .decode(content_b64.as_bytes())
88 .map_err(|e| ApplyError::FileDrop(format!("base64 inválido: {e}")))?;
89 let actual = ArtifactHash::of_inputs(&[bytes.as_slice()]);
90 // Normalizamos ambos al hex puro (sin "b3:") para comparar.
91 let declared_hex = content_hash.strip_prefix("b3:").unwrap_or(content_hash);
92 let actual_hex = actual
93 .as_str()
94 .strip_prefix("b3:")
95 .expect("of_inputs siempre devuelve 'b3:'");
96 if declared_hex != actual_hex {
97 return Err(ApplyError::FileDrop(format!(
98 "hash de contenido no coincide: declarado={declared_hex}, calculado={actual_hex}",
99 )));
100 }
101 if let Some(parent) = path.parent() {
102 std::fs::create_dir_all(parent)?;
103 }
104 std::fs::write(path, &bytes)?;
105 Ok(())
106}
107
108/// Acción de un `init_rule`. Decide si la regla se **materializa** (el servicio debe
109/// existir/correr) o se **retira** (no debe correr).
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum InitAction {
112 Enable,
113 Disable,
114 Start,
115 Stop,
116 Restart,
117}
118
119impl InitAction {
120 pub fn parse(s: &str) -> Result<Self, String> {
121 match s {
122 "enable" => Ok(Self::Enable),
123 "disable" => Ok(Self::Disable),
124 "start" => Ok(Self::Start),
125 "stop" => Ok(Self::Stop),
126 "restart" => Ok(Self::Restart),
127 other => Err(format!(
128 "acción desconocida '{other}' (enable|disable|start|stop|restart)"
129 )),
130 }
131 }
132
133 /// `true` si la acción implica que el servicio debe quedar declarado en disco.
134 /// `disable`/`stop` son las "negativas": retiran la regla.
135 fn materializes(self) -> bool {
136 !matches!(self, Self::Disable | Self::Stop)
137 }
138}
139
140/// Forma serializada de una regla de init en disco (TOML).
141#[derive(Serialize)]
142struct InitRuleFile<'a> {
143 service: &'a str,
144 action: &'a str,
145 command: &'a str,
146}
147
148/// Aplica un `init_rule`: materializa (o retira) `{service}.rule` bajo `rules_dir` (el
149/// caller pasa [`INIT_RULES_DIR`] re-rooteado al overlay o a un prefix de test). Acciones
150/// positivas (enable/start/restart) escriben la regla con su `command`; negativas
151/// (disable/stop) la borran (idempotente). Devuelve el path de la regla afectada.
152pub fn apply_init_rule(
153 rules_dir: &Path,
154 action: &str,
155 service: &str,
156 command: &str,
157) -> ApplyResult<PathBuf> {
158 let act = InitAction::parse(action).map_err(ApplyError::InitRule)?;
159 // El nombre del servicio se vuelve un nombre de archivo: no debe escapar del dir.
160 if service.is_empty() || service.contains('/') || service.contains("..") {
161 return Err(ApplyError::InitRule(format!(
162 "nombre de servicio inválido: '{service}'"
163 )));
164 }
165 let rule_path = rules_dir.join(format!("{service}.rule"));
166
167 if !act.materializes() {
168 // disable/stop: retiramos la regla si existe. No es error que falte.
169 match std::fs::remove_file(&rule_path) {
170 Ok(()) => {}
171 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
172 Err(e) => return Err(ApplyError::Io(e)),
173 }
174 return Ok(rule_path);
175 }
176
177 if command.is_empty() {
178 return Err(ApplyError::InitRule(format!(
179 "init_rule '{action} {service}': command vacío"
180 )));
181 }
182 let body = toml::to_string(&InitRuleFile { service, action, command })
183 .map_err(|e| ApplyError::InitRule(format!("serializando regla: {e}")))?;
184 if let Some(parent) = rule_path.parent() {
185 std::fs::create_dir_all(parent)?;
186 }
187 std::fs::write(&rule_path, body)?;
188 Ok(rule_path)
189}
190
191/// Para el caller que ya quiere chequear el hash sin escribir nada (p. ej. `swm verify`).
192pub fn verify_content_hash(content_b64: &str, content_hash: &str) -> ApplyResult<()> {
193 let bytes = STANDARD
194 .decode(content_b64.as_bytes())
195 .map_err(|e| ApplyError::FileDrop(format!("base64 inválido: {e}")))?;
196 let actual = ArtifactHash::of_inputs(&[bytes.as_slice()]);
197 let declared_hex = content_hash.strip_prefix("b3:").unwrap_or(content_hash);
198 let actual_hex = actual.as_str().strip_prefix("b3:").unwrap();
199 if declared_hex != actual_hex {
200 return Err(ApplyError::FileDrop(format!(
201 "hash no coincide: declarado={declared_hex}, calculado={actual_hex}",
202 )));
203 }
204 Ok(())
205}
206
207/// Conveniencia para `file_drop` cuando el llamador (CLI/test) ya leyó los bytes desde una
208/// URL u otro lado: pasa los bytes crudos y se verifica + escribe igual que el caso inline.
209pub fn write_filedrop_bytes(
210 path: &Path,
211 bytes: &[u8],
212 content_hash: &str,
213) -> ApplyResult<()> {
214 let actual = ArtifactHash::of_inputs(&[bytes]);
215 let declared_hex = content_hash.strip_prefix("b3:").unwrap_or(content_hash);
216 let actual_hex = actual.as_str().strip_prefix("b3:").unwrap();
217 if declared_hex != actual_hex {
218 return Err(ApplyError::FileDrop(format!(
219 "hash no coincide: declarado={declared_hex}, calculado={actual_hex}",
220 )));
221 }
222 if let Some(parent) = path.parent() {
223 std::fs::create_dir_all(parent)?;
224 }
225 std::fs::write(path, bytes)?;
226 Ok(())
227}
228
229/// Re-rootea una ruta absoluta del `.swm` bajo un prefix. `/etc/foo` con prefix
230/// `/tmp/overlay/etc` → `/tmp/overlay/etc/etc/foo`. Para overlay real, prefix es vacío y
231/// devuelve la propia ruta.
232pub fn rebase_path(path: &str, prefix: Option<&Path>) -> PathBuf {
233 let p = Path::new(path);
234 let rel = p.strip_prefix("/").unwrap_or(p);
235 match prefix {
236 Some(pre) => pre.join(rel),
237 None => p.to_path_buf(),
238 }
239}
240
241/// Un hunk del inline_diff: el bloque de líneas a quitar (sin el prefijo `- `) y el bloque
242/// a añadir (sin `+ `). Cualquiera puede ser vacío, pero no ambos a la vez.
243#[derive(Debug, Clone, PartialEq, Eq)]
244struct Hunk {
245 remove: Vec<String>,
246 add: Vec<String>,
247}
248
249fn parse_hunks(inline_diff: &str) -> ApplyResult<Vec<Hunk>> {
250 let mut hunks = Vec::new();
251 let mut cur = Hunk { remove: Vec::new(), add: Vec::new() };
252 let mut seen_add = false;
253
254 for raw in inline_diff.lines() {
255 if let Some(rest) = raw.strip_prefix("- ") {
256 // Un `-` después de `+` cierra el hunk actual.
257 if seen_add {
258 hunks.push(std::mem::replace(
259 &mut cur,
260 Hunk { remove: Vec::new(), add: Vec::new() },
261 ));
262 seen_add = false;
263 }
264 cur.remove.push(rest.to_string());
265 } else if let Some(rest) = raw.strip_prefix("+ ") {
266 seen_add = true;
267 cur.add.push(rest.to_string());
268 } else {
269 // Línea que no es ni `- ` ni `+ `: separador. Cierra el hunk en curso si lo hay.
270 if !cur.remove.is_empty() || !cur.add.is_empty() {
271 hunks.push(std::mem::replace(
272 &mut cur,
273 Hunk { remove: Vec::new(), add: Vec::new() },
274 ));
275 seen_add = false;
276 }
277 }
278 }
279 if !cur.remove.is_empty() || !cur.add.is_empty() {
280 hunks.push(cur);
281 }
282 Ok(hunks)
283}
284
285fn apply_hunk(content: &str, hunk: &Hunk) -> Result<String, String> {
286 // Hunk vacío (no debería ocurrir, parse lo filtra) → no-op.
287 if hunk.remove.is_empty() && hunk.add.is_empty() {
288 return Ok(content.to_string());
289 }
290 // Hunk de pura inserción: sin remove no sabemos dónde insertar.
291 if hunk.remove.is_empty() {
292 return Err(
293 "hunk sin líneas '- ' (no sabemos dónde insertar); usa '- ' como anclaje"
294 .to_string(),
295 );
296 }
297 // Buscar el bloque remove como secuencia EXACTA de líneas en el archivo. Trabajamos
298 // a nivel de línea (no de bytes) para que la coincidencia no se rompa por finales de
299 // línea o por sub-cadenas accidentales dentro de líneas más largas.
300 let file_lines: Vec<&str> = content.split('\n').collect();
301 let n = hunk.remove.len();
302
303 let mut matches = Vec::new();
304 if file_lines.len() >= n {
305 for i in 0..=(file_lines.len() - n) {
306 if (0..n).all(|k| file_lines[i + k] == hunk.remove[k]) {
307 matches.push(i);
308 }
309 }
310 }
311 match matches.len() {
312 0 => Err(format!(
313 "bloque a eliminar no aparece en el archivo: primeras líneas = [{}]",
314 hunk.remove.first().cloned().unwrap_or_default()
315 )),
316 1 => {
317 let i = matches[0];
318 let mut new_lines: Vec<&str> = Vec::with_capacity(file_lines.len() - n + hunk.add.len());
319 new_lines.extend_from_slice(&file_lines[..i]);
320 for a in &hunk.add {
321 new_lines.push(a.as_str());
322 }
323 new_lines.extend_from_slice(&file_lines[i + n..]);
324 Ok(new_lines.join("\n"))
325 }
326 m => Err(format!(
327 "bloque a eliminar aparece {m} veces: el hunk es ambiguo. Añade más contexto."
328 )),
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
337 fn parse_simple_hunk() {
338 let d = "- DHCP=yes\n+ IP=192.168.1.100\n";
339 let hs = parse_hunks(d).unwrap();
340 assert_eq!(hs.len(), 1);
341 assert_eq!(hs[0].remove, vec!["DHCP=yes"]);
342 assert_eq!(hs[0].add, vec!["IP=192.168.1.100"]);
343 }
344
345 #[test]
346 fn parse_multi_hunk_separated_by_blank() {
347 let d = "- a\n+ A\n\n- b\n+ B\n";
348 let hs = parse_hunks(d).unwrap();
349 assert_eq!(hs.len(), 2);
350 assert_eq!(hs[0].remove, vec!["a"]);
351 assert_eq!(hs[1].add, vec!["B"]);
352 }
353
354 #[test]
355 fn parse_multi_hunk_separated_by_back_to_dash() {
356 // Sin línea en blanco entre hunks: en cuanto vemos un `-` después de un `+`, cierra.
357 let d = "- a\n+ A\n- b\n+ B\n";
358 let hs = parse_hunks(d).unwrap();
359 assert_eq!(hs.len(), 2);
360 assert_eq!(hs[1].remove, vec!["b"]);
361 assert_eq!(hs[1].add, vec!["B"]);
362 }
363
364 #[test]
365 fn apply_replace_single_line() {
366 let d = tempfile::tempdir().unwrap();
367 let f = d.path().join("net.conf");
368 std::fs::write(&f, "USE=eth0\nDHCP=yes\nMTU=1500\n").unwrap();
369 apply_config_edit(&f, "- DHCP=yes\n+ IP=192.168.1.100\n").unwrap();
370 let got = std::fs::read_to_string(&f).unwrap();
371 assert_eq!(got, "USE=eth0\nIP=192.168.1.100\nMTU=1500\n");
372 }
373
374 #[test]
375 fn apply_replace_multi_line_block() {
376 let d = tempfile::tempdir().unwrap();
377 let f = d.path().join("conf");
378 std::fs::write(
379 &f,
380 "[net]\nUSE=eth0\nDHCP=yes\nMTU=1500\n[end]\n",
381 )
382 .unwrap();
383 let diff = "- USE=eth0\n- DHCP=yes\n+ USE=eth1\n+ STATIC=1\n+ IP=10.0.0.1\n";
384 apply_config_edit(&f, diff).unwrap();
385 let got = std::fs::read_to_string(&f).unwrap();
386 assert_eq!(
387 got,
388 "[net]\nUSE=eth1\nSTATIC=1\nIP=10.0.0.1\nMTU=1500\n[end]\n"
389 );
390 }
391
392 #[test]
393 fn apply_pure_deletion() {
394 let d = tempfile::tempdir().unwrap();
395 let f = d.path().join("conf");
396 std::fs::write(&f, "keep\nDROP_ME\nkeep2\n").unwrap();
397 apply_config_edit(&f, "- DROP_ME\n").unwrap();
398 let got = std::fs::read_to_string(&f).unwrap();
399 assert_eq!(got, "keep\nkeep2\n");
400 }
401
402 #[test]
403 fn apply_fails_when_block_not_found() {
404 let d = tempfile::tempdir().unwrap();
405 let f = d.path().join("conf");
406 std::fs::write(&f, "A\nB\nC\n").unwrap();
407 let err = apply_config_edit(&f, "- NO-EXISTE\n+ X\n")
408 .unwrap_err()
409 .to_string();
410 assert!(err.contains("no aparece"), "{err}");
411 }
412
413 #[test]
414 fn apply_fails_when_block_ambiguous() {
415 let d = tempfile::tempdir().unwrap();
416 let f = d.path().join("conf");
417 std::fs::write(&f, "X=1\nX=1\n").unwrap();
418 let err = apply_config_edit(&f, "- X=1\n+ X=2\n").unwrap_err().to_string();
419 assert!(err.contains("ambiguo"), "{err}");
420 }
421
422 #[test]
423 fn apply_pure_insertion_rejected() {
424 let d = tempfile::tempdir().unwrap();
425 let f = d.path().join("conf");
426 std::fs::write(&f, "A\n").unwrap();
427 let err = apply_config_edit(&f, "+ extra\n").unwrap_err().to_string();
428 assert!(err.contains("anclaje"), "{err}");
429 }
430
431 #[test]
432 fn file_drop_roundtrip() {
433 let d = tempfile::tempdir().unwrap();
434 let path = d.path().join("var/lib/x/data");
435 let payload = b"hola hammer\n";
436 let b64 = STANDARD.encode(payload);
437 let h = ArtifactHash::of_inputs(&[payload.as_slice()]);
438 apply_file_drop(&path, &b64, h.as_str()).unwrap();
439 let got = std::fs::read(&path).unwrap();
440 assert_eq!(got, payload);
441 }
442
443 #[test]
444 fn file_drop_accepts_hex_without_prefix() {
445 let d = tempfile::tempdir().unwrap();
446 let path = d.path().join("x");
447 let payload = b"abc";
448 let b64 = STANDARD.encode(payload);
449 let h = ArtifactHash::of_inputs(&[payload.as_slice()]);
450 let hex = h.as_str().strip_prefix("b3:").unwrap().to_string();
451 apply_file_drop(&path, &b64, &hex).unwrap();
452 }
453
454 #[test]
455 fn file_drop_rejects_wrong_hash() {
456 let d = tempfile::tempdir().unwrap();
457 let path = d.path().join("x");
458 let b64 = STANDARD.encode(b"abc");
459 let err = apply_file_drop(&path, &b64, "b3:deadbeef").unwrap_err().to_string();
460 assert!(err.contains("no coincide"), "{err}");
461 assert!(!path.exists(), "no debe haberse escrito si el hash falla");
462 }
463
464 #[test]
465 fn file_drop_rejects_invalid_b64() {
466 let d = tempfile::tempdir().unwrap();
467 let path = d.path().join("x");
468 let err = apply_file_drop(&path, "not base64!!!", "b3:00").unwrap_err().to_string();
469 assert!(err.contains("base64"), "{err}");
470 }
471
472 #[test]
473 fn rebase_with_prefix() {
474 let r = rebase_path("/etc/foo", Some(Path::new("/tmp/overlay")));
475 assert_eq!(r, PathBuf::from("/tmp/overlay/etc/foo"));
476 let r = rebase_path("/etc/foo", None);
477 assert_eq!(r, PathBuf::from("/etc/foo"));
478 }
479
480 #[test]
481 fn init_rule_enable_escribe_regla() {
482 let d = tempfile::tempdir().unwrap();
483 let dir = d.path().join("etc/hammer/init.d");
484 let p = apply_init_rule(&dir, "enable", "nginx", "/usr/sbin/nginx -g 'daemon off;'").unwrap();
485 assert_eq!(p, dir.join("nginx.rule"));
486 let body = std::fs::read_to_string(&p).unwrap();
487 assert!(body.contains("service = \"nginx\""), "{body}");
488 assert!(body.contains("action = \"enable\""), "{body}");
489 assert!(body.contains("daemon off"), "{body}");
490 }
491
492 #[test]
493 fn init_rule_disable_retira_la_regla() {
494 let d = tempfile::tempdir().unwrap();
495 let dir = d.path().join("init.d");
496 let p = apply_init_rule(&dir, "enable", "foo", "/bin/foo").unwrap();
497 assert!(p.exists());
498 // disable borra; y es idempotente (segunda vez no falla aunque ya no esté).
499 apply_init_rule(&dir, "disable", "foo", "/bin/foo").unwrap();
500 assert!(!p.exists(), "disable debió borrar la regla");
501 apply_init_rule(&dir, "stop", "foo", "/bin/foo").unwrap();
502 }
503
504 #[test]
505 fn init_rule_rechaza_accion_y_servicio_invalidos() {
506 let d = tempfile::tempdir().unwrap();
507 let dir = d.path().join("init.d");
508 let e1 = apply_init_rule(&dir, "frobnicate", "x", "/bin/x").unwrap_err().to_string();
509 assert!(e1.contains("acción desconocida"), "{e1}");
510 let e2 = apply_init_rule(&dir, "enable", "../escape", "/bin/x").unwrap_err().to_string();
511 assert!(e2.contains("servicio inválido"), "{e2}");
512 let e3 = apply_init_rule(&dir, "enable", "x", "").unwrap_err().to_string();
513 assert!(e3.contains("command vacío"), "{e3}");
514 }
515}