//! Primitivas para aplicar mutaciones de un `.swm` a disco. Ver `docs/06-swm-format.md` §3. //! //! Estas funciones son **puras** sobre el sistema de archivos: reciben paths absolutos (ya //! re-rooteados al overlay o a un prefix de test) y aplican un único `Mutation` cada una. //! La orquestación (montar overlay, iterar mutaciones, build de source_patch) vive en //! `hammer-cli`, no aquí — hammer-core no depende de hammer-build ni del overlay. //! //! ## `config_edit` //! //! El `inline_diff` se parsea como una secuencia de hunks. Un hunk es un bloque contiguo de //! líneas `- ` (a eliminar) seguido de un bloque contiguo de líneas `+ ` (a insertar). Las //! líneas que no empiezan por `- ` ni `+ ` son separadores entre hunks (líneas vacías, //! contexto humano) y se ignoran. //! //! Aplicación: para cada hunk, se busca el bloque "removed" **exacto** en el archivo y se //! sustituye por el "added". Si aparece varias veces ⇒ error (ambiguo). Si no aparece ⇒ //! error. No hay 3-way ni fuzz por ahora; un `.swm` que pinche dos veces falla pronto. //! //! ## `file_drop` //! //! Decodifica el base64, verifica que el BLAKE3 del contenido coincide con el //! `content_hash` declarado, y escribe el archivo (creando los directorios padre). //! `content_url` se rechaza aquí — el fetch externo lo decide la capa CLI. use std::path::{Path, PathBuf}; use base64::{engine::general_purpose::STANDARD, Engine as _}; use serde::Serialize; use crate::hash::ArtifactHash; /// Directorio canónico donde `init_rule` materializa una regla por servicio. El init /// (arje, PID 1) lee este árbol para saber qué supervisar. Es el contrato on-disk entre /// hammer (declara) y el init (ejecuta) — análogo a `/etc/systemd/system` pero nativo. pub const INIT_RULES_DIR: &str = "/etc/hammer/init.d"; #[derive(Debug, thiserror::Error)] pub enum ApplyError { #[error("io: {0}")] Io(#[from] std::io::Error), #[error("config_edit: {0}")] ConfigEdit(String), #[error("file_drop: {0}")] FileDrop(String), #[error("init_rule: {0}")] InitRule(String), } pub type ApplyResult = std::result::Result; /// Aplica un `config_edit` re-rooteado: `file` ya es la ruta final en disco (el caller /// re-rootea al upper del overlay o a un prefix de test antes de llamar). pub fn apply_config_edit(file: &Path, inline_diff: &str) -> ApplyResult<()> { let original = std::fs::read_to_string(file).map_err(|e| { ApplyError::ConfigEdit(format!("leyendo {}: {e}", file.display())) })?; let hunks = parse_hunks(inline_diff)?; if hunks.is_empty() { return Err(ApplyError::ConfigEdit( "inline_diff no contiene hunks (líneas con '- ' o '+ ')".into(), )); } let mut content = original; for (i, hunk) in hunks.iter().enumerate() { content = apply_hunk(&content, hunk).map_err(|e| { ApplyError::ConfigEdit(format!( "hunk #{} en {}: {e}", i + 1, file.display() )) })?; } // Escribimos con `cp -a`-equivalente: para preservar permisos, usamos write directo y // confiamos en que el archivo destino ya existe (config_edit nunca crea archivos). std::fs::write(file, content)?; Ok(()) } /// Aplica un `file_drop`: decodifica `content_b64`, verifica el hash, escribe `path`. /// El `content_hash` admite el prefijo `b3:` (es el formato canónico) o el hex puro. pub fn apply_file_drop( path: &Path, content_b64: &str, content_hash: &str, ) -> ApplyResult<()> { let bytes = STANDARD .decode(content_b64.as_bytes()) .map_err(|e| ApplyError::FileDrop(format!("base64 inválido: {e}")))?; let actual = ArtifactHash::of_inputs(&[bytes.as_slice()]); // Normalizamos ambos al hex puro (sin "b3:") para comparar. let declared_hex = content_hash.strip_prefix("b3:").unwrap_or(content_hash); let actual_hex = actual .as_str() .strip_prefix("b3:") .expect("of_inputs siempre devuelve 'b3:'"); if declared_hex != actual_hex { return Err(ApplyError::FileDrop(format!( "hash de contenido no coincide: declarado={declared_hex}, calculado={actual_hex}", ))); } if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(path, &bytes)?; Ok(()) } /// Acción de un `init_rule`. Decide si la regla se **materializa** (el servicio debe /// existir/correr) o se **retira** (no debe correr). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InitAction { Enable, Disable, Start, Stop, Restart, } impl InitAction { pub fn parse(s: &str) -> Result { match s { "enable" => Ok(Self::Enable), "disable" => Ok(Self::Disable), "start" => Ok(Self::Start), "stop" => Ok(Self::Stop), "restart" => Ok(Self::Restart), other => Err(format!( "acción desconocida '{other}' (enable|disable|start|stop|restart)" )), } } /// `true` si la acción implica que el servicio debe quedar declarado en disco. /// `disable`/`stop` son las "negativas": retiran la regla. fn materializes(self) -> bool { !matches!(self, Self::Disable | Self::Stop) } } /// Forma serializada de una regla de init en disco (TOML). #[derive(Serialize)] struct InitRuleFile<'a> { service: &'a str, action: &'a str, command: &'a str, } /// Aplica un `init_rule`: materializa (o retira) `{service}.rule` bajo `rules_dir` (el /// caller pasa [`INIT_RULES_DIR`] re-rooteado al overlay o a un prefix de test). Acciones /// positivas (enable/start/restart) escriben la regla con su `command`; negativas /// (disable/stop) la borran (idempotente). Devuelve el path de la regla afectada. pub fn apply_init_rule( rules_dir: &Path, action: &str, service: &str, command: &str, ) -> ApplyResult { let act = InitAction::parse(action).map_err(ApplyError::InitRule)?; // El nombre del servicio se vuelve un nombre de archivo: no debe escapar del dir. if service.is_empty() || service.contains('/') || service.contains("..") { return Err(ApplyError::InitRule(format!( "nombre de servicio inválido: '{service}'" ))); } let rule_path = rules_dir.join(format!("{service}.rule")); if !act.materializes() { // disable/stop: retiramos la regla si existe. No es error que falte. match std::fs::remove_file(&rule_path) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(ApplyError::Io(e)), } return Ok(rule_path); } if command.is_empty() { return Err(ApplyError::InitRule(format!( "init_rule '{action} {service}': command vacío" ))); } let body = toml::to_string(&InitRuleFile { service, action, command }) .map_err(|e| ApplyError::InitRule(format!("serializando regla: {e}")))?; if let Some(parent) = rule_path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(&rule_path, body)?; Ok(rule_path) } /// Para el caller que ya quiere chequear el hash sin escribir nada (p. ej. `swm verify`). pub fn verify_content_hash(content_b64: &str, content_hash: &str) -> ApplyResult<()> { let bytes = STANDARD .decode(content_b64.as_bytes()) .map_err(|e| ApplyError::FileDrop(format!("base64 inválido: {e}")))?; let actual = ArtifactHash::of_inputs(&[bytes.as_slice()]); let declared_hex = content_hash.strip_prefix("b3:").unwrap_or(content_hash); let actual_hex = actual.as_str().strip_prefix("b3:").unwrap(); if declared_hex != actual_hex { return Err(ApplyError::FileDrop(format!( "hash no coincide: declarado={declared_hex}, calculado={actual_hex}", ))); } Ok(()) } /// Conveniencia para `file_drop` cuando el llamador (CLI/test) ya leyó los bytes desde una /// URL u otro lado: pasa los bytes crudos y se verifica + escribe igual que el caso inline. pub fn write_filedrop_bytes( path: &Path, bytes: &[u8], content_hash: &str, ) -> ApplyResult<()> { let actual = ArtifactHash::of_inputs(&[bytes]); let declared_hex = content_hash.strip_prefix("b3:").unwrap_or(content_hash); let actual_hex = actual.as_str().strip_prefix("b3:").unwrap(); if declared_hex != actual_hex { return Err(ApplyError::FileDrop(format!( "hash no coincide: declarado={declared_hex}, calculado={actual_hex}", ))); } if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(path, bytes)?; Ok(()) } /// Re-rootea una ruta absoluta del `.swm` bajo un prefix. `/etc/foo` con prefix /// `/tmp/overlay/etc` → `/tmp/overlay/etc/etc/foo`. Para overlay real, prefix es vacío y /// devuelve la propia ruta. pub fn rebase_path(path: &str, prefix: Option<&Path>) -> PathBuf { let p = Path::new(path); let rel = p.strip_prefix("/").unwrap_or(p); match prefix { Some(pre) => pre.join(rel), None => p.to_path_buf(), } } /// Un hunk del inline_diff: el bloque de líneas a quitar (sin el prefijo `- `) y el bloque /// a añadir (sin `+ `). Cualquiera puede ser vacío, pero no ambos a la vez. #[derive(Debug, Clone, PartialEq, Eq)] struct Hunk { remove: Vec, add: Vec, } fn parse_hunks(inline_diff: &str) -> ApplyResult> { let mut hunks = Vec::new(); let mut cur = Hunk { remove: Vec::new(), add: Vec::new() }; let mut seen_add = false; for raw in inline_diff.lines() { if let Some(rest) = raw.strip_prefix("- ") { // Un `-` después de `+` cierra el hunk actual. if seen_add { hunks.push(std::mem::replace( &mut cur, Hunk { remove: Vec::new(), add: Vec::new() }, )); seen_add = false; } cur.remove.push(rest.to_string()); } else if let Some(rest) = raw.strip_prefix("+ ") { seen_add = true; cur.add.push(rest.to_string()); } else { // Línea que no es ni `- ` ni `+ `: separador. Cierra el hunk en curso si lo hay. if !cur.remove.is_empty() || !cur.add.is_empty() { hunks.push(std::mem::replace( &mut cur, Hunk { remove: Vec::new(), add: Vec::new() }, )); seen_add = false; } } } if !cur.remove.is_empty() || !cur.add.is_empty() { hunks.push(cur); } Ok(hunks) } fn apply_hunk(content: &str, hunk: &Hunk) -> Result { // Hunk vacío (no debería ocurrir, parse lo filtra) → no-op. if hunk.remove.is_empty() && hunk.add.is_empty() { return Ok(content.to_string()); } // Hunk de pura inserción: sin remove no sabemos dónde insertar. if hunk.remove.is_empty() { return Err( "hunk sin líneas '- ' (no sabemos dónde insertar); usa '- ' como anclaje" .to_string(), ); } // Buscar el bloque remove como secuencia EXACTA de líneas en el archivo. Trabajamos // a nivel de línea (no de bytes) para que la coincidencia no se rompa por finales de // línea o por sub-cadenas accidentales dentro de líneas más largas. let file_lines: Vec<&str> = content.split('\n').collect(); let n = hunk.remove.len(); let mut matches = Vec::new(); if file_lines.len() >= n { for i in 0..=(file_lines.len() - n) { if (0..n).all(|k| file_lines[i + k] == hunk.remove[k]) { matches.push(i); } } } match matches.len() { 0 => Err(format!( "bloque a eliminar no aparece en el archivo: primeras líneas = [{}]", hunk.remove.first().cloned().unwrap_or_default() )), 1 => { let i = matches[0]; let mut new_lines: Vec<&str> = Vec::with_capacity(file_lines.len() - n + hunk.add.len()); new_lines.extend_from_slice(&file_lines[..i]); for a in &hunk.add { new_lines.push(a.as_str()); } new_lines.extend_from_slice(&file_lines[i + n..]); Ok(new_lines.join("\n")) } m => Err(format!( "bloque a eliminar aparece {m} veces: el hunk es ambiguo. Añade más contexto." )), } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_simple_hunk() { let d = "- DHCP=yes\n+ IP=192.168.1.100\n"; let hs = parse_hunks(d).unwrap(); assert_eq!(hs.len(), 1); assert_eq!(hs[0].remove, vec!["DHCP=yes"]); assert_eq!(hs[0].add, vec!["IP=192.168.1.100"]); } #[test] fn parse_multi_hunk_separated_by_blank() { let d = "- a\n+ A\n\n- b\n+ B\n"; let hs = parse_hunks(d).unwrap(); assert_eq!(hs.len(), 2); assert_eq!(hs[0].remove, vec!["a"]); assert_eq!(hs[1].add, vec!["B"]); } #[test] fn parse_multi_hunk_separated_by_back_to_dash() { // Sin línea en blanco entre hunks: en cuanto vemos un `-` después de un `+`, cierra. let d = "- a\n+ A\n- b\n+ B\n"; let hs = parse_hunks(d).unwrap(); assert_eq!(hs.len(), 2); assert_eq!(hs[1].remove, vec!["b"]); assert_eq!(hs[1].add, vec!["B"]); } #[test] fn apply_replace_single_line() { let d = tempfile::tempdir().unwrap(); let f = d.path().join("net.conf"); std::fs::write(&f, "USE=eth0\nDHCP=yes\nMTU=1500\n").unwrap(); apply_config_edit(&f, "- DHCP=yes\n+ IP=192.168.1.100\n").unwrap(); let got = std::fs::read_to_string(&f).unwrap(); assert_eq!(got, "USE=eth0\nIP=192.168.1.100\nMTU=1500\n"); } #[test] fn apply_replace_multi_line_block() { let d = tempfile::tempdir().unwrap(); let f = d.path().join("conf"); std::fs::write( &f, "[net]\nUSE=eth0\nDHCP=yes\nMTU=1500\n[end]\n", ) .unwrap(); let diff = "- USE=eth0\n- DHCP=yes\n+ USE=eth1\n+ STATIC=1\n+ IP=10.0.0.1\n"; apply_config_edit(&f, diff).unwrap(); let got = std::fs::read_to_string(&f).unwrap(); assert_eq!( got, "[net]\nUSE=eth1\nSTATIC=1\nIP=10.0.0.1\nMTU=1500\n[end]\n" ); } #[test] fn apply_pure_deletion() { let d = tempfile::tempdir().unwrap(); let f = d.path().join("conf"); std::fs::write(&f, "keep\nDROP_ME\nkeep2\n").unwrap(); apply_config_edit(&f, "- DROP_ME\n").unwrap(); let got = std::fs::read_to_string(&f).unwrap(); assert_eq!(got, "keep\nkeep2\n"); } #[test] fn apply_fails_when_block_not_found() { let d = tempfile::tempdir().unwrap(); let f = d.path().join("conf"); std::fs::write(&f, "A\nB\nC\n").unwrap(); let err = apply_config_edit(&f, "- NO-EXISTE\n+ X\n") .unwrap_err() .to_string(); assert!(err.contains("no aparece"), "{err}"); } #[test] fn apply_fails_when_block_ambiguous() { let d = tempfile::tempdir().unwrap(); let f = d.path().join("conf"); std::fs::write(&f, "X=1\nX=1\n").unwrap(); let err = apply_config_edit(&f, "- X=1\n+ X=2\n").unwrap_err().to_string(); assert!(err.contains("ambiguo"), "{err}"); } #[test] fn apply_pure_insertion_rejected() { let d = tempfile::tempdir().unwrap(); let f = d.path().join("conf"); std::fs::write(&f, "A\n").unwrap(); let err = apply_config_edit(&f, "+ extra\n").unwrap_err().to_string(); assert!(err.contains("anclaje"), "{err}"); } #[test] fn file_drop_roundtrip() { let d = tempfile::tempdir().unwrap(); let path = d.path().join("var/lib/x/data"); let payload = b"hola hammer\n"; let b64 = STANDARD.encode(payload); let h = ArtifactHash::of_inputs(&[payload.as_slice()]); apply_file_drop(&path, &b64, h.as_str()).unwrap(); let got = std::fs::read(&path).unwrap(); assert_eq!(got, payload); } #[test] fn file_drop_accepts_hex_without_prefix() { let d = tempfile::tempdir().unwrap(); let path = d.path().join("x"); let payload = b"abc"; let b64 = STANDARD.encode(payload); let h = ArtifactHash::of_inputs(&[payload.as_slice()]); let hex = h.as_str().strip_prefix("b3:").unwrap().to_string(); apply_file_drop(&path, &b64, &hex).unwrap(); } #[test] fn file_drop_rejects_wrong_hash() { let d = tempfile::tempdir().unwrap(); let path = d.path().join("x"); let b64 = STANDARD.encode(b"abc"); let err = apply_file_drop(&path, &b64, "b3:deadbeef").unwrap_err().to_string(); assert!(err.contains("no coincide"), "{err}"); assert!(!path.exists(), "no debe haberse escrito si el hash falla"); } #[test] fn file_drop_rejects_invalid_b64() { let d = tempfile::tempdir().unwrap(); let path = d.path().join("x"); let err = apply_file_drop(&path, "not base64!!!", "b3:00").unwrap_err().to_string(); assert!(err.contains("base64"), "{err}"); } #[test] fn rebase_with_prefix() { let r = rebase_path("/etc/foo", Some(Path::new("/tmp/overlay"))); assert_eq!(r, PathBuf::from("/tmp/overlay/etc/foo")); let r = rebase_path("/etc/foo", None); assert_eq!(r, PathBuf::from("/etc/foo")); } #[test] fn init_rule_enable_escribe_regla() { let d = tempfile::tempdir().unwrap(); let dir = d.path().join("etc/hammer/init.d"); let p = apply_init_rule(&dir, "enable", "nginx", "/usr/sbin/nginx -g 'daemon off;'").unwrap(); assert_eq!(p, dir.join("nginx.rule")); let body = std::fs::read_to_string(&p).unwrap(); assert!(body.contains("service = \"nginx\""), "{body}"); assert!(body.contains("action = \"enable\""), "{body}"); assert!(body.contains("daemon off"), "{body}"); } #[test] fn init_rule_disable_retira_la_regla() { let d = tempfile::tempdir().unwrap(); let dir = d.path().join("init.d"); let p = apply_init_rule(&dir, "enable", "foo", "/bin/foo").unwrap(); assert!(p.exists()); // disable borra; y es idempotente (segunda vez no falla aunque ya no esté). apply_init_rule(&dir, "disable", "foo", "/bin/foo").unwrap(); assert!(!p.exists(), "disable debió borrar la regla"); apply_init_rule(&dir, "stop", "foo", "/bin/foo").unwrap(); } #[test] fn init_rule_rechaza_accion_y_servicio_invalidos() { let d = tempfile::tempdir().unwrap(); let dir = d.path().join("init.d"); let e1 = apply_init_rule(&dir, "frobnicate", "x", "/bin/x").unwrap_err().to_string(); assert!(e1.contains("acción desconocida"), "{e1}"); let e2 = apply_init_rule(&dir, "enable", "../escape", "/bin/x").unwrap_err().to_string(); assert!(e2.contains("servicio inválido"), "{e2}"); let e3 = apply_init_rule(&dir, "enable", "x", "").unwrap_err().to_string(); assert!(e3.contains("command vacío"), "{e3}"); } }