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 604Rust 56Shell 36Markdown 281227 Definiciones

[[user]] en la receta: la otra mitad del SDD 30 — un demonio que no corre como root necesita cuenta

El SDD 30 sacó las Cards de una constante de Rust y las puso en la receta. Los USUARIOS se quedaron
donde estaban las Cards: `/etc/passwd` de la imagen es `takana_bootstrap::PRODUCT_PASSWD`, un literal
con `root` y `sshd`, y añadir un tercero era editar Rust y recompilar takana.

No es simetría por elegancia, es un servicio que no arranca: `gitea` se niega a correr como root, su
Card hace `setuidgid gitea`, y sin la cuenta arranca, muere y reintenta para siempre con un log que
dice `unknown user` — no «a la imagen le falta una cuenta».

    [[user]]
    name = "gitea"
    uid  = 916
    home = "/var/lib/gitea"

**El uid se declara, no se asigna**, por la misma razón que el ULID de la Card: un «primero libre a
partir de 1000» hace que dos imágenes del mismo perfil salgan con dueños distintos y el rootfs deje
de reproducir SIN QUE NADA FALLE — los ficheros se ven iguales y `ls -l` dice otro número. Fuera de
`hash_inputs`, medido: el hash de gitea no se movió (`b3:391a613e…` antes y después).

`takana users <recetas…> [--merge <rootfs>]` es el gemelo de `service-cards`, y fusiona **por clave,
no por línea entera** — componer dos veces no duplica, y `grep -q` de la línea completa no serviría
porque la misma cuenta con otro GECOS se leería como nueva. Tres decisiones con su control:

· Una cuenta ya presente con OTRA línea es CONFLICTO y no se pisa: sale ≠0. Pisarla es cambiarle el
  uid a ficheros que ya son de alguien, y eso se descubre dentro de la VM.
· Se planea todo y sólo entonces se escribe. Fichero a fichero, un conflicto en `passwd` dejaba el
  `group` ya escrito: media cuenta es peor que ninguna, porque parece que está. Comprobado con el
  caso exacto — `group` sin la cuenta, `passwd` con otro uid — y los DOS ficheros quedan intactos.
· Un `passwd` ausente es un error, no un fichero a crear: crearlo dejaría una imagen SIN `root`.

La validación rechaza lo que rompe tarde: `root`/`sshd`/`nobody` y sus uids, uid fuera de
100..=65533, `home` relativo y un `:` en cualquier campo — que partiría la línea y fallaría lejos.

Y `--user-paths` NO es `--service-paths` con otro nombre: aquél lista los servicios HABILITADOS,
éste los paquetes INSTALADOS que declaran cuentas. `postgres` instalado y sin levantar necesita su
usuario igual, porque los ficheros de la imagen ya son suyos.

`scripts/servidor-image.sh` lo aplica entre hidratar el perfil y sellar la imagen, y aborta si hay
conflicto: un rootfs con el uid equivocado produce ficheros de un dueño que no existe.

Verde: 6 tests del módulo, core 234, cli 88, bootstrap 42, `targets.py --selftest` 7/7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Tf9T4vGzsMoT7eS8YzMFn

d611f8577d0ebb0f265143be9007a5b94a7dd1fc · por Sergio · (hace 1 mes) · padre 1b3af538d · ver el árbol en este commit

Delta semántico · 24

crates/takana-cli/src/main.rs

  • cambió de firma enum Cmd
  • cambió por dentro fn main

crates/takana-core/src/lib.rs

  • nueva mod user pub

crates/takana-core/src/recipe.rs

crates/takana-core/src/user.rs

scripts/targets.py

  • cambió por dentro fn main

8 archivos · +519 −0

diff --git a/crates/takana-cli/src/main.rs b/crates/takana-cli/src/main.rs
index 0879089f..da418808 100644
--- a/crates/takana-cli/src/main.rs
+++ b/crates/takana-cli/src/main.rs
@@ -67,6 +67,27 @@ enum Cmd {
         #[arg(long, default_value = "system")]
         scope: String,
     },
