gioser

tawasuyu/brahman

Rust 212TOML 64JSON 14HTML 12703 Sutichaykuna

⎇ 8f946b449c2e6dc4f2bf09921750075499193a05 crates/compat/arje-compat-common/src/lib.rs Rust · 168 siq'ikuna · 5.7 KB Ñawpa willay Pi imata llamkarqan ch'uya

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

1//! `arje-compat-common` — lógica pura compartida por los shims D-Bus de
2//! compat (`arje-*-compat`).
3//!
4//! Los shims son binarios que traducen interfaces de systemd al bus
5//! interno de `arje`. Antes cada uno copiaba su propio parseo de
6//! archivos `KEY=value`, su escritura atómica y sus validadores —
7//! código duplicado catorce veces y **sin un solo test**. Esta crate
8//! junta esa lógica pura en un lugar y la cubre con tests; los shims
9//! quedan como pura cáscara D-Bus.
10
11#![forbid(unsafe_code)]
12
13use std::io;
14use std::path::Path;
15
16/// Escribe `content` en `path` de forma atómica: vuelca a un archivo
17/// temporal vecino, hace `fsync`, y renombra sobre el destino —de modo
18/// que un lector nunca ve un archivo a medio escribir—. Permisos 0644.
19/// Crea el directorio padre si falta.
20pub fn atomic_write(path: impl AsRef<Path>, content: &[u8]) -> io::Result<()> {
21    use io::Write;
22    use std::os::unix::fs::OpenOptionsExt;
23    let p = path.as_ref();
24    if let Some(parent) = p.parent() {
25        let _ = std::fs::create_dir_all(parent);
26    }
27    let tmp = p.with_extension("tmp");
28    {
29        let mut f = std::fs::OpenOptions::new()
30            .create(true)
31            .write(true)
32            .truncate(true)
33            .mode(0o644)
34            .open(&tmp)?;
35        f.write_all(content)?;
36        f.sync_all()?;
37    }
38    std::fs::rename(&tmp, p)
39}
40
41/// Busca `key` en un contenido de líneas `KEY=value` y devuelve su
42/// valor, trimeado y sin las comillas envolventes. Ignora líneas en
43/// blanco y comentarios (`#`). `None` si la clave no aparece.
44pub fn parse_kv(content: &str, key: &str) -> Option<String> {
45    for line in content.lines() {
46        let line = line.trim();
47        if line.is_empty() || line.starts_with('#') {
48            continue;
49        }
50        if let Some((k, v)) = line.split_once('=') {
51            if k.trim() == key {
52                return Some(v.trim().trim_matches('"').to_string());
53            }
54        }
55    }
56    None
57}
58
59/// Actualiza —o inserta, si no existe— la clave `key` con `value` en un
60/// contenido de líneas `KEY=value`. Conserva el resto de las líneas y
61/// su orden. El resultado termina en `\n`.
62pub fn merge_kv(existing: &str, key: &str, value: &str) -> String {
63    let mut out = String::new();
64    let mut found = false;
65    for line in existing.lines() {
66        if let Some((k, _)) = line.split_once('=') {
67            if k.trim() == key {
68                out.push_str(&format!("{key}={value}\n"));
69                found = true;
70                continue;
71            }
72        }
73        out.push_str(line);
74        out.push('\n');
75    }
76    if !found {
77        out.push_str(&format!("{key}={value}\n"));
78    }
79    out
80}
81
82/// Las entradas significativas de un archivo de config tipo
83/// `locale.conf`: las líneas no vacías y no comentadas, trimeadas.
84pub fn conf_entries(content: &str) -> Vec<String> {
85    content
86        .lines()
87        .map(|l| l.trim())
88        .filter(|l| !l.is_empty() && !l.starts_with('#'))
89        .map(String::from)
90        .collect()
91}
92
93/// `true` si `s` es un hostname válido: ASCII alfanumérico más `-`,
94/// `.`, `_`; longitud `1..=253`; sin espacios ni caracteres de control.
95pub fn is_valid_hostname(s: &str) -> bool {
96    if s.is_empty() || s.len() > 253 {
97        return false;
98    }
99    s.chars()
100        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_')
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn parse_kv_encuentra_trimea_y_quita_comillas() {
109        let c = "# comentario\nLANG=en_US.UTF-8\n  LC_TIME = \"es_AR.UTF-8\" \n";
110        assert_eq!(parse_kv(c, "LANG").as_deref(), Some("en_US.UTF-8"));
111        assert_eq!(parse_kv(c, "LC_TIME").as_deref(), Some("es_AR.UTF-8"));
112        assert_eq!(parse_kv(c, "AUSENTE"), None);
113    }
114
115    #[test]
116    fn parse_kv_ignora_comentarios_y_blancos() {
117        // Una clave comentada no cuenta.
118        assert_eq!(parse_kv("#LANG=x\n\nLANG=real\n", "LANG").as_deref(), Some("real"));
119    }
120
121    #[test]
122    fn merge_kv_actualiza_la_clave_existente_y_conserva_el_resto() {
123        let antes = "LANG=C\nKEYMAP=us\n";
124        let despues = merge_kv(antes, "KEYMAP", "es");
125        assert!(despues.contains("LANG=C\n"), "conserva otras líneas");
126        assert!(despues.contains("KEYMAP=es\n"));
127        assert!(!despues.contains("KEYMAP=us"));
128    }
129
130    #[test]
131    fn merge_kv_inserta_la_clave_nueva_al_final() {
132        let despues = merge_kv("LANG=C\n", "KEYMAP", "us");
133        assert!(despues.contains("LANG=C\n"));
134        assert!(despues.ends_with("KEYMAP=us\n"));
135    }
136
137    #[test]
138    fn merge_kv_sobre_contenido_vacio() {
139        assert_eq!(merge_kv("", "KEYMAP", "us"), "KEYMAP=us\n");
140    }
141
142    #[test]
143    fn conf_entries_descarta_blancos_y_comentarios() {
144        let c = "\n# header\nLANG=en\n   \nLC_TIME=es\n";
145        assert_eq!(conf_entries(c), vec!["LANG=en", "LC_TIME=es"]);
146    }
147
148    #[test]
149    fn is_valid_hostname_acepta_y_rechaza() {
150        assert!(is_valid_hostname("arje-host"));
151        assert!(is_valid_hostname("host.local"));
152        assert!(!is_valid_hostname(""), "vacío");
153        assert!(!is_valid_hostname("con espacio"));
154        assert!(!is_valid_hostname("tab\tinside"));
155        assert!(!is_valid_hostname(&"x".repeat(254)), "demasiado largo");
156    }
157
158    #[test]
159    fn atomic_write_escribe_y_sobrescribe() {
160        let dir = std::env::temp_dir();
161        let path = dir.join(format!("arje-compat-common-test-{}.conf", std::process::id()));
162        atomic_write(&path, b"primero\n").expect("escribe");
163        assert_eq!(std::fs::read_to_string(&path).unwrap(), "primero\n");
164        atomic_write(&path, b"segundo\n").expect("sobrescribe");
165        assert_eq!(std::fs::read_to_string(&path).unwrap(), "segundo\n");
166        let _ = std::fs::remove_file(&path);
167    }
168}