diff --git a/Cargo.lock b/Cargo.lock index ecd98df..39b30d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -448,6 +448,10 @@ dependencies = [ "tracing", ] +[[package]] +name = "arje-compat-common" +version = "0.1.0" + [[package]] name = "arje-echo" version = "0.0.1" @@ -467,6 +471,7 @@ dependencies = [ "anyhow", "arje-bus", "arje-card", + "arje-compat-common", "libc", "nix 0.29.0", "tokio", @@ -522,6 +527,7 @@ dependencies = [ "anyhow", "arje-bus", "arje-card", + "arje-compat-common", "tokio", "tracing", "tracing-subscriber", @@ -3059,6 +3065,7 @@ dependencies = [ name = "cosmobiologia-engine" version = "0.1.0" dependencies = [ + "cosmobiologia-corpus", "cosmobiologia-model", "cosmobiologia-render", "eternal-astrology", diff --git a/Cargo.toml b/Cargo.toml index 0452296..0322342 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ members = [ # ============================================================ # compat/ — Shims D-Bus para correr software systemd-aware # ============================================================ + "crates/compat/arje-compat-common", "crates/compat/arje-policy-provider", "crates/compat/arje-logind-compat", "crates/compat/arje-hostnamed-compat", diff --git a/crates/compat/arje-compat-common/Cargo.toml b/crates/compat/arje-compat-common/Cargo.toml new file mode 100644 index 0000000..ab8b7e5 --- /dev/null +++ b/crates/compat/arje-compat-common/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "arje-compat-common" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Lógica pura compartida por los shims D-Bus de compat: parseo KEY=value, escritura atómica, validadores. Testeada en un solo lugar." + +[dependencies] diff --git a/crates/compat/arje-compat-common/src/lib.rs b/crates/compat/arje-compat-common/src/lib.rs new file mode 100644 index 0000000..e9d9219 --- /dev/null +++ b/crates/compat/arje-compat-common/src/lib.rs @@ -0,0 +1,168 @@ +//! `arje-compat-common` — lógica pura compartida por los shims D-Bus de +//! compat (`arje-*-compat`). +//! +//! Los shims son binarios que traducen interfaces de systemd al bus +//! interno de `arje`. Antes cada uno copiaba su propio parseo de +//! archivos `KEY=value`, su escritura atómica y sus validadores — +//! código duplicado catorce veces y **sin un solo test**. Esta crate +//! junta esa lógica pura en un lugar y la cubre con tests; los shims +//! quedan como pura cáscara D-Bus. + +#![forbid(unsafe_code)] + +use std::io; +use std::path::Path; + +/// Escribe `content` en `path` de forma atómica: vuelca a un archivo +/// temporal vecino, hace `fsync`, y renombra sobre el destino —de modo +/// que un lector nunca ve un archivo a medio escribir—. Permisos 0644. +/// Crea el directorio padre si falta. +pub fn atomic_write(path: impl AsRef, content: &[u8]) -> io::Result<()> { + use io::Write; + use std::os::unix::fs::OpenOptionsExt; + let p = path.as_ref(); + if let Some(parent) = p.parent() { + let _ = std::fs::create_dir_all(parent); + } + let tmp = p.with_extension("tmp"); + { + let mut f = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(0o644) + .open(&tmp)?; + f.write_all(content)?; + f.sync_all()?; + } + std::fs::rename(&tmp, p) +} + +/// Busca `key` en un contenido de líneas `KEY=value` y devuelve su +/// valor, trimeado y sin las comillas envolventes. Ignora líneas en +/// blanco y comentarios (`#`). `None` si la clave no aparece. +pub fn parse_kv(content: &str, key: &str) -> Option { + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some((k, v)) = line.split_once('=') { + if k.trim() == key { + return Some(v.trim().trim_matches('"').to_string()); + } + } + } + None +} + +/// Actualiza —o inserta, si no existe— la clave `key` con `value` en un +/// contenido de líneas `KEY=value`. Conserva el resto de las líneas y +/// su orden. El resultado termina en `\n`. +pub fn merge_kv(existing: &str, key: &str, value: &str) -> String { + let mut out = String::new(); + let mut found = false; + for line in existing.lines() { + if let Some((k, _)) = line.split_once('=') { + if k.trim() == key { + out.push_str(&format!("{key}={value}\n")); + found = true; + continue; + } + } + out.push_str(line); + out.push('\n'); + } + if !found { + out.push_str(&format!("{key}={value}\n")); + } + out +} + +/// Las entradas significativas de un archivo de config tipo +/// `locale.conf`: las líneas no vacías y no comentadas, trimeadas. +pub fn conf_entries(content: &str) -> Vec { + content + .lines() + .map(|l| l.trim()) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .map(String::from) + .collect() +} + +/// `true` si `s` es un hostname válido: ASCII alfanumérico más `-`, +/// `.`, `_`; longitud `1..=253`; sin espacios ni caracteres de control. +pub fn is_valid_hostname(s: &str) -> bool { + if s.is_empty() || s.len() > 253 { + return false; + } + s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_kv_encuentra_trimea_y_quita_comillas() { + let c = "# comentario\nLANG=en_US.UTF-8\n LC_TIME = \"es_AR.UTF-8\" \n"; + assert_eq!(parse_kv(c, "LANG").as_deref(), Some("en_US.UTF-8")); + assert_eq!(parse_kv(c, "LC_TIME").as_deref(), Some("es_AR.UTF-8")); + assert_eq!(parse_kv(c, "AUSENTE"), None); + } + + #[test] + fn parse_kv_ignora_comentarios_y_blancos() { + // Una clave comentada no cuenta. + assert_eq!(parse_kv("#LANG=x\n\nLANG=real\n", "LANG").as_deref(), Some("real")); + } + + #[test] + fn merge_kv_actualiza_la_clave_existente_y_conserva_el_resto() { + let antes = "LANG=C\nKEYMAP=us\n"; + let despues = merge_kv(antes, "KEYMAP", "es"); + assert!(despues.contains("LANG=C\n"), "conserva otras líneas"); + assert!(despues.contains("KEYMAP=es\n")); + assert!(!despues.contains("KEYMAP=us")); + } + + #[test] + fn merge_kv_inserta_la_clave_nueva_al_final() { + let despues = merge_kv("LANG=C\n", "KEYMAP", "us"); + assert!(despues.contains("LANG=C\n")); + assert!(despues.ends_with("KEYMAP=us\n")); + } + + #[test] + fn merge_kv_sobre_contenido_vacio() { + assert_eq!(merge_kv("", "KEYMAP", "us"), "KEYMAP=us\n"); + } + + #[test] + fn conf_entries_descarta_blancos_y_comentarios() { + let c = "\n# header\nLANG=en\n \nLC_TIME=es\n"; + assert_eq!(conf_entries(c), vec!["LANG=en", "LC_TIME=es"]); + } + + #[test] + fn is_valid_hostname_acepta_y_rechaza() { + assert!(is_valid_hostname("arje-host")); + assert!(is_valid_hostname("host.local")); + assert!(!is_valid_hostname(""), "vacío"); + assert!(!is_valid_hostname("con espacio")); + assert!(!is_valid_hostname("tab\tinside")); + assert!(!is_valid_hostname(&"x".repeat(254)), "demasiado largo"); + } + + #[test] + fn atomic_write_escribe_y_sobrescribe() { + let dir = std::env::temp_dir(); + let path = dir.join(format!("arje-compat-common-test-{}.conf", std::process::id())); + atomic_write(&path, b"primero\n").expect("escribe"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "primero\n"); + atomic_write(&path, b"segundo\n").expect("sobrescribe"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "segundo\n"); + let _ = std::fs::remove_file(&path); + } +} diff --git a/crates/compat/arje-hostnamed-compat/Cargo.toml b/crates/compat/arje-hostnamed-compat/Cargo.toml index 5f6ac90..30f9170 100644 --- a/crates/compat/arje-hostnamed-compat/Cargo.toml +++ b/crates/compat/arje-hostnamed-compat/Cargo.toml @@ -12,6 +12,7 @@ path = "src/main.rs" [dependencies] arje-card = { path = "../../protocol/arje-card" } arje-bus = { path = "../../runtime/arje-bus" } +arje-compat-common = { path = "../arje-compat-common" } nix = { workspace = true } libc = { workspace = true } anyhow = { workspace = true } diff --git a/crates/compat/arje-hostnamed-compat/src/main.rs b/crates/compat/arje-hostnamed-compat/src/main.rs index 738aa1a..03f687c 100644 --- a/crates/compat/arje-hostnamed-compat/src/main.rs +++ b/crates/compat/arje-hostnamed-compat/src/main.rs @@ -10,6 +10,7 @@ use arje_bus::{BusClient, BusRequest, BusResponse}; use arje_card::Capability; +use arje_compat_common::{atomic_write, is_valid_hostname, merge_kv, parse_kv}; use std::sync::Mutex; use tokio::signal::unix::{signal, SignalKind}; use tracing::{info, warn}; @@ -230,23 +231,11 @@ fn gethostname_libc() -> Option { } fn read_os_release_field(field: &str) -> Option { - parse_kv_file("/etc/os-release", field) + parse_kv(&std::fs::read_to_string("/etc/os-release").ok()?, field) } fn read_machine_info_field(field: &str) -> Option { - parse_kv_file("/etc/machine-info", field) -} - -fn parse_kv_file(path: &str, field: &str) -> Option { - let content = std::fs::read_to_string(path).ok()?; - for line in content.lines() { - if let Some((k, v)) = line.split_once('=') { - if k.trim() == field { - return Some(v.trim().trim_matches('"').to_string()); - } - } - } - None + parse_kv(&std::fs::read_to_string("/etc/machine-info").ok()?, field) } fn read_dmi(path: &str) -> String { @@ -255,56 +244,12 @@ fn read_dmi(path: &str) -> String { .unwrap_or_default() } -/// RFC 1123 + extra: ASCII alfanumérico, dash, dot. Longitud 1..253. -/// Rechaza vacíos, espacios, control chars. -fn is_valid_hostname(s: &str) -> bool { - if s.is_empty() || s.len() > 253 { return false; } - s.chars().all(|c| - c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' - ) -} - -/// Escritura atómica via tmp + rename. fsync del directorio para -/// garantizar durabilidad post-crash. Permisos 0644. -fn atomic_write(path: &str, content: &[u8]) -> std::io::Result<()> { - use std::io::Write; - use std::os::unix::fs::OpenOptionsExt; - let p = std::path::Path::new(path); - if let Some(parent) = p.parent() { let _ = std::fs::create_dir_all(parent); } - let tmp = p.with_extension("tmp"); - { - let mut f = std::fs::OpenOptions::new() - .create(true).write(true).truncate(true) - .mode(0o644) - .open(&tmp)?; - f.write_all(content)?; - f.sync_all()?; - } - std::fs::rename(&tmp, p)?; - Ok(()) -} - -/// Lee /etc/machine-info, actualiza/inserta una clave, escribe atómico. +/// Lee `/etc/machine-info`, actualiza/inserta una clave y reescribe el +/// archivo de forma atómica. fn update_machine_info(key: &str, value: &str) -> std::io::Result<()> { let path = "/etc/machine-info"; let existing = std::fs::read_to_string(path).unwrap_or_default(); - let mut found = false; - let mut out = String::new(); - for line in existing.lines() { - if let Some((k, _)) = line.split_once('=') { - if k.trim() == key { - out.push_str(&format!("{key}={value}\n")); - found = true; - continue; - } - } - out.push_str(line); - out.push('\n'); - } - if !found { - out.push_str(&format!("{key}={value}\n")); - } - atomic_write(path, out.as_bytes()) + atomic_write(path, merge_kv(&existing, key, value).as_bytes()) } async fn announce_to_fractal() { diff --git a/crates/compat/arje-localed-compat/Cargo.toml b/crates/compat/arje-localed-compat/Cargo.toml index 12aca62..e397486 100644 --- a/crates/compat/arje-localed-compat/Cargo.toml +++ b/crates/compat/arje-localed-compat/Cargo.toml @@ -12,6 +12,7 @@ path = "src/main.rs" [dependencies] arje-card = { path = "../../protocol/arje-card" } arje-bus = { path = "../../runtime/arje-bus" } +arje-compat-common = { path = "../arje-compat-common" } anyhow = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } diff --git a/crates/compat/arje-localed-compat/src/main.rs b/crates/compat/arje-localed-compat/src/main.rs index 67b4703..d316595 100644 --- a/crates/compat/arje-localed-compat/src/main.rs +++ b/crates/compat/arje-localed-compat/src/main.rs @@ -5,6 +5,7 @@ use arje_bus::{BusClient, BusRequest, BusResponse}; use arje_card::Capability; +use arje_compat_common::{atomic_write, conf_entries, merge_kv, parse_kv}; use std::sync::Mutex; use tokio::signal::unix::{signal, SignalKind}; use tracing::{info, warn}; @@ -57,10 +58,7 @@ impl LocaleManager { return v; } match std::fs::read_to_string("/etc/locale.conf") { - Ok(c) => c.lines() - .filter(|l| !l.trim().is_empty() && !l.starts_with('#')) - .map(|s| s.trim().to_string()) - .collect(), + Ok(c) => conf_entries(&c), Err(_) => vec!["LANG=C.UTF-8".into()], } } @@ -121,7 +119,14 @@ impl LocaleManager { _convert: bool, _interactive: bool, ) -> fdo::Result<()> { - info!(%keymap, %keymap_toggle, "SetVConsoleKeymap (stub)"); + let existing = std::fs::read_to_string("/etc/vconsole.conf").unwrap_or_default(); + let mut out = merge_kv(&existing, "KEYMAP", &keymap); + if !keymap_toggle.is_empty() { + out = merge_kv(&out, "KEYMAP_TOGGLE", &keymap_toggle); + } + atomic_write("/etc/vconsole.conf", out.as_bytes()) + .map_err(|e| fdo::Error::Failed(format!("write /etc/vconsole.conf: {e}")))?; + info!(%keymap, %keymap_toggle, "SetVConsoleKeymap → /etc/vconsole.conf"); Ok(()) } @@ -134,56 +139,53 @@ impl LocaleManager { _convert: bool, _interactive: bool, ) -> fdo::Result<()> { - info!(%layout, %model, %variant, %options, "SetX11Keyboard (stub)"); + let conf = format_x11_keyboard_conf(&layout, &model, &variant, &options); + atomic_write("/etc/X11/xorg.conf.d/00-keyboard.conf", conf.as_bytes()) + .map_err(|e| fdo::Error::Failed(format!("write 00-keyboard.conf: {e}")))?; + info!(%layout, %model, %variant, %options, "SetX11Keyboard → 00-keyboard.conf"); Ok(()) } } -fn atomic_write(path: &str, content: &[u8]) -> std::io::Result<()> { - use std::io::Write; - use std::os::unix::fs::OpenOptionsExt; - let p = std::path::Path::new(path); - if let Some(parent) = p.parent() { let _ = std::fs::create_dir_all(parent); } - let tmp = p.with_extension("tmp"); - { - let mut f = std::fs::OpenOptions::new() - .create(true).write(true).truncate(true) - .mode(0o644) - .open(&tmp)?; - f.write_all(content)?; - f.sync_all()?; +/// Lee el valor de un `Option "Clave" "valor"` de un snippet +/// `xorg.conf.d` — el valor es la SEGUNDA cadena entre comillas. +fn parse_xorg_option(content: &str, key: &str) -> Option { + for line in content.lines() { + let t = line.trim(); + if t.starts_with(&format!("Option \"{key}\"")) { + return t.split('"').nth(3).map(str::to_string); + } } - std::fs::rename(&tmp, p)?; - Ok(()) + None +} + +/// Genera el snippet `xorg.conf.d` que fija el teclado X11 — el mismo +/// `InputClass` que escribe systemd-localed. Omite los campos vacíos. +fn format_x11_keyboard_conf(layout: &str, model: &str, variant: &str, options: &str) -> String { + let mut s = String::from("# Generado por arje-localed-compat\n"); + s.push_str("Section \"InputClass\"\n"); + s.push_str(" Identifier \"system-keyboard\"\n"); + s.push_str(" MatchIsKeyboard \"on\"\n"); + for (opt, val) in [ + ("XkbLayout", layout), + ("XkbModel", model), + ("XkbVariant", variant), + ("XkbOptions", options), + ] { + if !val.is_empty() { + s.push_str(&format!(" Option \"{opt}\" \"{val}\"\n")); + } + } + s.push_str("EndSection\n"); + s } fn read_kv(path: &str, key: &str) -> Option { - let content = std::fs::read_to_string(path).ok()?; - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.starts_with(&format!("Option \"{key}\"")) || trimmed.starts_with(key) { - // Best-effort parse: tomar lo que está entre comillas. - if let Some(start) = trimmed.find('"') { - let rest = &trimmed[start + 1..]; - if let Some(end) = rest.find('"') { - return Some(rest[..end].to_string()); - } - } - } - } - None + parse_xorg_option(&std::fs::read_to_string(path).ok()?, key) } fn read_vconsole(key: &str) -> Option { - let content = std::fs::read_to_string("/etc/vconsole.conf").ok()?; - for line in content.lines() { - if let Some((k, v)) = line.split_once('=') { - if k.trim() == key { - return Some(v.trim().trim_matches('"').to_string()); - } - } - } - None + parse_kv(&std::fs::read_to_string("/etc/vconsole.conf").ok()?, key) } async fn announce_to_fractal() { @@ -217,3 +219,26 @@ fn init_tracing() { .unwrap_or_else(|_| EnvFilter::new("arje_localed_compat=info")); tracing_subscriber::fmt().with_env_filter(filter).with_target(true).init(); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_xorg_option_toma_la_segunda_cadena() { + let c = "Section \"InputClass\"\n Option \"XkbLayout\" \"us\"\n Option \"XkbVariant\" \"intl\"\nEndSection\n"; + assert_eq!(parse_xorg_option(c, "XkbLayout").as_deref(), Some("us")); + assert_eq!(parse_xorg_option(c, "XkbVariant").as_deref(), Some("intl")); + assert_eq!(parse_xorg_option(c, "XkbModel"), None); + } + + #[test] + fn format_x11_keyboard_conf_omite_los_campos_vacios() { + let conf = format_x11_keyboard_conf("us", "pc105", "", ""); + assert!(conf.contains("Option \"XkbLayout\" \"us\"")); + assert!(conf.contains("Option \"XkbModel\" \"pc105\"")); + assert!(!conf.contains("XkbVariant"), "el variant vacío se omite"); + assert!(conf.contains("Section \"InputClass\"")); + assert!(conf.contains("EndSection")); + } +}