feat(mirada): mirada-greeter — greeter de login del escritorio carmen

App GPUI con app_id carmen.greeter: formulario usuario+contraseña que
autentica con brahman-auth en un hilo de fondo y, en éxito, emite un
SessionTicket por stdout para que el compositor haga el traspaso a modo
sesión. Backend mock (MIRADA_GREETER_MOCK) o PAM.

Incluye brahman-auth::SessionTicket (contrato de tiquet greeter→compositor,
serializado a una línea con prefijo versionado) y el modo enmascarado de
nahual-widget-text-input (TextInput::with_mask para contraseñas).

18 tests nuevos; greeter verificado por compilación + clippy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-21 17:59:12 +00:00
parent 8a15b812f9
commit bb21c28eb1
11 changed files with 536 additions and 11 deletions
+2
View File
@@ -17,9 +17,11 @@
//! compositor necesita para arrancar la sesión.
mod pam_backend;
mod ticket;
mod user;
pub use pam_backend::{PamAuthenticator, DEFAULT_SERVICE};
pub use ticket::{SessionTicket, TICKET_TAG};
pub use user::{resolve_user, UserInfo};
use std::collections::HashMap;
+139
View File
@@ -0,0 +1,139 @@
//! El tiquet de sesión: lo que el greeter le entrega al compositor tras
//! una autenticación exitosa.
//!
//! El greeter de carmen corre como proceso hijo del compositor. Cuando
//! el login tiene éxito, imprime **una línea** de tiquet a su stdout; el
//! compositor escanea las líneas del hijo buscando el prefijo
//! [`TICKET_TAG`] y, al encontrarlo, hace el traspaso a modo sesión.
use std::path::PathBuf;
use crate::UserInfo;
/// Etiqueta + versión de la línea de tiquet. El compositor sólo trata
/// como tiquet las líneas que empiezan con esto — el resto del stdout
/// del greeter (logs, ruido) se ignora.
pub const TICKET_TAG: &str = "MIRADA-SESSION-TICKET-v1";
/// Resultado de un login: la identidad autenticada más, opcionalmente,
/// el comando de sesión elegido. El greeter lo produce; el compositor lo
/// consume para arrancar la sesión (setuid al usuario + spawn).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionTicket {
/// Identidad del usuario autenticado.
pub user: UserInfo,
/// Comando de sesión a ejecutar como el usuario. Vacío = que el
/// compositor decida (su autostart por defecto).
pub session: String,
}
impl SessionTicket {
/// Crea un tiquet sin comando de sesión explícito.
pub fn new(user: UserInfo) -> Self {
Self {
user,
session: String::new(),
}
}
/// Fija el comando de sesión. Encadenable.
pub fn with_session(mut self, session: impl Into<String>) -> Self {
self.session = session.into();
self
}
/// Serializa el tiquet a una línea única, apta para stdout. Campos
/// separados por tabulador: ni los nombres de usuario, ni los paths,
/// ni los comandos de sesión suelen contener tabuladores.
pub fn to_line(&self) -> String {
format!(
"{TICKET_TAG}\t{}\t{}\t{}\t{}\t{}\t{}",
self.user.name,
self.user.uid,
self.user.gid,
self.user.home.display(),
self.user.shell.display(),
self.session,
)
}
/// Parsea una línea producida por [`to_line`]. `None` si la línea no
/// es un tiquet (otra salida del greeter) o está malformada.
pub fn from_line(line: &str) -> Option<SessionTicket> {
let mut f = line.trim_end_matches(['\r', '\n']).split('\t');
if f.next()? != TICKET_TAG {
return None;
}
let name = f.next()?.to_string();
let uid = f.next()?.parse().ok()?;
let gid = f.next()?.parse().ok()?;
let home = PathBuf::from(f.next()?);
let shell = PathBuf::from(f.next()?);
// El comando de sesión puede venir vacío.
let session = f.next().unwrap_or("").to_string();
Some(SessionTicket {
user: UserInfo {
name,
uid,
gid,
home,
shell,
},
session,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> UserInfo {
UserInfo {
name: "sergio".into(),
uid: 1000,
gid: 1000,
home: PathBuf::from("/home/sergio"),
shell: PathBuf::from("/usr/bin/bash"),
}
}
#[test]
fn round_trip_without_session() {
let t = SessionTicket::new(sample());
let back = SessionTicket::from_line(&t.to_line()).expect("parsea");
assert_eq!(back, t);
assert!(back.session.is_empty());
}
#[test]
fn round_trip_with_session() {
let t = SessionTicket::new(sample()).with_session("shuma-shell --launcher");
let back = SessionTicket::from_line(&t.to_line()).expect("parsea");
assert_eq!(back, t);
assert_eq!(back.session, "shuma-shell --launcher");
}
#[test]
fn from_line_ignores_non_ticket() {
assert!(SessionTicket::from_line("[INFO] arrancando greeter").is_none());
assert!(SessionTicket::from_line("").is_none());
}
#[test]
fn from_line_rejects_malformed() {
// Prefijo correcto pero faltan campos.
assert!(SessionTicket::from_line(&format!("{TICKET_TAG}\tsergio")).is_none());
// uid no numérico.
assert!(
SessionTicket::from_line(&format!("{TICKET_TAG}\tsergio\tXX\t1000\t/h\t/sh\t"))
.is_none()
);
}
#[test]
fn tolerates_trailing_newline() {
let line = format!("{}\n", SessionTicket::new(sample()).to_line());
assert!(SessionTicket::from_line(&line).is_some());
}
}