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

⎇ b580c5bfc5b134c64a571136441f585c025deaaf crates/hammer-core/src/apply.rs Rust · 515 lines · 19.1 KB History Who touched what raw

feat(swm): init_rule se materializa a /etc/hammer/init.d/{service}.rule 8a5e75344 · Sergio ·

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}