+    /// [SDD 30] Emite las **cuentas de sistema** que declaran estas recetas (`[[user]]`) como
+    /// líneas de `/etc/passwd` y `/etc/group`, o las FUSIONA en un rootfs con `--merge`.
+    ///
+    /// Es la otra mitad de `service-cards`: la Card de `gitea` hace `setuidgid gitea` porque el
+    /// demonio se niega a correr como root, y sin la cuenta el servicio muere en bucle. Hasta hoy
+    /// el `passwd` de la imagen era una constante de Rust (`PRODUCT_PASSWD`) y añadir un usuario
+    /// era recompilar takana.
+    ///
+    /// `--merge` fusiona **por clave, no por línea**, y es idempotente: componer una imagen dos
+    /// veces no deja la cuenta dos veces. Una cuenta que ya está con OTRA línea es un conflicto y
+    /// sale ≠0 sin tocar el fichero — pisarla es cambiarle el uid a ficheros que ya son suyos.
+    Users {
+        /// Rutas de recetas TOML. `scripts/targets.py --user-paths <perfil>` las lista.
+        recipes: Vec<String>,
+        /// Qué emitir por stdout: `passwd` (default), `group`, `both` o `json`.
+        #[arg(long, default_value = "passwd")]
+        format: String,
+        /// Rootfs donde fusionar `etc/passwd` y `etc/group` en vez de imprimir.
+        #[arg(long)]
+        merge: Option<String>,
+    },
     /// [Fase 0] Calcula el `ArtifactHash` de una receta SIN construir — puro sobre las recetas
     /// (source_id + compiler/target/link + patches + flags + fases + hashes de deps). No baja
     /// fuentes ni compila. Responde "¿cuál es el hash VIGENTE de esta receta?", que el store no
@@ -984,6 +1005,64 @@ fn main() -> anyhow::Result<()> {
             }
             println!("{}", serde_json::to_string_pretty(&cards)?);
         }
