feat: advance login workflow and integration gates

This commit is contained in:
2026-09-26 01:18:57 +05:30
parent 71de5bc6cd
commit 9492dbd7c9
16 changed files with 1471 additions and 151 deletions

1
.gitignore vendored
View File

@@ -14,3 +14,4 @@ FEATURE_CHECKLIST.md
IMPLEMENTATION_CHECKLIST.md IMPLEMENTATION_CHECKLIST.md
audit_code.md audit_code.md
audit_rust_code.md audit_rust_code.md
.latchd-work/

View File

@@ -40,6 +40,8 @@ rust-verify:
stage: verify stage: verify
script: script:
- make check COLOR=0 - make check COLOR=0
- make integration-test COLOR=0
- make system-e2e COLOR=0
- make version COLOR=0 - make version COLOR=0
artifacts: artifacts:
when: always when: always

View File

@@ -1,5 +1,24 @@
# Changelog # Changelog
## 0.4.0 — Advanced login workflow
- Added account policy inspection for locked passwords, password expiry,
account expiry, inactivity, and missing shadow data without exposing hashes.
- Added active UTMPX session discovery and systemd-logind capability detection
with an explicit UTMPX fallback.
- Improved faillock parsing for legacy, username-prefixed, and timestamped
helper output. PAM remains authoritative for every login decision.
- Added smart-case filtering for users and sessions, non-blocking Ctrl-R discovery
refresh, contextual footer hints, high-contrast mode, and policy notices in
the TUI.
- Added bounded local authentication backoff after repeated failures and reset
it when switching accounts.
- Added CLI integration tests and host capability probes. Docker now runs the
release binary through the safe CLI and capability suites.
- Added documented strict VM validation for PAM policy, VT, X11, Wayland, and
systemd-logind; missing capabilities are reported as skips in ordinary CI.
- Bumped the workspace release version to 0.4.0.
## 0.3.0 — Rust workspace release preparation ## 0.3.0 — Rust workspace release preparation
- Rust is the supported login manager; the Go/cgo prototype is quarantined at - Rust is the supported login manager; the Go/cgo prototype is quarantined at

8
Cargo.lock generated
View File

