feat: complete session preferences and PAM fixture gates

This commit is contained in:
2026-09-26 02:44:29 +05:30
parent 83345a1842
commit ee6186ed0b
34 changed files with 801 additions and 327 deletions

View File

@@ -1,5 +1,17 @@
# Changelog # Changelog
## Unreleased
- Fixed Wayland compositors launched through user services, including Niri, by
providing the selected session type, desktop, seat, and VT to PAM before
`pam_open_session`.
- Session children now receive allowlisted runtime values produced by PAM,
including the real logind session ID, runtime directory, and user D-Bus
address. Latchd no longer fabricates `XDG_SESSION_ID=latchd`.
- Extended the disposable PAM fixture to verify both sides of the handoff:
selected session metadata entering PAM and PAM-generated values reaching the
session child.
## 0.5.0 — PAM password policy flow ## 0.5.0 — PAM password policy flow
- Added PAM password-change handling for accounts that return - Added PAM password-change handling for accounts that return
@@ -11,8 +23,12 @@
current-password/new-password prompt sequence. current-password/new-password prompt sequence.
- Added redacted structured audit events for authentication, password changes, - Added redacted structured audit events for authentication, password changes,
and session handoff. Raw PAM messages and password material are excluded. and session handoff. Raw PAM messages and password material are excluded.
- Added TUI and core policy-transition tests; the workspace now contains 33 - Added TUI, core, and preference tests; the workspace now contains 37
Rust unit tests. Rust unit tests.
- Added bounded, atomic last-session preferences per account with owner-only
file permissions and a safe fallback when the state file is unavailable.
- Added disposable PAM deny-policy fault-injection tests. Docker runs them as
root; ordinary host runs skip them without touching the host PAM policy.
## 0.4.0 — Advanced login workflow ## 0.4.0 — Advanced login workflow

View File

@@ -21,4 +21,6 @@ RUN cargo fmt --all -- --check \
&& RUSTDOCFLAGS="-D warnings" cargo doc --locked --workspace --no-deps \ && RUSTDOCFLAGS="-D warnings" cargo doc --locked --workspace --no-deps \
&& cargo build --locked --release \ && cargo build --locked --release \
&& ./scripts/test-cli.sh target/release/latchd \ && ./scripts/test-cli.sh target/release/latchd \
&& ./scripts/test-system-e2e.sh target/release/latchd && ./scripts/test-system-e2e.sh target/release/latchd \
&& install -m 0664 /dev/null /run/utmp \
&& ./scripts/test-pam-fixture.sh

View File

@@ -48,7 +48,7 @@ RED :=
endif endif
.PHONY: all deps build release-build test check fmt lint doc audit docker-test \ .PHONY: all deps build release-build test check fmt lint doc audit docker-test \
integration-test system-e2e system-e2e-strict \ integration-test pam-fixture-test system-e2e system-e2e-strict \
package release release-check version install uninstall clean run-preview \ package release release-check version install uninstall clean run-preview \
legacy-build legacy-check enable update help legacy-build legacy-check enable update help
@@ -98,6 +98,9 @@ docker-test: ## Run the pinned Docker verification suite
integration-test: build ## Exercise safe, non-authenticating CLI paths integration-test: build ## Exercise safe, non-authenticating CLI paths
@./scripts/test-cli.sh '$(BUILD_DIR)/$(BINARY)' @./scripts/test-cli.sh '$(BUILD_DIR)/$(BINARY)'
pam-fixture-test: ## Run the disposable PAM fault-injection tests when privileged
@./scripts/test-pam-fixture.sh
system-e2e: ## Probe PAM, VT, X11, Wayland, and logind without requiring privileges system-e2e: ## Probe PAM, VT, X11, Wayland, and logind without requiring privileges
@./scripts/test-system-e2e.sh '$(BUILD_DIR)/$(BINARY)' @./scripts/test-system-e2e.sh '$(BUILD_DIR)/$(BINARY)'

View File

@@ -10,8 +10,11 @@ Latchd is written in Rust. The old Go prototype is kept in `legacy/go/` for refe
- Shows password, account-expiry, locked-account, and faillock status without replacing PAM's decision. - Shows password, account-expiry, locked-account, and faillock status without replacing PAM's decision.
- Runs PAM password changes for accounts that require a new password before login. - Runs PAM password changes for accounts that require a new password before login.
- Lets you switch users and sessions without leaving the login screen. - Lets you switch users and sessions without leaving the login screen.
- Remembers each account's last successful session choice in a root-owned state file.
- Finds X11 and Wayland desktop entries and can include a TTY shell. - Finds X11 and Wayland desktop entries and can include a TTY shell.
- Starts sessions with a cleared, explicit environment and drops privileges before running the user process. - Starts sessions with a cleared, explicit environment and drops privileges before running the user process.
- Registers the selected desktop, session type, seat, and VT with PAM/logind,
then imports only approved PAM runtime values into the user session.
- Records user sessions with UTMPX when the platform provides it. - Records user sessions with UTMPX when the platform provides it.
- Provides a Ratatui TUI with filtering, keyboard help, refresh, notifications, wallpaper, and reduced-motion support. - Provides a Ratatui TUI with filtering, keyboard help, refresh, notifications, wallpaper, and reduced-motion support.
- Runs safely in preview mode for UI work and configuration checks. - Runs safely in preview mode for UI work and configuration checks.
@@ -72,7 +75,8 @@ The complete example is in [`extra/config.toml`](extra/config.toml). Important s
- `[accounts]` controls NSS discovery, the faillock helper, faillock policy, and `/etc/shadow` account-age checks. - `[accounts]` controls NSS discovery, the faillock helper, faillock policy, and `/etc/shadow` account-age checks.
- `[x11]` and `[wayland]` select desktop-entry and administrator script directories. - `[x11]` and `[wayland]` select desktop-entry and administrator script directories.
- `[environment_switcher]` controls session choices and the optional TTY shell. - `[environment_switcher]` controls session choices, the optional TTY shell,
and the owner-checked last-session preference file.
- `[visual]` controls wallpaper size, animation, notifications, and reduced motion. - `[visual]` controls wallpaper size, animation, notifications, and reduced motion.
- `[power_controls]` contains absolute program paths and argv arrays. Commands are never passed through a shell. - `[power_controls]` contains absolute program paths and argv arrays. Commands are never passed through a shell.
@@ -107,6 +111,7 @@ make check
make audit make audit
make docker-test make docker-test
make integration-test make integration-test
make pam-fixture-test # runs when root is available; otherwise reports SKIP
make system-e2e make system-e2e
``` ```

View File

