gioser

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 815Rust 56Shell 37Markdown 291229 Definitions

⎇ d611f8577d0ebb0f265143be9007a5b94a7dd1fc crates/takana-core/src/user.rs Rust · 294 lines · 11.7 KB History Who touched what raw

[[user]] en la receta: la otra mitad del SDD 30 — un demonio que no corre como root necesita cuenta d611f8577 · 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#[cfg(test)]
217mod tests {
218    use super::*;
219
220    fn gitea() -> User {
221        User {
222            name: "gitea".into(),
223            uid: 916,
224            gid: None,
225            group: None,
226            home: "/var/lib/gitea".into(),
227            shell: "/bin/false".into(),
228            comment: None,
229        }
230    }
231
232    #[test]
233    fn lineas_con_el_formato_de_passwd_y_group() {
234        let u = gitea();
235        assert_eq!(u.passwd_line(), "gitea:x:916:916:gitea:/var/lib/gitea:/bin/false");
236        assert_eq!(u.group_line(), "gitea:x:916:");
237    }
238
239    #[test]
240    fn rechaza_lo_reservado_por_el_producto() {
241        let mut u = gitea();
242        u.uid = 74; // el de `sshd` en PRODUCT_PASSWD
243        assert!(u.validate().unwrap_err().to_string().contains("reserva"));
244        let mut u = gitea();
245        u.name = "root".into();
246        assert!(u.validate().is_err());
247    }
248
249    #[test]
250    fn rechaza_nombre_con_dos_puntos_que_partiria_la_linea() {
251        let mut u = gitea();
252        u.name = "git:ea".into();
253        assert!(u.validate().unwrap_err().to_string().contains("user.name"));
254    }
255
256    #[test]
257    fn rechaza_uid_fuera_de_rango_y_home_relativo() {
258        let mut u = gitea();
259        u.uid = 42;
260        assert!(u.validate().unwrap_err().to_string().contains("fuera de rango"));
261        let mut u = gitea();
262        u.home = "var/lib/gitea".into();
263        assert!(u.validate().unwrap_err().to_string().contains("absoluta"));
264    }
265
266    #[test]
267    fn duplicados_dentro_de_la_receta() {
268        let a = gitea();
269        let mut b = gitea();
270        b.name = "otro".into();
271        assert!(validate_all(&[a.clone(), b]).unwrap_err().to_string().contains("uid duplicado"));
272        assert!(validate_all(&[a.clone(), a]).unwrap_err().to_string().contains("name duplicado"));
273    }
274
275    #[test]
276    fn fusionar_es_idempotente_y_no_pisa() {
277        let base = "root:x:0:0:root:/root:/bin/sh\nsshd:x:74:74:sshd privsep:/var/empty:/bin/false\n";
278        let l = vec![gitea().passwd_line()];
279        let (uno, add1, conf1) = fusionar(base, &l);
280        assert_eq!(add1.len(), 1);
281        assert!(conf1.is_empty());
282        // Segunda pasada: nada nuevo, nada duplicado. Es lo que hace que componer dos veces sea seguro.
283        let (dos, add2, conf2) = fusionar(&uno, &l);
284        assert_eq!(uno, dos);
285        assert!(add2.is_empty() && conf2.is_empty());
286        assert_eq!(uno.lines().filter(|l| l.starts_with("gitea:")).count(), 1);
287        // Misma cuenta con otro uid: NO se pisa, se reporta.
288        let mut otro = gitea();
289        otro.uid = 917;
290        let (tres, add3, conf3) = fusionar(&uno, &[otro.passwd_line()]);
291        assert_eq!(tres, uno, "un conflicto no cambia el fichero");
292        assert!(add3.is_empty() && conf3.len() == 1, "{conf3:?}");
293    }
294}