//! `[[user]]` — **las cuentas que un paquete necesita para correr** (SDD 30, segunda mitad). //! //! ## Por qué existe //! //! El SDD 30 sacó las Cards de servicio de una constante de Rust y las puso en la receta: el //! paquete que trae el demonio es el que dice cómo se supervisa. Los **usuarios** se quedaron donde //! estaban las Cards — `takana_bootstrap::PRODUCT_PASSWD` es un literal con `root` y `sshd`, y //! añadir un tercero seguía siendo editar Rust y recompilar takana. //! //! Eso no es simetría incompleta: es un servicio que no arranca. `gitea` **se niega a correr como //! root** (`[F] Gitea is not supposed to be run as root`), así que su Card hace `setuidgid gitea` y //! sin la cuenta el demonio muere en bucle. El mismo patrón tienen postgres, caddy cuando no quiere //! privilegios, y cualquier cosa que haga `drop privileges` por su cuenta. //! //! ## El uid se DECLARA, no se asigna //! //! Igual que el ULID de una Card, y por la misma razón: un uid asignado al vuelo («el primero libre //! a partir de 1000») hace que dos imágenes del mismo perfil salgan con dueños distintos, y el //! rootfs **deja de reproducir sin que nada falle** — los ficheros se ven iguales, `ls -l` dice otro //! número. Que sea estable es requisito, no detalle. //! //! ## Fuera de `hash_inputs` //! //! Como `license`, `slots`, `evidence` y `[[service]]`: describe qué cuenta necesita el artefacto //! para correr, no qué bytes tiene. Declararlo en recetas YA SELLADAS no mueve un solo //! `ArtifactHash`. //! //! ## Lo que este módulo NO hace //! //! No crea usuarios ni escribe `/etc/passwd`: emite las LÍNEAS y quien compone la imagen las //! fusiona ([`User::passwd_line`], [`User::group_line`]). Tampoco pone contraseña: todas las cuentas //! salen con `x` en el campo de password y sin entrada en `/etc/shadow`, o sea **sin login posible** //! — una cuenta de servicio a la que alguien pueda entrar es una puerta que nadie declaró. use serde::{Deserialize, Serialize}; /// `root` y `sshd` los pone el producto (`takana_bootstrap::PRODUCT_PASSWD`); `nobody` es /// convención universal. Una receta que los redeclare estaría pisando al producto con otro uid, y /// el síntoma sería un rootfs cuyos ficheros pertenecen a quien no debe. const RESERVADOS: &[(&str, u32)] = &[("root", 0), ("sshd", 74), ("nobody", 65534)]; /// Una cuenta de sistema que una receta declara necesitar. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct User { /// Nombre de la cuenta (`gitea`). Es también el nombre del grupo salvo que `group` diga otro. pub name: String, /// **Se declara, no se asigna** — ver el doc del módulo. pub uid: u32, /// Grupo primario. Ausente ⇒ el mismo número que `uid` (el patrón usuario-grupo propio). #[serde(default, skip_serializing_if = "Option::is_none")] pub gid: Option, /// Nombre del grupo primario. Ausente ⇒ `name`. #[serde(default, skip_serializing_if = "Option::is_none")] pub group: Option, /// Directorio personal, absoluto. Es donde el demonio guarda su estado (`/var/lib/gitea`). pub home: String, /// Shell. Default `/bin/false`: una cuenta de servicio no inicia sesión. #[serde(default = "shell_por_defecto")] pub shell: String, /// Campo GECOS. Default: el nombre. #[serde(default, skip_serializing_if = "Option::is_none")] pub comment: Option, } fn shell_por_defecto() -> String { "/bin/false".to_string() } impl User { pub fn gid(&self) -> u32 { self.gid.unwrap_or(self.uid) } pub fn group(&self) -> &str { self.group.as_deref().unwrap_or(&self.name) } /// La línea de `/etc/passwd`, sin salto final. pub fn passwd_line(&self) -> String { let gecos = self.comment.as_deref().unwrap_or(&self.name); format!( "{}:x:{}:{}:{}:{}:{}", self.name, self.uid, self.gid(), gecos, self.home, self.shell ) } /// La línea de `/etc/group`, sin salto final. pub fn group_line(&self) -> String { format!("{}:x:{}:", self.group(), self.gid()) } /// Valida la FORMA. No comprueba que el `home` exista: eso es del que compone la imagen. pub fn validate(&self) -> crate::Result<()> { let bad = |m: String| Err(crate::Error::Recipe(m)); // El nombre entra en una línea separada por `:` y es también nombre de fichero en algunos // consumidores: un `:` de más parte la línea en dos campos y el fallo aparece lejos. let nombre_ok = !self.name.is_empty() && self.name.len() <= 32 && self .name .bytes() .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-') && !self.name.starts_with('-'); if !nombre_ok { return bad(format!( "user.name inválido: {:?} (minúsculas, dígitos, `_` y `-`; ≤32; sin `:`)", self.name )); } for (n, u) in RESERVADOS { if self.name == *n || self.uid == *u { return bad(format!( "user[{}]: `{n}`/uid {u} lo reserva el producto o la convención — \ redeclararlo pisaría al dueño de ficheros que ya existen", self.name )); } } // 1..100 es territorio de las cuentas históricas de las distros; ≥65534 son `nobody` y el // uid inválido (u32::MAX). El rango de cuentas de servicio es el de en medio. if !(100..=65533).contains(&self.uid) { return bad(format!( "user[{}].uid = {} fuera de rango: usá 100..=65533 (cuentas de servicio)", self.name, self.uid )); } if let Some(g) = self.gid { if !(100..=65533).contains(&g) { return bad(format!( "user[{}].gid = {g} fuera de rango: usá 100..=65533", self.name )); } } if !self.home.starts_with('/') || self.home.contains(':') { return bad(format!( "user[{}].home debe ser ruta absoluta y sin `:`, es {:?}", self.name, self.home )); } if !self.shell.starts_with('/') || self.shell.contains(':') { return bad(format!( "user[{}].shell debe ser ruta absoluta y sin `:`, es {:?}", self.name, self.shell )); } if let Some(c) = &self.comment { if c.contains(':') { return bad(format!("user[{}].comment no puede contener `:`", self.name)); } } Ok(()) } } /// Valida una lista y rechaza nombres y uids repetidos **dentro de la misma receta**. pub fn validate_all(users: &[User]) -> crate::Result<()> { let mut nombres = std::collections::BTreeSet::new(); let mut uids = std::collections::BTreeSet::new(); for u in users { u.validate()?; if !nombres.insert(&u.name) { return Err(crate::Error::Recipe(format!("user.name duplicado: {:?}", u.name))); } if !uids.insert(u.uid) { return Err(crate::Error::Recipe(format!( "user.uid duplicado: {} (lo usan {:?} y otro)", u.uid, u.name ))); } } Ok(()) } /// Fusiona líneas en un fichero tipo `passwd`/`group` **por CLAVE, no por línea entera**. /// /// Devuelve `(contenido_nuevo, añadidas, conflictos)`. Un conflicto es una cuenta cuyo nombre ya /// está y con otra línea: no se pisa nunca — se reporta. Pisar la entrada de un usuario existente /// es cambiarle el uid a los ficheros que ya son suyos, y eso no se ve hasta que algo no puede leer /// su propio estado. /// /// La idempotencia es el requisito: componer una imagen dos veces no puede dejar la cuenta dos /// veces, y `grep -q` sobre la línea entera no alcanza porque la misma cuenta con el GECOS cambiado /// se leería como nueva. pub fn fusionar(contenido: &str, lineas: &[String]) -> (String, Vec, Vec) { let clave = |l: &str| l.split(':').next().unwrap_or("").to_string(); let existentes: std::collections::BTreeMap = contenido .lines() .filter(|l| !l.trim().is_empty()) .map(|l| (clave(l), l.to_string())) .collect(); let (mut anadidas, mut conflictos) = (Vec::new(), Vec::new()); let mut out = contenido.trim_end_matches('\n').to_string(); for l in lineas { match existentes.get(&clave(l)) { Some(viejo) if viejo == l => {} // ya está, idéntica ⇒ nada que hacer Some(viejo) => conflictos.push(format!("{} ≠ {}", viejo, l)), None => { if !out.is_empty() { out.push('\n'); } out.push_str(l); anadidas.push(l.clone()); } } } out.push('\n'); (out, anadidas, conflictos) } /// Escribe `contenido` en `p` **rompiendo el hardlink**: temporal en el mismo directorio + `rename`. /// /// ⚠ Esto no es higiene, es lo único correcto, y se descubrió al armar la primera imagen: un rootfs /// hidratado se arma con **hardlinks contra el store**, así que su `/etc/passwd` y el del artefacto /// `product-rootfs` **son el mismo inode** (medido: mismo número de inode, `2 links`, modo `444`). /// Un `fs::write` sobre él abre ESE inode en truncate y **modifica el artefacto sellado** — todas /// las imágenes futuras saldrían con la cuenta metida dentro del producto, y el store mentiría /// sobre lo que contiene su propio hash. Acá se salvó porque el store es de sólo lectura y el /// primer intento murió con `Permission denied`; confiar en eso sería confiar en un permiso. /// /// `rename` sobre el mismo directorio es atómico y deja el fichero del store intacto con un link /// menos. El modo sale 0644: el del store es 0444 y un `passwd` de sólo lectura rompe a cualquiera /// que después añada una cuenta. pub fn escribir_rompiendo_hardlink(p: &std::path::Path, contenido: &str) -> crate::Result<()> { let dir = p.parent().unwrap_or(std::path::Path::new(".")); let tmp = dir.join(format!( ".{}.users-tmp", p.file_name().and_then(|s| s.to_str()).unwrap_or("fichero") )); std::fs::write(&tmp, contenido)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644))?; } std::fs::rename(&tmp, p)?; Ok(()) } #[cfg(test)] mod tests { use super::*; fn gitea() -> User { User { name: "gitea".into(), uid: 916, gid: None, group: None, home: "/var/lib/gitea".into(), shell: "/bin/false".into(), comment: None, } } #[test] fn lineas_con_el_formato_de_passwd_y_group() { let u = gitea(); assert_eq!(u.passwd_line(), "gitea:x:916:916:gitea:/var/lib/gitea:/bin/false"); assert_eq!(u.group_line(), "gitea:x:916:"); } #[test] fn rechaza_lo_reservado_por_el_producto() { let mut u = gitea(); u.uid = 74; // el de `sshd` en PRODUCT_PASSWD assert!(u.validate().unwrap_err().to_string().contains("reserva")); let mut u = gitea(); u.name = "root".into(); assert!(u.validate().is_err()); } #[test] fn rechaza_nombre_con_dos_puntos_que_partiria_la_linea() { let mut u = gitea(); u.name = "git:ea".into(); assert!(u.validate().unwrap_err().to_string().contains("user.name")); } #[test] fn rechaza_uid_fuera_de_rango_y_home_relativo() { let mut u = gitea(); u.uid = 42; assert!(u.validate().unwrap_err().to_string().contains("fuera de rango")); let mut u = gitea(); u.home = "var/lib/gitea".into(); assert!(u.validate().unwrap_err().to_string().contains("absoluta")); } #[test] fn duplicados_dentro_de_la_receta() { let a = gitea(); let mut b = gitea(); b.name = "otro".into(); assert!(validate_all(&[a.clone(), b]).unwrap_err().to_string().contains("uid duplicado")); assert!(validate_all(&[a.clone(), a]).unwrap_err().to_string().contains("name duplicado")); } /// El control del fallo que casi envenena el store: escribir el `passwd` de un rootfs /// hidratado NO puede tocar el fichero del artefacto con el que comparte inode. #[test] fn escribir_no_toca_el_fichero_hardlinkeado() { use std::os::unix::fs::MetadataExt; let dir = std::env::temp_dir().join(format!("takana-user-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(dir.join("store")).unwrap(); std::fs::create_dir_all(dir.join("rootfs")).unwrap(); let en_store = dir.join("store/passwd"); let en_rootfs = dir.join("rootfs/passwd"); std::fs::write(&en_store, "root:x:0:0:root:/root:/bin/sh\n").unwrap(); std::fs::hard_link(&en_store, &en_rootfs).unwrap(); assert_eq!( std::fs::metadata(&en_store).unwrap().ino(), std::fs::metadata(&en_rootfs).unwrap().ino(), "el montaje del test tiene que hardlinkear para que el control valga" ); 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(); assert_eq!( std::fs::read_to_string(&en_store).unwrap(), "root:x:0:0:root:/root:/bin/sh\n", "SE MODIFICÓ EL ARTEFACTO DEL STORE" ); assert!(std::fs::read_to_string(&en_rootfs).unwrap().contains("gitea:")); assert_ne!( std::fs::metadata(&en_store).unwrap().ino(), std::fs::metadata(&en_rootfs).unwrap().ino(), "el hardlink no se rompió" ); assert_eq!(std::fs::metadata(&en_store).unwrap().nlink(), 1); let _ = std::fs::remove_dir_all(&dir); } #[test] fn fusionar_es_idempotente_y_no_pisa() { let base = "root:x:0:0:root:/root:/bin/sh\nsshd:x:74:74:sshd privsep:/var/empty:/bin/false\n"; let l = vec![gitea().passwd_line()]; let (uno, add1, conf1) = fusionar(base, &l); assert_eq!(add1.len(), 1); assert!(conf1.is_empty()); // Segunda pasada: nada nuevo, nada duplicado. Es lo que hace que componer dos veces sea seguro. let (dos, add2, conf2) = fusionar(&uno, &l); assert_eq!(uno, dos); assert!(add2.is_empty() && conf2.is_empty()); assert_eq!(uno.lines().filter(|l| l.starts_with("gitea:")).count(), 1); // Misma cuenta con otro uid: NO se pisa, se reporta. let mut otro = gitea(); otro.uid = 917; let (tres, add3, conf3) = fusionar(&uno, &[otro.passwd_line()]); assert_eq!(tres, uno, "un conflicto no cambia el fichero"); assert!(add3.is_empty() && conf3.len() == 1, "{conf3:?}"); } }