@@ -1,8 +1,6 @@
//! PAM authentication with one dedicated worker thread. //! PAM authentication with one dedicated worker thread.
//! //!
//! PAM handles are intentionally never moved out of the worker. The public //! The worker owns all PAM handles. Public tickets contain only an opaque ID.
//! ticket contains only an opaque ID, so the UI cannot accidentally use a PAM
//! transaction from the wrong thread or forget to close it.
use std::{ use std::{
collections::HashMap, collections::HashMap,
@@ -35,6 +33,17 @@ const PAM_ERROR_MSG: c_int = 3;
const PAM_TEXT_INFO: c_int = 4; const PAM_TEXT_INFO: c_int = 4;
const PAM_ESTABLISH_CRED: c_int = 0x0002; const PAM_ESTABLISH_CRED: c_int = 0x0002;
const PAM_DELETE_CRED: c_int = 0x0004; const PAM_DELETE_CRED: c_int = 0x0004;
const PAM_TTY: c_int = 3;
const PAM_CHILD_ENVIRONMENT_KEYS: [&CStr; 8] = [
c"XDG_SESSION_CLASS",
c"XDG_SESSION_TYPE",
c"XDG_SESSION_DESKTOP",
c"XDG_SEAT",
c"XDG_VTNR",
c"XDG_RUNTIME_DIR",
c"XDG_SESSION_ID",
c"DBUS_SESSION_BUS_ADDRESS",
];
#[repr(C)] #[repr(C)]
struct PamHandle { struct PamHandle {
@@ -83,6 +92,12 @@ unsafe extern "C" {
fn pam_acct_mgmt(pamh: *mut PamHandle, flags: c_int) -> c_int; fn pam_acct_mgmt(pamh: *mut PamHandle, flags: c_int) -> c_int;
#[link_name = "pam_setcred"] #[link_name = "pam_setcred"]
fn pam_setcred(pamh: *mut PamHandle, flags: c_int) -> c_int; fn pam_setcred(pamh: *mut PamHandle, flags: c_int) -> c_int;
#[link_name = "pam_set_item"]
fn pam_set_item(pamh: *mut PamHandle, item_type: c_int, item: *const c_void) -> c_int;
#[link_name = "pam_putenv"]
fn pam_putenv(pamh: *mut PamHandle, name_value: *const c_char) -> c_int;
#[link_name = "pam_getenv"]
fn pam_getenv(pamh: *mut PamHandle, name: *const c_char) -> *const c_char;
#[link_name = "pam_open_session"] #[link_name = "pam_open_session"]
fn pam_open_session(pamh: *mut PamHandle, flags: c_int) -> c_int; fn pam_open_session(pamh: *mut PamHandle, flags: c_int) -> c_int;
#[link_name = "pam_close_session"] #[link_name = "pam_close_session"]
@@ -101,7 +116,6 @@ pub struct AuthTicket {
} }
impl AuthTicket { impl AuthTicket {
/// Returns the account identity associated with this ticket.
pub fn identity(&self) -> &Identity { pub fn identity(&self) -> &Identity {
&self.identity &self.identity
} }
@@ -146,7 +160,6 @@ pub struct AuthService {
} }
impl AuthService { impl AuthService {
/// Starts the PAM worker.
pub fn new() -> Result<Self, Error> { pub fn new() -> Result<Self, Error> {
let (commands, requests) = sync_channel::<Command>(1); let (commands, requests) = sync_channel::<Command>(1);
thread::Builder::new() thread::Builder::new()
@@ -156,7 +169,6 @@ impl AuthService {
Ok(Self { commands }) Ok(Self { commands })
} }
/// Starts one authentication transaction without blocking the UI thread.
pub fn authenticate( pub fn authenticate(
&self, &self,
username: String, username: String,
@@ -182,9 +194,8 @@ impl AuthService {
/// Changes an expired account password through the configured PAM policy. /// Changes an expired account password through the configured PAM policy.
/// ///
/// PAM owns password-strength, history, and token validation. The current /// PAM enforces password strength, history, and token rules.
/// and replacement passwords remain zeroizing buffers for the entire /// Zeroizing buffers hold both passwords and errors never include them.
/// worker transaction and are never returned in an error.
pub fn change_password( pub fn change_password(
&self, &self,
username: String, username: String,
@@ -220,7 +231,6 @@ impl AuthService {
.map_err(|_| Error::Worker("PAM worker disconnected".into()))? .map_err(|_| Error::Worker("PAM worker disconnected".into()))?
} }
/// Cancels a ticket that will not be used for a session.
pub fn cancel(&self, mut ticket: AuthTicket) -> Result<(), Error> { pub fn cancel(&self, mut ticket: AuthTicket) -> Result<(), Error> {
let id = ticket.id; let id = ticket.id;
ticket.owner = None; ticket.owner = None;
@@ -312,30 +322,7 @@ fn worker(requests: Receiver<Command>) {
} }
Command::Run { id, spec, response } => { Command::Run { id, spec, response } => {
let result = match sessions.remove(&id) { let result = match sessions.remove(&id) {
Some(mut session) => { Some(session) => run_authenticated_session(session, spec),
let opened = unsafe { pam_open_session(session.handle, 0) };
if opened != PAM_SUCCESS {
let error = pam_error(session.handle, "pam_open_session", opened);
let _ = session.close(opened);
Err(error)
} else {
session.opened = true;
let run = match session.identity.as_ref() {
Some(identity) => session::run_user_session(identity, &spec),
None => Err(Error::Worker("ticket has no account identity".into())),
};
let close = session.close(if run.is_ok() {
PAM_SUCCESS
} else {
PAM_SYSTEM_ERR
});
match (run, close) {
(Err(error), _) => Err(error),
(Ok(()), Err(error)) => Err(error),
(Ok(()), Ok(())) => Ok(()),
}
}
}
None => Err(Error::Worker("unknown authentication ticket".into())), None => Err(Error::Worker("unknown authentication ticket".into())),
}; };
let _ = response.send(result); let _ = response.send(result);
@@ -347,6 +334,86 @@ fn worker(requests: Receiver<Command>) {
} }
} }
fn run_authenticated_session(mut session: PamSession, mut spec: SessionSpec) -> Result<(), Error> {
if let Err(error) = configure_pam_session(session.handle, &spec) {
let _ = session.close(PAM_SYSTEM_ERR);
return Err(error);
}
// SAFETY: This worker owns the live handle until `PamSession::close`.
let opened = unsafe { pam_open_session(session.handle, 0) };
if opened != PAM_SUCCESS {
let error = pam_error(session.handle, "pam_open_session", opened);
let _ = session.close(opened);
return Err(error);
}
session.opened = true;
let run = apply_pam_environment(session.handle, &mut spec).and_then(|()| {
session.identity.as_ref().map_or_else(
|| Err(Error::Worker("ticket has no account identity".into())),
|identity| session::run_user_session(identity, &spec),
)
});
let close = session.close(if run.is_ok() {
PAM_SUCCESS
} else {
PAM_SYSTEM_ERR
});
match (run, close) {
(Err(error), _) | (Ok(()), Err(error)) => Err(error),
(Ok(()), Ok(())) => Ok(()),
}
}
fn configure_pam_session(handle: *mut PamHandle, spec: &SessionSpec) -> Result<(), Error> {
let tty = CString::new(spec.pam_tty())
.map_err(|_| Error::Session("PAM terminal name contains NUL".into()))?;
// SAFETY: The worker owns the live handle. `tty` is NUL-terminated, and PAM copies the value.
let result = unsafe { pam_set_item(handle, PAM_TTY, tty.as_ptr().cast()) };
if result != PAM_SUCCESS {
return Err(pam_error(handle, "pam_set_item(PAM_TTY)", result));
}
for (key, value) in spec.pam_environment() {
let assignment = CString::new(format!("{key}={value}"))
.map_err(|_| Error::Session("PAM session environment contains NUL".into()))?;
// SAFETY: The worker owns the live handle. PAM copies the NUL-terminated assignment.
let result = unsafe { pam_putenv(handle, assignment.as_ptr()) };
if result != PAM_SUCCESS {
return Err(pam_error(handle, "pam_putenv", result));
}
}
Ok(())
}
fn apply_pam_environment(handle: *mut PamHandle, spec: &mut SessionSpec) -> Result<(), Error> {
for name in PAM_CHILD_ENVIRONMENT_KEYS {
// SAFETY: The worker owns the live handle. Each name is a static C string.
let value = unsafe { pam_getenv(handle, name.as_ptr()) };
if value.is_null() {
continue;
}
// SAFETY: PAM owns this C string. Copy it before the next PAM call.
let value = unsafe { CStr::from_ptr(value) }
.to_str()
.map_err(|_| Error::Session("PAM session environment is not UTF-8".into()))?
.to_owned();
let key = name
.to_str()
.map_err(|_| Error::Session("PAM environment key is not UTF-8".into()))?;
if let Some((_, existing)) = spec
.environment
.iter_mut()
.find(|(existing, _)| existing == key)
{
*existing = value;
} else {
spec.environment.push((key.to_owned(), value));
}
}
Ok(())
}
fn authenticate( fn authenticate(
username: &str, username: &str,
password: Zeroizing<Vec<u8>>, password: Zeroizing<Vec<u8>>,
@@ -378,8 +445,7 @@ fn authenticate(
&mut handle, &mut handle,
) )
}; };
// Reconstitute the allocation on every path after pam_start. PAM copies the // SAFETY: `data` came from `Box::into_raw`. PAM borrows `appdata_ptr`.
// conversation structure, but does not own appdata_ptr.
let conversation = unsafe { Box::from_raw(data) }; let conversation = unsafe { Box::from_raw(data) };
if started != PAM_SUCCESS { if started != PAM_SUCCESS {
let error = pam_error(handle, "pam_start", started); let error = pam_error(handle, "pam_start", started);
@@ -431,7 +497,7 @@ fn authenticate(
} }
session.credentials_established = true; session.credentials_established = true;
// Authentication is complete. Do not retain the password for the session. // Erase the password before the PAM session remains open.
session.conversation.password.zeroize(); session.conversation.password.zeroize();
let identity = session let identity = session
.identity .identity

View File

@@ -0,0 +1,133 @@
use std::env;
use latchd_auth::AuthService;
use latchd_core::{
accounts::Identity,
config::Config,
error::Error,
session::{self, SessionDescriptor, SessionKind},
};
fn fixture_service() -> String {
env::var("LATCHD_PAM_FIXTURE_SERVICE")
.expect("LATCHD_PAM_FIXTURE_SERVICE must point at the disposable PAM fixture")
}
fn session_fixture_service() -> String {
env::var("LATCHD_PAM_SESSION_FIXTURE_SERVICE")
.expect("LATCHD_PAM_SESSION_FIXTURE_SERVICE must point at the disposable PAM fixture")
}
fn assert_sanitized_pam_failure(result: Result<(), Error>, secret: &str) {
let error = result.expect_err("the fault-injection PAM fixture must reject the operation");
match error {
Error::Pam {
status, message, ..
} => {
assert_ne!(status, 0);
assert!(!message.contains(secret));
}
_ => panic!("fault-injection fixture returned a non-PAM error"),
}
}
#[test]
#[ignore = "requires the disposable PAM fixture installed by the Docker/VM harness"]
fn authentication_fault_is_rejected_without_secret_leakage() {
let service = AuthService::new().expect("PAM worker should start");
let secret = "fixture-auth-secret";
let result = service.authenticate("root".into(), secret.as_bytes().to_vec(), fixture_service());
let error = match result {
Ok(_) => panic!("pam_deny fixture must reject authentication"),
Err(error) => error,
};
match error {
Error::Pam {
status, message, ..
} => {
assert_ne!(status, 0);
assert!(!message.contains(secret));
}
_ => panic!("fault-injection fixture returned a non-PAM error"),
}
}
#[test]
#[ignore = "requires the disposable PAM fixture installed by the Docker/VM harness"]
fn password_change_fault_is_rejected_without_secret_leakage() {
let service = AuthService::new().expect("PAM worker should start");
assert_sanitized_pam_failure(
service.change_password(
"root".into(),
b"fixture-old-secret".to_vec(),
b"fixture-new-secret".to_vec(),
fixture_service(),
),
"fixture-new-secret",
);
}
#[test]
#[ignore = "requires the disposable PAM fixture installed by the Docker/VM harness"]
fn selected_session_metadata_reaches_pam_before_open() {
let service = AuthService::new().expect("PAM worker should start");
let ticket = service
.authenticate("root".into(), Vec::new(), session_fixture_service())
.expect("permit fixture should authenticate root");
let identity = Identity {
username: "root".into(),
uid: 0,
gid: 0,
home: "/root".into(),
shell: "/bin/sh".into(),
};
let descriptor = SessionDescriptor {
kind: SessionKind::Wayland,
title: "Niri fixture".into(),
argv: vec!["/bin/true".into()],
current_desktop: Some("niri".into()),
session_desktop: Some("niri".into()),
};
let spec = session::build_spec(&identity, &Config::default(), &descriptor)
.expect("fixture session should build");
service
.run_session(ticket, spec)
.expect("PAM must receive the selected graphical session metadata");
}
#[test]
#[ignore = "requires the disposable PAM fixture installed by the Docker/VM harness"]
fn pam_runtime_environment_reaches_the_session_child() {
let service = AuthService::new().expect("PAM worker should start");
let ticket = service
.authenticate("root".into(), Vec::new(), session_fixture_service())
.expect("permit fixture should authenticate root");
let identity = Identity {
username: "root".into(),
uid: 0,
gid: 0,
home: "/root".into(),
shell: "/bin/sh".into(),
};
let descriptor = SessionDescriptor {
kind: SessionKind::Wayland,
title: "PAM environment fixture".into(),
argv: vec![
"/bin/sh".into(),
"-c".into(),
"test \"$XDG_SESSION_ID\" = fixture-session && \
test \"$XDG_RUNTIME_DIR\" = /tmp/latchd-runtime-fixture && \
test \"$DBUS_SESSION_BUS_ADDRESS\" = unix:path=/tmp/latchd-runtime-fixture/bus"
.into(),
],
current_desktop: Some("niri".into()),
session_desktop: Some("niri".into()),
};
let spec = session::build_spec(&identity, &Config::default(), &descriptor)
.expect("fixture session should build");
service
.run_session(ticket, spec)
.expect("the child must receive runtime values produced by PAM");
}

View File

@@ -4,7 +4,6 @@ use std::{io, mem, os::raw::c_char, ptr};
use crate::error::Error; use crate::error::Error;
/// A live user-process record in UTMPX.
pub struct UtmpxGuard { pub struct UtmpxGuard {
record: libc::utmpx, record: libc::utmpx,
closed: bool, closed: bool,

View File

@@ -14,90 +14,61 @@ use crate::{
error::{Error, ResultExt}, error::{Error, ResultExt},
}; };
/// An account that can be offered by the login screen.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Identity { pub struct Identity {
/// Canonical username.
pub username: String, pub username: String,
/// Numeric user ID.
pub uid: u32, pub uid: u32,
/// Primary group ID.
pub gid: u32, pub gid: u32,
/// Home directory.
pub home: PathBuf, pub home: PathBuf,
/// Login shell.
pub shell: PathBuf, pub shell: PathBuf,
} }
/// Effective account state determined from the login shell and shadow aging data. /// Effective account state determined from the login shell and shadow aging data.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountState { pub enum AccountState {
/// Account can be offered for login.
Active, Active,
/// Password is disabled by the shadow lock marker.
Locked, Locked,
/// Password must be changed before login can continue.
PasswordExpired, PasswordExpired,
/// Account expiration date has elapsed.
AccountExpired, AccountExpired,
/// Password inactivity period has elapsed.
AccountInactive, AccountInactive,
/// Login shell explicitly disallows interactive sessions.
NoLoginShell, NoLoginShell,
/// Shadow data was not available or could not be parsed. /// Shadow data was not available or could not be parsed.
Unknown, Unknown,
} }
/// Non-sensitive password and account aging information.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountStatus { pub struct AccountStatus {
/// Effective state used by the chooser and diagnostics.
pub state: AccountState, pub state: AccountState,
/// Day the password was last changed, if present.
pub password_changed_day: Option<i64>, pub password_changed_day: Option<i64>,
/// Day the password expires, if the policy specifies one.
pub password_expiry_day: Option<i64>, pub password_expiry_day: Option<i64>,
/// Day the account expires, if specified.
pub account_expiry_day: Option<i64>, pub account_expiry_day: Option<i64>,
/// Day after which an expired password disables the account.
pub inactive_after_day: Option<i64>, pub inactive_after_day: Option<i64>,
} }
impl AccountStatus { impl AccountStatus {
/// Returns whether this account may be presented as an active login choice.
pub const fn is_login_allowed(self) -> bool { pub const fn is_login_allowed(self) -> bool {
matches!(self.state, AccountState::Active) matches!(self.state, AccountState::Active)
} }
} }
/// An identity paired with its non-sensitive account policy state.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountRecord { pub struct AccountRecord {
/// Discovered NSS identity.
pub identity: Identity, pub identity: Identity,
/// Current local account status.
pub status: AccountStatus, pub status: AccountStatus,
} }
/// A currently logged-in process obtained from UTMPX.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActiveSession { pub struct ActiveSession {
/// Login name associated with the session.
pub username: String, pub username: String,
/// UTMPX line, such as `tty2` or `pts/0`.
pub terminal: String, pub terminal: String,
/// Session leader PID when reported by UTMPX.
pub pid: libc::pid_t, pub pid: libc::pid_t,
/// Remote host, if supplied by the session producer.
pub host: Option<String>, pub host: Option<String>,
} }
/// Runtime session-management backend detected on this host. /// Runtime session-management backend detected on this host.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionBackend { pub enum SessionBackend {
/// systemd-logind runtime markers are available.
SystemdLogind, SystemdLogind,
/// UTMPX is available as a conservative fallback.
Utmpx, Utmpx,
} }
@@ -113,15 +84,10 @@ struct ShadowEntry {
/// A non-authoritative account lockout summary. /// A non-authoritative account lockout summary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FaillockStatus { pub struct FaillockStatus {
/// Whether the faillock helper returned a usable result.
pub available: bool, pub available: bool,
/// Number of observed failed attempts.
pub failures: u32, pub failures: u32,
/// Configured failure threshold, when discoverable.
pub max_tries: Option<u32>, pub max_tries: Option<u32>,
/// Remaining attempts before the advisory threshold.
pub remaining: Option<u32>, pub remaining: Option<u32>,
/// Whether the advisory threshold has been reached.
pub locked_out: bool, pub locked_out: bool,
} }
@@ -291,12 +257,8 @@ pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result<Faillo
Ok(faillock_status_from_output(&output.stdout, max_tries)) Ok(faillock_status_from_output(&output.stdout, max_tries))
} }
/// Converts stable helper output into an advisory lockout status. /// Parses the legacy count format and current rows with usernames or timestamps.
/// /// Malformed output reports zero failures and does not control authentication.
/// This parser accepts both the legacy `count timestamp` format and modern
/// rows containing a username prefix or timestamp columns. It intentionally
/// treats malformed output as zero failures instead of making authentication
/// fail open or fail closed.
pub fn faillock_status_from_output(output: &[u8], max_tries: Option<u32>) -> FaillockStatus { pub fn faillock_status_from_output(output: &[u8], max_tries: Option<u32>) -> FaillockStatus {
let failures = parse_failure_count(output); let failures = parse_failure_count(output);
let remaining = max_tries.map(|limit| limit.saturating_sub(failures)); let remaining = max_tries.map(|limit| limit.saturating_sub(failures));

View File

@@ -1,8 +1,6 @@
//! Redacted security audit events. //! Redacted security audit events.
//! //!
//! Audit records deliberately contain no password, token, PAM message, or //! Audit records exclude secrets, PAM messages, and command arguments.
//! command-line detail. Callers can send the fields to a structured logger
//! without accidentally turning an authentication failure into a secret dump.
use std::fmt; use std::fmt;
@@ -11,11 +9,8 @@ use crate::error::Error;
/// The security operation that produced an audit event. /// The security operation that produced an audit event.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditAction { pub enum AuditAction {
/// A PAM authentication attempt.
Authentication, Authentication,
/// A PAM password-change transaction.
PasswordChange, PasswordChange,
/// A user session handoff.
Session, Session,
} }
@@ -32,13 +27,9 @@ impl fmt::Display for AuditAction {
/// The outcome of a security operation. /// The outcome of a security operation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditOutcome { pub enum AuditOutcome {
/// The operation was requested.
Started, Started,
/// The operation completed successfully.
Succeeded, Succeeded,
/// The operation was rejected or failed.
Failed, Failed,
/// PAM requires a password change before proceeding.
PasswordChangeRequired, PasswordChangeRequired,
} }
@@ -56,21 +47,13 @@ impl fmt::Display for AuditOutcome {
/// A safe category for a PAM failure, without exposing PAM's free-form text. /// A safe category for a PAM failure, without exposing PAM's free-form text.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PamReason { pub enum PamReason {
/// Password or other authentication material was rejected.
AuthenticationRejected, AuthenticationRejected,
/// PAM could not reach its backing authentication service.
AuthenticationUnavailable, AuthenticationUnavailable,
/// PAM reports that the account has expired.
AccountExpired, AccountExpired,
/// PAM requires a password change.
PasswordChangeRequired, PasswordChangeRequired,
/// PAM reports that the retry limit was reached.
TooManyAttempts, TooManyAttempts,
/// PAM conversation or token handling failed.
Conversation, Conversation,
/// A PAM/system operation failed.
System, System,
/// An unclassified PAM status was returned.
Unknown, Unknown,
} }
@@ -92,18 +75,14 @@ impl fmt::Display for PamReason {
/// A structured event whose fields are safe to send to an audit logger. /// A structured event whose fields are safe to send to an audit logger.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuditEvent<'a> { pub struct AuditEvent<'a> {
/// Security operation.
pub action: AuditAction, pub action: AuditAction,
/// Operation outcome.
pub outcome: AuditOutcome, pub outcome: AuditOutcome,
/// Account name supplied by the user; never a password or token. /// Account name supplied by the user; never a password or token.
pub account: &'a str, pub account: &'a str,
/// Sanitized PAM failure category, when applicable.
pub pam_reason: Option<PamReason>, pub pam_reason: Option<PamReason>,
} }
impl<'a> AuditEvent<'a> { impl<'a> AuditEvent<'a> {
/// Creates an event for an operation that has just started.
pub const fn started(action: AuditAction, account: &'a str) -> Self { pub const fn started(action: AuditAction, account: &'a str) -> Self {
Self { Self {
action, action,
@@ -113,7 +92,6 @@ impl<'a> AuditEvent<'a> {
} }
} }
/// Creates a successful event.
pub const fn succeeded(action: AuditAction, account: &'a str) -> Self { pub const fn succeeded(action: AuditAction, account: &'a str) -> Self {
Self { Self {
action, action,
@@ -123,7 +101,6 @@ impl<'a> AuditEvent<'a> {
} }
} }
/// Creates a failure event with a sanitized PAM category.
pub fn failed(action: AuditAction, account: &'a str, error: &Error) -> Self { pub fn failed(action: AuditAction, account: &'a str, error: &Error) -> Self {
Self { Self {
action, action,

View File

@@ -9,33 +9,20 @@ use serde::Deserialize;
use crate::error::{Error, ResultExt}; use crate::error::{Error, ResultExt};
/// Validated application configuration.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct Config { pub struct Config {
/// Virtual terminal number used by the service.
pub tty: u8, pub tty: u8,
/// Main log file.
pub main_log_path: PathBuf, pub main_log_path: PathBuf,
/// Per-session log file.
pub client_log_path: PathBuf, pub client_log_path: PathBuf,
/// PAM service name.
pub pam_service: String, pub pam_service: String,
/// Default shell used only when the account has no shell value.
pub system_shell: PathBuf, pub system_shell: PathBuf,
/// Session PATH.
pub initial_path: String, pub initial_path: String,
/// X11 settings.
pub x11: X11Config, pub x11: X11Config,
/// Wayland settings.
pub wayland: WaylandConfig, pub wayland: WaylandConfig,
/// Environment switcher settings.
pub environment_switcher: SwitcherConfig, pub environment_switcher: SwitcherConfig,
/// Power actions shown by the login screen.
pub power_controls: PowerControls, pub power_controls: PowerControls,
/// Account enumeration and advisory lockout settings.
pub accounts: AccountsConfig, pub accounts: AccountsConfig,
/// TUI-only background, animation, and notification settings.
pub visual: VisualConfig, pub visual: VisualConfig,
} }
@@ -58,21 +45,14 @@ impl Default for Config {
} }
} }
/// Account discovery and faillock settings.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct AccountsConfig { pub struct AccountsConfig {
/// Minimum UID shown by automatic account discovery.
pub minimum_uid: u32, pub minimum_uid: u32,
/// Usernames excluded from the chooser.
pub excluded_users: Vec<String>, pub excluded_users: Vec<String>,
/// Whether the chooser lists NSS accounts.
pub enumerate: bool, pub enumerate: bool,
/// Absolute faillock helper path.
pub faillock_path: PathBuf, pub faillock_path: PathBuf,
/// faillock configuration used to read the deny threshold.
pub faillock_config: PathBuf, pub faillock_config: PathBuf,
/// Shadow database used for local password and account aging checks.
pub shadow_path: PathBuf, pub shadow_path: PathBuf,
} }
@@ -89,21 +69,14 @@ impl Default for AccountsConfig {
} }
} }
/// Visual effects used only by the terminal login screen.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct VisualConfig { pub struct VisualConfig {
/// Optional root-owned image used as a low-contrast terminal wallpaper.
pub wallpaper_path: Option<PathBuf>, pub wallpaper_path: Option<PathBuf>,
/// Enable the wallpaper and procedural background layer.
pub background_enabled: bool, pub background_enabled: bool,
/// Advance animated background and spinner frames.
pub animations: bool, pub animations: bool,
/// Show transient in-TUI notifications for user-visible events.
pub notifications: bool, pub notifications: bool,
/// Disable motion while retaining static visual styling.
pub reduced_motion: bool, pub reduced_motion: bool,
/// Maximum wallpaper file size accepted before decoding.
pub max_wallpaper_bytes: u64, pub max_wallpaper_bytes: u64,
} }
@@ -120,23 +93,15 @@ impl Default for VisualConfig {
} }
} }
/// X11 session configuration.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct X11Config { pub struct X11Config {
/// X display name.
pub display: String, pub display: String,
/// X server executable.
pub server_path: PathBuf, pub server_path: PathBuf,
/// Xauthority executable.
pub xauth_path: PathBuf, pub xauth_path: PathBuf,
/// X server startup timeout.
pub server_timeout_secs: u16, pub server_timeout_secs: u16,
/// X server log.
pub server_log_path: PathBuf, pub server_log_path: PathBuf,
/// Desktop-entry directory.
pub sessions_path: PathBuf, pub sessions_path: PathBuf,
/// Custom script directory.
pub scripts_path: PathBuf, pub scripts_path: PathBuf,
} }
@@ -154,13 +119,10 @@ impl Default for X11Config {
} }
} }
/// Wayland session configuration.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct WaylandConfig { pub struct WaylandConfig {
/// Desktop-entry directory.
pub sessions_path: PathBuf, pub sessions_path: PathBuf,
/// Custom script directory.
pub scripts_path: PathBuf, pub scripts_path: PathBuf,
} }
@@ -173,14 +135,13 @@ impl Default for WaylandConfig {
} }
} }
/// Session switcher settings.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct SwitcherConfig { pub struct SwitcherConfig {
/// Include the fallback TTY shell in the chooser.
pub include_tty_shell: bool, pub include_tty_shell: bool,
/// Maximum visible title width.
pub max_display_length: usize, pub max_display_length: usize,
pub remember_last_session: bool,
pub last_session_path: PathBuf,
} }
impl Default for SwitcherConfig { impl Default for SwitcherConfig {
@@ -188,29 +149,24 @@ impl Default for SwitcherConfig {
Self { Self {
include_tty_shell: false, include_tty_shell: false,
max_display_length: 18, max_display_length: 18,
remember_last_session: true,
last_session_path: PathBuf::from("/var/lib/latchd/last-session.toml"),
} }
} }
} }
/// Power actions available to the login screen.
#[derive(Debug, Clone, Default, Deserialize)] #[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct PowerControls { pub struct PowerControls {
/// Built-in or administrator-defined actions.
pub entries: Vec<PowerAction>, pub entries: Vec<PowerAction>,
} }
/// A power action represented as an executable argv vector.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct PowerAction { pub struct PowerAction {
/// Displayed key, such as `F1`.
pub key: String, pub key: String,
/// Displayed label.
pub hint: String, pub hint: String,
/// Executable path.
pub program: PathBuf, pub program: PathBuf,
/// Arguments passed without shell interpretation.
#[serde(default)] #[serde(default)]
pub args: Vec<String>, pub args: Vec<String>,
} }
@@ -287,6 +243,13 @@ pub fn validate(config: &Config) -> Result<(), Error> {
"environment_switcher.max_display_length must be greater than zero".into(), "environment_switcher.max_display_length must be greater than zero".into(),
)); ));
} }
if config.environment_switcher.remember_last_session
&& !config.environment_switcher.last_session_path.is_absolute()
{
return Err(Error::Config(
"environment_switcher.last_session_path must be absolute".into(),
));
}
for (name, path) in [ for (name, path) in [
("main_log_path", &config.main_log_path), ("main_log_path", &config.main_log_path),
("client_log_path", &config.client_log_path), ("client_log_path", &config.client_log_path),

View File

@@ -2,47 +2,32 @@ use std::io;
use thiserror::Error; use thiserror::Error;
/// The application-wide error type.
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum Error { pub enum Error {
/// A command-line or configuration value was invalid.
#[error("configuration: {0}")] #[error("configuration: {0}")]
Config(String), Config(String),
/// A filesystem operation failed.
#[error("{context}: {source}")] #[error("{context}: {source}")]
Io { Io {
/// The operation being performed.
context: String, context: String,
/// The underlying error.
#[source] #[source]
source: io::Error, source: io::Error,
}, },
/// TOML could not be decoded.
#[error("parse {path}: {source}")] #[error("parse {path}: {source}")]
Toml { Toml {
/// The file being parsed.
path: String, path: String,
/// The parser error.
#[source] #[source]
source: toml::de::Error, source: toml::de::Error,
}, },
/// PAM rejected or could not complete an operation.
#[error("PAM {operation} failed with status {status}: {message}")] #[error("PAM {operation} failed with status {status}: {message}")]
Pam { Pam {
/// The PAM operation.
operation: &'static str, operation: &'static str,
/// The numeric PAM status.
status: i32, status: i32,
/// The human-readable PAM message.
message: String, message: String,
}, },
/// A session process failed.
#[error("session: {0}")] #[error("session: {0}")]
Session(String), Session(String),
/// The terminal could not be initialized or restored.
#[error("terminal: {0}")] #[error("terminal: {0}")]
Terminal(String), Terminal(String),
/// An authentication worker request was cancelled or disconnected.
#[error("authentication worker: {0}")] #[error("authentication worker: {0}")]
Worker(String), Worker(String),
} }
@@ -62,7 +47,6 @@ impl Error {
} }
} }
/// Adds a stable operation label to an I/O error.
pub fn io_error(context: impl Into<String>, source: io::Error) -> Error { pub fn io_error(context: impl Into<String>, source: io::Error) -> Error {
Error::Io { Error::Io {
context: context.into(), context: context.into(),
@@ -70,9 +54,7 @@ pub fn io_error(context: impl Into<String>, source: io::Error) -> Error {
} }
} }
/// Converts a failed result into an application error with context.
pub trait ResultExt<T> { pub trait ResultExt<T> {
/// Adds context to an I/O operation.
fn io_context(self, context: impl Into<String>) -> Result<T, Error>; fn io_context(self, context: impl Into<String>) -> Result<T, Error>;
} }

View File

@@ -1,17 +1,11 @@
//! Core configuration, account, session, and accounting contracts for latchd. //! Core configuration, account, session, and accounting contracts for latchd.
#![deny(unsafe_op_in_unsafe_fn)] #![deny(unsafe_op_in_unsafe_fn)]
#![warn(missing_docs)]
/// Linux UTMPX accounting primitives.
pub mod accounting; pub mod accounting;
/// NSS account discovery and faillock status.
pub mod accounts; pub mod accounts;
/// Redacted, structured security audit events.
pub mod audit; pub mod audit;
/// Validated application configuration.
pub mod config; pub mod config;
/// Structured application errors.
pub mod error; pub mod error;
/// Session discovery and process lifecycle. pub mod preferences;
pub mod session; pub mod session;

View File

@@ -0,0 +1,253 @@
//! Bounded, owner-checked persistence for last-used session choices.
use std::{
fs::{self, OpenOptions},
io::Write,
os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt},
path::Path,
time::{SystemTime, UNIX_EPOCH},
};
use serde::{Deserialize, Serialize};
use crate::{
error::{Error, ResultExt, io_error},
session::{SessionDescriptor, SessionKind},
};
const MAX_ENTRIES: usize = 128;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LastUsedEntry {
pub username: String,
pub kind: SessionKind,
pub title: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct LastUsedPreferences {
entries: Vec<LastUsedEntry>,
}
impl LastUsedPreferences {
/// Records a successful selection, moving the newest entry to the front.
pub fn remember(&mut self, username: &str, session: &SessionDescriptor) {
if username.is_empty() || session.title.is_empty() {
return;
}
self.entries.retain(|entry| entry.username != username);
self.entries.insert(
0,
LastUsedEntry {
username: username.to_owned(),
kind: session.kind,
title: session.title.clone(),
},
);
self.entries.truncate(MAX_ENTRIES);
}
/// Finds the exact previously selected session for an account.
pub fn preferred_index(&self, username: &str, sessions: &[SessionDescriptor]) -> Option<usize> {
let entry = self
.entries
.iter()
.find(|entry| entry.username == username)?;
sessions
.iter()
.position(|session| session.kind == entry.kind && session.title == entry.title)
}
pub fn entries(&self) -> &[LastUsedEntry] {
&self.entries
}
}
/// Loads preferences from an owner-checked TOML file.
pub fn load(path: &Path) -> Result<LastUsedPreferences, Error> {
if !path.is_absolute() {
return Err(Error::Config("last-session path must be absolute".into()));
}
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(LastUsedPreferences::default());
}
Err(error) => return Err(io_error(format!("inspect {}", path.display()), error)),
};
if !trusted_file(&metadata) {
return Err(Error::Config(format!(
"last-session file must be an owner-only regular file: {}",
path.display()
)));
}
let raw = fs::read_to_string(path).io_context(format!("read {}", path.display()))?;
let preferences =
toml::from_str::<LastUsedPreferences>(&raw).map_err(|source| Error::Toml {
path: path.display().to_string(),
source,
})?;
validate(&preferences, path)?;
Ok(preferences)
}
/// Atomically saves preferences with mode `0600` under a trusted directory.
pub fn save(path: &Path, preferences: &LastUsedPreferences) -> Result<(), Error> {
if !path.is_absolute() {
return Err(Error::Config("last-session path must be absolute".into()));
}
validate(preferences, path)?;
let parent = path.parent().ok_or_else(|| {
Error::Config(format!(
"last-session path has no parent: {}",
path.display()
))
})?;
fs::create_dir_all(parent).io_context(format!("create {}", parent.display()))?;
let parent_metadata =
fs::symlink_metadata(parent).io_context(format!("inspect {}", parent.display()))?;
if !trusted_directory(&parent_metadata) {
return Err(Error::Config(format!(
"last-session directory must be owner-owned and non-writable: {}",
parent.display()
)));
}
let content = toml::to_string_pretty(preferences)
.map_err(|error| Error::Config(format!("serialize last-session preferences: {error}")))?;
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let temporary = parent.join(format!(
".{}.tmp.{}.{}",
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("last-session"),
std::process::id(),
nonce
));
let write_result = (|| -> Result<(), Error> {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(&temporary)
.io_context(format!("create {}", temporary.display()))?;
file.write_all(content.as_bytes())
.io_context(format!("write {}", temporary.display()))?;
file.sync_all()
.io_context(format!("sync {}", temporary.display()))?;
fs::rename(&temporary, path).io_context(format!("replace {}", path.display()))?;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
.io_context(format!("secure {}", path.display()))?;
Ok(())
})();
if write_result.is_err() {
let _ = fs::remove_file(&temporary);
}
write_result
}
fn validate(preferences: &LastUsedPreferences, path: &Path) -> Result<(), Error> {
if preferences.entries.len() > MAX_ENTRIES {
return Err(Error::Config(format!(
"last-session file contains more than {MAX_ENTRIES} entries: {}",
path.display()
)));
}
if preferences
.entries
.iter()
.any(|entry| entry.username.is_empty() || entry.title.is_empty())
{
return Err(Error::Config(format!(
"last-session entries must have a username and title: {}",
path.display()
)));
}
Ok(())
}
fn trusted_file(metadata: &std::fs::Metadata) -> bool {
metadata.is_file() && metadata.uid() == effective_uid() && metadata.mode() & 0o077 == 0
}
fn trusted_directory(metadata: &std::fs::Metadata) -> bool {
metadata.is_dir() && metadata.uid() == effective_uid() && metadata.mode() & 0o022 == 0
}
fn effective_uid() -> u32 {
// SAFETY: geteuid has no pointer arguments or memory preconditions.
unsafe { libc::geteuid() }
}
#[cfg(test)]
mod tests {
use super::*;
fn descriptor(kind: SessionKind, title: &str) -> SessionDescriptor {
SessionDescriptor {
kind,
title: title.into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
}
}
fn test_path() -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"latchd-preferences-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos())
))
}
#[test]
fn latest_successful_session_is_preferred_per_account() {
let wayland = descriptor(SessionKind::Wayland, "Sway");
let tty = descriptor(SessionKind::Tty, "TTY shell");
let mut preferences = LastUsedPreferences::default();
preferences.remember("alice", &wayland);
assert_eq!(
preferences.preferred_index("alice", &[tty.clone(), wayland]),
Some(1)
);
preferences.remember("alice", &tty);
assert_eq!(
preferences.preferred_index("alice", &[tty, descriptor(SessionKind::Wayland, "Sway")]),
Some(0)
);
assert_eq!(preferences.entries()[0].username, "alice");
}
#[test]
fn preferences_round_trip_atomically_with_private_permissions() {
let directory = test_path();
let path = directory.join("last-session.toml");
let session = descriptor(SessionKind::X11, "GNOME");
let mut preferences = LastUsedPreferences::default();
preferences.remember("alice", &session);
save(&path, &preferences).expect("preference save should succeed");
let loaded = load(&path).expect("preference load should succeed");
assert_eq!(loaded, preferences);
let mode = fs::symlink_metadata(&path)
.expect("preference metadata should exist")
.mode();
assert_eq!(mode & 0o077, 0);
let _ = fs::remove_dir_all(directory);
}
#[test]
fn missing_preferences_are_a_safe_empty_store() {
let path = test_path().join("missing.toml");
assert_eq!(
load(&path).expect("missing preferences are optional"),
LastUsedPreferences::default()
);
}
}

