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 · by sergio · 2026-05-22T20:25:58Z (5 months ago) · parent c4d1dd7bc · browse the tree at this commit
Semantic delta · 31
crates/compat/arje-compat-common/src/lib.rs
crates/compat/arje-hostnamed-compat/src/main.rs
crates/compat/arje-localed-compat/src/main.rs
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"));
+ }
+}