feat: complete session preferences and PAM fixture gates
This commit is contained in:
@@ -11,8 +11,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
|
||||||
|
|
||||||
|
|||||||
@@ -21,4 +21,5 @@ 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 \
|
||||||
|
&& ./scripts/test-pam-fixture.sh
|
||||||
|
|||||||
5
Makefile
5
Makefile
@@ -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)'
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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.
|
||||||
- Records user sessions with UTMPX when the platform provides it.
|
- Records user sessions with UTMPX when the platform provides it.
|
||||||
@@ -72,7 +73,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 +109,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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
58
crates/latchd-auth/tests/pam_fixture.rs
Normal file
58
crates/latchd-auth/tests/pam_fixture.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
use std::env;
|
||||||
|
|
||||||
|
use latchd_auth::AuthService;
|
||||||
|
use latchd_core::error::Error;
|
||||||
|
|
||||||
|
fn fixture_service() -> String {
|
||||||
|
env::var("LATCHD_PAM_FIXTURE_SERVICE")
|
||||||
|
.expect("LATCHD_PAM_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",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -181,6 +181,10 @@ pub struct SwitcherConfig {
|
|||||||
pub include_tty_shell: bool,
|
pub include_tty_shell: bool,
|
||||||
/// Maximum visible title width.
|
/// Maximum visible title width.
|
||||||
pub max_display_length: usize,
|
pub max_display_length: usize,
|
||||||
|
/// Restore each account's last successful session selection.
|
||||||
|
pub remember_last_session: bool,
|
||||||
|
/// Owner-checked file used for the last-session preferences.
|
||||||
|
pub last_session_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SwitcherConfig {
|
impl Default for SwitcherConfig {
|
||||||
@@ -188,6 +192,8 @@ 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"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,6 +293,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),
|
||||||
|
|||||||
@@ -13,5 +13,7 @@ pub mod audit;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
/// Structured application errors.
|
/// Structured application errors.
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
/// Safe persistence for per-account last-used session preferences.
|
||||||
|
pub mod preferences;
|
||||||
/// Session discovery and process lifecycle.
|
/// Session discovery and process lifecycle.
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
|||||||
259
crates/latchd-core/src/preferences.rs
Normal file
259
crates/latchd-core/src/preferences.rs
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
//! 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;
|
||||||
|
|
||||||
|
/// A previously successful session selection for one account.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct LastUsedEntry {
|
||||||
|
/// NSS username.
|
||||||
|
pub username: String,
|
||||||
|
/// Session family.
|
||||||
|
pub kind: SessionKind,
|
||||||
|
/// Stable discovered session title.
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded last-used session preferences.
|
||||||
|
#[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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns persisted entries for diagnostics and tests.
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,10 +26,12 @@ 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.
|
/// A session family supported by latchd.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum SessionKind {
|
pub enum SessionKind {
|
||||||
/// A login shell on the current virtual terminal.
|
/// A login shell on the current virtual terminal.
|
||||||
Tty,
|
Tty,
|
||||||
|
|||||||
@@ -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},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,6 +131,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 +184,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 +263,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 +340,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 +1020,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 +1394,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 +1696,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");
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ 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 a unique temporary `pam_deny` service only
|
||||||
|
when run as root, executes the ignored authentication/password-change fault
|
||||||
|
tests, and removes the service 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 +25,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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ 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 path and argv, never a shell command string.
|
||||||
# Keep this section empty unless the installation explicitly wants console
|
# Keep this section empty unless the installation explicitly wants console
|
||||||
|
|||||||
27
scripts/test-pam-fixture.sh
Executable file
27
scripts/test-pam-fixture.sh
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Run ignored PAM fault-injection tests against a disposable service policy.
|
||||||
|
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-$$"
|
||||||
|
policy="/etc/pam.d/$service"
|
||||||
|
cleanup() {
|
||||||
|
rm -f "$policy"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT HUP INT TERM
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
cat >"$policy" <<'PAM'
|
||||||
|
# Disposable deny policy used only by the ignored fixture tests.
|
||||||
|
auth required pam_deny.so
|
||||||
|
account required pam_permit.so
|
||||||
|
password required pam_deny.so
|
||||||
|
session required pam_permit.so
|
||||||
|
PAM
|
||||||
|
|
||||||
|
LATCHD_PAM_FIXTURE_SERVICE="$service" \
|
||||||
|
cargo test --locked -p latchd-auth --test pam_fixture -- --ignored
|
||||||
Reference in New Issue
Block a user