View File

@@ -26,72 +26,71 @@ use crate::{
error::{Error, ResultExt, io_error}, error::{Error, ResultExt, io_error},
}; };
use rand::RngCore; use rand::RngCore;
use serde::{Deserialize, Serialize};
use signal_hook::{consts::SIGUSR1, flag}; use signal_hook::{consts::SIGUSR1, flag};
/// A session family supported by latchd. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "lowercase")]
pub enum SessionKind { pub enum SessionKind {
/// A login shell on the current virtual terminal.
Tty, Tty,
/// An X11 desktop session.
X11, X11,
/// A Wayland desktop session.
Wayland, Wayland,
} }
/// A discoverable desktop or script session.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SessionDescriptor { pub struct SessionDescriptor {
/// Session family.
pub kind: SessionKind, pub kind: SessionKind,
/// User-facing title.
pub title: String, pub title: String,
/// Program and arguments, without shell interpretation. /// Program and arguments, without shell interpretation.
pub argv: Vec<String>, pub argv: Vec<String>,
/// XDG current-desktop value.
pub current_desktop: Option<String>, pub current_desktop: Option<String>,
/// XDG session-desktop value.
pub session_desktop: Option<String>, pub session_desktop: Option<String>,
} }
/// A fully materialized session request.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SessionSpec { pub struct SessionSpec {
/// Session family.
pub kind: SessionKind, pub kind: SessionKind,
/// Session title for logs.
pub title: String, pub title: String,
/// Program and arguments.
pub argv: Vec<String>, pub argv: Vec<String>,
/// Explicit child environment.
pub environment: Vec<(String, String)>, pub environment: Vec<(String, String)>,
/// Working directory.
pub working_directory: PathBuf, pub working_directory: PathBuf,
/// Private stdout/stderr log for graphical sessions.
pub client_log_path: PathBuf, pub client_log_path: PathBuf,
/// Login shell for TTY fallback.
pub shell: PathBuf, pub shell: PathBuf,
/// Virtual terminal number.
pub tty: u8, pub tty: u8,
/// X11 configuration when this is an X11 session.
pub x11: Option<X11Runtime>, pub x11: Option<X11Runtime>,
} }
/// Runtime X11 parameters passed to a session.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct X11Runtime { pub struct X11Runtime {
/// X display name.
pub display: String, pub display: String,
/// X server command.
pub server_path: PathBuf, pub server_path: PathBuf,
/// Xauthority command.
pub xauth_path: PathBuf, pub xauth_path: PathBuf,
/// X server timeout.
pub timeout: Duration, pub timeout: Duration,
/// X server log path.
pub log_path: PathBuf, pub log_path: PathBuf,
} }
const PAM_SESSION_ENVIRONMENT_KEYS: [&str; 5] = [
"XDG_SESSION_CLASS",
"XDG_SESSION_TYPE",
"XDG_SESSION_DESKTOP",
"XDG_SEAT",
"XDG_VTNR",
];
impl SessionSpec {
pub fn pam_tty(&self) -> String {
format!("tty{}", self.tty)
}
/// Returns the selected-session metadata that PAM and logind must see.
pub fn pam_environment(&self) -> impl Iterator<Item = (&str, &str)> {
self.environment
.iter()
.filter(|(key, _)| PAM_SESSION_ENVIRONMENT_KEYS.contains(&key.as_str()))
.map(|(key, value)| (key.as_str(), value.as_str()))
}
}
/// Discovers configured sessions in stable directory order. /// Discovers configured sessions in stable directory order.
pub fn discover(config: &Config) -> Vec<SessionDescriptor> { pub fn discover(config: &Config) -> Vec<SessionDescriptor> {
let mut sessions = Vec::new(); let mut sessions = Vec::new();
@@ -236,7 +235,6 @@ pub fn build_spec(
"XDG_RUNTIME_DIR", "XDG_RUNTIME_DIR",
format!("/run/user/{}", identity.uid), format!("/run/user/{}", identity.uid),
); );
insert_env(&mut environment, "XDG_SESSION_ID", "latchd".into());
insert_env( insert_env(
&mut environment, &mut environment,
"XDG_CONFIG_HOME", "XDG_CONFIG_HOME",
@@ -858,4 +856,40 @@ mod tests {
.all(|(key, _)| key != "DISPLAY" && key != "XAUTHORITY") .all(|(key, _)| key != "DISPLAY" && key != "XAUTHORITY")
); );
} }
#[test]
fn selected_wayland_metadata_is_ready_for_pam() {
let identity = Identity {
username: "alice".into(),
uid: 1000,
gid: 1000,
home: "/home/alice".into(),
shell: "/bin/sh".into(),
};
let descriptor = SessionDescriptor {
kind: SessionKind::Wayland,
title: "Niri".into(),
argv: vec!["/usr/bin/niri-session".into()],
current_desktop: Some("niri".into()),
session_desktop: Some("niri".into()),
};
let spec = build_spec(&identity, &Config::default(), &descriptor).expect("valid spec");
let pam_environment = spec
.pam_environment()
.collect::<std::collections::HashMap<_, _>>();
assert_eq!(spec.pam_tty(), "tty2");
assert_eq!(pam_environment.get("XDG_SESSION_CLASS"), Some(&"user"));
assert_eq!(pam_environment.get("XDG_SESSION_TYPE"), Some(&"wayland"));
assert_eq!(pam_environment.get("XDG_SESSION_DESKTOP"), Some(&"niri"));
assert_eq!(pam_environment.get("XDG_SEAT"), Some(&"seat0"));
assert_eq!(pam_environment.get("XDG_VTNR"), Some(&"2"));
assert!(
spec.environment
.iter()
.all(|(key, _)| key != "XDG_SESSION_ID"),
"logind, not latchd, owns the session identifier"
);
}
} }

