tawasuyu

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Sutichaykuna

feat(compat): arje-compat-common — núcleo puro y testeado para los shims

El hallazgo del repaso del monorepo: la capa compat (14 shims D-Bus de
systemd) era lo más incompleto relativo a su peso — load-bearing para
correr GNOME/KDE sobre arje, y con CERO tests. Cada shim copiaba su
propio `atomic_write`, su parseo `KEY=value` y sus validadores.

Primer golpe:

- `arje-compat-common`: crate nuevo con la lógica pura compartida
  (atomic_write, parse_kv, merge_kv, conf_entries, is_valid_hostname),
  cubierta con 8 tests. Antes esa lógica vivía duplicada y sin un test.
- `arje-hostnamed-compat` y `arje-localed-compat` migrados al núcleo —
  quedan más finos y su lógica pasa a estar cubierta.
- localed: los dos setters que eran stub (sólo loggeaban) ahora
  escriben de verdad — `SetVConsoleKeymap` → /etc/vconsole.conf,
  `SetX11Keyboard` → 00-keyboard.conf. + 2 tests propios.
- Bug corregido de paso: el parser xorg de localed devolvía el NOMBRE
  de la opción en vez del valor (tomaba la 1ª comilla); ahora toma la
  2ª cadena, la correcta.

Compat: de 0 a 10 tests. Quedan 12 shims con la misma migración
mecánica pendiente; el plato fuerte real es `Inhibit` en logind.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

8f946b449c2e6dc4f2bf09921750075499193a05 · ruwaq sergio · (5 killa ñawpaq) · tayta c4d1dd7bc · kay commitpi sach'ata qhaway

Semántico sayk'uy · 31

crates/compat/arje-hostnamed-compat/src/main.rs

crates/compat/arje-localed-compat/src/main.rs

8 qillqakuna · +261 −104

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<Path>, 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<String> {
+    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<String> {
+    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<String> {
 }
 
 fn read_os_release_field(field: &str) -> Option<String> {
-    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<String> {
-    parse_kv_file("/etc/machine-info", field)
-}
-
-fn parse_kv_file(path: &str, field: &str) -> Option<String> {
-    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()?;
-    }
-    std::fs::rename(&tmp, p)?;
-    Ok(())
-}
-
-fn read_kv(path: &str, key: &str) -> Option<String> {
-    let content = std::fs::read_to_string(path).ok()?;
+/// 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<String> {
     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());
-                }
-            }
+        let t = line.trim();
+        if t.starts_with(&format!("Option \"{key}\"")) {
+            return t.split('"').nth(3).map(str::to_string);
         }
     }
     None
 }
 
-fn read_vconsole(key: &str) -> Option<String> {
-    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());
-            }
+/// 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"));
         }
     }
-    None
+    s.push_str("EndSection\n");
+    s
+}
+
+fn read_kv(path: &str, key: &str) -> Option<String> {
+    parse_xorg_option(&std::fs::read_to_string(path).ok()?, key)
+}
+
+fn read_vconsole(key: &str) -> Option<String> {
+    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"));
+    }
+}