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…
⎇ 534014c3ec69daea789a1d0523e1d00a69c6ed65
crates/takana-core/src/user.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
la imagen del servidor, armada y booteada — tres fallos que sólo aparecen ahí
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}