feat: mirada standalone — compositor Wayland + WM sobre Llimphi (build magro)
Stack de display extraído del monorepo: compositor teselante (Cuerpo smithay + Cerebro WM agnóstico), greeter PAM, portal XDG, CLI de control. Llimphi se consume por git desde su repo publicado; las hojas compartidas (format, auth-core, rimay-localize, wawa-config, app-bus) y el widget menubar van vendorizados. Sin el asistente IA (pluma-llm) ni la barra web wasm — el compositor no los necesita. cargo check --workspace pasa (18 crates, 0 warn). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "auth-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "auth-core — autenticación del escritorio: contrato Authenticator agnóstico + backend PAM + mock. Lo consume el greeter de carmen (mirada)."
|
||||
|
||||
[dependencies]
|
||||
nix = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
pam = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
rpassword = { workspace = true }
|
||||
@@ -0,0 +1,52 @@
|
||||
# auth-core
|
||||
|
||||
Autenticación del escritorio. Contrato `Authenticator` agnóstico del
|
||||
backend, con dos implementaciones.
|
||||
|
||||
## Para qué
|
||||
|
||||
El greeter de carmen (mirada) necesita verificar la contraseña del
|
||||
usuario y, en éxito, saber su `uid/gid/home/shell` para arrancar la
|
||||
sesión. Eso es exactamente lo que entrega `Authenticator::authenticate`:
|
||||
|
||||
```rust
|
||||
use brahman_auth::{Authenticator, PamAuthenticator};
|
||||
|
||||
let auth = PamAuthenticator::carmen();
|
||||
match auth.authenticate("sergio", &password) {
|
||||
Ok(info) => arrancar_sesion(info), // info: UserInfo
|
||||
Err(e) => mostrar_error_en_greeter(e),
|
||||
}
|
||||
```
|
||||
|
||||
## Backends
|
||||
|
||||
- **`PamAuthenticator`** — verifica contra PAM (`/etc/pam.d/<servicio>`),
|
||||
el mismo subsistema de `login` y `sudo`. Hereda lo que el
|
||||
administrador configure ahí (2FA, FIDO2, `pam_faillock`…) sin que el
|
||||
crate lo sepa.
|
||||
- **`MockAuthenticator`** — credenciales fijas en memoria. Para tests y
|
||||
para iterar el greeter en cajas sin PAM configurado.
|
||||
|
||||
`AuthError` es deliberadamente grueso: el greeter sólo distingue
|
||||
"reintentá" (`BadCredentials`) de "cuenta vetada" (`AccountUnavailable`),
|
||||
y nunca puede saber si un usuario existe.
|
||||
|
||||
## Servicio PAM
|
||||
|
||||
`data/carmen` es el archivo de servicio. Instalarlo:
|
||||
|
||||
```sh
|
||||
install -Dm644 data/carmen /etc/pam.d/carmen
|
||||
```
|
||||
|
||||
Ajustar el `include` a la pila de login de la distribución (ver los
|
||||
comentarios del archivo).
|
||||
|
||||
## Probar contra PAM en una máquina real
|
||||
|
||||
```sh
|
||||
cargo run -p auth-core --example auth-probe -- "$USER" login
|
||||
```
|
||||
|
||||
Pide la contraseña sin eco e informa el `UserInfo` resuelto.
|
||||
@@ -0,0 +1,15 @@
|
||||
#%PAM-1.0
|
||||
#
|
||||
# Servicio PAM del greeter de carmen (mirada). Instalar como
|
||||
# /etc/pam.d/carmen.
|
||||
#
|
||||
# El `include` apunta a la pila de login de la distribución; ajustar
|
||||
# según corresponda:
|
||||
# Arch system-login
|
||||
# Debian/Ubuntu common-auth / common-account / ... (una por línea)
|
||||
# Fedora/RHEL system-auth + postlogin
|
||||
#
|
||||
auth include system-login
|
||||
account include system-login
|
||||
password include system-login
|
||||
session include system-login
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Prueba interactiva de `brahman-auth` contra PAM. Sirve para verificar
|
||||
//! la configuración de `/etc/pam.d/<servicio>` en una máquina real.
|
||||
//!
|
||||
//! `cargo run -p brahman-auth --example auth-probe -- [usuario] [servicio]`
|
||||
//!
|
||||
//! Pide la contraseña sin eco. El servicio por defecto es `carmen`; si
|
||||
//! `/etc/pam.d/carmen` aún no está instalado, probar con `login`.
|
||||
|
||||
use auth_core::{Authenticator, PamAuthenticator};
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let user = args
|
||||
.next()
|
||||
.or_else(|| std::env::var("USER").ok())
|
||||
.unwrap_or_else(|| "root".into());
|
||||
let service = args.next().unwrap_or_else(|| "carmen".into());
|
||||
|
||||
let password = match rpassword::prompt_password(format!("Contraseña de {user}: ")) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("no se pudo leer la contraseña: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
|
||||
let auth = PamAuthenticator::new(&service);
|
||||
println!("autenticando «{user}» contra el servicio PAM «{service}»…");
|
||||
match auth.authenticate(&user, &password) {
|
||||
Ok(info) => {
|
||||
println!("✓ autenticado");
|
||||
println!(" uid={} gid={}", info.uid, info.gid);
|
||||
println!(" home={}", info.home.display());
|
||||
println!(" shell={}", info.shell.display());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("✗ {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! `brahman-auth` — autenticación del escritorio.
|
||||
//!
|
||||
//! Contrato [`Authenticator`] agnóstico del backend, con dos
|
||||
//! implementaciones:
|
||||
//!
|
||||
//! - [`PamAuthenticator`] — el camino real: verifica contra PAM
|
||||
//! (`/etc/pam.d/<servicio>`), el mismo subsistema que usan `login`,
|
||||
//! `sudo` y los gestores de login clásicos. Hereda lo que el
|
||||
//! administrador configure ahí (2FA, llaves FIDO2, `pam_faillock`…)
|
||||
//! sin que `brahman-auth` tenga que saberlo.
|
||||
//! - [`MockAuthenticator`] — credenciales fijas en memoria, para tests
|
||||
//! y para iterar el greeter en cajas sin PAM configurado.
|
||||
//!
|
||||
//! Lo consume el greeter de carmen (mirada): el usuario teclea su
|
||||
//! contraseña, el greeter llama a [`Authenticator::authenticate`], y en
|
||||
//! éxito recibe un [`UserInfo`] con uid/gid/home/shell — lo que el
|
||||
//! 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;
|
||||
|
||||
/// Por qué falló una autenticación. Variantes deliberadamente gruesas:
|
||||
/// el greeter sólo necesita saber si conviene reintentar (problema de
|
||||
/// credenciales) o si la cuenta está vetada — y nunca debe poder
|
||||
/// distinguir "usuario inexistente" de "contraseña errada".
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum AuthError {
|
||||
/// Usuario o contraseña incorrectos. El greeter deja reintentar sin
|
||||
/// revelar cuál de los dos falló.
|
||||
#[error("usuario o contraseña incorrectos")]
|
||||
BadCredentials,
|
||||
|
||||
/// Las credenciales son válidas pero la cuenta está deshabilitada,
|
||||
/// expirada o requiere una acción (cambio de contraseña).
|
||||
#[error("la cuenta no está disponible: {0}")]
|
||||
AccountUnavailable(String),
|
||||
|
||||
/// Fallo del subsistema PAM no atribuible a las credenciales
|
||||
/// (servicio mal configurado, módulo roto, etc.).
|
||||
#[error("fallo de PAM: {0}")]
|
||||
Pam(String),
|
||||
|
||||
/// No se pudo resolver la identidad del usuario en el sistema tras
|
||||
/// una autenticación válida (caso raro: `/etc/passwd` inconsistente).
|
||||
#[error("no se pudo resolver el usuario «{0}» en el sistema")]
|
||||
UnresolvedUser(String),
|
||||
}
|
||||
|
||||
/// Verifica credenciales y, en éxito, entrega la identidad del sistema.
|
||||
///
|
||||
/// `&self`: cada llamada es un intento de login independiente. Las
|
||||
/// implementaciones crean su propio estado por intento — PAM exige un
|
||||
/// handle nuevo por transacción, reusarlo entre intentos es un bug.
|
||||
pub trait Authenticator {
|
||||
fn authenticate(&self, username: &str, secret: &str) -> Result<UserInfo, AuthError>;
|
||||
}
|
||||
|
||||
/// Autenticador de credenciales fijas en memoria. No toca PAM: sirve
|
||||
/// para tests y para iterar el greeter en cajas headless sin
|
||||
/// `/etc/pam.d` configurado.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct MockAuthenticator {
|
||||
creds: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl MockAuthenticator {
|
||||
/// Crea un autenticador sin usuarios: todo intento falla con
|
||||
/// [`AuthError::BadCredentials`] hasta registrar alguno.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Registra un par usuario/secreto aceptado. Encadenable.
|
||||
pub fn with_user(mut self, username: &str, secret: &str) -> Self {
|
||||
self.creds.insert(username.to_string(), secret.to_string());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Authenticator for MockAuthenticator {
|
||||
fn authenticate(&self, username: &str, secret: &str) -> Result<UserInfo, AuthError> {
|
||||
// Mismo error para usuario inexistente y para contraseña errada:
|
||||
// no filtra la existencia de cuentas.
|
||||
match self.creds.get(username) {
|
||||
Some(expected) if expected == secret => {
|
||||
// Si el usuario existe en el SO, info real; sino,
|
||||
// sintética (suficiente para tests y dev headless).
|
||||
Ok(resolve_user(username).unwrap_or_else(|_| UserInfo::synthetic(username)))
|
||||
}
|
||||
_ => Err(AuthError::BadCredentials),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mock_accepts_registered_user() {
|
||||
let auth = MockAuthenticator::new().with_user("sergio", "clave");
|
||||
let info = auth.authenticate("sergio", "clave").expect("debe pasar");
|
||||
assert_eq!(info.name, "sergio");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_rejects_wrong_password() {
|
||||
let auth = MockAuthenticator::new().with_user("sergio", "clave");
|
||||
assert_eq!(
|
||||
auth.authenticate("sergio", "mala"),
|
||||
Err(AuthError::BadCredentials)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_unknown_user_indistinguishable_from_wrong_password() {
|
||||
let auth = MockAuthenticator::new().with_user("sergio", "clave");
|
||||
assert_eq!(
|
||||
auth.authenticate("nadie", "x"),
|
||||
Err(AuthError::BadCredentials)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_mock_rejects_everything() {
|
||||
assert!(MockAuthenticator::new().authenticate("root", "").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_error_is_displayable() {
|
||||
assert!(!AuthError::BadCredentials.to_string().is_empty());
|
||||
assert!(AuthError::Pam("x".into()).to_string().contains("PAM"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Backend PAM del contrato [`Authenticator`](crate::Authenticator).
|
||||
//!
|
||||
//! El módulo se llama `pam_backend` (no `pam`) para no chocar con el
|
||||
//! crate externo `pam`, del que depende.
|
||||
|
||||
use pam::{Client, PamError, PamReturnCode};
|
||||
|
||||
use crate::{resolve_user, AuthError, Authenticator, UserInfo};
|
||||
|
||||
/// Servicio PAM por defecto del escritorio carmen. Resuelve a
|
||||
/// `/etc/pam.d/carmen` — ver el archivo `data/carmen` de este crate.
|
||||
pub const DEFAULT_SERVICE: &str = "carmen";
|
||||
|
||||
/// Autentica contra PAM: el mismo subsistema de `login`/`sudo`. Honra
|
||||
/// `/etc/pam.d/<service>` — módulos, 2FA, llaves FIDO2, `pam_faillock`,
|
||||
/// lo que el administrador configure ahí, sin que `brahman-auth` lo sepa.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PamAuthenticator {
|
||||
service: String,
|
||||
}
|
||||
|
||||
impl PamAuthenticator {
|
||||
/// Autenticador para un servicio PAM concreto (`/etc/pam.d/<service>`).
|
||||
pub fn new(service: impl Into<String>) -> Self {
|
||||
Self {
|
||||
service: service.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Autenticador para el servicio por defecto del escritorio,
|
||||
/// [`DEFAULT_SERVICE`].
|
||||
pub fn carmen() -> Self {
|
||||
Self::new(DEFAULT_SERVICE)
|
||||
}
|
||||
|
||||
/// Nombre del servicio PAM que usa este autenticador.
|
||||
pub fn service(&self) -> &str {
|
||||
&self.service
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PamAuthenticator {
|
||||
fn default() -> Self {
|
||||
Self::carmen()
|
||||
}
|
||||
}
|
||||
|
||||
impl Authenticator for PamAuthenticator {
|
||||
fn authenticate(&self, username: &str, secret: &str) -> Result<UserInfo, AuthError> {
|
||||
// Un handle PAM nuevo por intento: PAM es stateful por
|
||||
// transacción y reusar el handle entre intentos es un bug. El
|
||||
// `Client` cierra la transacción (`pam_end`) en su `Drop`.
|
||||
let mut client = Client::with_password(&self.service)
|
||||
.map_err(|e| AuthError::Pam(format!("pam_start({}): {e}", self.service)))?;
|
||||
client.conversation_mut().set_credentials(username, secret);
|
||||
|
||||
// `authenticate()` del crate hace pam_authenticate + pam_acct_mgmt:
|
||||
// cubre credenciales Y estado de la cuenta en un solo paso.
|
||||
client.authenticate().map_err(map_pam_error)?;
|
||||
|
||||
// Credenciales válidas: resolvemos la identidad del sistema.
|
||||
resolve_user(username)
|
||||
}
|
||||
}
|
||||
|
||||
/// Traduce un error de PAM a la taxonomía gruesa de [`AuthError`].
|
||||
fn map_pam_error(err: PamError) -> AuthError {
|
||||
match err.0 {
|
||||
// Credenciales: el greeter debe dejar reintentar.
|
||||
PamReturnCode::Auth_Err
|
||||
| PamReturnCode::User_Unknown
|
||||
| PamReturnCode::Cred_Insufficient
|
||||
| PamReturnCode::MaxTries => AuthError::BadCredentials,
|
||||
|
||||
// Cuenta válida pero vetada o que requiere una acción.
|
||||
PamReturnCode::Acct_Expired => AuthError::AccountUnavailable("la cuenta expiró".into()),
|
||||
PamReturnCode::Cred_Expired => {
|
||||
AuthError::AccountUnavailable("las credenciales expiraron".into())
|
||||
}
|
||||
PamReturnCode::AuthTok_Expired => {
|
||||
AuthError::AccountUnavailable("la contraseña expiró".into())
|
||||
}
|
||||
PamReturnCode::New_Authtok_Reqd => {
|
||||
AuthError::AccountUnavailable("requiere cambiar la contraseña".into())
|
||||
}
|
||||
PamReturnCode::Perm_Denied => {
|
||||
AuthError::AccountUnavailable("acceso denegado por política".into())
|
||||
}
|
||||
|
||||
// Todo lo demás: fallo de infraestructura PAM.
|
||||
other => AuthError::Pam(format!("{other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn carmen_uses_default_service() {
|
||||
assert_eq!(PamAuthenticator::carmen().service(), DEFAULT_SERVICE);
|
||||
assert_eq!(PamAuthenticator::default().service(), "carmen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_service_name() {
|
||||
assert_eq!(PamAuthenticator::new("login").service(), "login");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_service_fails_gracefully() {
|
||||
// Sin `/etc/pam.d/<servicio>` PAM cae a `other` (deny). Debe
|
||||
// devolver un `AuthError`, nunca paniquear.
|
||||
let auth = PamAuthenticator::new("brahman-auth-servicio-inexistente-xyz");
|
||||
assert!(
|
||||
auth.authenticate("root", "contraseña-cualquiera").is_err(),
|
||||
"un servicio inexistente debe fallar limpio"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! 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,
|
||||
/// `true` si la sesión es un compositor **ajeno** (sway, Plasma…): el
|
||||
/// servidor actual debe soltar el DRM y hacer `exec`, no correrla como
|
||||
/// cliente. `false` para sesiones nativas de mirada (pata, autostart),
|
||||
/// que sí corren como clientes del mismo compositor.
|
||||
pub foreign: bool,
|
||||
}
|
||||
|
||||
impl SessionTicket {
|
||||
/// Crea un tiquet sin comando de sesión explícito.
|
||||
pub fn new(user: UserInfo) -> Self {
|
||||
Self {
|
||||
user,
|
||||
session: String::new(),
|
||||
foreign: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fija el comando de sesión. Encadenable.
|
||||
pub fn with_session(mut self, session: impl Into<String>) -> Self {
|
||||
self.session = session.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Marca la sesión como compositor ajeno (handoff por `exec`).
|
||||
/// Encadenable.
|
||||
pub fn foreign(mut self, foreign: bool) -> Self {
|
||||
self.foreign = foreign;
|
||||
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{}\t{}",
|
||||
self.user.name,
|
||||
self.user.uid,
|
||||
self.user.gid,
|
||||
self.user.home.display(),
|
||||
self.user.shell.display(),
|
||||
self.session,
|
||||
if self.foreign { "1" } else { "0" },
|
||||
)
|
||||
}
|
||||
|
||||
/// 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();
|
||||
// El flag `foreign` es opcional (tiquets viejos no lo traen).
|
||||
let foreign = matches!(f.next(), Some("1"));
|
||||
Some(SessionTicket {
|
||||
user: UserInfo {
|
||||
name,
|
||||
uid,
|
||||
gid,
|
||||
home,
|
||||
shell,
|
||||
},
|
||||
session,
|
||||
foreign,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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 round_trip_with_foreign() {
|
||||
let t = SessionTicket::new(sample())
|
||||
.with_session("sway")
|
||||
.foreign(true);
|
||||
let back = SessionTicket::from_line(&t.to_line()).expect("parsea");
|
||||
assert_eq!(back, t);
|
||||
assert!(back.foreign);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_defaults_false_sin_campo() {
|
||||
// Una línea estilo v1 (sin el campo foreign) parsea con foreign=false.
|
||||
let line = format!("{TICKET_TAG}\tsergio\t1000\t1000\t/home/sergio\t/usr/bin/bash\tsway");
|
||||
let back = SessionTicket::from_line(&line).expect("parsea");
|
||||
assert!(!back.foreign);
|
||||
assert_eq!(back.session, "sway");
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Resolución de la identidad de un usuario del sistema.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::AuthError;
|
||||
|
||||
/// Identidad de un usuario en el sistema: lo que el compositor necesita
|
||||
/// para arrancar una sesión — fijar uid/gid, `cd` al home, ejecutar el
|
||||
/// shell o la sesión de escritorio.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UserInfo {
|
||||
/// Nombre de login.
|
||||
pub name: String,
|
||||
/// User ID.
|
||||
pub uid: u32,
|
||||
/// Group ID primario.
|
||||
pub gid: u32,
|
||||
/// Directorio personal.
|
||||
pub home: PathBuf,
|
||||
/// Shell de login.
|
||||
pub shell: PathBuf,
|
||||
}
|
||||
|
||||
impl UserInfo {
|
||||
/// Identidad sintética para tests y para cajas donde el usuario no
|
||||
/// está en `/etc/passwd`. **No** representa a un usuario real del SO
|
||||
/// — no usar para fijar privilegios de un proceso real.
|
||||
pub fn synthetic(name: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
home: PathBuf::from(format!("/home/{name}")),
|
||||
shell: PathBuf::from("/bin/sh"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resuelve un usuario por nombre vía `getpwnam`. `Err` si no existe o
|
||||
/// si la consulta a `/etc/passwd` (o NSS) falla.
|
||||
pub fn resolve_user(name: &str) -> Result<UserInfo, AuthError> {
|
||||
match nix::unistd::User::from_name(name) {
|
||||
Ok(Some(u)) => Ok(UserInfo {
|
||||
name: u.name,
|
||||
uid: u.uid.as_raw(),
|
||||
gid: u.gid.as_raw(),
|
||||
home: u.dir,
|
||||
shell: u.shell,
|
||||
}),
|
||||
Ok(None) => Err(AuthError::UnresolvedUser(name.to_string())),
|
||||
Err(e) => Err(AuthError::Pam(format!("getpwnam({name}): {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolves_root() {
|
||||
// root (uid 0) existe en todo sistema Unix.
|
||||
let info = resolve_user("root").expect("root debe existir");
|
||||
assert_eq!(info.uid, 0);
|
||||
assert_eq!(info.name, "root");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_user_errs() {
|
||||
let r = resolve_user("usuario-que-no-existe-xyzzy");
|
||||
assert!(matches!(r, Err(AuthError::UnresolvedUser(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_has_home_under_slash_home() {
|
||||
let info = UserInfo::synthetic("prueba");
|
||||
assert_eq!(info.home, PathBuf::from("/home/prueba"));
|
||||
assert_eq!(info.uid, 1000);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user