@@ -782,7 +782,7 @@ checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f"
[[package]] [[package]]
name = "latchd" name = "latchd"
version = "0.3.0" version = "0.4.0"
dependencies = [ dependencies = [
"clap", "clap",
"color-eyre", "color-eyre",
@@ -796,7 +796,7 @@ dependencies = [
[[package]] [[package]]
name = "latchd-auth" name = "latchd-auth"
version = "0.3.0" version = "0.4.0"
dependencies = [ dependencies = [
"latchd-core", "latchd-core",
"libc", "libc",
@@ -805,7 +805,7 @@ dependencies = [
[[package]] [[package]]
name = "latchd-core" name = "latchd-core"
version = "0.3.0" version = "0.4.0"
dependencies = [ dependencies = [
"libc", "libc",
"rand 0.9.5", "rand 0.9.5",
@@ -818,7 +818,7 @@ dependencies = [
[[package]] [[package]]
name = "latchd-tui" name = "latchd-tui"
version = "0.3.0" version = "0.4.0"
dependencies = [ dependencies = [
"crossterm", "crossterm",
"image", "image",

View File

@@ -3,7 +3,7 @@ members = [".", "crates/latchd-core", "crates/latchd-auth", "crates/latchd-tui"]
resolver = "3" resolver = "3"
[workspace.package] [workspace.package]
version = "0.3.0" version = "0.4.0"
edition = "2024" edition = "2024"
rust-version = "1.88" rust-version = "1.88"
license = "Apache-2.0" license = "Apache-2.0"

View File

@@ -13,8 +13,12 @@ COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
COPY build.rs ./ COPY build.rs ./
COPY rust ./rust COPY rust ./rust
COPY crates ./crates COPY crates ./crates
COPY scripts ./scripts
RUN cargo fmt --all -- --check \ RUN cargo fmt --all -- --check \
&& cargo test --locked --all-targets \ && cargo test --locked --workspace --all-targets \
&& cargo clippy --locked --all-targets --all-features -- -D warnings \ && cargo clippy --locked --workspace --all-targets --all-features -- -D warnings \
&& RUSTDOCFLAGS="-D warnings" cargo doc --locked --no-deps && RUSTDOCFLAGS="-D warnings" cargo doc --locked --workspace --no-deps \
&& cargo build --locked --release \
&& ./scripts/test-cli.sh target/release/latchd \
&& ./scripts/test-system-e2e.sh target/release/latchd

View File

@@ -48,6 +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 \
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
@@ -94,6 +95,15 @@ check: fmt test lint doc ## Run the complete local verification gate
docker-test: ## Run the pinned Docker verification suite docker-test: ## Run the pinned Docker verification suite
@./scripts/test-docker.sh @./scripts/test-docker.sh
integration-test: build ## Exercise safe, non-authenticating CLI paths
@./scripts/test-cli.sh '$(BUILD_DIR)/$(BINARY)'
system-e2e: ## Probe PAM, VT, X11, Wayland, and logind without requiring privileges
@./scripts/test-system-e2e.sh '$(BUILD_DIR)/$(BINARY)'
system-e2e-strict: ## Require all real login-manager capabilities (prepared VM only)
@./scripts/test-system-e2e.sh --strict '$(BUILD_DIR)/$(BINARY)'
release-check: ## Validate a release version and optional tag release-check: ## Validate a release version and optional tag
@[[ '$(VERSION)' =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$$ ]] || { echo 'invalid VERSION: $(VERSION)'; exit 1; } @[[ '$(VERSION)' =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$$ ]] || { echo 'invalid VERSION: $(VERSION)'; exit 1; }
@cargo_version="$$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)"; \ @cargo_version="$$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)"; \
@@ -125,7 +135,7 @@ package: release-check build ## Create a deterministic install archive and check
'$(PACKAGE_FILE)' "$$checksum" '$(VERSION)' > '$(DIST_DIR)/release.env' '$(PACKAGE_FILE)' "$$checksum" '$(VERSION)' > '$(DIST_DIR)/release.env'
@printf '$(GREEN)created$(RESET) %s\n' '$(PACKAGE_FILE)' @printf '$(GREEN)created$(RESET) %s\n' '$(PACKAGE_FILE)'
release: check audit docker-test package ## Run all gates and produce release artifacts release: check audit integration-test system-e2e docker-test package ## Run all gates and produce release artifacts
install: build ## Install the binary, config, PAM policy, and systemd unit install: build ## Install the binary, config, PAM policy, and systemd unit
@install -Dm755 '$(BUILD_DIR)/$(BINARY)' '$(BINDIR)/$(BINARY)' @install -Dm755 '$(BUILD_DIR)/$(BINARY)' '$(BINDIR)/$(BINARY)'

196
README.md
View File

@@ -1,148 +1,142 @@
# latchd # latchd
`latchd` is a Linux/PAM terminal login manager written in Rust. It presents a keyboard-first Ratatui login screen on a virtual terminal, authenticates through PAM, and starts an isolated TTY, X11, or Wayland session with an explicit per-user environment. Want a TUI-based login manager because you are tired of GDM or Plasma Login? Latchd gives you a small, keyboard-first login screen on a Linux virtual terminal. It uses PAM for authentication and can start a TTY shell, an X11 desktop, or a Wayland desktop.
The Rust binary is the supported implementation. The original Go/cgo tree under `legacy/go/` is retained as a quarantined comparison artifact and is not built by the default targets. Latchd is written in Rust. The old Go prototype is kept in `legacy/go/` for reference and is not built or shipped.
## Security model ## What it does
- The service requires root for real sessions and refuses to start from an existing desktop session. - Finds normal login accounts through NSS.
- PAM work is serialized on one dedicated worker thread. PAM handles never cross that thread boundary; authentication tickets are opaque RAII-owned transactions. - Shows password, account-expiry, locked-account, and faillock status without replacing PAM's decision.
- Password buffers are zeroized and are never placed in argv, logs, or the rendered UI. - Lets you switch users and sessions without leaving the login screen.
- Session environments are rebuilt from an allowlist for every login. Shell commands are argv vectors; no configured command is passed through a shell. - Finds X11 and Wayland desktop entries and can include a TTY shell.
- Desktop files and session scripts must be root-owned, regular, non-group-writable files. Account shells such as `false` and `nologin` are rejected. - Starts sessions with a cleared, explicit environment and drops privileges before running the user process.
- Xauthority cookies are generated in a `0600` file owned by the account, supplied to `xauth` through stdin, and removed by an RAII cleanup guard. - Records user sessions with UTMPX when the platform provides it.
- Main, graphical-session, and Xorg logs are private (`0600`) and opened without following symlinks. - Provides a Ratatui TUI with filtering, keyboard help, refresh, notifications, wallpaper, and reduced-motion support.
- Power actions are an explicit absolute-path allowlist and require confirmation. They are disabled in `--preview` mode. - Runs safely in preview mode for UI work and configuration checks.
The implementation intentionally does not provide an administrator unlock shortcut: account lockout and authorization remain the responsibility of the configured PAM policy. The real login handoff still needs a Linux system with PAM, a virtual terminal, and the display/session services you want to use. The Docker tests cover builds, unit tests, rendering, configuration, and capability probes; they do not pretend to be a real VT or systemd-logind session.
## Build and test ## Build
Requirements: Rust 1.88 or newer, a Linux PAM development package, and a C toolchain. Debian/Ubuntu packages are `libpam0g-dev`, `libpam-modules`, and `build-essential`. On Debian or Ubuntu, install the build dependencies first:
```bash ```sh
make deps sudo apt-get install build-essential pkg-config libpam0g-dev libpam-modules
make build
make test # unit tests and rustdoc warnings
make lint # Clippy with warnings denied
make audit # RustSec dependency advisory scan
make docker-test # the same checks in a pinned Debian container
make package # deterministic install archive plus SHA-256
make release # all local gates, Docker, and package output
``` ```
`make build` installs the release binary at `build/latchd`. The reproducible test image is defined by [Dockerfile.test](Dockerfile.test) and does not require `--privileged`. Then run:
## Releases ```sh
make check
make docker-test
make integration-test
```
The supported release pipeline is GitLab CI in [`.gitlab-ci.yml`](.gitlab-ci.yml). `make build` writes the optimized binary to `build/latchd`. `make package` creates a deterministic archive and SHA-256 file in `dist/`.
Every branch and merge request runs formatting, tests, Clippy, rustdoc, a
RustSec dependency audit, and the Docker suite. Docker-in-Docker requires a trusted privileged GitLab Runner with
the runner client-certificate volume configured; untrusted fork pipelines
should not be assigned to that runner. A SemVer tag
such as `v0.3.0` additionally builds a deterministic
Linux archive containing the binary, systemd unit, PAM policy, configuration,
license, changelog, build metadata, and a SHA-256 checksum, then publishes it
as a GitLab Release. The tag version must match the workspace version. CI
creates the GitLab Release only after both Rust and Docker verification pass.
Release builds use `SOURCE_DATE_EPOCH`, commit metadata, stripped symbols, ## Try the UI
`opt-level = 3`, thin LTO, one codegen unit, no incremental state, and
`panic = "abort"`. Set `TARGET` or `DESTDIR` when invoking `make package` for a
controlled package build; `VERSION` must match the workspace version. The
legacy Go tree is physically isolated under `legacy/go/` and is never included
in release artifacts.
## Installation Preview mode does not call PAM, start a session, or run power actions:
```bash ```sh
make run-preview
# or
./build/latchd --preview --no-log
```
Use `LATCHD_ASCII=1` for terminals without reliable Unicode support, `NO_COLOR=1` for monochrome output, and `LATCHD_HIGH_CONTRAST=1` for a stronger focus palette.
## Install on a test machine
Do this on a disposable machine first. Keep another login path available while changing a display manager.
```sh
sudo make install sudo make install
sudo systemctl enable latchd.service sudo systemctl enable latchd.service
``` ```
Installation places the binary at `/usr/bin/latchd`, the configuration at `/etc/latchd/config.toml`, the PAM policy at `/etc/pam.d/latchd`, and the systemd unit at `/usr/lib/systemd/system/latchd.service`. The unit owns `/dev/tty2`; adjust `TTYPath`, `After`, and the `tty` configuration together if another VT is required. This installs:
Keep the previous package/binary available while validating a deployment. `--preview` is a safe UI exercise that does not authenticate, create a user session, or execute power actions. `--no-tui` (also `--preflight`) validates configuration and exits without entering the login screen. - `/usr/bin/latchd`
- `/etc/latchd/config.toml`
- `/etc/pam.d/latchd`
- `/usr/lib/systemd/system/latchd.service`
## Command line The default service owns `/dev/tty2`. Change the `tty` setting and the unit's `TTYPath` together when using another virtual terminal.
```text
latchd [OPTIONS]
-c, --config <PATH> Configuration TOML (default /etc/latchd/config.toml)
-v, --variables <PATH> Optional TOML variable file
--tty <N> Override the virtual terminal number
--xsessions <DIR> Override the X11 desktop-entry directory
--wlsessions <DIR> Override the Wayland desktop-entry directory
--initial-path <PATH> Override the session PATH
--preview Draw the UI without authentication or session startup
--no-tui, --preflight Validate configuration and exit
--no-log Disable the main log
--show-config Print the key validated settings and exit
--build-info Print version, commit, and build epoch and exit
-V, --version Print the version
```
An explicit configuration or variables file used by a real (non-preview) service must be root-owned, regular, and not group/other writable. The default configuration is optional: when `/etc/latchd/config.toml` is absent, built-in defaults are used.
## Configuration ## Configuration
See [extra/config.toml](extra/config.toml) for the complete schema. Values may be substituted from a TOML variables file with `$NAME` syntax. Power entries contain an executable path and an argv array, never a shell command string. The complete example is in [`extra/config.toml`](extra/config.toml). Important sections are:
Session discovery reads root-owned `.desktop` files from the configured X11 and Wayland directories and executable root-owned scripts from `/etc/latchd/wms` and `/etc/latchd/wayland`. Unsupported desktop-entry field codes are rejected rather than interpreted by a shell. A TTY shell is offered when no graphical sessions are available, or when `include_tty_shell = true`. - `[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.
- `[environment_switcher]` controls session choices and the optional TTY shell.
- `[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.
When enabled, the account chooser discovers eligible NSS users (by default, Configuration files used by a real root service must be root-owned and not writable by group or other users. Use a variables file for deployment-specific values instead of putting secrets in command arguments.
UID 1000 and above with a real home directory and login shell). `Ctrl-N` and
`Ctrl-P` cycle accounts, clear the password, and start a bounded advisory
`faillock` query. The displayed lockout state never overrides PAM: a PAM
conversation remains the only authorization decision. Set
`accounts.enumerate = false` when listing local accounts is not appropriate.
The `[visual]` section is TUI-only. It can load a root-owned PNG/JPEG as a ## Controls
downsampled terminal-cell wallpaper, run a low-rate cellular-automata ambient
layer, and show short-lived notifications. `reduced_motion = true` disables
motion while preserving the static layout; `NO_COLOR=1` disables the image
layer and all color effects.
## TUI controls
The screen is designed for 80×24 and remains usable down to 60 columns. Below the supported minimum it reports the required size without drawing a partial frame.
| Key | Action | | Key | Action |
| --- | --- | | --- | --- |
| Tab / Shift-Tab | Move focus | | Tab / Shift-Tab | Move focus |
| Up / Down | Move focus | | Up / Down | Move focus or move in a filtered list |
| Left / Right | Change session when the chooser is focused | | Left / Right | Change the selected session |
| Ctrl-N / Ctrl-P | Select the next / previous discovered account | | `/` | Filter sessions or discovered accounts |
| Ctrl-B | Toggle the TUI-only wallpaper/automata background | | Ctrl-R | Refresh account and session discovery |
| Ctrl-N / Ctrl-P | Select the next or previous account |
| Ctrl-B | Toggle wallpaper and ambient effects |
| Ctrl-M | Toggle reduced motion | | Ctrl-M | Toggle reduced motion |
| Enter | Authenticate from the password field | | Enter | Authenticate from the password field |
| Esc | Clear the password / cancel power confirmation | | Esc | Clear the password or close a popup |
| `?` | Show keyboard help | | `?` | Show keyboard help |
| Ctrl-C | Restore the terminal and quit | | Ctrl-C | Restore the terminal and quit |
| F1–F12 | Confirm and run a configured power action | | F1–F12 | Confirm a configured power action |
`NO_COLOR=1` requests a monochrome semantic style, and `LATCHD_ASCII=1` selects ASCII session markers for terminals without reliable Unicode support. The filter is smart-case: lowercase searches ignore case; a search containing uppercase characters is case-sensitive. The account-age and faillock messages are advisory. PAM remains the only authorization decision.
## Testing a deployment
The ordinary tests do not need a privileged container:
```sh
make check
make audit
make docker-test
make integration-test
make system-e2e
```
`make system-e2e` reports real host capabilities as `PASS`, `SKIP`, or `FAIL`. It does not call an account password or claim that a pseudo-terminal is a virtual terminal. `make system-e2e-strict` is for a prepared disposable VM with a test PAM policy, a real VT, systemd-logind, and the X11 or Wayland stack under test. See [`docs/e2e-testing.md`](docs/e2e-testing.md).
## Security notes
- PAM handles stay on one worker thread.
- Password buffers are zeroized and never placed in argv, logs, or the TUI.
- Session environments are rebuilt from an allowlist.
- Desktop files, scripts, binaries, homes, logs, and configuration sources are checked before use.
- Xauthority files and session logs use private permissions and cleanup guards.
- Power actions require confirmation and are disabled in preview mode.
- Account status and faillock output never bypass PAM or provide an administrator unlock shortcut.
## Project layout ## Project layout
```text ```text
crates/ crates/latchd-core/ configuration, account discovery, sessions, UTMPX
├── latchd-core/ validated config, accounts, sessions, and UTMPX accounting crates/latchd-auth/ PAM FFI and the authentication worker
├── latchd-auth/ PAM FFI, conversation callback, and transaction worker crates/latchd-tui/ Ratatui state, rendering, filtering, and effects
└── latchd-tui/ Ratatui state machine, wallpaper, automata, and effects rust/main.rs command-line entry point and service orchestration
rust/main.rs CLI, privilege checks, logging, and orchestration extra/ example configuration, PAM policy, and systemd unit
extra/ example config, PAM policy, systemd unit, session directories scripts/ Docker and CLI/system capability tests
scripts/ Docker test entry point legacy/go/ non-production Go reference
legacy/
└── go/ non-production Go reference (explicit `make legacy-build` only)
``` ```
## Platform support ## Releases
The first release targets Linux with PAM and systemd. BSD and non-PAM platforms are not claimed or tested. Real PAM/VT/X11 integration should be exercised on an isolated test host; the ordinary Docker suite covers compilation, unit tests, rendering tests, formatting, Clippy, and rustdoc without privileged container access. GitLab CI runs formatting, tests, Clippy, rustdoc, RustSec auditing, CLI integration tests, capability probes, and the Docker suite. SemVer tags produce a reproducible Linux archive with checksums. Live VT, PAM-policy, X11, Wayland, and logind validation belongs in the isolated VM gate described above.
## License ## License
Apache-2.0; see [LICENSE](LICENSE). Apache-2.0. See [`LICENSE`](LICENSE).

View File

@@ -6,7 +6,7 @@ use std::{
path::{Path, PathBuf}, path::{Path, PathBuf},
process::{Command, Stdio}, process::{Command, Stdio},
thread, thread,
time::{Duration, Instant}, time::{Duration, Instant, SystemTime, UNIX_EPOCH},
}; };
use crate::{ use crate::{
@@ -29,6 +29,87 @@ pub struct Identity {
pub shell: PathBuf, pub shell: PathBuf,
} }
/// Effective account state determined from the login shell and shadow aging data.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountState {
/// Account can be offered for login.
Active,
/// Password is disabled by the shadow lock marker.
Locked,
/// Password must be changed before login can continue.
PasswordExpired,
/// Account expiration date has elapsed.
AccountExpired,
/// Password inactivity period has elapsed.
AccountInactive,
/// Login shell explicitly disallows interactive sessions.
NoLoginShell,
/// Shadow data was not available or could not be parsed.
Unknown,
}
/// Non-sensitive password and account aging information.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountStatus {
/// Effective state used by the chooser and diagnostics.
pub state: AccountState,
/// Day the password was last changed, if present.
pub password_changed_day: Option<i64>,
/// Day the password expires, if the policy specifies one.
pub password_expiry_day: Option<i64>,
/// Day the account expires, if specified.
pub account_expiry_day: Option<i64>,
/// Day after which an expired password disables the account.
pub inactive_after_day: Option<i64>,
}
impl AccountStatus {
/// Returns whether this account may be presented as an active login choice.
pub const fn is_login_allowed(self) -> bool {
matches!(self.state, AccountState::Active)
}
}
/// An identity paired with its non-sensitive account policy state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountRecord {
/// Discovered NSS identity.
pub identity: Identity,
/// Current local account status.
pub status: AccountStatus,
}
/// A currently logged-in process obtained from UTMPX.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActiveSession {
/// Login name associated with the session.
pub username: String,
/// UTMPX line, such as `tty2` or `pts/0`.
pub terminal: String,
/// Session leader PID when reported by UTMPX.
pub pid: libc::pid_t,
/// Remote host, if supplied by the session producer.
pub host: Option<String>,
}
/// Runtime session-management backend detected on this host.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionBackend {
/// systemd-logind runtime markers are available.
SystemdLogind,
/// UTMPX is available as a conservative fallback.
Utmpx,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ShadowEntry {
hash: String,
last_change: Option<i64>,
max_days: Option<i64>,
inactive_days: Option<i64>,
account_expiry: Option<i64>,
}
/// 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 {
@@ -81,6 +162,96 @@ pub fn discover(config: &AccountsConfig) -> Vec<Identity> {
accounts accounts
} }
/// Discovers identities and attaches account-aging state when the shadow
/// database is readable. A missing or inaccessible shadow file yields
/// [`AccountState::Unknown`] rather than hiding the account.
pub fn discover_with_status(config: &AccountsConfig) -> Vec<AccountRecord> {
discover(config)
.into_iter()
.map(|identity| {
let status = inspect_account(&identity, config).unwrap_or(AccountStatus {
state: AccountState::Unknown,
password_changed_day: None,
password_expiry_day: None,
account_expiry_day: None,
inactive_after_day: None,
});
AccountRecord { identity, status }
})
.collect()
}
/// Inspects password and account aging fields without returning password
/// hashes. The configured shadow path must be root-owned and non-writable.
pub fn inspect_account(
identity: &Identity,
config: &AccountsConfig,
) -> Result<AccountStatus, Error> {
if !trusted_shadow(&config.shadow_path) {
return Err(Error::Config("shadow_path is not a trusted file".into()));
}
let content = std::fs::read_to_string(&config.shadow_path)
.io_context(format!("read {}", config.shadow_path.display()))?;
let entry = content.lines().find_map(|line| {
let (username, _) = line.split_once(':')?;
(username == identity.username)
.then(|| parse_shadow_entry(line))
.flatten()
});
let day = unix_day();
Ok(account_status_from_shadow(identity, entry, day))
}
/// Returns all active UTMPX user-process records in deterministic order.
pub fn active_sessions() -> Vec<ActiveSession> {
let mut sessions = Vec::new();
unsafe {
libc::setutxent();
loop {
let entry = libc::getutxent();
if entry.is_null() {
break;
}
let record = &*entry;
if record.ut_type != libc::USER_PROCESS {
continue;
}
let Some(username) = utmp_field(&record.ut_user) else {
continue;
};
let Some(terminal) = utmp_field(&record.ut_line) else {
continue;
};
sessions.push(ActiveSession {
username,
terminal,
pid: record.ut_pid,
host: utmp_field(&record.ut_host),
});
}
libc::endutxent();
}
sessions.sort_by(|left, right| {
left.username
.cmp(&right.username)
.then_with(|| left.terminal.cmp(&right.terminal))
.then_with(|| left.pid.cmp(&right.pid))
});
sessions
}
/// Detects whether systemd-logind has initialized its runtime seat state.
pub fn session_backend() -> SessionBackend {
if logind_markers_present(
Path::new("/run/systemd/system").is_dir(),
Path::new("/run/systemd/seats").is_dir(),
) {
SessionBackend::SystemdLogind
} else {
SessionBackend::Utmpx
}
}
/// Runs the configured helper and parses its stable numeric output. /// Runs the configured helper and parses its stable numeric output.
pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result<FaillockStatus, Error> { pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result<FaillockStatus, Error> {
let helper = &config.faillock_path; let helper = &config.faillock_path;
@@ -116,16 +287,26 @@ pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result<Faillo
if !output.status.success() { if !output.status.success() {
return Ok(FaillockStatus::default()); return Ok(FaillockStatus::default());
} }
let failures = parse_failure_count(&output.stdout);
let max_tries = read_deny(&config.faillock_config); let max_tries = read_deny(&config.faillock_config);
Ok(faillock_status_from_output(&output.stdout, max_tries))
}
/// Converts stable helper output into an advisory lockout status.
///
/// 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 {
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));
Ok(FaillockStatus { FaillockStatus {
available: true, available: true,
failures, failures,
max_tries, max_tries,
remaining, remaining,
locked_out: remaining.is_some_and(|value| value == 0), locked_out: remaining.is_some_and(|value| value == 0),
}) }
} }
fn eligible(username: &str, uid: u32, home: &Path, shell: &Path, config: &AccountsConfig) -> bool { fn eligible(username: &str, uid: u32, home: &Path, shell: &Path, config: &AccountsConfig) -> bool {
@@ -165,11 +346,49 @@ fn c_path(pointer: *const libc::c_char) -> Option<PathBuf> {
} }
fn parse_failure_count(bytes: &[u8]) -> u32 { fn parse_failure_count(bytes: &[u8]) -> u32 {
String::from_utf8_lossy(bytes) let mut rows = 0u32;
.lines() for line in String::from_utf8_lossy(bytes).lines() {
.filter_map(|line| line.split_whitespace().next()) let line = line.trim();
.find_map(|value| value.parse::<u32>().ok()) if line.is_empty() {
.unwrap_or_default() continue;
}
if let Some((_, count)) = line.split_once(':')
&& let Some(value) = count.split_whitespace().next()
&& let Ok(parsed) = value.parse::<u32>()
{
return parsed;
}
let mut fields = line.split_whitespace();
let Some(first) = fields.next() else {
continue;
};
if let Ok(parsed) = first.parse::<u32>() {
return parsed;
}
if is_date(first) && fields.next().is_some_and(is_time) {
rows = rows.saturating_add(1);
}
}
rows
}
fn is_date(value: &str) -> bool {
let mut fields = value.split('-');
fields
.next()
.is_some_and(|year| year.len() == 4 && year.chars().all(|c| c.is_ascii_digit()))
&& fields
.next()
.is_some_and(|month| month.len() == 2 && month.chars().all(|c| c.is_ascii_digit()))
&& fields
.next()
.is_some_and(|day| day.len() == 2 && day.chars().all(|c| c.is_ascii_digit()))
&& fields.next().is_none()
}
fn is_time(value: &str) -> bool {
let mut fields = value.split(':');
fields.all(|part| part.len() == 2 && part.chars().all(|c| c.is_ascii_digit()))
} }
fn read_deny(path: &Path) -> Option<u32> { fn read_deny(path: &Path) -> Option<u32> {
@@ -185,6 +404,118 @@ fn read_deny(path: &Path) -> Option<u32> {
}) })
} }
fn trusted_shadow(path: &Path) -> bool {
let Ok(metadata) = std::fs::symlink_metadata(path) else {
return false;
};
metadata.is_file() && metadata.uid() == 0 && metadata.mode() & 0o022 == 0
}
fn parse_shadow_entry(line: &str) -> Option<ShadowEntry> {
let mut fields = line.split(':');
let username = fields.next()?;
if username.is_empty() {
return None;
}
let hash = fields.next()?.to_owned();
let last_change = parse_shadow_day(fields.next()?);
let _minimum_days = parse_shadow_day(fields.next()?);
let max_days = parse_shadow_day(fields.next()?);
let _warning_days = parse_shadow_day(fields.next()?);
let inactive_days = parse_shadow_day(fields.next()?);
let account_expiry = parse_shadow_day(fields.next()?);
let _reserved = fields.next()?;
Some(ShadowEntry {
hash,
last_change,
max_days,
inactive_days,
account_expiry,
})
}
fn parse_shadow_day(value: &str) -> Option<i64> {
(!value.is_empty() && value != "-1")
.then(|| value.parse::<i64>().ok())
.flatten()
.filter(|day| *day >= 0)
}
fn shadow_hash_is_locked(hash: &str) -> bool {
hash.is_empty() || hash.starts_with('!') || hash.starts_with('*')
}
fn account_status_from_shadow(
identity: &Identity,
entry: Option<ShadowEntry>,
today: i64,
) -> AccountStatus {
let Some(entry) = entry else {
return AccountStatus {
state: AccountState::Unknown,
password_changed_day: None,
password_expiry_day: None,
account_expiry_day: None,
inactive_after_day: None,
};
};
let password_expiry_day = entry
.last_change
.zip(entry.max_days)
.and_then(|(changed, max)| changed.checked_add(max));
let inactive_after_day = password_expiry_day
.zip(entry.inactive_days)
.and_then(|(expiry, inactive)| expiry.checked_add(inactive));
let state = if !identity.shell.is_absolute()
|| identity.shell == Path::new("/bin/false")
|| identity.shell == Path::new("/usr/sbin/nologin")
|| identity.shell == Path::new("/sbin/nologin")
{
AccountState::NoLoginShell
} else if entry.account_expiry.is_some_and(|expiry| expiry <= today) {
AccountState::AccountExpired
} else if shadow_hash_is_locked(&entry.hash) {
AccountState::Locked
} else if entry.last_change == Some(0)
|| password_expiry_day.is_some_and(|expiry| expiry <= today)
{
AccountState::PasswordExpired
} else if inactive_after_day.is_some_and(|expiry| expiry <= today) {
AccountState::AccountInactive
} else {
AccountState::Active
};
AccountStatus {
state,
password_changed_day: entry.last_change,
password_expiry_day,
account_expiry_day: entry.account_expiry,
inactive_after_day,
}
}
fn unix_day() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| (duration.as_secs() / 86_400) as i64)
.unwrap_or_default()
}
fn utmp_field<const N: usize>(field: &[libc::c_char; N]) -> Option<String> {
let length = field.iter().position(|byte| *byte == 0).unwrap_or(N);
if length == 0 {
return None;
}
let bytes: Vec<u8> = field[..length].iter().map(|byte| *byte as u8).collect();
String::from_utf8(bytes)
.ok()
.filter(|value| !value.is_empty())
}
fn logind_markers_present(system_dir: bool, seats_dir: bool) -> bool {
system_dir && seats_dir
}
fn trusted_helper(path: &Path) -> bool { fn trusted_helper(path: &Path) -> bool {
let Ok(metadata) = std::fs::symlink_metadata(path) else { let Ok(metadata) = std::fs::symlink_metadata(path) else {
return false; return false;
@@ -199,6 +530,56 @@ fn trusted_helper(path: &Path) -> bool {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn parses_faillock_rows_with_username_and_timestamp_prefixes() {
assert_eq!(parse_failure_count(b"alice: 4\n"), 4);
assert_eq!(
parse_failure_count(b"2026-01-01 12:00:00 TTY 1\n2026-01-02 12:00:00 TTY 1\n"),
2
);
}
#[test]
fn parses_shadow_aging_and_lock_markers_without_exposing_hashes() {
let entry = parse_shadow_entry("alice:$6$redacted:0:0:30:7:14:20000:").expect("entry");
assert_eq!(entry.last_change, Some(0));
assert_eq!(entry.max_days, Some(30));
assert_eq!(entry.inactive_days, Some(14));
assert_eq!(entry.account_expiry, Some(20_000));
assert!(shadow_hash_is_locked("!$6$hash"));
assert!(!shadow_hash_is_locked("$6$hash"));
}
#[test]
fn account_state_prioritises_non_login_shell_and_expiry() {
let identity = Identity {
username: "alice".into(),
uid: 1000,
gid: 1000,
home: "/home/alice".into(),
shell: "/usr/sbin/nologin".into(),
};
let status = account_status_from_shadow(
&identity,
Some(ShadowEntry {
hash: "*".into(),
last_change: Some(1),
max_days: Some(1),
inactive_days: Some(1),
account_expiry: Some(1),
}),
20_000,
);
assert_eq!(status.state, AccountState::NoLoginShell);
}
#[test]
fn detects_systemd_logind_only_when_runtime_markers_exist() {
assert!(logind_markers_present(true, true));
assert!(!logind_markers_present(true, false));
assert!(!logind_markers_present(false, true));
}
#[test] #[test]
fn parses_legacy_failure_count() { fn parses_legacy_failure_count() {
assert_eq!(parse_failure_count(b" 4 2026-01-01 source\n"), 4); assert_eq!(parse_failure_count(b" 4 2026-01-01 source\n"), 4);

View File

@@ -72,6 +72,8 @@ pub struct AccountsConfig {
pub faillock_path: PathBuf, pub faillock_path: PathBuf,
/// faillock configuration used to read the deny threshold. /// 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,
} }
impl Default for AccountsConfig { impl Default for AccountsConfig {
@@ -82,6 +84,7 @@ impl Default for AccountsConfig {
enumerate: true, enumerate: true,
faillock_path: PathBuf::from("/usr/sbin/faillock"), faillock_path: PathBuf::from("/usr/sbin/faillock"),
faillock_config: PathBuf::from("/etc/security/faillock.conf"), faillock_config: PathBuf::from("/etc/security/faillock.conf"),
shadow_path: PathBuf::from("/etc/shadow"),
} }
} }
} }
@@ -293,6 +296,7 @@ pub fn validate(config: &Config) -> Result<(), Error> {
("x11.server_log_path", &config.x11.server_log_path), ("x11.server_log_path", &config.x11.server_log_path),
("accounts.faillock_path", &config.accounts.faillock_path), ("accounts.faillock_path", &config.accounts.faillock_path),
("accounts.faillock_config", &config.accounts.faillock_config), ("accounts.faillock_config", &config.accounts.faillock_config),
("accounts.shadow_path", &config.accounts.shadow_path),
] { ] {
if !path.is_absolute() { if !path.is_absolute() {
return Err(Error::Config(format!("{name} must be an absolute path"))); return Err(Error::Config(format!("{name} must be an absolute path")));

View File

@@ -19,6 +19,8 @@ use ratatui::{
const MAX_IMAGE_SIDE: u32 = 480; const MAX_IMAGE_SIDE: u32 = 480;
const MAX_SOURCE_SIDE: u32 = 8_192; const MAX_SOURCE_SIDE: u32 = 8_192;
const MAX_SOURCE_PIXELS: u64 = 16_777_216; const MAX_SOURCE_PIXELS: u64 = 16_777_216;
const MAX_ANIMATION_WIDTH: usize = 160;
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. /// Background state owned by the TUI thread.
@@ -189,6 +191,10 @@ 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
// unusually large window. Rendering remains clipped to this layer.
let width = width.min(MAX_ANIMATION_WIDTH);
let height = height.min(MAX_ANIMATION_HEIGHT);
if self.width == width && self.height == height { if self.width == width && self.height == height {
return; return;
} }
@@ -221,11 +227,11 @@ impl Automata {
let mut count = 0; let mut count = 0;
for dy in [self.height.wrapping_sub(1), 0, 1] { for dy in [self.height.wrapping_sub(1), 0, 1] {
for dx in [self.width.wrapping_sub(1), 0, 1] { for dx in [self.width.wrapping_sub(1), 0, 1] {
if dx == 0 && dy == 0 {
continue;
}
let nx = (x + dx) % self.width; let nx = (x + dx) % self.width;
let ny = (y + dy) % self.height; let ny = (y + dy) % self.height;
if nx == x && ny == y {
continue;
}
count += self.current[ny * self.width + nx]; count += self.current[ny * self.width + nx];
} }
} }
@@ -264,4 +270,16 @@ mod tests {
automata.tick(); automata.tick();
assert_eq!(automata.current.len(), 32); assert_eq!(automata.current.len(), 32);
} }
#[test]
fn automata_caps_untrusted_terminal_dimensions() {
let mut automata = Automata::default();
automata.resize(usize::MAX, usize::MAX);
assert_eq!(automata.width, MAX_ANIMATION_WIDTH);
assert_eq!(automata.height, MAX_ANIMATION_HEIGHT);
assert_eq!(
automata.current.len(),
MAX_ANIMATION_WIDTH * MAX_ANIMATION_HEIGHT
);
}
} }

View File

@@ -17,7 +17,7 @@ use zeroize::Zeroizing;
use latchd_auth::{AuthService, AuthTicket}; use latchd_auth::{AuthService, AuthTicket};
use latchd_core::{ use latchd_core::{
accounts::{self, FaillockStatus, Identity}, accounts::{self, AccountState, AccountStatus, FaillockStatus, Identity},
config::Config, config::Config,
error::{Error, ResultExt}, error::{Error, ResultExt},
session::{self, SessionDescriptor}, session::{self, SessionDescriptor},
@@ -30,6 +30,16 @@ const MIN_WIDTH: u16 = 60;
const MIN_HEIGHT: u16 = 16; const MIN_HEIGHT: u16 = 16;
const MAX_INPUT: usize = 256; const MAX_INPUT: usize = 256;
fn unknown_account_status() -> AccountStatus {
AccountStatus {
state: AccountState::Unknown,
password_changed_day: None,
password_expiry_day: None,
account_expiry_day: None,
inactive_after_day: None,
}
}
/// The result of one completed login-screen interaction. /// The result of one completed login-screen interaction.
pub struct LoginSelection { pub struct LoginSelection {
/// Authenticated PAM ticket. /// Authenticated PAM ticket.
@@ -45,6 +55,20 @@ enum Focus {
Password, Password,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FilterTarget {
Sessions,
Accounts,
}
#[derive(Debug, Clone)]
struct FilterState {
target: FilterTarget,
query: String,
}
type DiscoverySnapshot = (Vec<SessionDescriptor>, Vec<Identity>, Vec<AccountStatus>);
#[derive(Debug)] #[derive(Debug)]
enum AuthState { enum AuthState {
Idle, Idle,
@@ -71,6 +95,7 @@ struct App {
preview: bool, preview: bool,
sessions: Vec<SessionDescriptor>, sessions: Vec<SessionDescriptor>,
accounts: Vec<Identity>, accounts: Vec<Identity>,
account_statuses: Vec<AccountStatus>,
account_index: Option<usize>, account_index: Option<usize>,
faillock_receiver: Option<Receiver<Result<FaillockStatus, Error>>>, faillock_receiver: Option<Receiver<Result<FaillockStatus, Error>>>,
faillock_status: Option<FaillockStatus>, faillock_status: Option<FaillockStatus>,
@@ -80,6 +105,8 @@ struct App {
password: Zeroizing<String>, password: Zeroizing<String>,
status: String, status: String,
auth_state: AuthState, auth_state: AuthState,
auth_failures: u32,
auth_blocked_until: Option<Instant>,
show_help: bool, show_help: bool,
power_confirmation: Option<usize>, power_confirmation: Option<usize>,
should_quit: bool, should_quit: bool,
@@ -89,6 +116,10 @@ struct App {
notice: Option<Notice>, notice: Option<Notice>,
animation_frame: usize, animation_frame: usize,
reduced_motion: bool, reduced_motion: bool,
filter: Option<FilterState>,
refresh_receiver: Option<Receiver<DiscoverySnapshot>>,
refreshing: bool,
high_contrast: bool,
} }
impl App { impl App {
@@ -111,6 +142,7 @@ impl App {
auth, auth,
preview, preview,
sessions, sessions,
account_statuses: vec![unknown_account_status(); accounts.len()],
accounts, accounts,
account_index, account_index,
faillock_receiver: None, faillock_receiver: None,
@@ -121,6 +153,8 @@ impl App {
password: Zeroizing::new(String::new()), password: Zeroizing::new(String::new()),
status: "Enter your username".into(), status: "Enter your username".into(),
auth_state: AuthState::Idle, auth_state: AuthState::Idle,
auth_failures: 0,
auth_blocked_until: None,
show_help: false, show_help: false,
power_confirmation: None, power_confirmation: None,
should_quit: false, should_quit: false,
@@ -130,12 +164,17 @@ impl App {
notice: None, notice: None,
animation_frame: 0, animation_frame: 0,
reduced_motion, reduced_motion,
filter: None,
refresh_receiver: None,
refreshing: false,
high_contrast: std::env::var_os("LATCHD_HIGH_CONTRAST").is_some(),
} }
} }
fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<Option<LoginSelection>, Error> { fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<Option<LoginSelection>, Error> {
loop { loop {
self.poll_faillock(); self.poll_faillock();
self.poll_refresh();
let now = Instant::now(); let now = Instant::now();
if self.background.tick_if_due(now) { if self.background.tick_if_due(now) {
self.animation_frame = self.animation_frame.wrapping_add(1); self.animation_frame = self.animation_frame.wrapping_add(1);
@@ -153,6 +192,8 @@ impl App {
if let Some(result) = self.poll_auth() { if let Some(result) = self.poll_auth() {
match result { match result {
Ok(ticket) => { Ok(ticket) => {
self.auth_failures = 0;
self.auth_blocked_until = None;
let selected = self let selected = self
.sessions .sessions
.get(self.selected) .get(self.selected)
@@ -167,6 +208,9 @@ impl App {
tracing::warn!(%error, "authentication attempt failed"); tracing::warn!(%error, "authentication attempt failed");
self.auth_state = AuthState::Failed; self.auth_state = AuthState::Failed;
self.password.clear(); self.password.clear();
self.auth_failures = self.auth_failures.saturating_add(1);
self.auth_blocked_until =
Some(Instant::now() + auth_backoff(self.auth_failures));
self.status = "Authentication failed".into(); self.status = "Authentication failed".into();
self.notify("Authentication failed", NoticeLevel::Warning); self.notify("Authentication failed", NoticeLevel::Warning);
self.start_faillock_query(); self.start_faillock_query();
@@ -230,6 +274,98 @@ impl App {
} }
} }
fn poll_refresh(&mut self) {
let Some(receiver) = &self.refresh_receiver else {
return;
};
match receiver.try_recv() {
Ok((sessions, accounts, account_statuses)) => {
self.sessions = sessions;
self.accounts = accounts;
self.account_statuses = account_statuses;
self.refresh_receiver = None;
self.refreshing = false;
self.reconcile_selection();
self.status = format!(
"Discovery refreshed: {} session(s), {} account(s)",
self.sessions.len(),
self.accounts.len()
);
self.notify("Account and session discovery refreshed", NoticeLevel::Info);
}
Err(mpsc::TryRecvError::Empty) => {}
Err(mpsc::TryRecvError::Disconnected) => {
self.refresh_receiver = None;
self.refreshing = false;
self.status = "Discovery refresh failed".into();
self.notify(
"Could not refresh account and session discovery",
NoticeLevel::Warning,
);
}
}
}
fn start_refresh(&mut self) {
if self.refreshing {
self.status = "Discovery refresh is already running".into();
return;
}
let config = self.config.clone();
let (sender, receiver) = mpsc::sync_channel(1);
let task = thread::Builder::new()
.name("latchd-discovery".into())
.spawn(move || {
let sessions = session::discover(&config);
let (accounts, account_statuses) = if config.accounts.enumerate {
let records = accounts::discover_with_status(&config.accounts);
records
.into_iter()
.map(|record| (record.identity, record.status))
.unzip()
} else {
(Vec::new(), Vec::new())
};
let _ = sender.send((sessions, accounts, account_statuses));
});
match task {
Ok(_) => {
self.refresh_receiver = Some(receiver);
self.refreshing = true;
self.status = "Refreshing accounts and sessions…".into();
}
Err(error) => {
tracing::debug!(%error, "could not start discovery refresh");
self.status = format!("Could not start discovery refresh: {error}");
}
}
}
fn reconcile_selection(&mut self) {
if self.sessions.is_empty() {
self.selected = 0;
} else if self.selected >= self.sessions.len() {
self.selected = self.sessions.len() - 1;
}
if self.accounts.is_empty() {
self.account_index = None;
self.account_statuses.clear();
} else if self.account_statuses.len() != self.accounts.len() {
self.account_statuses
.resize(self.accounts.len(), unknown_account_status());
} else if self
.account_index
.is_some_and(|index| index >= self.accounts.len())
{
self.account_index = Some(0);
}
if let Some(index) = self.account_index {
if let Some(account) = self.accounts.get(index) {
self.username = account.username.clone();
}
}
}
fn start_faillock_query(&mut self) { fn start_faillock_query(&mut self) {
let username = self.username.clone(); let username = self.username.clone();
if username.is_empty() { if username.is_empty() {
@@ -262,6 +398,149 @@ impl App {
} }
} }
fn begin_filter(&mut self) {
let target = match self.focus {
Focus::Sessions => FilterTarget::Sessions,
Focus::Username => FilterTarget::Accounts,
Focus::Password => return,
};
self.filter = Some(FilterState {
target,
query: String::new(),
});
self.status = match target {
FilterTarget::Sessions => {
"Filter sessions: type text, Enter selects, Esc clears".into()
}
FilterTarget::Accounts => {
"Filter accounts: type text, Enter selects, Esc clears".into()
}
};
}
fn filter_key(&mut self, key: KeyEvent) {
let Some(target) = self.filter.as_ref().map(|filter| filter.target) else {
return;
};
match key.code {
KeyCode::Esc => {
self.filter = None;
self.status = "Filter cleared".into();
}
KeyCode::Enter => self.accept_filter(),
KeyCode::Backspace => {
if let Some(filter) = &mut self.filter {
filter.query.pop();
}
self.update_filter_status();
}
KeyCode::Up | KeyCode::Left => match target {
FilterTarget::Sessions => self.previous_session(),
FilterTarget::Accounts => self.previous_account(),
},
KeyCode::Down | KeyCode::Right => match target {
FilterTarget::Sessions => self.next_session(),
FilterTarget::Accounts => self.next_account(),
},
KeyCode::Char(character)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
&& self
.filter
.as_ref()
.is_some_and(|filter| filter.query.chars().count() < MAX_INPUT) =>
{
if let Some(filter) = &mut self.filter {
filter.query.push(character);
}
self.update_filter_status();
}
_ => {}
}
}
fn update_filter_status(&mut self) {
let Some(filter) = &self.filter else {
return;
};
let count = match filter.target {
FilterTarget::Sessions => self.matching_session_indices().len(),
FilterTarget::Accounts => self.matching_account_indices().len(),
};
self.status = format!(
"Filter {}: {} match(es) — Enter selects, Esc clears",
match filter.target {
FilterTarget::Sessions => "sessions",
FilterTarget::Accounts => "accounts",
},
count
);
}
fn accept_filter(&mut self) {
let Some(filter) = self.filter.take() else {
return;
};
match filter.target {
FilterTarget::Sessions => {
let index = self
.sessions
.iter()
.enumerate()
.find(|(_, session)| matches_filter(&session.title, &filter.query))
.map(|(index, _)| index);
if let Some(index) = index {
self.selected = index;
self.status = format!("Selected session {}", self.sessions[index].title);
} else {
self.status = "No matching sessions".into();
}
}
FilterTarget::Accounts => {
let index = self
.accounts
.iter()
.enumerate()
.find(|(_, account)| matches_filter(&account.username, &filter.query))
.map(|(index, _)| index);
if let Some(index) = index {
self.select_account_index(index);
} else {
self.status = "No matching accounts".into();
}
}
}
}
fn matching_session_indices(&self) -> Vec<usize> {
let query = self
.filter
.as_ref()
.filter(|filter| filter.target == FilterTarget::Sessions)
.map_or("", |filter| filter.query.as_str());
self.sessions
.iter()
.enumerate()
.filter(|(_, session)| matches_filter(&session.title, query))
.map(|(index, _)| index)
.collect()
}
fn matching_account_indices(&self) -> Vec<usize> {
let query = self
.filter
.as_ref()
.filter(|filter| filter.target == FilterTarget::Accounts)
.map_or("", |filter| filter.query.as_str());
self.accounts
.iter()
.enumerate()
.filter(|(_, account)| matches_filter(&account.username, query))
.map(|(index, _)| index)
.collect()
}
fn handle_key(&mut self, key: KeyEvent) { fn handle_key(&mut self, key: KeyEvent) {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
self.should_quit = true; self.should_quit = true;
@@ -279,6 +558,10 @@ impl App {
} }
return; return;
} }
if self.filter.is_some() {
self.filter_key(key);
return;
}
if self.power_confirmation.is_none() && key.modifiers.contains(KeyModifiers::CONTROL) { if self.power_confirmation.is_none() && key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code { match key.code {
KeyCode::Char('n') => { KeyCode::Char('n') => {
@@ -314,6 +597,10 @@ impl App {
); );
return; return;
} }
KeyCode::Char('r') => {
self.start_refresh();
return;
}
_ => {} _ => {}
} }
} }
@@ -328,6 +615,7 @@ impl App {
} }
match key.code { match key.code {
KeyCode::Char('?') => self.show_help = true, KeyCode::Char('?') => self.show_help = true,
KeyCode::Char('/') => self.begin_filter(),
KeyCode::Esc => { KeyCode::Esc => {
self.password.clear(); self.password.clear();
self.status = "Login cancelled".into(); self.status = "Login cancelled".into();
@@ -409,6 +697,16 @@ impl App {
self.status = "Username and password are required".into(); self.status = "Username and password are required".into();
return; return;
} }
if let Some(until) = self.auth_blocked_until {
if let Some(remaining) = until.checked_duration_since(Instant::now()) {
self.status = format!(
"Authentication paused after failed attempts; try again in {}s",
remaining.as_secs().saturating_add(1)
);
return;
}
self.auth_blocked_until = None;
}
let username = self.username.clone(); let username = self.username.clone();
let password = self.password.as_bytes().to_vec(); let password = self.password.as_bytes().to_vec();
let service = self.config.pam_service.clone(); let service = self.config.pam_service.clone();
@@ -483,17 +781,23 @@ impl App {
} }
fn next_session(&mut self) { fn next_session(&mut self) {
if !self.sessions.is_empty() { let indices = self.matching_session_indices();
self.selected = (self.selected + 1) % self.sessions.len(); if let Some(position) = indices.iter().position(|&index| index == self.selected) {
self.selected = indices[(position + 1) % indices.len()];
} else if let Some(&index) = indices.first() {
self.selected = index;
} }
} }
fn previous_session(&mut self) { fn previous_session(&mut self) {
if !self.sessions.is_empty() { let indices = self.matching_session_indices();
self.selected = self if let Some(position) = indices.iter().position(|&index| index == self.selected) {
.selected self.selected = indices
.checked_sub(1) .get(position.checked_sub(1).unwrap_or(indices.len() - 1))
.unwrap_or(self.sessions.len() - 1); .copied()
.unwrap_or(self.selected);
} else if let Some(&index) = indices.first() {
self.selected = index;
} }
} }
@@ -506,20 +810,36 @@ impl App {
} }
fn select_account(&mut self, delta: isize) { fn select_account(&mut self, delta: isize) {
if self.accounts.is_empty() { let indices = self.matching_account_indices();
if indices.is_empty() {
self.notify( self.notify(
"No eligible accounts discovered; enter a username manually", "No eligible accounts discovered; enter a username manually",
NoticeLevel::Info, NoticeLevel::Info,
); );
return; return;
} }
let current = self.account_index.unwrap_or(0) as isize; let current = self
let length = self.accounts.len() as isize; .account_index
let index = (current + delta).rem_euclid(length) as usize; .and_then(|index| indices.iter().position(|&candidate| candidate == index))
.unwrap_or(0) as isize;
let index = indices[(current + delta).rem_euclid(indices.len() as isize) as usize];
self.select_account_index(index);
}
fn select_account_index(&mut self, index: usize) {
let Some(username) = self
.accounts
.get(index)
.map(|account| account.username.clone())
else {
return;
};
self.account_index = Some(index); self.account_index = Some(index);
self.username = self.accounts[index].username.clone(); self.username = username;
self.password.clear(); self.password.clear();
self.auth_state = AuthState::Idle; self.auth_state = AuthState::Idle;
self.auth_failures = 0;
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.start_faillock_query(); self.start_faillock_query();
@@ -576,10 +896,10 @@ impl App {
} else { } else {
vertical[1] vertical[1]
}; };
let session_items: Vec<ListItem> = self let session_indices = self.matching_session_indices();
.sessions let session_items: Vec<ListItem> = session_indices
.iter() .iter()
.enumerate() .filter_map(|&index| self.sessions.get(index).map(|session| (index, session)))
.map(|(index, session)| { .map(|(index, session)| {
let marker = if index == self.selected { let marker = if index == self.selected {
if self.ascii { ">" } else { "▶" } if self.ascii { ">" } else { "▶" }
@@ -600,8 +920,22 @@ impl App {
}) })
}) })
.collect(); .collect();
let sessions = let session_items = if session_items.is_empty() {
List::new(session_items).block(self.block("Session", self.focus == Focus::Sessions)); vec![ListItem::new(" No matching sessions").style(self.style_muted())]
} else {
session_items
};
let session_title = if self
.filter
.as_ref()
.is_some_and(|filter| filter.target == FilterTarget::Sessions)
{
format!("Session {}/{}", session_indices.len(), self.sessions.len())
} else {
"Session".into()
};
let sessions = List::new(session_items)
.block(self.block(&session_title, self.focus == Focus::Sessions));
frame.render_widget(sessions, session_area); frame.render_widget(sessions, session_area);
let fields = Layout::default() let fields = Layout::default()
@@ -622,26 +956,97 @@ impl App {
frame.render_widget(password, fields[1]); frame.render_widget(password, fields[1]);
let status = self.status_line(); let status = self.status_line();
frame.render_widget(Paragraph::new(status).wrap(Wrap { trim: true }), fields[2]); frame.render_widget(Paragraph::new(status).wrap(Wrap { trim: true }), fields[2]);
frame.render_widget( frame.render_widget(Paragraph::new(self.footer_hint()), vertical[2]);
Paragraph::new(
"Tab focus ←/→ session Ctrl-N/P account Ctrl-B effects Ctrl-M motion ? help",
),
vertical[2],
);
frame.render_widget( frame.render_widget(
Paragraph::new("PAM authentication • session output stays off the login screen"), Paragraph::new("PAM authentication • session output stays off the login screen"),
vertical[3], vertical[3],
); );
if self.show_help { if self.show_help {
self.draw_help(frame, area); self.draw_help(frame, area);
} else if self.filter.is_some() {
self.draw_filter(frame, area);
} }
} }
fn footer_hint(&self) -> String {
let mut hint = match self.focus {
Focus::Sessions => "Tab focus ←/→ choose / filter Ctrl-R refresh ? help",
Focus::Username => "Tab focus / filter Ctrl-N/P switch Ctrl-R refresh ? help",
Focus::Password => "Tab focus Enter login Ctrl-M motion ? help",
}
.to_owned();
if self.no_color {
hint.push_str(" [monochrome]");
} else if self.high_contrast {
hint.push_str(" [high contrast]");
}
if self.reduced_motion {
hint.push_str(" [reduced motion]");
}
hint
}
fn draw_filter(&self, frame: &mut ratatui::Frame, area: Rect) {
let Some(filter) = &self.filter else {
return;
};
let popup = centered_rect(72, 52, area);
frame.render_widget(Clear, popup);
let (label, values): (&str, Vec<String>) = match filter.target {
FilterTarget::Sessions => (
"Sessions",
self.matching_session_indices()
.iter()
.filter_map(|&index| self.sessions.get(index))
.map(|session| session.title.clone())
.collect(),
),
FilterTarget::Accounts => (
"Accounts",
self.matching_account_indices()
.iter()
.filter_map(|&index| self.accounts.get(index))
.map(|account| account.username.clone())
.collect(),
),
};
let mut lines = vec![
Line::from(format!("{label} /{}", filter.query)),
Line::raw(""),
];
if values.is_empty() {
lines.push(Line::from("No matches"));
} else {
for value in values.iter().take(8) {
lines.push(Line::from(format!(
" {}",
truncate_cells(value, popup.width.saturating_sub(5) as usize)
)));
}
}
lines.push(Line::raw(""));
lines.push(Line::from(
"Type to filter • ↑/↓ choose • Enter accept • Esc clear",
));
frame.render_widget(
Paragraph::new(lines)
.block(self.block("Filter", true))
.wrap(Wrap { trim: true }),
popup,
);
}
fn draw_help(&self, frame: &mut ratatui::Frame, area: Rect) { fn draw_help(&self, frame: &mut ratatui::Frame, area: Rect) {
let popup = centered_rect(70, 50, area); let popup = centered_rect(70, 50, area);
frame.render_widget(Clear, popup); frame.render_widget(Clear, popup);
frame.render_widget(Paragraph::new("Keyboard help\n\nTab / Shift-Tab move focus\nArrow keys choose session\nCtrl-N / Ctrl-P switch account\nCtrl-B toggle background\nCtrl-M toggle reduced motion\nEnter authenticate\nEsc clear password\nCtrl-C quit cleanly\n? close help") frame.render_widget(
.block(self.block("Help", false)).wrap(Wrap { trim: true }), popup); Paragraph::new(
"Keyboard help\n\nTab / Shift-Tab move focus\nArrow keys choose session\n/ filter sessions or accounts\nCtrl-N / Ctrl-P switch account\nCtrl-R refresh discovery\nCtrl-B toggle background\nCtrl-M toggle reduced motion\nEnter authenticate / accept filter\nEsc clear password / close popup\nCtrl-C quit cleanly\n? close help",
)
.block(self.block("Help", false))
.wrap(Wrap { trim: true }),
popup,
);
} }
fn block(&self, title: &str, focused: bool) -> Block<'static> { fn block(&self, title: &str, focused: bool) -> Block<'static> {
@@ -680,6 +1085,14 @@ impl App {
}; };
status = format!("{spinner} Authenticating…"); status = format!("{spinner} Authenticating…");
} }
if let Some(until) = self.auth_blocked_until {
if let Some(remaining) = until.checked_duration_since(Instant::now()) {
status.push_str(&format!(
" • retry cooldown {}s",
remaining.as_secs().saturating_add(1)
));
}
}
if let Some(lockout) = self.faillock_status { if let Some(lockout) = self.faillock_status {
if lockout.locked_out { if lockout.locked_out {
status status
@@ -694,6 +1107,21 @@ impl App {
)); ));
} }
} }
if let Some(index) = self.account_index {
if let Some(account) = self.account_statuses.get(index) {
let policy = match account.state {
AccountState::Active | AccountState::Unknown => None,
AccountState::Locked => Some("account password is locked"),
AccountState::PasswordExpired => Some("password must be changed"),
AccountState::AccountExpired => Some("account has expired"),
AccountState::AccountInactive => Some("account is inactive"),
AccountState::NoLoginShell => Some("account has no login shell"),
};
if let Some(policy) = policy {
status.push_str(&format!(" • {policy}; PAM remains authoritative"));
}
}
}
if let Some(notice) = &self.notice { if let Some(notice) = &self.notice {
let marker = match notice.level { let marker = match notice.level {
NoticeLevel::Info => { NoticeLevel::Info => {
@@ -713,12 +1141,20 @@ impl App {
}; };
status.push_str(&format!(" {marker} {}", notice.message)); status.push_str(&format!(" {marker} {}", notice.message));
} }
if self.refreshing {
status.push_str(" • refreshing discovery");
}
status status
} }
fn style_accent(&self) -> Style { fn style_accent(&self) -> Style {
if self.no_color { if self.no_color {
Style::default().add_modifier(ratatui::prelude::Modifier::BOLD) Style::default().add_modifier(ratatui::prelude::Modifier::BOLD)
} else if self.high_contrast {
Style::default()
.fg(ratatui::prelude::Color::White)
.bg(ratatui::prelude::Color::Blue)
.add_modifier(ratatui::prelude::Modifier::BOLD)
} else { } else {
Style::default() Style::default()
.fg(ratatui::prelude::Color::Cyan) .fg(ratatui::prelude::Color::Cyan)
@@ -728,6 +1164,11 @@ impl App {
fn style_selected(&self) -> Style { fn style_selected(&self) -> Style {
if self.no_color { if self.no_color {
Style::default().add_modifier(ratatui::prelude::Modifier::REVERSED) Style::default().add_modifier(ratatui::prelude::Modifier::REVERSED)
} else if self.high_contrast {
Style::default()
.fg(ratatui::prelude::Color::Black)
.bg(ratatui::prelude::Color::White)
.add_modifier(ratatui::prelude::Modifier::BOLD)
} else { } else {
Style::default() Style::default()
.fg(ratatui::prelude::Color::Yellow) .fg(ratatui::prelude::Color::Yellow)
@@ -746,13 +1187,18 @@ pub fn run(
preview: bool, preview: bool,
) -> Result<Option<LoginSelection>, Error> { ) -> Result<Option<LoginSelection>, Error> {
let sessions = session::discover(&config); let sessions = session::discover(&config);
let accounts = if config.accounts.enumerate { let (accounts, account_statuses) = if config.accounts.enumerate {
accounts::discover(&config.accounts) let records = accounts::discover_with_status(&config.accounts);
records
.into_iter()
.map(|record| (record.identity, record.status))
.unzip()
} else { } else {
Vec::new() (Vec::new(), Vec::new())
}; };
let background = Background::load(&config.visual)?; let background = Background::load(&config.visual)?;
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_index = (!app.accounts.is_empty()).then_some(0); app.account_index = (!app.accounts.is_empty()).then_some(0);
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()))
@@ -795,6 +1241,22 @@ fn truncate_cells(value: &str, width: usize) -> String {
output output
} }
fn matches_filter(value: &str, query: &str) -> bool {
if query.is_empty() {
return true;
}
if query.chars().any(char::is_uppercase) {
value.contains(query)
} else {
value.to_lowercase().contains(query)
}
}
fn auth_backoff(failures: u32) -> Duration {
let shift = failures.saturating_sub(1).min(4);
Duration::from_secs(1u64 << shift)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -839,6 +1301,40 @@ mod tests {
assert!(rendered.contains("Password")); assert!(rendered.contains("Password"));
} }
#[test]
fn renders_without_layout_errors_at_supported_breakpoints() {
for (width, height) in [(80, 24), (79, 23), (60, 24)] {
let auth = AuthService::new().expect("PAM worker should start");
let mut app = App::new(
Config::default(),
auth,
true,
vec![SessionDescriptor {
kind: session::SessionKind::Tty,
title: "TTY shell".into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
}],
Vec::new(),
Background::load(&Config::default().visual)
.expect("default background should load"),
);
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("test terminal should initialize");
terminal
.draw(|frame| app.draw(frame))
.expect("frame should render");
let rendered = terminal.backend().buffer().content();
assert!(rendered.iter().any(|cell| cell.symbol() == "l"));
assert!(
!rendered
.iter()
.any(|cell| cell.symbol() == "terminal too small")
);
}
}
#[test] #[test]
fn renders_a_truthful_too_small_state() { fn renders_a_truthful_too_small_state() {
let auth = AuthService::new().expect("PAM worker should start"); let auth = AuthService::new().expect("PAM worker should start");
@@ -898,4 +1394,106 @@ mod tests {
assert!(app.password.is_empty()); assert!(app.password.is_empty());
assert!(matches!(app.auth_state, AuthState::Idle)); assert!(matches!(app.auth_state, AuthState::Idle));
} }
#[test]
fn account_policy_state_is_visible_without_overriding_pam() {
let auth = AuthService::new().expect("PAM worker should start");
let account = Identity {
username: "alice".into(),
uid: 1000,
gid: 1000,
home: "/home/alice".into(),
shell: "/bin/sh".into(),
};
let mut app = App::new(
Config::default(),
auth,
true,
Vec::new(),
vec![account],
Background::load(&Config::default().visual).expect("default background should load"),
);
app.account_statuses[0].state = AccountState::Locked;
let status = app.status_line();
assert!(status.contains("password is locked"));
assert!(status.contains("PAM remains authoritative"));
}
#[test]
fn authentication_backoff_is_exponential_and_bounded() {
assert_eq!(auth_backoff(1), Duration::from_secs(1));
assert_eq!(auth_backoff(2), Duration::from_secs(2));
assert_eq!(auth_backoff(5), Duration::from_secs(16));
assert_eq!(auth_backoff(100), Duration::from_secs(16));
}
#[test]
fn filtering_is_smart_case_and_selects_a_matching_session() {
let auth = AuthService::new().expect("PAM worker should start");
let mut app = App::new(
Config::default(),
auth,
true,
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 session".into(),
argv: vec!["sway".into()],
current_desktop: None,
session_desktop: None,
},
],
Vec::new(),
Background::load(&Config::default().visual).expect("default background should load"),
);
app.focus = Focus::Sessions;
app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
app.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
app.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));
assert_eq!(app.matching_session_indices(), vec![1]);
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.selected, 1);
assert!(app.filter.is_none());
}
#[test]
fn refresh_reconciles_selection_without_blocking_state() {
let auth = AuthService::new().expect("PAM worker should start");
let mut app = App::new(
Config::default(),
auth,
true,
Vec::new(),
Vec::new(),
Background::load(&Config::default().visual).expect("default background should load"),
);
app.refresh_receiver = Some({
let (sender, receiver) = mpsc::sync_channel(1);
sender
.send((
vec![SessionDescriptor {
kind: session::SessionKind::Tty,
title: "TTY shell".into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
}],
Vec::new(),
Vec::new(),
))
.expect("refresh result should queue");
receiver
});
app.refreshing = true;
app.poll_refresh();
assert!(!app.refreshing);
assert_eq!(app.sessions.len(), 1);
}
} }

35
docs/e2e-testing.md Normal file
View File

@@ -0,0 +1,35 @@
# End-to-end testing
Latchd has two test layers:
* `make integration-test` builds the binary and exercises metadata, TOML
validation, preflight, and the non-authenticating preview path. It does not
need PAM credentials, root, a VT, X11, or Wayland.
* `make system-e2e` probes the host for PAM, faillock, VT, X11, Wayland, and
systemd-logind capabilities. Missing capabilities are reported as `SKIP`,
never as a successful test. Use `make system-e2e-strict` only in a prepared
VM or privileged runner; strict mode fails when a capability is unavailable.
The Docker test image runs the first layer and starts an isolated Xvfb server
when available. It does not use host display sockets, host PAM files, or a
host VT. PAM authentication and a real login handoff require a disposable VM
with a dedicated test account and an explicitly provisioned PAM policy; the
container probe intentionally reports those checks as capability information
only.
For a local release-style run:
```sh
make integration-test
make docker-test
make system-e2e
```
For a VM with a real seat, compositor, and logind:
```sh
make system-e2e-strict
```
Do not run strict mode on a normal developer workstation unless the VM's
display-manager handoff and test PAM policy are isolated from the host.

View File

@@ -36,6 +36,7 @@ excluded_users = []
enumerate = true enumerate = true
faillock_path = "/usr/sbin/faillock" faillock_path = "/usr/sbin/faillock"
faillock_config = "/etc/security/faillock.conf" faillock_config = "/etc/security/faillock.conf"
shadow_path = "/etc/shadow"
[visual] [visual]
background_enabled = true background_enabled = true

107
scripts/test-cli.sh Executable file
View File

@@ -0,0 +1,107 @@
#!/bin/sh
# Exercise the non-interactive CLI contract without authenticating a user.
#
# 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
binary=${1:-target/debug/latchd}
if [ ! -x "$binary" ]; then
echo "test-cli: binary is not executable: $binary" >&2
exit 2
fi
workdir=$(mktemp -d "${TMPDIR:-/tmp}/latchd-cli.XXXXXX")
trap 'rm -rf "$workdir"' EXIT HUP INT TERM
config="$workdir/config.toml"
cat >"$config" <<'TOML'
tty = 2
main_log_path = "/tmp/latchd-test.log"
client_log_path = "/tmp/latchd-test.client.log"
pam_service = "latchd"
system_shell = "/bin/sh"
initial_path = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
[x11]
display = ":99"
server_path = "/usr/bin/X"
xauth_path = "/usr/bin/xauth"
server_timeout_secs = 5
server_log_path = "/tmp/latchd-test.xorg.log"
sessions_path = "/nonexistent/latchd-test/xsessions"
scripts_path = "/nonexistent/latchd-test/wms"
[wayland]
sessions_path = "/nonexistent/latchd-test/wayland-sessions"
scripts_path = "/nonexistent/latchd-test/wayland"
[environment_switcher]
include_tty_shell = false
max_display_length = 18
[power_controls]
entries = []
[accounts]
minimum_uid = 1000
excluded_users = []
enumerate = true
faillock_path = "/usr/sbin/faillock"
faillock_config = "/etc/security/faillock.conf"
[visual]
background_enabled = false
animations = false
notifications = false
reduced_motion = true
max_wallpaper_bytes = 1048576
TOML
assert_contains() {
needle=$1
haystack=$2
grep -F -- "$needle" "$haystack" >/dev/null || {
echo "test-cli: expected '$needle' in $haystack" >&2
cat "$haystack" >&2
exit 1
}
}
info="$workdir/build-info"
"$binary" --build-info >"$info"
assert_contains "version=" "$info"
assert_contains "commit=" "$info"
assert_contains "epoch=" "$info"
version="$workdir/version"
"$binary" --version >"$version"
assert_contains "latchd" "$version"
valid="$workdir/valid"
"$binary" --preview --no-tui --no-log --config "$config" >"$valid"
assert_contains "configuration valid" "$valid"
shown="$workdir/show-config"
"$binary" --preview --show-config --no-log --config "$config" >"$shown"
assert_contains "tty = 2" "$shown"
assert_contains "pam_service" "$shown"
unknown="$workdir/unknown.toml"
printf 'unknown = true\n' >"$unknown"
if "$binary" --preview --no-tui --no-log --config "$unknown" >"$workdir/unknown.out" 2>&1; then
echo "test-cli: unknown configuration field was accepted" >&2
exit 1
fi
assert_contains "unknown" "$workdir/unknown.out"
invalid_tty="$workdir/invalid-tty"
if "$binary" --preview --no-tui --no-log --config "$config" --tty 0 >"$invalid_tty" 2>&1; then
echo "test-cli: --tty 0 was accepted" >&2
exit 1
fi
assert_contains "--tty must be greater than zero" "$invalid_tty"
echo "test-cli: PASS (CLI metadata, config validation, preflight, and safe preview paths)"

146
scripts/test-system-e2e.sh Executable file
View File

@@ -0,0 +1,146 @@
#!/bin/sh
# Probe host capabilities needed for a real login-manager deployment.
#
# 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
binary=${1:-}
strict=0
if [ "${1:-}" = "--strict" ]; then
strict=1
shift
binary=${1:-}
fi
failures=0
skips=0
passes=0
pass() {
passes=$((passes + 1))
printf 'PASS: %s\n' "$*"
}
skip() {
skips=$((skips + 1))
printf 'SKIP: %s\n' "$*"
}
fail() {
failures=$((failures + 1))
printf 'FAIL: %s\n' "$*" >&2
}
need_command() {
command -v "$1" >/dev/null 2>&1
}
if [ -n "$binary" ]; then
if [ ! -x "$binary" ]; then
fail "latchd binary is not executable: $binary"
else
pass "latchd binary is available"
fi
else
skip "latchd binary not supplied; capability-only probe"
fi
# This is deliberately a runtime capability check, not an authentication
# 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
pass "PAM runtime library is available"
else
skip "PAM runtime library is unavailable"
fi
if [ -r /etc/pam.d/latchd ]; then
pass "latchd PAM policy is installed"
else
skip "latchd PAM policy is not installed (authentication E2E requires a provisioned VM)"
fi
if need_command faillock; then
pass "faillock helper is available"
else
skip "faillock helper is unavailable"
fi
# A real VT is a character device and must be allocated by the host/VM. A
# pseudo-terminal from CI is not equivalent and is intentionally not accepted.
if [ -c /dev/tty0 ] && [ -d /sys/class/tty/tty0 ]; then
pass "Linux virtual terminal device is available"
else
skip "Linux virtual terminal is unavailable (container or non-console host)"
fi
# Xvfb is a genuine X server and is safe to exercise without host display
# access. If it is absent, this is a skip rather than a synthetic success.
if need_command Xvfb; then
display_number=$((100 + ($$ % 500)))
display=":$display_number"
xlog=${TMPDIR:-/tmp}/latchd-xvfb.$$.log
Xvfb "$display" -screen 0 640x480x24 -nolisten tcp >"$xlog" 2>&1 &
xvfb_pid=$!
cleanup_xvfb() {
kill "$xvfb_pid" >/dev/null 2>&1 || true
wait "$xvfb_pid" >/dev/null 2>&1 || true
rm -f "$xlog"
}
trap cleanup_xvfb EXIT HUP INT TERM
ready=0
i=0
while [ "$i" -lt 20 ]; do
if [ -S "/tmp/.X11-unix/X$display_number" ] && kill -0 "$xvfb_pid" 2>/dev/null; then
ready=1
break
fi
i=$((i + 1))
sleep 0.1
done
if [ "$ready" -eq 1 ]; then
pass "X11 server starts in an isolated display ($display)"
else
fail "Xvfb did not start; see $xlog"
fi
cleanup_xvfb
trap - EXIT HUP INT TERM
else
skip "Xvfb is unavailable"
fi
# A Wayland socket is meaningful only when a compositor owns it. Do not claim
# Wayland support merely because a client library or executable is installed.
wayland_socket=
if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -n "${XDG_RUNTIME_DIR:-}" ]; then
case "$WAYLAND_DISPLAY" in
/*) wayland_socket=$WAYLAND_DISPLAY ;;
*) wayland_socket=$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY ;;
esac
fi
if [ -n "$wayland_socket" ] && [ -S "$wayland_socket" ]; then
pass "Wayland compositor socket is available ($wayland_socket)"
else
skip "Wayland compositor socket is unavailable (requires a compositor-backed VM/session)"
fi
# systemd-logind is not normally PID 1 in a Docker container. loginctl output
# is checked only when the manager and seat are actually reachable.
if need_command loginctl && [ -d /run/systemd/system ]; then
if loginctl --no-pager show-seat seat0 >/dev/null 2>&1; then
pass "systemd-logind seat0 is reachable"
else
fail "systemd-logind is present but seat0 is not reachable"
fi
else
skip "systemd-logind seat0 is unavailable"
fi
printf 'E2E summary: pass=%s skip=%s fail=%s\n' "$passes" "$skips" "$failures"
if [ "$failures" -ne 0 ]; then
exit 1
fi
if [ "$strict" -eq 1 ] && [ "$skips" -ne 0 ]; then
echo "E2E strict mode: unavailable capabilities are errors" >&2
exit 2
fi
exit 0