feat: complete session preferences and PAM fixture gates

This commit is contained in:
2026-09-26 02:44:29 +05:30
parent 83345a1842
commit f266a992b4
13 changed files with 473 additions and 5 deletions

View File

@@ -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

View File

@@ -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

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,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
``` ```

View 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",
);
}

View File

@@ -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),

View File

@@ -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;

View 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()
);
}
}

View File

@@ -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,

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},
}; };
@@ -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");

View File

@@ -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
``` ```

View File

@@ -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
View 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