tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Sutichaykuna

⎇ main crates/takana-core/src/user.rs Rust · 359 siq'ikuna · 14.9 KB Ñawpa willay Pi imata llamkarqan ch'uya

la imagen del servidor, armada y booteada — tres fallos que sólo aparecen ahí 467a87cdb · Sergio ·

1//! `[[user]]` — **las cuentas que un paquete necesita para correr** (SDD 30, segunda mitad).
2//!
3//! ## Por qué existe
4//!
5//! El SDD 30 sacó las Cards de servicio de una constante de Rust y las puso en la receta: el
6//! paquete que trae el demonio es el que dice cómo se supervisa. Los **usuarios** se quedaron donde
7//! estaban las Cards — `takana_bootstrap::PRODUCT_PASSWD` es un literal con `root` y `sshd`, y
8//! añadir un tercero seguía siendo editar Rust y recompilar takana.
9//!
10//! Eso no es simetría incompleta: es un servicio que no arranca. `gitea` **se niega a correr como
11//! root** (`[F] Gitea is not supposed to be run as root`), así que su Card hace `setuidgid gitea` y
12//! sin la cuenta el demonio muere en bucle. El mismo patrón tienen postgres, caddy cuando no quiere
13//! privilegios, y cualquier cosa que haga `drop privileges` por su cuenta.
14//!
15//! ## El uid se DECLARA, no se asigna
16//!
17//! Igual que el ULID de una Card, y por la misma razón: un uid asignado al vuelo («el primero libre
18//! a partir de 1000») hace que dos imágenes del mismo perfil salgan con dueños distintos, y el
19//! rootfs **deja de reproducir sin que nada falle** — los ficheros se ven iguales, `ls -l` dice otro
20//! número. Que sea estable es requisito, no detalle.
21//!
22//! ## Fuera de `hash_inputs`
23//!
24//! Como `license`, `slots`, `evidence` y `[[service]]`: describe qué cuenta necesita el artefacto
25//! para correr, no qué bytes tiene. Declararlo en recetas YA SELLADAS no mueve un solo
26//! `ArtifactHash`.
27//!
28//! ## Lo que este módulo NO hace
29//!
30//! No crea usuarios ni escribe `/etc/passwd`: emite las LÍNEAS y quien compone la imagen las
31//! fusiona ([`User::passwd_line`], [`User::group_line`]). Tampoco pone contraseña: todas las cuentas
32//! salen con `x` en el campo de password y sin entrada en `/etc/shadow`, o sea **sin login posible**
33//! — una cuenta de servicio a la que alguien pueda entrar es una puerta que nadie declaró.
34
35use serde::{Deserialize, Serialize};
36
37/// `root` y `sshd` los pone el producto (`takana_bootstrap::PRODUCT_PASSWD`); `nobody` es
38/// convención universal. Una receta que los redeclare estaría pisando al producto con otro uid, y
39/// el síntoma sería un rootfs cuyos ficheros pertenecen a quien no debe.
40const RESERVADOS: &[(&str, u32)] = &[("root", 0), ("sshd", 74), ("nobody", 65534)];
41
42/// Una cuenta de sistema que una receta declara necesitar.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct User {
45    /// Nombre de la cuenta (`gitea`). Es también el nombre del grupo salvo que `group` diga otro.
46    pub name: String,
47    /// **Se declara, no se asigna** — ver el doc del módulo.
48    pub uid: u32,
49    /// Grupo primario. Ausente ⇒ el mismo número que `uid` (el patrón usuario-grupo propio).
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub gid: Option<u32>,
52    /// Nombre del grupo primario. Ausente ⇒ `name`.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub group: Option<String>,
55    /// Directorio personal, absoluto. Es donde el demonio guarda su estado (`/var/lib/gitea`).
56    pub home: String,
57    /// Shell. Default `/bin/false`: una cuenta de servicio no inicia sesión.
58    #[serde(default = "shell_por_defecto")]
59    pub shell: String,
60    /// Campo GECOS. Default: el nombre.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub comment: Option<String>,
63}
64
65fn shell_por_defecto() -> String {
66    "/bin/false".to_string()
67}
68
69impl User {
70    pub fn gid(&self) -> u32 {
71        self.gid.unwrap_or(self.uid)
72    }
73
74    pub fn group(&self) -> &str {
75        self.group.as_deref().unwrap_or(&self.name)
76    }
77
78    /// La línea de `/etc/passwd`, sin salto final.
79    pub fn passwd_line(&self) -> String {
80        let gecos = self.comment.as_deref().unwrap_or(&self.name);
81        format!(
82            "{}:x:{}:{}:{}:{}:{}",
83            self.name,
84            self.uid,
85            self.gid(),
86            gecos,
87            self.home,
88            self.shell
89        )
90    }
91
92    /// La línea de `/etc/group`, sin salto final.
93    pub fn group_line(&self) -> String {
94        format!("{}:x:{}:", self.group(), self.gid())
95    }
96
97    /// Valida la FORMA. No comprueba que el `home` exista: eso es del que compone la imagen.
98    pub fn validate(&self) -> crate::Result<()> {
99        let bad = |m: String| Err(crate::Error::Recipe(m));
100        // El nombre entra en una línea separada por `:` y es también nombre de fichero en algunos
101        // consumidores: un `:` de más parte la línea en dos campos y el fallo aparece lejos.
102        let nombre_ok = !self.name.is_empty()
103            && self.name.len() <= 32
104            && self
105                .name
106                .bytes()
107                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
108            && !self.name.starts_with('-');
109        if !nombre_ok {
110            return bad(format!(
111                "user.name inválido: {:?} (minúsculas, dígitos, `_` y `-`; ≤32; sin `:`)",
112                self.name
113            ));
114        }
115        for (n, u) in RESERVADOS {
116            if self.name == *n || self.uid == *u {
117                return bad(format!(
118                    "user[{}]: `{n}`/uid {u} lo reserva el producto o la convención — \
119                     redeclararlo pisaría al dueño de ficheros que ya existen",
120                    self.name
121                ));
122            }
123        }
124        // 1..100 es territorio de las cuentas históricas de las distros; ≥65534 son `nobody` y el
125        // uid inválido (u32::MAX). El rango de cuentas de servicio es el de en medio.
126        if !(100..=65533).contains(&self.uid) {
127            return bad(format!(
128                "user[{}].uid = {} fuera de rango: usá 100..=65533 (cuentas de servicio)",
129                self.name, self.uid
130            ));
131        }
132        if let Some(g) = self.gid {
133            if !(100..=65533).contains(&g) {
134                return bad(format!(
135                    "user[{}].gid = {g} fuera de rango: usá 100..=65533",
136                    self.name
137                ));
138            }
139        }
140        if !self.home.starts_with('/') || self.home.contains(':') {
141            return bad(format!(
142                "user[{}].home debe ser ruta absoluta y sin `:`, es {:?}",
143                self.name, self.home
144            ));
145        }
146        if !self.shell.starts_with('/') || self.shell.contains(':') {
147            return bad(format!(
148                "user[{}].shell debe ser ruta absoluta y sin `:`, es {:?}",
149                self.name, self.shell
150            ));
151        }
152        if let Some(c) = &self.comment {
153            if c.contains(':') {
154                return bad(format!("user[{}].comment no puede contener `:`", self.name));
155            }
156        }
157        Ok(())
158    }
159}
160
161/// Valida una lista y rechaza nombres y uids repetidos **dentro de la misma receta**.
162pub fn validate_all(users: &[User]) -> crate::Result<()> {
163    let mut nombres = std::collections::BTreeSet::new();
164    let mut uids = std::collections::BTreeSet::new();
165    for u in users {
166        u.validate()?;
167        if !nombres.insert(&u.name) {
168            return Err(crate::Error::Recipe(format!("user.name duplicado: {:?}", u.name)));
169        }
170        if !uids.insert(u.uid) {
171            return Err(crate::Error::Recipe(format!(
172                "user.uid duplicado: {} (lo usan {:?} y otro)",
173                u.uid, u.name
174            )));
175        }
176    }
177    Ok(())
178}
179
180/// Fusiona líneas en un fichero tipo `passwd`/`group` **por CLAVE, no por línea entera**.
181///
182/// Devuelve `(contenido_nuevo, añadidas, conflictos)`. Un conflicto es una cuenta cuyo nombre ya
183/// está y con otra línea: no se pisa nunca — se reporta. Pisar la entrada de un usuario existente
184/// es cambiarle el uid a los ficheros que ya son suyos, y eso no se ve hasta que algo no puede leer
185/// su propio estado.
186///
187/// La idempotencia es el requisito: componer una imagen dos veces no puede dejar la cuenta dos
188/// veces, y `grep -q` sobre la línea entera no alcanza porque la misma cuenta con el GECOS cambiado
189/// se leería como nueva.
190pub fn fusionar(contenido: &str, lineas: &[String]) -> (String, Vec<String>, Vec<String>) {
191    let clave = |l: &str| l.split(':').next().unwrap_or("").to_string();
192    let existentes: std::collections::BTreeMap<String, String> = contenido
193        .lines()
194        .filter(|l| !l.trim().is_empty())
195        .map(|l| (clave(l), l.to_string()))
196        .collect();
197    let (mut anadidas, mut conflictos) = (Vec::new(), Vec::new());
198    let mut out = contenido.trim_end_matches('\n').to_string();
199    for l in lineas {
200        match existentes.get(&clave(l)) {
201            Some(viejo) if viejo == l => {}           // ya está, idéntica ⇒ nada que hacer
202            Some(viejo) => conflictos.push(format!("{} ≠ {}", viejo, l)),
203            None => {
204                if !out.is_empty() {
205                    out.push('\n');
206                }
207                out.push_str(l);
208                anadidas.push(l.clone());
209            }
210        }
211    }
212    out.push('\n');
213    (out, anadidas, conflictos)
214}
215
216/// Escribe `contenido` en `p` **rompiendo el hardlink**: temporal en el mismo directorio + `rename`.
217///
218/// ⚠ Esto no es higiene, es lo único correcto, y se descubrió al armar la primera imagen: un rootfs
219/// hidratado se arma con **hardlinks contra el store**, así que su `/etc/passwd` y el del artefacto
220/// `product-rootfs` **son el mismo inode** (medido: mismo número de inode, `2 links`, modo `444`).
221/// Un `fs::write` sobre él abre ESE inode en truncate y **modifica el artefacto sellado** — todas
222/// las imágenes futuras saldrían con la cuenta metida dentro del producto, y el store mentiría
223/// sobre lo que contiene su propio hash. Acá se salvó porque el store es de sólo lectura y el
224/// primer intento murió con `Permission denied`; confiar en eso sería confiar en un permiso.
225///
226/// `rename` sobre el mismo directorio es atómico y deja el fichero del store intacto con un link
227/// menos. El modo sale 0644: el del store es 0444 y un `passwd` de sólo lectura rompe a cualquiera
228/// que después añada una cuenta.
229pub fn escribir_rompiendo_hardlink(p: &std::path::Path, contenido: &str) -> crate::Result<()> {
230    let dir = p.parent().unwrap_or(std::path::Path::new("."));
231    let tmp = dir.join(format!(
232        ".{}.users-tmp",
233        p.file_name().and_then(|s| s.to_str()).unwrap_or("fichero")
234    ));
235    std::fs::write(&tmp, contenido)?;
236    #[cfg(unix)]
237    {
238        use std::os::unix::fs::PermissionsExt;
239        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644))?;
240    }
241    std::fs::rename(&tmp, p)?;
242    Ok(())
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    fn gitea() -> User {
250        User {
251            name: "gitea".into(),
252            uid: 916,
253            gid: None,
254            group: None,
255            home: "/var/lib/gitea".into(),
256            shell: "/bin/false".into(),
257            comment: None,
258        }
259    }
260
261    #[test]
262    fn lineas_con_el_formato_de_passwd_y_group() {
263        let u = gitea();
264        assert_eq!(u.passwd_line(), "gitea:x:916:916:gitea:/var/lib/gitea:/bin/false");
265        assert_eq!(u.group_line(), "gitea:x:916:");
266    }
267
268    #[test]
269    fn rechaza_lo_reservado_por_el_producto() {
270        let mut u = gitea();
271        u.uid = 74; // el de `sshd` en PRODUCT_PASSWD
272        assert!(u.validate().unwrap_err().to_string().contains("reserva"));
273        let mut u = gitea();
274        u.name = "root".into();
275        assert!(u.validate().is_err());
276    }
277
278    #[test]
279    fn rechaza_nombre_con_dos_puntos_que_partiria_la_linea() {
280        let mut u = gitea();
281        u.name = "git:ea".into();
282        assert!(u.validate().unwrap_err().to_string().contains("user.name"));
283    }
284
285    #[test]
286    fn rechaza_uid_fuera_de_rango_y_home_relativo() {
287        let mut u = gitea();
288        u.uid = 42;
289        assert!(u.validate().unwrap_err().to_string().contains("fuera de rango"));
290        let mut u = gitea();
291        u.home = "var/lib/gitea".into();
292        assert!(u.validate().unwrap_err().to_string().contains("absoluta"));
293    }
294
295    #[test]
296    fn duplicados_dentro_de_la_receta() {
297        let a = gitea();
298        let mut b = gitea();
299        b.name = "otro".into();
300        assert!(validate_all(&[a.clone(), b]).unwrap_err().to_string().contains("uid duplicado"));
301        assert!(validate_all(&[a.clone(), a]).unwrap_err().to_string().contains("name duplicado"));
302    }
303
304    /// El control del fallo que casi envenena el store: escribir el `passwd` de un rootfs
305    /// hidratado NO puede tocar el fichero del artefacto con el que comparte inode.
306    #[test]
307    fn escribir_no_toca_el_fichero_hardlinkeado() {
308        use std::os::unix::fs::MetadataExt;
309        let dir = std::env::temp_dir().join(format!("takana-user-test-{}", std::process::id()));
310        let _ = std::fs::remove_dir_all(&dir);
311        std::fs::create_dir_all(dir.join("store")).unwrap();
312        std::fs::create_dir_all(dir.join("rootfs")).unwrap();
313        let en_store = dir.join("store/passwd");
314        let en_rootfs = dir.join("rootfs/passwd");
315        std::fs::write(&en_store, "root:x:0:0:root:/root:/bin/sh\n").unwrap();
316        std::fs::hard_link(&en_store, &en_rootfs).unwrap();
317        assert_eq!(
318            std::fs::metadata(&en_store).unwrap().ino(),
319            std::fs::metadata(&en_rootfs).unwrap().ino(),
320            "el montaje del test tiene que hardlinkear para que el control valga"
321        );
322
323        escribir_rompiendo_hardlink(&en_rootfs, "root:x:0:0:root:/root:/bin/sh\ngitea:x:916:916:gitea:/var/lib/gitea:/bin/false\n").unwrap();
324
325        assert_eq!(
326            std::fs::read_to_string(&en_store).unwrap(),
327            "root:x:0:0:root:/root:/bin/sh\n",
328            "SE MODIFICÓ EL ARTEFACTO DEL STORE"
329        );
330        assert!(std::fs::read_to_string(&en_rootfs).unwrap().contains("gitea:"));
331        assert_ne!(
332            std::fs::metadata(&en_store).unwrap().ino(),
333            std::fs::metadata(&en_rootfs).unwrap().ino(),
334            "el hardlink no se rompió"
335        );
336        assert_eq!(std::fs::metadata(&en_store).unwrap().nlink(), 1);
337        let _ = std::fs::remove_dir_all(&dir);
338    }
339
340    #[test]
341    fn fusionar_es_idempotente_y_no_pisa() {
342        let base = "root:x:0:0:root:/root:/bin/sh\nsshd:x:74:74:sshd privsep:/var/empty:/bin/false\n";
343        let l = vec![gitea().passwd_line()];
344        let (uno, add1, conf1) = fusionar(base, &l);
345        assert_eq!(add1.len(), 1);
346        assert!(conf1.is_empty());
347        // Segunda pasada: nada nuevo, nada duplicado. Es lo que hace que componer dos veces sea seguro.
348        let (dos, add2, conf2) = fusionar(&uno, &l);
349        assert_eq!(uno, dos);
350        assert!(add2.is_empty() && conf2.is_empty());
351        assert_eq!(uno.lines().filter(|l| l.starts_with("gitea:")).count(), 1);
352        // Misma cuenta con otro uid: NO se pisa, se reporta.
353        let mut otro = gitea();
354        otro.uid = 917;
355        let (tres, add3, conf3) = fusionar(&uno, &[otro.passwd_line()]);
356        assert_eq!(tres, uno, "un conflicto no cambia el fichero");
357        assert!(add3.is_empty() && conf3.len() == 1, "{conf3:?}");
358    }
359}