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>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user