View File

@@ -23,7 +23,6 @@ const MAX_ANIMATION_WIDTH: usize = 160;
const MAX_ANIMATION_HEIGHT: usize = 64; const MAX_ANIMATION_HEIGHT: usize = 64;
const FRAME_INTERVAL: Duration = Duration::from_millis(120); const FRAME_INTERVAL: Duration = Duration::from_millis(120);
/// Background state owned by the TUI thread.
pub(crate) struct Background { pub(crate) struct Background {
wallpaper: Option<RgbImage>, wallpaper: Option<RgbImage>,
automata: Automata, automata: Automata,
@@ -34,7 +33,6 @@ pub(crate) struct Background {
} }
impl Background { impl Background {
/// Loads a bounded wallpaper image and prepares the procedural layer.
pub(crate) fn load(config: &VisualConfig) -> Result<Self, Error> { pub(crate) fn load(config: &VisualConfig) -> Result<Self, Error> {
let wallpaper = config let wallpaper = config
.wallpaper_path .wallpaper_path
@@ -191,8 +189,7 @@ struct Automata {
impl Automata { impl Automata {
fn resize(&mut self, width: usize, height: usize) { fn resize(&mut self, width: usize, height: usize) {
// Keep procedural state bounded even when a terminal reports an // Cap the state size. Some terminal emulators report extreme dimensions.
// unusually large window. Rendering remains clipped to this layer.
let width = width.min(MAX_ANIMATION_WIDTH); let width = width.min(MAX_ANIMATION_WIDTH);
let height = height.min(MAX_ANIMATION_HEIGHT); let height = height.min(MAX_ANIMATION_HEIGHT);
if self.width == width && self.height == height { if self.width == width && self.height == height {

View File

@@ -21,6 +21,7 @@ use latchd_core::{
audit::{AuditAction, AuditEvent}, audit::{AuditAction, AuditEvent},
config::Config, config::Config,
error::{Error, ResultExt}, error::{Error, ResultExt},
preferences::{self, LastUsedPreferences},
session::{self, SessionDescriptor}, session::{self, SessionDescriptor},
}; };
@@ -41,11 +42,8 @@ fn unknown_account_status() -> AccountStatus {
} }
} }
/// The result of one completed login-screen interaction.
pub struct LoginSelection { pub struct LoginSelection {
/// Authenticated PAM ticket.
pub ticket: AuthTicket, pub ticket: AuthTicket,
/// Selected session descriptor.
pub session: SessionDescriptor, pub session: SessionDescriptor,
} }
@@ -130,6 +128,8 @@ struct App {
refresh_receiver: Option<Receiver<DiscoverySnapshot>>, refresh_receiver: Option<Receiver<DiscoverySnapshot>>,
refreshing: bool, refreshing: bool,
high_contrast: bool, high_contrast: bool,
preferences: LastUsedPreferences,
preference_path: Option<std::path::PathBuf>,
} }
impl App { impl App {
@@ -181,6 +181,8 @@ impl App {
refresh_receiver: None, refresh_receiver: None,
refreshing: false, refreshing: false,
high_contrast: std::env::var_os("LATCHD_HIGH_CONTRAST").is_some(), high_contrast: std::env::var_os("LATCHD_HIGH_CONTRAST").is_some(),
preferences: LastUsedPreferences::default(),
preference_path: None,
} }
} }
@@ -258,6 +260,7 @@ impl App {
.get(self.selected) .get(self.selected)
.cloned() .cloned()
.ok_or_else(|| Error::Session("no session selected".into()))?; .ok_or_else(|| Error::Session("no session selected".into()))?;
self.remember_selected_session(&selected);
Ok(Some(LoginSelection { Ok(Some(LoginSelection {
ticket, ticket,
session: selected, session: selected,
@@ -334,6 +337,25 @@ impl App {
); );
} }
fn apply_preferred_session(&mut self) {
if let Some(index) = self
.preferences
.preferred_index(&self.username, &self.sessions)
{
self.selected = index;
}
}
fn remember_selected_session(&mut self, session: &SessionDescriptor) {
self.preferences.remember(&self.username, session);
let Some(path) = self.preference_path.clone() else {
return;
};
if let Err(error) = preferences::save(&path, &self.preferences) {
tracing::debug!(%error, "could not persist last-session preference");
}
}
fn poll_faillock(&mut self) { fn poll_faillock(&mut self) {
let Some(receiver) = &self.faillock_receiver else { let Some(receiver) = &self.faillock_receiver else {
return; return;
@@ -995,6 +1017,7 @@ impl App {
self.auth_blocked_until = None; self.auth_blocked_until = None;
self.faillock_status = None; self.faillock_status = None;
self.status = format!("Selected account {}", self.username); self.status = format!("Selected account {}", self.username);
self.apply_preferred_session();
self.start_faillock_query(); self.start_faillock_query();
self.notify( self.notify(
&format!("Selected account {}", self.username), &format!("Selected account {}", self.username),
@@ -1368,9 +1391,25 @@ pub fn run(
(Vec::new(), Vec::new()) (Vec::new(), Vec::new())
}; };
let background = Background::load(&config.visual)?; let background = Background::load(&config.visual)?;
let preference_path = config
.environment_switcher
.remember_last_session
.then(|| config.environment_switcher.last_session_path.clone());
let preferences =
preference_path
.as_deref()
.map_or_else(LastUsedPreferences::default, |path| {
preferences::load(path).unwrap_or_else(|error| {
tracing::debug!(%error, "could not load last-session preferences");
LastUsedPreferences::default()
})
});
let mut app = App::new(config, auth, preview, sessions, accounts, background); let mut app = App::new(config, auth, preview, sessions, accounts, background);
app.account_statuses = account_statuses; app.account_statuses = account_statuses;
app.account_index = (!app.accounts.is_empty()).then_some(0); app.account_index = (!app.accounts.is_empty()).then_some(0);
app.preferences = preferences;
app.preference_path = preference_path;
app.apply_preferred_session();
app.start_faillock_query(); app.start_faillock_query();
ratatui::run(|terminal| app.run(terminal)).map_err(|error| Error::Terminal(error.to_string())) ratatui::run(|terminal| app.run(terminal)).map_err(|error| Error::Terminal(error.to_string()))
} }
@@ -1654,6 +1693,54 @@ mod tests {
assert!(rendered.contains("New password")); assert!(rendered.contains("New password"));
} }
#[test]
fn last_session_preference_follows_account_and_persists_success() {
let auth = AuthService::new().expect("PAM worker should start");
let sessions = vec![
SessionDescriptor {
kind: session::SessionKind::Tty,
title: "TTY shell".into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
},
SessionDescriptor {
kind: session::SessionKind::Wayland,
title: "Sway".into(),
argv: vec!["sway".into()],
current_desktop: None,
session_desktop: None,
},
];
let mut app = App::new(
Config::default(),
auth,
true,
sessions,
vec![Identity {
username: "alice".into(),
uid: 1000,
gid: 1000,
home: "/home/alice".into(),
shell: "/bin/sh".into(),
}],
Background::load(&Config::default().visual).expect("default background should load"),
);
app.preferences.remember("alice", &app.sessions[1]);
app.apply_preferred_session();
assert_eq!(app.selected, 1);
let directory =
std::env::temp_dir().join(format!("latchd-tui-preferences-{}", std::process::id()));
app.preference_path = Some(directory.join("last-session.toml"));
let selected = app.sessions[1].clone();
app.remember_selected_session(&selected);
let loaded = preferences::load(&directory.join("last-session.toml"))
.expect("selected session should persist");
assert_eq!(loaded.preferred_index("alice", &app.sessions), Some(1));
let _ = std::fs::remove_dir_all(directory);
}
#[test] #[test]
fn filtering_is_smart_case_and_selects_a_matching_session() { fn filtering_is_smart_case_and_selects_a_matching_session() {
let auth = AuthService::new().expect("PAM worker should start"); let auth = AuthService::new().expect("PAM worker should start");

View File

@@ -5,6 +5,10 @@ Latchd has two test layers:
* `make integration-test` builds the binary and exercises metadata, TOML * `make integration-test` builds the binary and exercises metadata, TOML
validation, preflight, and the non-authenticating preview path. It does not validation, preflight, and the non-authenticating preview path. It does not
need PAM credentials, root, a VT, X11, or Wayland. need PAM credentials, root, a VT, X11, or Wayland.
* `make pam-fixture-test` installs unique temporary PAM services only when run
as root, executes authentication/password-change fault tests and the session
metadata/environment handoff tests, then removes the services on exit.
Non-root runs report `SKIP`.
* `make system-e2e` probes the host for PAM, faillock, VT, X11, Wayland, and * `make system-e2e` probes the host for PAM, faillock, VT, X11, Wayland, and
systemd-logind capabilities. Missing capabilities are reported as `SKIP`, systemd-logind capabilities. Missing capabilities are reported as `SKIP`,
never as a successful test. Use `make system-e2e-strict` only in a prepared never as a successful test. Use `make system-e2e-strict` only in a prepared
@@ -22,6 +26,7 @@ For a local release-style run:
```sh ```sh
make integration-test make integration-test
make pam-fixture-test
make docker-test make docker-test
make system-e2e make system-e2e
``` ```

View File

@@ -1,5 +1,3 @@
# latchd configuration. All paths are validated before the login screen starts.
tty = 2 tty = 2
main_log_path = "/var/log/latchd.log" main_log_path = "/var/log/latchd.log"
client_log_path = "/var/log/latchd.client.log" client_log_path = "/var/log/latchd.client.log"
@@ -23,10 +21,10 @@ scripts_path = "/etc/latchd/wayland"
[environment_switcher] [environment_switcher]
include_tty_shell = false include_tty_shell = false
max_display_length = 18 max_display_length = 18
remember_last_session = true
last_session_path = "/var/lib/latchd/last-session.toml"
# Power actions use an executable path and argv, never a shell command string. # Power actions use an executable and arguments. Latchd does not use a shell.
# Keep this section empty unless the installation explicitly wants console
# power controls.
[power_controls] [power_controls]
entries = [] entries = []

View File

@@ -1,7 +1,3 @@
// auth — faillock integration.
//
// Reads /etc/security/faillock.conf for deny count, executes
// faillock --user to get tally, supports --reset via admin bypass.
package auth package auth
import ( import (

View File

@@ -1,9 +1,4 @@
// auth — PAM authentication with CGO bindings. // Keep each PAM transaction on one OS thread. PAM modules can use thread-local state.
//
// Uses runtime.LockOSThread to prevent goroutine migration during
// PAM transactions, as PAM modules rely on thread-local storage.
//
// Mirrors vendor/src/auth/pam.rs and vendor/src/auth/mod.rs.
package auth package auth
/* /*
@@ -12,11 +7,11 @@ package auth
#include <stdlib.h> #include <stdlib.h>
#include <stdint.h> #include <stdint.h>
// Match the //export-generated signature. // Match the signature that cgo generates for //export.
extern int latchd_pam_conv(int, struct pam_message **, extern int latchd_pam_conv(int, struct pam_message **,
struct pam_response **, void *); struct pam_response **, void *);
// Store a uintptr_t handle in C heap so the Go GC never sees a Go pointer. // Store the numeric handle in C memory. Do not pass a Go pointer to PAM.
static void *storeHandle(uintptr_t h) { static void *storeHandle(uintptr_t h) {
uintptr_t *p = malloc(sizeof(uintptr_t)); uintptr_t *p = malloc(sizeof(uintptr_t));
if (p) *p = h; if (p) *p = h;

View File

@@ -1,6 +1,3 @@
// auth — UTMPX user accounting (glibc only).
//
// Mirrors vendor/src/auth/utmpx.rs.
package auth package auth
/* /*

View File

@@ -1,7 +1,4 @@
// Helper: assign seconds + microseconds to utmpx's ut_tv field. // Let the C compiler convert values for either native or compatibility ut_tv fields.
// The C compiler handles the type conversion, so this works whether
// ut_tv is struct timeval (long tv_sec/tv_usec) or the compat struct
// (__uint32_t tv_sec / __int32_t tv_usec).
#include <stdint.h> #include <stdint.h>
static inline void latchd_set_utmpx_time(struct utmpx *ut, int64_t sec, int32_t usec) { static inline void latchd_set_utmpx_time(struct utmpx *ut, int64_t sec, int32_t usec) {

View File

@@ -168,7 +168,6 @@ func LoadVariables(filename string) (map[string]string, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("read %s: %w", filename, err) return nil, fmt.Errorf("read %s: %w", filename, err)
} }
// Parse a flat TOML table of string values.
vars := make(map[string]string) vars := make(map[string]string)
var table map[string]any var table map[string]any
if err := toml.Unmarshal(raw, &table); err != nil { if err := toml.Unmarshal(raw, &table); err != nil {
@@ -180,8 +179,6 @@ func LoadVariables(filename string) (map[string]string, error) {
return vars, nil return vars, nil
} }
// ── variable substitution ───────────────────────────────────────────────
func resolveVars(in string, vars map[string]string) string { func resolveVars(in string, vars map[string]string) string {
for k, v := range vars { for k, v := range vars {
in = strings.ReplaceAll(in, "$"+k, v) in = strings.ReplaceAll(in, "$"+k, v)
@@ -189,8 +186,6 @@ func resolveVars(in string, vars map[string]string) string {
return in return in
} }
// ── embedded default config ─────────────────────────────────────────────
const defaultTOML = ` const defaultTOML = `
tty = 2 tty = 2
main_log_path = "/var/log/latchd.log" main_log_path = "/var/log/latchd.log"

View File

@@ -18,8 +18,6 @@ import (
"latchd/tui/bg" "latchd/tui/bg"
) )
// ── pixelated user avatar ───────────────────────────────────────────────
var avatarArt = []string{ var avatarArt = []string{
` ▄▄▄▄▄ `, ` ▄▄▄▄▄ `,
` ▄███████▄ `, ` ▄███████▄ `,
@@ -31,8 +29,6 @@ var avatarArt = []string{
` ██ ██ `, ` ██ ██ `,
} }
// ── messages ─────────────────────────────────────────────────────────────
type tickMsg time.Time type tickMsg time.Time
type authResult struct { type authResult struct {
@@ -57,8 +53,6 @@ const (
modeAdminPass modeAdminPass
) )
// ── result (sent back to main on successful login) ──────────────────────
type Result struct { type Result struct {
Username string Username string
Password string Password string
@@ -67,8 +61,6 @@ type Result struct {
Preview bool Preview bool
} }
// ── model ───────────────────────────────────────────────────────────────
type Model struct { type Model struct {
cfg config.Config cfg config.Config
preview bool preview bool
@@ -137,8 +129,6 @@ func (m *Model) Init() tea.Cmd {
return tea.Batch(tick(), tea.EnterAltScreen) return tea.Batch(tick(), tea.EnterAltScreen)
} }
// ── update ──────────────────────────────────────────────────────────────
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
@@ -222,8 +212,6 @@ func (m *Model) handleBypass(msg adminBypassResult) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
// ── keys ────────────────────────────────────────────────────────────────
func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
k := msg.String() k := msg.String()
@@ -392,8 +380,6 @@ func (m *Model) selectedEnv() session.PostLoginEnv {
return m.envs[m.selIdx] return m.envs[m.selIdx]
} }
// ── commands ────────────────────────────────────────────────────────────
func (m *Model) loginCmd() tea.Cmd { func (m *Model) loginCmd() tea.Cmd {
if m.preview { if m.preview {
return func() tea.Msg { time.Sleep(800 * time.Millisecond); return authResult{ok: true} } return func() tea.Msg { time.Sleep(800 * time.Millisecond); return authResult{ok: true} }
@@ -422,14 +408,10 @@ func (m *Model) adminBypassCmd() tea.Cmd {
} }
} }
// ── queries ────────────────────────────────────────────────────────────
func (m *Model) Result() *Result { return m.result } func (m *Model) Result() *Result { return m.result }
func (m *Model) Quitting() bool { return m.quitting } func (m *Model) Quitting() bool { return m.quitting }
func (m *Model) SetAutomata(a *bg.Automata) { m.automata = a } func (m *Model) SetAutomata(a *bg.Automata) { m.automata = a }
// ── view ───────────────────────────────────────────────────────────────
var ( var (
accent = lipgloss.Color("208") // orange accent = lipgloss.Color("208") // orange
subtle = lipgloss.Color("8") // grey subtle = lipgloss.Color("8") // grey
@@ -527,7 +509,6 @@ func overlayCenter(w, h int, content string) string {
func (m *Model) renderLayout() string { func (m *Model) renderLayout() string {
clock := m.renderClock() clock := m.renderClock()
// top bar: power hints on left, clock on right
var topParts []string var topParts []string
topParts = append(topParts, m.renderPowerHints()) topParts = append(topParts, m.renderPowerHints())
if clock != "" { if clock != "" {
@@ -535,24 +516,19 @@ func (m *Model) renderLayout() string {
} }
top := strings.Join(topParts, " ") top := strings.Join(topParts, " ")
// avatar + form side by side
avatar := m.renderAvatar() avatar := m.renderAvatar()
form := m.renderForm() form := m.renderForm()
mid := lipgloss.JoinHorizontal(lipgloss.Top, avatar, " ", form) mid := lipgloss.JoinHorizontal(lipgloss.Top, avatar, " ", form)
// DE switcher with specifier label
sw := m.renderSwitcher() sw := m.renderSwitcher()
// caps lock
caps := m.renderCapsLock() caps := m.renderCapsLock()
// admin unlock
var admin string var admin string
if m.mode == modeAdminUser || m.mode == modeAdminPass { if m.mode == modeAdminUser || m.mode == modeAdminPass {
admin = m.renderAdminForm() admin = m.renderAdminForm()
} }
// status
st := m.renderStatus() st := m.renderStatus()
var parts []string var parts []string
@@ -655,7 +631,6 @@ func (m *Model) renderSwitcher() string {
var parts []string var parts []string
// session specifier (like "x11", "wayland")
spec := swDot.Render(m.envs[m.selIdx].Kind + " ") spec := swDot.Render(m.envs[m.selIdx].Kind + " ")
if cfg.ShowMovers && m.selIdx > 0 { if cfg.ShowMovers && m.selIdx > 0 {
@@ -679,7 +654,6 @@ func (m *Model) renderSwitcher() string {
swLine := lipgloss.JoinHorizontal(lipgloss.Center, parts...) swLine := lipgloss.JoinHorizontal(lipgloss.Center, parts...)
// Dot indicators
var dots []string var dots []string
for i := 0; i < len(m.envs); i++ { for i := 0; i < len(m.envs); i++ {
if i == m.selIdx { if i == m.selIdx {
@@ -747,8 +721,6 @@ func (m *Model) renderStatus() string {
return st.Align(lipgloss.Center).Width(70).Render(m.status) return st.Align(lipgloss.Center).Width(70).Render(m.status)
} }
// ── caps lock detection ────────────────────────────────────────────────
const kdgkbled = 0x4B64 const kdgkbled = 0x4B64
func capsLockOn() bool { func capsLockOn() bool {
@@ -762,8 +734,6 @@ func capsLockOn() bool {
return leds&0x04 != 0 return leds&0x04 != 0
} }
// ── field editing ──────────────────────────────────────────────────────
func handleField(k string, buf *string, cur, scroll *int) { func handleField(k string, buf *string, cur, scroll *int) {
switch { switch {
case k == "backspace" || k == "ctrl+h": case k == "backspace" || k == "ctrl+h":
@@ -846,8 +816,6 @@ func abs(n int) int {
return n return n
} }
// ── tick ────────────────────────────────────────────────────────────────
func tick() tea.Cmd { func tick() tea.Cmd {
return tea.Tick(time.Second/30, func(t time.Time) tea.Msg { return tea.Tick(time.Second/30, func(t time.Time) tea.Msg {
return tickMsg(t) return tickMsg(t)

View File

@@ -1,7 +1,3 @@
// latchd — TUI display/login manager for GNU/Linux and BSD.
//
// Supports TTY, X11, and Wayland sessions with PAM authentication.
// Go rewrite of Lemurs (Rust source under vendor/).
package main package main
import ( import (
@@ -193,8 +189,6 @@ func setupLog(path string) {
log.SetFlags(log.LstdFlags) log.SetFlags(log.LstdFlags)
} }
// ── chvt ────────────────────────────────────────────────────────────────
const vtA = 0x5606 const vtA = 0x5606
const vtW = 0x5607 const vtW = 0x5607
const kbt = 0x4B33 const kbt = 0x4B33

View File

@@ -1,6 +1,3 @@
// session — environment variable setup for latchd sessions.
//
// Mirrors vendor/src/post_login/env_variables.rs.
package session package session
import ( import (

View File

@@ -1,6 +1,3 @@
// session — process execution and session spawning.
//
// Mirrors vendor/src/post_login/mod.rs and wait_with_log.rs.
package session package session
import ( import (

View File

@@ -1,7 +1,3 @@
// session — environment scanning from xsessions, wayland-sessions,
// and custom script directories. Includes desktop entry parser.
//
// Mirrors vendor/src/post_login/mod.rs:get_envs and parse_desktop_entry.
package session package session
import ( import (

View File

@@ -1,4 +1 @@
package session package session
// Wayland session spawning is handled in exec.go/spawnWayland.
// This file exists for future Wayland-specific compositor logic.

View File

@@ -1,6 +1,3 @@
// session — X11 server setup.
//
// Mirrors vendor/src/post_login/x.rs:setup_x.
package session package session
import ( import (
@@ -47,7 +44,7 @@ func setupX(uid, gid uint32, groups []uint32, cfg *config.Config) (*exec.Cmd, er
dd = "0" + dd dd = "0" + dd
} }
// Ignore SIGUSR1 so Xorg sends it when ready. // Xorg sends SIGUSR1 only if the parent ignores it during startup.
signal.Ignore(syscall.SIGUSR1) signal.Ignore(syscall.SIGUSR1)
cmd := exec.Command(cfg.SystemShell, "-c", cmd := exec.Command(cfg.SystemShell, "-c",

View File

@@ -1,10 +1,5 @@
// bg — cellular automata background for the TUI. // The renderer packs two cell rows into each Unicode half-block row.
// // Two buffers let Tick update the grid without an allocation per frame.
// Conway's Game of Life rendered at 30 Hz using Unicode half-block
// characters (▀) to double the effective vertical resolution.
// The grid is double-buffered for zero-allocation ticking, and
// render output is built in a pre-allocated strings.Builder so the
package bg package bg
import ( import (
@@ -12,7 +7,6 @@ import (
"strings" "strings"
) )
// Bright orange (accent) for alive cells, dark grey for dead.
const ( const (
fgAlive = "\x1b[38;2;255;127;0m" fgAlive = "\x1b[38;2;255;127;0m"
fgDead = "\x1b[38;2;30;30;30m" fgDead = "\x1b[38;2;30;30;30m"
@@ -92,8 +86,6 @@ func (a *Automata) count(g []uint8, x, y, w, h int) uint8 {
return c return c
} }
// Frame returns the current generation as a single string suitable for
// writing to the alternate screen.
func (a *Automata) Frame() string { func (a *Automata) Frame() string {
a.buf.Reset() a.buf.Reset()
src := a.a src := a.a

View File

@@ -1,9 +1,5 @@
#!/bin/sh #!/bin/sh
# Exercise the non-interactive CLI contract without authenticating a user. # Use --preview so this test does not need PAM, a VT, or a display server.
#
# This suite intentionally uses --preview. It validates TOML parsing, command
# line validation, build metadata, and the preflight path without requiring a
# PAM secret, a controlling VT, or a running display server.
set -eu set -eu
binary=${1:-target/debug/latchd} binary=${1:-target/debug/latchd}

93
scripts/test-pam-fixture.sh Executable file
View File

@@ -0,0 +1,93 @@
#!/bin/sh
set -eu
if [ "$(id -u)" -ne 0 ]; then
echo "pam-fixture: SKIP (root is required to install a temporary PAM policy)"
exit 0
fi
service="latchd-fixture-$$"
session_service="latchd-session-fixture-$$"
policy="/etc/pam.d/$service"
session_policy="/etc/pam.d/$session_service"
checker="/tmp/latchd-pam-session-check-$$"
module_source="/tmp/latchd-pam-session-env-$$.c"
module="/tmp/latchd-pam-session-env-$$.so"
cleanup() {
rm -f "$policy" "$session_policy" "$checker" "$module_source" "$module"
}
trap cleanup EXIT HUP INT TERM
umask 077
cat >"$policy" <<'PAM'
auth required pam_deny.so
account required pam_permit.so
password required pam_deny.so
session required pam_permit.so
PAM
cat >"$checker" <<'SH'
#!/bin/sh
set -eu
[ "${PAM_TTY:-}" = "tty2" ]
[ "${XDG_SESSION_CLASS:-}" = "user" ]
[ "${XDG_SESSION_TYPE:-}" = "wayland" ]
[ "${XDG_SESSION_DESKTOP:-}" = "niri" ]
[ "${XDG_SEAT:-}" = "seat0" ]
[ "${XDG_VTNR:-}" = "2" ]
SH
chmod 700 "$checker"
cat >"$module_source" <<'C'
#include <security/pam_appl.h>
#include <security/pam_modules.h>
PAM_EXTERN int pam_sm_open_session(
pam_handle_t *pamh,
int flags,
int argc,
const char **argv
) {
int result;
(void)flags;
(void)argc;
(void)argv;
result = pam_putenv(pamh, "XDG_SESSION_ID=fixture-session");
if (result != PAM_SUCCESS) return result;
result = pam_putenv(pamh, "XDG_RUNTIME_DIR=/tmp/latchd-runtime-fixture");
if (result != PAM_SUCCESS) return result;
return pam_putenv(
pamh,
"DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/latchd-runtime-fixture/bus"
);
}
PAM_EXTERN int pam_sm_close_session(
pam_handle_t *pamh,
int flags,
int argc,
const char **argv
) {
(void)pamh;
(void)flags;
(void)argc;
(void)argv;
return PAM_SUCCESS;
}
C
${CC:-cc} -shared -fPIC "$module_source" -o "$module" -lpam
chmod 700 "$module"
cat >"$session_policy" <<PAM
auth required pam_permit.so
account required pam_permit.so
password required pam_deny.so
session required $module
session required pam_exec.so $checker
PAM
LATCHD_PAM_FIXTURE_SERVICE="$service" \
LATCHD_PAM_SESSION_FIXTURE_SERVICE="$session_service" \
cargo test --locked -p latchd-auth --test pam_fixture -- --ignored

View File

@@ -1,9 +1,5 @@
#!/bin/sh #!/bin/sh
# Probe host capabilities needed for a real login-manager deployment. # Report unavailable capabilities as SKIP. In strict mode, each SKIP is an error.
#
# This script never reports an unavailable capability as a pass. On an
# ordinary container it prints SKIP and exits successfully; --strict turns
# every SKIP into a failure for a prepared VM or privileged runner.
set -u set -u
binary=${1:-} binary=${1:-}
@@ -47,8 +43,7 @@ else
skip "latchd binary not supplied; capability-only probe" skip "latchd binary not supplied; capability-only probe"
fi fi
# This is deliberately a runtime capability check, not an authentication # This checks the PAM runtime only. Authentication requires a test account and policy.
# claim. Authentication needs a disposable VM user and a test PAM policy.
if need_command ldconfig && ldconfig -p 2>/dev/null | grep -q 'libpam\.so'; then if need_command ldconfig && ldconfig -p 2>/dev/null | grep -q 'libpam\.so'; then
pass "PAM runtime library is available" pass "PAM runtime library is available"
else else
@@ -70,16 +65,14 @@ else
skip "faillock helper is unavailable" skip "faillock helper is unavailable"
fi fi
# A real VT is a character device and must be allocated by the host/VM. A # A pseudo-terminal does not test VT allocation by the host or VM.
# pseudo-terminal from CI is not equivalent and is intentionally not accepted.
if [ -c /dev/tty0 ] && [ -d /sys/class/tty/tty0 ]; then if [ -c /dev/tty0 ] && [ -d /sys/class/tty/tty0 ]; then
pass "Linux virtual terminal device is available" pass "Linux virtual terminal device is available"
else else
skip "Linux virtual terminal is unavailable (container or non-console host)" skip "Linux virtual terminal is unavailable (container or non-console host)"
fi fi
# Xvfb is a genuine X server and is safe to exercise without host display # Xvfb tests X11 without access to the host display.
# access. If it is absent, this is a skip rather than a synthetic success.
if need_command Xvfb; then if need_command Xvfb; then
display_number=$((100 + ($$ % 500))) display_number=$((100 + ($$ % 500)))
display=":$display_number" display=":$display_number"
@@ -113,8 +106,7 @@ else
skip "Xvfb is unavailable" skip "Xvfb is unavailable"
fi fi
# A Wayland socket is meaningful only when a compositor owns it. Do not claim # A compositor must own the Wayland socket. Installed client libraries are not sufficient.
# Wayland support merely because a client library or executable is installed.
wayland_socket= wayland_socket=
if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -n "${XDG_RUNTIME_DIR:-}" ]; then if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -n "${XDG_RUNTIME_DIR:-}" ]; then
case "$WAYLAND_DISPLAY" in case "$WAYLAND_DISPLAY" in
@@ -128,8 +120,7 @@ else
skip "Wayland compositor socket is unavailable (requires a compositor-backed VM/session)" skip "Wayland compositor socket is unavailable (requires a compositor-backed VM/session)"
fi fi
# systemd-logind is not normally PID 1 in a Docker container. loginctl output # Check loginctl only when systemd-logind is available.
# is checked only when the manager and seat are actually reachable.
if need_command loginctl && [ -d /run/systemd/system ]; then if need_command loginctl && [ -d /run/systemd/system ]; then
if loginctl --no-pager show-seat seat0 >/dev/null 2>&1; then if loginctl --no-pager show-seat seat0 >/dev/null 2>&1; then
pass "systemd-logind seat0 is reachable" pass "systemd-logind seat0 is reachable"