+        Cmd::Users { recipes, format, merge } => {
+            let mut users: Vec<takana_core::user::User> = Vec::new();
+            for ruta in &recipes {
+                let r = takana_core::Recipe::load_from_path(ruta)?;
+                users.extend(r.users.iter().cloned());
+            }
+            // Las recetas se validan una por una al cargar; esto caza lo que sólo se ve al juntarlas:
+            // dos paquetes distintos que reclaman el MISMO uid con nombres distintos. En una imagen
+            // eso deja ficheros de dos dueños con el mismo número y no lo nota nadie.
+            takana_core::user::validate_all(&users)?;
+            let passwd: Vec<String> = users.iter().map(|u| u.passwd_line()).collect();
+            let group: Vec<String> = users.iter().map(|u| u.group_line()).collect();
+            if let Some(rootfs) = merge {
+                let root = std::path::Path::new(&rootfs);
+                // DOS PASADAS a propósito: se planea todo y sólo entonces se escribe. Fusionar y
+                // escribir fichero a fichero deja estado a medias cuando el segundo conflictúa —
+                // un `group` con la cuenta y un `passwd` sin ella es peor que no haber hecho nada,
+                // porque parece que la cuenta está.
+                let mut plan = Vec::new();
+                let mut conflictos_totales = Vec::new();
+                for (rel, lineas) in [("etc/passwd", &passwd), ("etc/group", &group)] {
+                    let p = root.join(rel);
+                    // Un `passwd` ausente es un rootfs que no es un rootfs: no se crea de la nada,
+                    // porque hacerlo dejaría una imagen SIN `root` y con el usuario del paquete.
+                    let previo = std::fs::read_to_string(&p).map_err(|e| {
+                        anyhow::anyhow!("no pude leer {}: {e} — ¿es un rootfs?", p.display())
+                    })?;
+                    let (nuevo, anadidas, conflictos) = takana_core::user::fusionar(&previo, lineas);
+                    for c in conflictos {
+                        conflictos_totales.push(format!("{rel}: {c}"));
+                    }
+                    plan.push((rel, p, nuevo, anadidas));
+                }
+                if !conflictos_totales.is_empty() {
+                    anyhow::bail!(
+                        "cuenta(s) ya presentes con OTRA línea — no se tocó ningún fichero:\n  {}",
+                        conflictos_totales.join("\n  ")
+                    );
+                }
+                for (rel, p, nuevo, anadidas) in plan {
+                    if !anadidas.is_empty() {
+                        std::fs::write(&p, &nuevo)?;
+                    }
+                    eprintln!("{}: +{} cuenta(s)", rel, anadidas.len());
+                    for a in &anadidas {
+                        eprintln!("   {a}");
+                    }
+                }
+            } else {
+                match format.as_str() {
+                    "passwd" => println!("{}", passwd.join("\n")),
+                    "group" => println!("{}", group.join("\n")),
+                    "both" => println!("{}\n{}", passwd.join("\n"), group.join("\n")),
+                    "json" => println!("{}", serde_json::to_string_pretty(&users)?),
+                    otro => anyhow::bail!("--format inválido: {otro} (passwd | group | both | json)"),
+                }
+            }
+        }
         Cmd::Hash { recipe, check } => {
             let store = takana_core::Store::open(&cli.store)?;
             let recipe = takana_core::Recipe::load_from_path(&recipe)?;
diff --git a/crates/takana-core/src/lib.rs b/crates/takana-core/src/lib.rs
index 9fcfed0d..c1ceb510 100644
--- a/crates/takana-core/src/lib.rs
+++ b/crates/takana-core/src/lib.rs
@@ -21,6 +21,7 @@ pub mod service;
 pub mod sign;
 pub mod store;
 pub mod swm;
+pub mod user;
 
 pub use caps::{AgentCapsConfig, CapRule};
 pub use differs::{why_differs, Causa, Divergencia, Informe};
diff --git a/crates/takana-core/src/recipe.rs b/crates/takana-core/src/recipe.rs
index 507807f1..3cefe448 100644
--- a/crates/takana-core/src/recipe.rs
+++ b/crates/takana-core/src/recipe.rs
@@ -76,6 +76,20 @@ pub struct Recipe {
     /// solo `ArtifactHash`.
     #[serde(default, rename = "service", skip_serializing_if = "Vec::is_empty")]
     pub services: Vec<crate::service::Service>,
+    /// **Las cuentas que este paquete necesita para correr** (`[[user]]`).
+    ///
+    /// La otra mitad del SDD 30, y no es simetría por elegancia: un demonio que se niega a correr
+    /// como root —`gitea` lo hace explícitamente— tiene su Card haciendo `setuidgid`, y sin la
+    /// cuenta muere en bucle. Hasta hoy el `/etc/passwd` de la imagen era la constante
+    /// `takana_bootstrap::PRODUCT_PASSWD`: añadir un usuario era editar Rust, exactamente lo que
+    /// este SDD arregló para las Cards y dejó sin arreglar para las cuentas.
+    ///
+    /// El `uid` **se declara, no se asigna** — ver [`crate::user`].
+    ///
+    /// **NO entra en `hash_inputs`**, como `license` y `[[service]]`: describe qué cuenta necesita
+    /// el artefacto para correr, no qué bytes tiene.
+    #[serde(default, rename = "user", skip_serializing_if = "Vec::is_empty")]
+    pub users: Vec<crate::user::User>,
     /// Directorio base contra el que se resuelven rutas relativas de la receta
     /// (típicamente, `patches`). Lo fija `load_from_path`; al deserializar puro queda vacío.
     #[serde(skip, default)]
@@ -523,6 +537,9 @@ impl Recipe {
         // después, en un rootfs que ya no reproduce y no dice por qué. Recetas sin `[[service]]`
         // pasan por una lista vacía, así que esto no toca a las 979 existentes.
         crate::service::validate_all(&r.services)?;
+        // Mismo criterio que los servicios: se valida al CARGAR. Un uid fuera de rango o un `:` en
+        // el nombre tienen que verse acá y no en el fichero `passwd` de una imagen ya compuesta.
+        crate::user::validate_all(&r.users)?;
         Ok(r)
     }
 
diff --git a/crates/takana-core/src/user.rs b/crates/takana-core/src/user.rs
new file mode 100644
index 00000000..3a46fa19
--- /dev/null
+++ b/crates/takana-core/src/user.rs
@@ -0,0 +1,294 @@
+//! `[[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<u32>,
+    /// Nombre del grupo primario. Ausente ⇒ `name`.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub group: Option<String>,
+    /// 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<String>,
+}
+
+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<String>, Vec<String>) {
+    let clave = |l: &str| l.split(':').next().unwrap_or("").to_string();
+    let existentes: std::collections::BTreeMap<String, String> = 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)
+}
+
+#[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"));
+    }
+
+    #[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:?}");
+    }
+}
diff --git a/docs/30-servicios-de-paquete.md b/docs/30-servicios-de-paquete.md
index 5b6e62d0..0371d695 100644
--- a/docs/30-servicios-de-paquete.md
+++ b/docs/30-servicios-de-paquete.md
@@ -298,6 +298,61 @@ Y las tres cosas de siempre: EXDEV. `store/` es bind-mount de `/dev/sdb` y `work
 `install-image-efi.sh` (configurable por `STAGE`). Hidratado, fundido y staging tienen que vivir bajo
 el mismo montaje.
 
+## 4d. La otra mitad, que faltaba: `[[user]]` *(2026-09-14)*
+
+Declarar cómo se supervisa un demonio no alcanza cuando el demonio **se niega a correr como root**.
+`gitea` lo dice y sale:
+
+    [F] Gitea is not supposed to be run as root. If you need to use privileged TCP ports please
+        instead use `setcap` and the `cap_net_bind_service` permission.
+
+Su Card hace `setuidgid gitea`, y sin la cuenta el servicio arranca, muere y reintenta para siempre
+con un log que dice `unknown user` — no «a la imagen le falta una cuenta». El `/etc/passwd` de la
+imagen era **la constante `takana_bootstrap::PRODUCT_PASSWD`** (`root` y `sshd`, nada más): añadir
+un usuario era editar Rust y recompilar takana, que es **exactamente el hueco del §1 de este
+documento**, resuelto para las Cards y dejado abierto para las cuentas. Simetría, no elegancia.
+
+```toml
+[[user]]
+name = "gitea"
+uid  = 916
+home = "/var/lib/gitea"
+```
+
+**El `uid` se declara, no se asigna**, por la misma razón que el ULID de la Card: un «primero libre
+a partir de 1000» hace que dos imágenes del mismo perfil salgan con dueños distintos y el rootfs
+**deje de reproducir sin que nada falle** — los ficheros se ven iguales y `ls -l` dice otro número.
+
+Fuera de `hash_inputs`, como `[[service]]`: medido, el hash de `gitea` no se movió al declararlo.
+
+### La pregunta que NO es la misma que la de los servicios
+
+`--service-paths` lista las recetas de los servicios **habilitados**; `--user-paths` lista las de los
+paquetes **instalados** que declaran cuentas. No es el mismo conjunto y confundirlos rompe el caso
+normal: `postgres` instalado y sin levantar necesita su usuario igual, porque los ficheros que ya
+están en la imagen **ya son suyos**.
+
+### Fusionar, no escribir
+
+`takana users <recetas…> --merge <rootfs>` fusiona **por clave, no por línea entera**: componer la
+imagen dos veces no deja la cuenta dos veces (y `grep -q` sobre la línea completa no serviría — la
+misma cuenta con el GECOS cambiado se leería como nueva). Tres decisiones, las tres con su control:
+
+1. **Una cuenta ya presente con OTRA línea es un conflicto y no se pisa**: sale ≠0. Pisarla es
+   cambiarle el uid a ficheros que ya son de alguien, y eso se descubre dentro de la VM.
+2. **Se planea todo y sólo entonces se escribe.** Fichero a fichero, un conflicto en `passwd`
+   dejaría el `group` ya escrito: media cuenta es peor que ninguna, porque parece que está.
+3. **Un `passwd` ausente es un error, no un fichero a crear.** Crearlo dejaría una imagen **sin
+   `root`** y con el usuario del paquete.
+
+Y la validación rechaza lo que rompe tarde: `root`/`sshd`/`nobody` y sus uids (los reserva el
+producto), uid fuera de `100..=65533`, `home` relativo, y un `:` en cualquier campo — que partiría
+la línea en dos y fallaría lejos de acá.
+
+**Verificado**: 6 tests del módulo, merge idempotente (segunda pasada `+0`), y los dos controles
+negativos con el fichero **intacto** después. `scripts/servidor-image.sh` lo aplica entre hidratar el
+perfil y sellar la imagen.
+
 ## 5. El hueco que no es nuestro: no hay readiness
 
 arje ordena el arranque por **capacidades**, con orden topológico real
@@ -331,6 +386,11 @@ arriba en arje.
 | La cadena receta→sidecar→card→genesis→arje, **en arranque real** | ✅ §4b.2 (`PRODUCT_SSH_OK`) |
 | Que la imagen de ESCRITORIO los arranque y no el script `&` | ✅ §4c.1, con control (y arregló `colord`) |
 | `takana service-cards` + `--service-paths` + el inyector | ✅ §4c.1 |
+| `[[user]]` en la receta + validación + fusión idempotente | ✅ `crates/takana-core/src/user.rs`, §4d |
+| `takana users` (+`--merge`) y `targets.py --user-paths` | ✅ §4d |
+| `gitea` declara su servicio **y** su cuenta (hash sin mover) | ✅ `recipes/gitea.toml` |
+| El merge de cuentas en el camino de imagen | ✅ `scripts/servidor-image.sh` |
+| Que el resto de imágenes (escritorios) llamen al merge | ◻ hoy sólo `servidor-image.sh` |
 | `escritorio-cosmic`: auditar su `cosmic-start.sh` y habilitar | ◻ hoy sale con 5 AVISO |
 
 **La primera lectura del vigía**, para que no haya que creerle a este documento: 0 errores y 18
diff --git a/recipes/gitea.toml b/recipes/gitea.toml
index 34aa5c30..f8f7c578 100644
--- a/recipes/gitea.toml
+++ b/recipes/gitea.toml
@@ -59,6 +59,21 @@ go build -trimpath -tags 'bindata sqlite sqlite_unlock_notify' \
 """
 install   = "true"
 
+# ── LA CUENTA QUE ESTE PAQUETE NECESITA (SDD 30) ────────────────────────────────────────────────
+# Fuera de `hash_inputs`: declarar esto NO re-hashea gitea.
+#
+# El `uid` se DECLARA y no se asigna, igual que el ULID de la Card: un «primero libre a partir de
+# 1000» hace que dos imágenes del mismo perfil salgan con dueños distintos y el rootfs deje de
+# reproducir **sin que nada falle** — los ficheros se ven iguales y `ls -l` dice otro número.
+#
+# 916 es el uid que Alpine reserva para `git`/gitea en sus `setup-*`; no hace falta que coincida con
+# nada, hace falta que no se mueva. `shell = /bin/false` por defecto: una cuenta de servicio a la que
+# alguien pueda entrar es una puerta que nadie declaró.
+[[user]]
+name = "gitea"
+uid  = 916
+home = "/var/lib/gitea"
+
 # ── EL SERVICIO QUE ESTE PAQUETE TRAE (SDD 30) ──────────────────────────────────────────────────
 # Fuera de `hash_inputs`: declarar esto NO re-hashea gitea.
 #
diff --git a/scripts/servidor-image.sh b/scripts/servidor-image.sh
index c27affb2..87d7c829 100755
--- a/scripts/servidor-image.sh
+++ b/scripts/servidor-image.sh
@@ -43,6 +43,12 @@ WORKDIR="${WORKDIR:-/mnt/cosecha/servidor-build}"
 IMG="${IMG:-$WORKDIR/servidor.img}"
 KERNELPKG="${KERNELPKG:-linux-generic}"
 ROOT_SIZE="${ROOT_SIZE:-6144}"; STORE_SIZE="${STORE_SIZE:-512}"; STATE_SIZE="${STATE_SIZE:-512}"
+# El binario: en un hub de desarrollo está en `target/release`, en una caja instalada es
+# `/usr/bin/takana` y `target/` ni existe. Es el bloqueo que ya costó una vez en la caja nueva
+# («unhashable 875» con el lab bien): los scripts asumían árbol de desarrollo.
+TAKANA="${TAKANA:-$ROOT/target/release/takana}"
+[ -x "$TAKANA" ] || TAKANA="$(command -v takana || true)"
+[ -n "$TAKANA" ] && [ -x "$TAKANA" ] || { echo "!! no encuentro el binario takana (probá TAKANA=/usr/bin/takana)" >&2; exit 2; }
 
 [ -n "${AUTHKEYS:-}" ] || { echo "!! falta AUTHKEYS: una imagen sin clave deja la caja INALCANZABLE" >&2; exit 2; }
 [ -r "$AUTHKEYS" ]     || { echo "!! no puedo leer AUTHKEYS=$AUTHKEYS" >&2; exit 2; }
@@ -74,6 +80,25 @@ cp -al "$PDIR/." "$RFS/"
 echo "==> hidratando perfil '$PERFIL'"
 python3 scripts/hydrate-profile.py "$PERFIL" --into "$RFS" --store "$STORE" --keep
 
+# ── 2 bis. las CUENTAS que los paquetes del perfil declaran (`[[user]]`, SDD 30) ─────────────────
+# El `/etc/passwd` del product-rootfs trae `root` y `sshd` y nada más, porque es una constante de
+# Rust. Un paquete cuyo demonio se niega a correr como root —`gitea` lo hace explícitamente— tiene
+# su Card haciendo `setuidgid`, así que SIN esta línea el servicio arranca, muere y reintenta para
+# siempre, y el log dice «unknown user», no «a la imagen le falta una cuenta».
+#
+# `--merge` fusiona por clave y es idempotente: componer dos veces no duplica. Si una cuenta ya está
+# con OTRA línea, sale ≠0 sin tocar ningún fichero — y acá eso ABORTA la imagen a propósito: un
+# rootfs con el uid equivocado produce ficheros de un dueño que no existe, y eso se descubre dentro
+# de la VM, varios pasos más tarde.
+USERPATHS=$(python3 scripts/targets.py --user-paths "$PERFIL")
+if [ -n "$USERPATHS" ]; then
+  echo "==> cuentas : $(echo "$USERPATHS" | wc -l) receta(s) declaran usuarios"
+  # shellcheck disable=SC2086
+  "$TAKANA" --store "$STORE" users $USERPATHS --merge "$RFS"
+else
+  echo "==> cuentas : ninguna receta del perfil declara [[user]]"
+fi
+
 # ── 3. reparar el init que la hidratación pisó (ver cabecera, punto 2) ───────────────────────────
 antes=$(readlink "$RFS/sbin/init" 2>/dev/null || echo "(fichero)")
 ln -sf /usr/bin/arje-zero "$RFS/sbin/init"
diff --git a/scripts/targets.py b/scripts/targets.py
index c1f99821..9e0080b8 100755
--- a/scripts/targets.py
+++ b/scripts/targets.py
@@ -305,6 +305,34 @@ def main():
         for r in vistas:
             print(r)
         return 0
+    if args[0] == "--user-paths":
+        # Las RUTAS de receta de los paquetes DEL PERFIL que declaran `[[user]]`, para que el
+        # ensamblado se las pase a `takana users --merge <rootfs>`. La pregunta NO es la misma que
+        # la de `--service-paths`: una cuenta hace falta porque el paquete está INSTALADO, no porque
+        # el perfil arranque su servicio. `postgres` instalado y sin levantar necesita su usuario
+        # igual — sus ficheros ya son suyos.
+        if len(args) < 2 or args[1] not in perfiles:
+            print("uso: targets.py --user-paths <perfil>", file=sys.stderr)
+            return 1
+        memb = _membresia()
+        raices = set(perfiles[args[1]]["raices"])
+        vistas = []
+        for f in sorted((ROOT / "recipes").rglob("*.toml")):
+            try:
+                doc = tomllib.load(open(f, "rb"))
+            except tomllib.TOMLDecodeError:
+                continue
+            if not doc.get("user"):
+                continue
+            receta = doc.get("name", f.stem)
+            if not (receta in raices or args[1] in memb.get(receta, [])):
+                continue
+            r = str(f.relative_to(ROOT))
+            if r not in vistas:
+                vistas.append(r)
+        for r in vistas:
+            print(r)
+        return 0
     if args[0] in ("--services", "-s"):
         if len(args) < 2 or args[1] not in perfiles:
             print(f"uso: targets.py --services <perfil>  (hay: {', '.join(sorted(perfiles))})",