diff --git a/bin/omarchy-apply-lock b/bin/omarchy-apply-lock index 5bb261cb..1472e04a 100755 --- a/bin/omarchy-apply-lock +++ b/bin/omarchy-apply-lock @@ -40,16 +40,41 @@ auth required pam_faillock.so authsucc account include system-local-login EOF -if [[ -x /usr/bin/fprintd-list ]] && - /usr/bin/fprintd-list "$target_user" 2>/dev/null | grep -qi finger; then +# Install resume recovery alongside fingerprint PAM, so setup and upgrades +# cannot leave one configured without the other. +resume_hook_src="$OMARCHY_PATH/default/systemd/system-sleep/fprintd-resume" +resume_hook_dst=/usr/lib/systemd/system-sleep/fprintd-resume +stop_timeout_src="$OMARCHY_PATH/default/systemd/system/fprintd.service.d/10-stop-timeout.conf" +stop_timeout_dst=/etc/systemd/system/fprintd.service.d/10-stop-timeout.conf + +# Keep the privileged probe pinned to /usr/bin; failure is not empty enrollment. +if [[ -x /usr/bin/fprintd-list ]]; then + fingerprint_status=$(/usr/bin/fprintd-list "$target_user" 2>&1 || true) +else + fingerprint_status="no" +fi + +if grep -qE '^[[:space:]]*-[[:space:]]*#[0-9]+:' <<< "$fingerprint_status"; then echo "Configuring lock screen fingerprint authentication..." + as_root install -Dm755 -o root -g root "$resume_hook_src" "$resume_hook_dst" + as_root install -Dm644 -o root -g root "$stop_timeout_src" "$stop_timeout_dst" + as_root systemctl daemon-reload as_root tee /etc/pam.d/omarchy-lock-fingerprint >/dev/null <<'EOF' #%PAM-1.0 auth required pam_fprintd.so account include system-local-login EOF +elif [[ $fingerprint_status == "no" ]] || grep -qi 'has no fingers enrolled' <<< "$fingerprint_status"; then + as_root rm -f /etc/pam.d/omarchy-lock-fingerprint "$resume_hook_dst" + # Only reload when something was there: this branch also runs in the + # install chroot, where there is no systemd to reload. + if [[ -e $stop_timeout_dst ]]; then + as_root rm -f "$stop_timeout_dst" + as_root rmdir --ignore-fail-on-non-empty "$(dirname "$stop_timeout_dst")" + as_root systemctl daemon-reload + fi else - as_root rm -f /etc/pam.d/omarchy-lock-fingerprint + echo "Could not check fingerprint enrollment; keeping the existing configuration." >&2 fi # omarchy-shell can't reach a running shell during chroot install. The echo diff --git a/bin/omarchy-remove-security-fingerprint b/bin/omarchy-remove-security-fingerprint index fc53c109..7c2254be 100755 --- a/bin/omarchy-remove-security-fingerprint +++ b/bin/omarchy-remove-security-fingerprint @@ -43,11 +43,25 @@ remove_lock_fingerprint_pam() { fi } +remove_resume_hook() { + if [[ -f /usr/lib/systemd/system-sleep/fprintd-resume ]]; then + echo "Removing fingerprint resume hook..." + sudo rm -f /usr/lib/systemd/system-sleep/fprintd-resume + fi + if [[ -f /etc/systemd/system/fprintd.service.d/10-stop-timeout.conf ]]; then + echo "Removing fprintd stop timeout bound..." + sudo rm -f /etc/systemd/system/fprintd.service.d/10-stop-timeout.conf + sudo rmdir --ignore-fail-on-non-empty /etc/systemd/system/fprintd.service.d + sudo systemctl daemon-reload + fi +} + echo -e "\e[32mRemoving fingerprint scanner from authentication.\n\e[0m" remove_pam_config remove_lock_fingerprint_pam +remove_resume_hook sudo rm -rf -- "/var/lib/fprint/$fingerprint_user" diff --git a/bin/omarchy-setup-security-fingerprint b/bin/omarchy-setup-security-fingerprint index 4b3c436a..0367cefa 100755 --- a/bin/omarchy-setup-security-fingerprint +++ b/bin/omarchy-setup-security-fingerprint @@ -56,16 +56,6 @@ EOF fi } -setup_lock_fingerprint_pam() { - echo "Configuring lock screen for fingerprint authentication..." - sudo tee /etc/pam.d/omarchy-lock-fingerprint >/dev/null <<'EOF' -#%PAM-1.0 -auth required pam_fprintd.so -account include system-local-login -EOF -} - - echo -e "\e[32mSetting up fingerprint scanner for authentication.\n\e[0m" # Bail before installing anything if there's no reader to talk to. @@ -99,7 +89,11 @@ if sudo fprintd-enroll "$USER"; then # Editing the stacks up front would leave those machines pointing at # pam_fprintd with nothing to match. setup_pam_config - setup_lock_fingerprint_pam + "$OMARCHY_PATH/bin/omarchy-apply-lock" + if [[ ! -f /etc/pam.d/omarchy-lock-fingerprint ]]; then + echo "Fingerprint enrollment succeeded, but lock-screen configuration could not be confirmed. Run omarchy-apply-lock and try again." >&2 + exit 1 + fi echo -e "\e[32m\nPerfect! Fingerprint authentication is now configured.\e[0m" echo "You can use your fingerprint for sudo, polkit, and lock screen (Super + Ctrl + L)." else diff --git a/default/systemd/system-sleep/fprintd-resume b/default/systemd/system-sleep/fprintd-resume new file mode 100755 index 00000000..6bdb778e --- /dev/null +++ b/default/systemd/system-sleep/fprintd-resume @@ -0,0 +1,9 @@ +#!/bin/bash + +# Suspend can wedge fprintd's claim; restart it after resume to clear it. +# Upstream: https://gitlab.freedesktop.org/libfprint/fprintd/-/issues/173 + +if [[ $1 == "post" ]]; then + # user.slice stays frozen until this hook returns; enqueue rather than wait. + systemctl --no-block try-restart fprintd.service 2>/dev/null || true +fi diff --git a/default/systemd/system/fprintd.service.d/10-stop-timeout.conf b/default/systemd/system/fprintd.service.d/10-stop-timeout.conf new file mode 100644 index 00000000..f67e9aea --- /dev/null +++ b/default/systemd/system/fprintd.service.d/10-stop-timeout.conf @@ -0,0 +1,4 @@ +# A stale device handle can make fprintd ignore SIGTERM after resume. +# The numeric prefix lets an administrator's override.conf take precedence. +[Service] +TimeoutStopSec=3s diff --git a/migrations/1791125650.sh b/migrations/1791125650.sh new file mode 100644 index 00000000..52301af7 --- /dev/null +++ b/migrations/1791125650.sh @@ -0,0 +1,22 @@ +echo "Install the fingerprint resume hook on existing fingerprint setups" + +# Existing enrolled machines never rerun setup. Install only missing files +# so administrator changes survive an upgrade. + +hook_src="${OMARCHY_FPRINTD_RESUME_SRC:-$OMARCHY_PATH/default/systemd/system-sleep/fprintd-resume}" +hook_dst="${OMARCHY_FPRINTD_RESUME_DST:-/usr/lib/systemd/system-sleep/fprintd-resume}" +stop_timeout_src="${OMARCHY_FPRINTD_STOP_TIMEOUT_SRC:-$OMARCHY_PATH/default/systemd/system/fprintd.service.d/10-stop-timeout.conf}" +stop_timeout_dst="${OMARCHY_FPRINTD_STOP_TIMEOUT_DST:-/etc/systemd/system/fprintd.service.d/10-stop-timeout.conf}" +lock_pam="${OMARCHY_LOCK_FINGERPRINT_PAM:-/etc/pam.d/omarchy-lock-fingerprint}" + +[[ -f $lock_pam ]] || exit 0 + +if [[ -f $hook_src && ! -e $hook_dst ]]; then + echo "Installing the fprintd resume hook" + sudo install -Dm755 "$hook_src" "$hook_dst" +fi + +if [[ -f $stop_timeout_src && ! -e $stop_timeout_dst ]]; then + sudo install -Dm644 "$stop_timeout_src" "$stop_timeout_dst" + sudo systemctl daemon-reload +fi diff --git a/shell/plugins/lock/FingerprintModel.js b/shell/plugins/lock/FingerprintModel.js new file mode 100644 index 00000000..b489c7ec --- /dev/null +++ b/shell/plugins/lock/FingerprintModel.js @@ -0,0 +1,91 @@ +// Back off missing prompts and fast device errors; ordinary mismatches +// and a full scan window without a finger keep the normal swipe interval. +var MATCH_RETRY_MS = 250 +var FAST_ERROR_MS = 2000 +var NUDGE_COOLDOWN_MS = 2000 +var IDLE_CLEAR_MS = 32000 +var ERROR_RETRY_BASE_MS = 1000 +// Leave enough idle time for fprintd to exit and clear a wedged claim. +var FPRINTD_IDLE_EXIT_MS = 30000 +var ERROR_RETRY_CAP_MS = 40000 +var UNAVAILABLE_AFTER = 3 +// Abort before the D-Bus claim timeout, while allowing slow device opens. +var REACH_TIMEOUT_MS = 20000 +// Monotonic timers pause across suspend; a wall-clock gap detects resume. +var SLEEP_GAP_MS = 2000 +// Ignore transient misses while the asynchronous resume restart lands. +var RESUME_GRACE_MS = 5000 + +function retryDelayMs(streak) { + if (streak <= 0) return MATCH_RETRY_MS + var delay = ERROR_RETRY_BASE_MS * Math.pow(2, streak - 1) + return Math.min(delay, ERROR_RETRY_CAP_MS) +} + +// Preserve the daemon idle window even under continuous user activity. +function shouldNudge(nowMs, lastNudgeMs, lastSettleMs, currentIntervalMs) { + if (currentIntervalMs <= MATCH_RETRY_MS) return false + var sinceNudge = nowMs - lastNudgeMs + var sinceSettle = nowMs - lastSettleMs + // A backward clock step cannot count as a completed idle window. + if (sinceNudge >= 0 && sinceNudge < Math.max(NUDGE_COOLDOWN_MS, currentIntervalMs)) return false + if (sinceSettle < 0) sinceSettle = 0 + if (currentIntervalMs >= ERROR_RETRY_CAP_MS && sinceSettle < IDLE_CLEAR_MS) return false + return true +} + + +// A failed probe is unknown, so it cannot disable authentication for the lock. +function classifyProbe(text) { + var s = String(text || "").trim() + if (/^[ \t]*-[ \t]*#[0-9]+:/m.test(s)) return "yes" + if (s === "no") return "no" + if (/has no fingers enrolled/i.test(s)) return "no" + return "unknown" +} + +// Resume-time misses stay at the first tier while fprintd restarts. +function nextStreak(streak, usableAttempt, inResumeGrace) { + if (usableAttempt) return 0 + if (inResumeGrace) return 1 + return streak + 1 +} + +// Event-loop stalls may also open the grace window; retries remain paced. +function spannedSleep(elapsedMs, expectedMs) { + return elapsedMs > expectedMs + SLEEP_GAP_MS +} + +function inResumeGrace(nowMs, resumedAtMs) { + if (resumedAtMs <= 0) return false + var elapsed = nowMs - resumedAtMs + return elapsed >= 0 && elapsed < RESUME_GRACE_MS +} + +// A few consecutive misses avoid reporting a single claim conflict. +function isUnavailable(streak) { + return streak >= UNAVAILABLE_AFTER +} + +if (typeof module !== "undefined") { + module.exports = { + MATCH_RETRY_MS: MATCH_RETRY_MS, + FAST_ERROR_MS: FAST_ERROR_MS, + NUDGE_COOLDOWN_MS: NUDGE_COOLDOWN_MS, + IDLE_CLEAR_MS: IDLE_CLEAR_MS, + shouldNudge: shouldNudge, + ERROR_RETRY_BASE_MS: ERROR_RETRY_BASE_MS, + ERROR_RETRY_CAP_MS: ERROR_RETRY_CAP_MS, + FPRINTD_IDLE_EXIT_MS: FPRINTD_IDLE_EXIT_MS, + UNAVAILABLE_AFTER: UNAVAILABLE_AFTER, + REACH_TIMEOUT_MS: REACH_TIMEOUT_MS, + SLEEP_GAP_MS: SLEEP_GAP_MS, + RESUME_GRACE_MS: RESUME_GRACE_MS, + spannedSleep: spannedSleep, + classifyProbe: classifyProbe, + inResumeGrace: inResumeGrace, + retryDelayMs: retryDelayMs, + nextStreak: nextStreak, + isUnavailable: isUnavailable + } +} diff --git a/shell/plugins/lock/LockView.qml b/shell/plugins/lock/LockView.qml index dea08319..e00ef6e9 100644 --- a/shell/plugins/lock/LockView.qml +++ b/shell/plugins/lock/LockView.qml @@ -10,6 +10,7 @@ Item { property string videoPosterPath: "" property int backgroundVersion: 0 property bool fingerprintConfigured: false + property bool fingerprintUnavailable: false property bool authenticatingPassword: false property string failureMessage: "" property int failedAttempts: 0 @@ -235,20 +236,41 @@ Item { // Fingerprint hint pinned inside the field's right edge when a sensor is // enrolled, so the user knows they can touch to unlock instead of typing. // Matches hyprlock, which draws its fingerprint icon in the same spot. + // A reader the shell cannot reach crosses out rather than disappears, so + // it stops inviting touches that can never unlock. Text { id: fingerprintIcon objectName: "fingerprintIndicator" + textFormat: Text.PlainText anchors.right: parent.right anchors.rightMargin: inputField.borderRight + 18 anchors.verticalCenter: parent.verticalCenter visible: root.fingerprintConfigured - text: "󰈷" - color: Color.lock.placeholder + text: root.fingerprintUnavailable ? "󰺱" : "󰈷" + color: root.fingerprintUnavailable ? Color.lock.textError : Color.lock.placeholder font.family: Style.font.family font.pixelSize: Math.round(root.fieldFontSize * 1.1) horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } } + + // The crossed-out icon has no meaning to a user who has never seen it — it + // is not intuitive that it signals a broken reader — so the words carry the + // explanation and the icon only reinforces it. + Text { + objectName: "fingerprintUnavailableNotice" + textFormat: Text.PlainText + anchors.top: inputField.bottom + anchors.topMargin: 18 + anchors.horizontalCenter: inputField.horizontalCenter + visible: root.fingerprintConfigured && root.fingerprintUnavailable + text: "Fingerprint reader unavailable" + color: Color.lock.textError + font.family: Style.font.family + font.pixelSize: Style.font.heading + font.italic: true + horizontalAlignment: Text.AlignHCenter + } } } diff --git a/shell/plugins/lock/Service.qml b/shell/plugins/lock/Service.qml index 845e1d1e..41336479 100644 --- a/shell/plugins/lock/Service.qml +++ b/shell/plugins/lock/Service.qml @@ -4,6 +4,7 @@ import Quickshell.Io import Quickshell.Services.Pam import Quickshell.Wayland import qs.Commons +import "FingerprintModel.js" as FingerprintModel Item { id: root @@ -22,6 +23,14 @@ Item { property bool fingerprintAuthenticating: false property bool passwordPamConfigured: false property bool fingerprintConfigured: false + property int fingerprintUnreachedStreak: 0 + property bool fingerprintAttemptReachedDevice: false + property bool fingerprintAttemptFastError: false + property double fingerprintAttemptPromptedAtMs: 0 + property double fingerprintLastNudgeMs: 0 + property double fingerprintLastSettleMs: 0 + property double fingerprintResumedAtMs: 0 + property int fingerprintProbeStreak: 0 property bool previewVisible: false property string enteredPassword: "" property string pendingPassword: "" @@ -50,6 +59,8 @@ Item { readonly property bool authenticating: authenticatingPassword || fingerprintAuthenticating readonly property var batteryService: shell && shell.services ? shell.firstPartyServiceFor("omarchy.battery") : null readonly property bool powerSaverActive: batteryService ? batteryService.powerSaverOnBattery : false + // A prompt clears the unavailable notice before the attempt finishes. + readonly property bool fingerprintUnavailable: FingerprintModel.isUnavailable(fingerprintProbeStreak) || (fingerprintConfigured && (!fingerprintAttemptReachedDevice || fingerprintAttemptFastError) && FingerprintModel.isUnavailable(fingerprintUnreachedStreak)) function realScreenCount() { var screens = Quickshell.screens || [] @@ -131,6 +142,33 @@ Item { if (!fingerprintCheckProc.running) fingerprintCheckProc.running = true } + // Only definitive enrollment results may disable authentication. + function applyFingerprintProbe(text) { + var status = FingerprintModel.classifyProbe(text) + if (status === "unknown") { + if (fingerprintConfigured) return + fingerprintProbeStreak += 1 + if (lockRequested) { + fingerprintRecheckTimer.interval = FingerprintModel.retryDelayMs(fingerprintProbeStreak) + fingerprintRecheckTimer.restart() + } + return + } + fingerprintProbeStreak = 0 + fingerprintRecheckTimer.stop() + fingerprintConfigured = status === "yes" + if (lockRequested && fingerprintConfigured) { + // A pending retry already owns the next attempt. + if (!fingerprintRetryTimer.running) startFingerprint() + } else if (!fingerprintConfigured) { + // abort() delivers no completion signal, so close the attempt here + // too; settle returns before arming a retry while unconfigured. + if (fingerprintPam.active) fingerprintPam.abort() + settleFingerprintAttempt() + fingerprintRetryTimer.stop() + } + } + function logEvent(event) { lastEvent = event lastEventAt = new Date().toISOString() @@ -144,7 +182,16 @@ Item { failedAttempts = 0 authenticatingPassword = false fingerprintAuthenticating = false + fingerprintUnreachedStreak = 0 + fingerprintAttemptFastError = false + fingerprintAttemptPromptedAtMs = 0 + fingerprintLastNudgeMs = 0 + fingerprintLastSettleMs = 0 + fingerprintResumedAtMs = 0 + fingerprintProbeStreak = 0 + fingerprintRecheckTimer.stop() fingerprintRetryTimer.stop() + fingerprintReachTimer.stop() if (passwordPam.active) passwordPam.abort() if (fingerprintPam.active) fingerprintPam.abort() } @@ -193,6 +240,55 @@ Item { root.monitorDpmsKnown = false if (!wakeProcess.running) wakeProcess.running = true if (lockRequested) armBlankTimer() + nudgeFingerprint() + } + + // User activity advances retries without recreating a busy retry loop. + function nudgeFingerprint() { + if (!lockRequested) return + if (!fingerprintConfigured && fingerprintRecheckTimer.running) { + var now = Date.now() + if (FingerprintModel.shouldNudge(now, fingerprintLastNudgeMs, now - FingerprintModel.IDLE_CLEAR_MS, fingerprintRecheckTimer.interval)) { + fingerprintLastNudgeMs = now + fingerprintRecheckTimer.interval = FingerprintModel.MATCH_RETRY_MS + fingerprintRecheckTimer.restart() + } + return + } + if (!fingerprintConfigured) return + if (fingerprintPam.active || fingerprintAuthenticating) return + if (!fingerprintRetryTimer.running) return + var now = Date.now() + if (!FingerprintModel.shouldNudge(now, fingerprintLastNudgeMs, fingerprintLastSettleMs, fingerprintRetryTimer.interval)) return + fingerprintLastNudgeMs = now + armFingerprintRetry(FingerprintModel.MATCH_RETRY_MS) + } + + + function armFingerprintRetry(delayMs) { + fingerprintRetryTimer.interval = delayMs + fingerprintRetryTimer.restart() + } + + // Reset pre-sleep failures while the resume hook restarts fprintd. + function noteFingerprintResumed() { + var now = Date.now() + if (FingerprintModel.inResumeGrace(now, fingerprintResumedAtMs)) return + logEvent("fingerprint-resume: streak=" + fingerprintUnreachedStreak) + fingerprintResumedAtMs = now + fingerprintUnreachedStreak = 0 + } + + // A suspended PAM conversation may be orphaned by the daemon restart. + function restartFingerprintAfterSleep() { + noteFingerprintResumed() + if (fingerprintAuthenticating || fingerprintPam.active) { + if (fingerprintPam.active) fingerprintPam.abort() + settleFingerprintAttempt() + return + } + if (!fingerprintRetryTimer.running) return + armFingerprintRetry(FingerprintModel.MATCH_RETRY_MS) } function runBlank() { @@ -263,19 +359,75 @@ Item { if (fingerprintPam.active || fingerprintAuthenticating) return fingerprintAuthenticating = true + fingerprintAttemptReachedDevice = false + fingerprintAttemptFastError = false + fingerprintAttemptPromptedAtMs = 0 if (!fingerprintPam.start()) { - fingerprintAuthenticating = false + // Pace a failed start while checking whether its PAM configuration was + // removed; a definitive "no" stops retries and hides the indicator. + settleFingerprintAttempt() + refreshFingerprintStatus() + return } + // Bound claims that never prompt; a normal verify waits for a finger + // under pam_fprintd's own timeout after reaching the reader. + fingerprintReachTimer.restart() + } + + // A prompt proves the claim landed, so stop waiting for reachability. + function noteFingerprintReachedDevice() { + if (fingerprintAttemptReachedDevice) return + fingerprintAttemptReachedDevice = true + fingerprintAttemptPromptedAtMs = Date.now() + fingerprintReachTimer.stop() + } + + // abort() gives no completion signal; settle the attempt here. + function timeoutFingerprintReach() { + logEvent("fingerprint-reach-timeout") + if (fingerprintPam.active) fingerprintPam.abort() + settleFingerprintAttempt() + } + + // onError and onCompleted can both fire; settle each attempt once. + function settleFingerprintAttempt(deviceError) { + if (!fingerprintAuthenticating) return + fingerprintAuthenticating = false + fingerprintReachTimer.stop() + if (!lockRequested || !fingerprintConfigured) return + + // An error can arrive before the sleep watcher notices the wall-clock gap. + var now = Date.now() + fingerprintAttemptFastError = !!deviceError && fingerprintAttemptReachedDevice && now - fingerprintAttemptPromptedAtMs < FingerprintModel.FAST_ERROR_MS + var usableAttempt = fingerprintAttemptReachedDevice && !fingerprintAttemptFastError + if (!usableAttempt && fingerprintSleepWatch.running + && FingerprintModel.spannedSleep(now - fingerprintSleepWatch.lastTickMs, fingerprintSleepWatch.interval)) { + noteFingerprintResumed() + } + + // Reached attempts are the steady state (one per swipe window), so only + // the misses and the recovery from them leave a trace. + var previousStreak = fingerprintUnreachedStreak + var inGrace = FingerprintModel.inResumeGrace(now, fingerprintResumedAtMs) + fingerprintUnreachedStreak = FingerprintModel.nextStreak(previousStreak, usableAttempt, inGrace) + if (!usableAttempt) { + var crossed = !FingerprintModel.isUnavailable(previousStreak) && FingerprintModel.isUnavailable(fingerprintUnreachedStreak) + logEvent((crossed ? "fingerprint-unavailable" : "fingerprint-unreached") + ": streak=" + fingerprintUnreachedStreak) + } else if (previousStreak > 0) { + logEvent("fingerprint-recovered: streak=" + previousStreak) + } + fingerprintLastSettleMs = now + armFingerprintRetry(FingerprintModel.retryDelayMs(fingerprintUnreachedStreak)) } function handleFingerprintFinished(result) { - fingerprintAuthenticating = false - - if (!lockRequested) return - if (result === PamResult.Success) { + if (result === PamResult.Success && lockRequested) { + // A match after a run of misses is the recovery too; the unlock resets + // the streak without settling, so log it here or it leaves no trace. + if (fingerprintUnreachedStreak > 0) logEvent("fingerprint-recovered: streak=" + fingerprintUnreachedStreak) finishUnlock() - } else if (fingerprintConfigured) { - fingerprintRetryTimer.restart() + } else { + settleFingerprintAttempt(result === PamResult.Error) } } @@ -323,7 +475,8 @@ Item { backgroundPath: root.backgroundPath videoPosterPath: root.videoPosterPath backgroundVersion: root.backgroundVersion - fingerprintConfigured: root.fingerprintConfigured + fingerprintConfigured: root.fingerprintConfigured || root.fingerprintUnavailable + fingerprintUnavailable: root.fingerprintUnavailable authenticatingPassword: root.authenticatingPassword failureMessage: root.failureMessage failedAttempts: root.failedAttempts @@ -356,7 +509,8 @@ Item { backgroundPath: root.backgroundPath videoPosterPath: root.videoPosterPath backgroundVersion: root.backgroundVersion - fingerprintConfigured: root.fingerprintConfigured + fingerprintConfigured: root.fingerprintConfigured || root.fingerprintUnavailable + fingerprintUnavailable: root.fingerprintUnavailable authenticatingPassword: false failureMessage: "" failedAttempts: 0 @@ -400,13 +554,16 @@ Item { config: "omarchy-lock-fingerprint" user: root.userName + onPamMessage: { + if (!messageIsError) root.noteFingerprintReachedDevice() + } + onCompleted: function(result) { root.handleFingerprintFinished(result) } onError: function(error) { - root.fingerprintAuthenticating = false - if (root.lockRequested && root.fingerprintConfigured) fingerprintRetryTimer.restart() + root.settleFingerprintAttempt(true) } } @@ -438,11 +595,34 @@ Item { Timer { id: fingerprintRetryTimer - interval: 250 + interval: FingerprintModel.MATCH_RETRY_MS repeat: false onTriggered: root.startFingerprint() } + // Detect resume both during an active attempt and during backoff. + Timer { + id: fingerprintSleepWatch + interval: 1000 + repeat: true + running: root.lockRequested && root.fingerprintConfigured + property double lastTickMs: 0 + onRunningChanged: lastTickMs = Date.now() + onTriggered: { + var now = Date.now() + var slept = FingerprintModel.spannedSleep(now - lastTickMs, interval) + lastTickMs = now + if (slept) root.restartFingerprintAfterSleep() + } + } + + Timer { + id: fingerprintReachTimer + interval: FingerprintModel.REACH_TIMEOUT_MS + repeat: false + onTriggered: root.timeoutFingerprintReach() + } + Process { id: readlinkProc command: ["bash", "-c", "path=$(readlink -f -- \"$1\") && printf '%s\\n%s\\n' \"$path\" \"$(stat -Lc %Y:%s -- \"$path\" 2>/dev/null)\"", "_", root.currentBackgroundLink] @@ -480,15 +660,21 @@ Item { } } + // Keep fprintd errors distinguishable from an explicit empty enrollment. Process { id: fingerprintCheckProc - command: ["bash", "-c", "if [[ -f /etc/pam.d/omarchy-lock-fingerprint ]] && command -v fprintd-list >/dev/null 2>&1 && fprintd-list \"$USER\" 2>/dev/null | grep -qi finger; then echo yes; else echo no; fi"] + command: ["bash", "-c", "if [[ -f /etc/pam.d/omarchy-lock-fingerprint ]] && command -v fprintd-list >/dev/null 2>&1; then LC_ALL=C fprintd-list \"$USER\" 2>&1; else echo no; fi"] stdout: StdioCollector { id: fingerprintCheckStdout; waitForEnd: true } - onExited: { - root.fingerprintConfigured = String(fingerprintCheckStdout.text || "").trim() === "yes" - if (root.lockRequested && root.fingerprintConfigured) root.startFingerprint() - else if (!root.fingerprintConfigured && fingerprintPam.active) fingerprintPam.abort() - } + onExited: root.applyFingerprintProbe(fingerprintCheckStdout.text) + } + + // Retries a probe that could not reach fprintd, paced like the attempt + // retries so a daemon that stays unreachable is asked about ever less often. + Timer { + id: fingerprintRecheckTimer + interval: FingerprintModel.ERROR_RETRY_BASE_MS + repeat: false + onTriggered: root.refreshFingerprintStatus() } Process { @@ -664,6 +850,7 @@ Item { realScreens: root.realScreenCount(), passwordPam: root.passwordPamConfigured, fingerprint: root.fingerprintConfigured, + fingerprintUnavailable: root.fingerprintUnavailable, authenticating: root.authenticating, lastEvent: root.lastEvent, lastEventAt: root.lastEventAt diff --git a/test/shell.d/apply-lock-test.sh b/test/shell.d/apply-lock-test.sh index 490e7c8c..b3cb03ee 100644 --- a/test/shell.d/apply-lock-test.sh +++ b/test/shell.d/apply-lock-test.sh @@ -66,12 +66,16 @@ patched_helper="$test_tmp/omarchy-apply-lock-patched" absolute_only_helper="$test_tmp/omarchy-apply-lock-absolute-only" root_path_only_helper="$test_tmp/omarchy-apply-lock-root-path-only" unprotected_helper="$test_tmp/omarchy-apply-lock-unprotected" +hook_source="$test_tmp/fprintd-resume-source" +cp "$ROOT/default/systemd/system-sleep/fprintd-resume" "$hook_source" +timeout_source="$test_tmp/fprintd-stop-timeout-source" +cp "$ROOT/default/systemd/system/fprintd.service.d/10-stop-timeout.conf" "$timeout_source" target_user=omarchy-regression-user mkdir -p "$poison_bin" "$trusted_root_bin" # The runtime copy pins to this isolated root path. It contains every bare # command the exercised helper needs, but deliberately no fprintd-list. -for helper in grep rm tee; do +for helper in grep rm tee install rmdir dirname; do ln -s "/usr/bin/$helper" "$trusted_root_bin/$helper" done @@ -85,7 +89,8 @@ cat >"$trusted_fprintd" <<'EOF' printf '%s\n' "$EUID" >"$TEST_TRUSTED_UID" printf '%s\n' "$*" >"$TEST_TRUSTED_ARGS" -echo "Fingerprints are enrolled" +printf '%s\n' "${TEST_FPRINTD_OUTPUT:- - #0: right-index-finger}" +exit "${TEST_FPRINTD_STATUS:-0}" EOF cat >"$poison_bin/fprintd-list" <<'EOF' @@ -93,7 +98,8 @@ cat >"$poison_bin/fprintd-list" <<'EOF' printf '%s\n' "$EUID" >"$TEST_ATTACK_MARKER" printf '%s\n' "$*" >"$TEST_ATTACK_ARGS" -echo "Fingerprints are enrolled" +printf '%s\n' "${TEST_FPRINTD_OUTPUT:- - #0: right-index-finger}" +exit "${TEST_FPRINTD_STATUS:-0}" EOF chmod +x "$trusted_fprintd" "$poison_bin/fprintd-list" @@ -107,7 +113,11 @@ prepare_helper() { -v trusted_root_bin="$trusted_root_bin" \ -v trusted_fprintd="$trusted_fprintd" \ -v keep_root_path="$keep_root_path" \ - -v use_absolute_fprintd="$use_absolute_fprintd" ' + -v use_absolute_fprintd="$use_absolute_fprintd" \ + -v hook_src="$hook_source" \ + -v hook_dst="$test_tmp/system-sleep/fprintd-resume" \ + -v timeout_src="$timeout_source" \ + -v timeout_dst="$test_tmp/fprintd.service.d/10-stop-timeout.conf" ' { line = $0 gsub("/etc/pam\\.d/omarchy-lock-password", "\"" password_pam "\"", line) @@ -121,22 +131,42 @@ prepare_helper() { print " export PATH=\"" trusted_root_bin "\"" next } - if (line == "if [[ -x /usr/bin/fprintd-list ]] &&") { + if (line == "if [[ -x /usr/bin/fprintd-list ]]; then") { if (use_absolute_fprintd == 1) { - print "if [[ -x \"" trusted_fprintd "\" ]] &&" + print "if [[ -x \"" trusted_fprintd "\" ]]; then" } else { - print "if command -v fprintd-list >/dev/null 2>&1 &&" + print "if command -v fprintd-list >/dev/null 2>&1; then" } next } - if (line == " /usr/bin/fprintd-list \"$target_user\" 2>/dev/null | grep -qi finger; then") { + if (line == " fingerprint_status=$(/usr/bin/fprintd-list \"$target_user\" 2>&1 || true)") { if (use_absolute_fprintd == 1) { - print " \"" trusted_fprintd "\" \"$target_user\" 2>/dev/null | grep -qi finger; then" + print " fingerprint_status=$(\"" trusted_fprintd "\" \"$target_user\" 2>&1 || true)" } else { - print " fprintd-list \"$target_user\" 2>/dev/null | grep -qi finger; then" + print " fingerprint_status=$(fprintd-list \"$target_user\" 2>&1 || true)" } next } + if (line == "resume_hook_src=\"$OMARCHY_PATH/default/systemd/system-sleep/fprintd-resume\"") { + print "resume_hook_src=\"" hook_src "\"" + next + } + if (line == "resume_hook_dst=/usr/lib/systemd/system-sleep/fprintd-resume") { + print "resume_hook_dst=\"" hook_dst "\"" + next + } + if (line == "stop_timeout_src=\"$OMARCHY_PATH/default/systemd/system/fprintd.service.d/10-stop-timeout.conf\"") { + print "stop_timeout_src=\"" timeout_src "\"" + next + } + if (line == "stop_timeout_dst=/etc/systemd/system/fprintd.service.d/10-stop-timeout.conf") { + print "stop_timeout_dst=\"" timeout_dst "\"" + next + } + if (line == " as_root systemctl daemon-reload" || line == " as_root systemctl daemon-reload") { + print " :" + next + } if (line == "if omarchy-shell lock status >/dev/null 2>&1; then") { print "if false; then" next @@ -156,6 +186,9 @@ prepare_helper "$unprotected_helper" 0 0 for helper in "$patched_helper" "$absolute_only_helper" "$root_path_only_helper" "$unprotected_helper"; do if grep -F '/etc/pam.d/' "$helper" >/dev/null || grep -F '/usr/bin/fprintd-list' "$helper" >/dev/null || + grep -F '/usr/lib/systemd/system-sleep' "$helper" >/dev/null || + grep -F '/etc/systemd/system/' "$helper" >/dev/null || + grep -F 'systemctl daemon-reload' "$helper" >/dev/null || grep -F 'omarchy-shell lock status' "$helper" >/dev/null; then fail "the isolated root fixture redirects every live-system lock-helper target" fi @@ -181,6 +214,10 @@ grep -Fx '0' "$trusted_uid" >/dev/null || fail "the trusted fprintd-list probe r grep -Fx "$target_user" "$trusted_args" >/dev/null || fail "the trusted fprintd-list probe receives the target user" [[ -s $password_pam && -s $fingerprint_pam ]] || fail "the isolated root lock-helper run writes both scratch PAM fixtures" +[[ -x $test_tmp/system-sleep/fprintd-resume ]] || + fail "the lock helper installs the resume hook beside the fingerprint PAM file" +[[ -f $test_tmp/fprintd.service.d/10-stop-timeout.conf ]] || + fail "the lock helper installs the stop-timeout drop-in beside the resume hook" pass "the hardened root lock helper uses the trusted fingerprint probe" reset_runtime_files @@ -204,3 +241,43 @@ grep -Fx "$target_user" "$attack_args" >/dev/null || fail "the planted fprintd-list receives the target user" [[ -s $fingerprint_pam ]] || fail "the planted fprintd-list controls the fingerprint PAM branch" pass "the root lock-helper matrix rejects the vulnerable PATH lookup" + +# A failed probe must retain the recovery machinery needed by a later resume. +run_as_root "$patched_helper" "restore the enrolled fingerprint fixture" +cp "$fingerprint_pam" "$test_tmp/saved-pam" +cp "$test_tmp/system-sleep/fprintd-resume" "$test_tmp/saved-hook" +cp "$test_tmp/fprintd.service.d/10-stop-timeout.conf" "$test_tmp/saved-timeout" +TEST_FPRINTD_OUTPUT="Impossible to get devices: Could not activate remote peer" TEST_FPRINTD_STATUS=1 \ + run_as_root "$patched_helper" "an unavailable daemon preserves fingerprint authentication" +for pair in "$fingerprint_pam:saved-pam" "$test_tmp/system-sleep/fprintd-resume:saved-hook" "$test_tmp/fprintd.service.d/10-stop-timeout.conf:saved-timeout"; do + cmp -s "${pair%:*}" "$test_tmp/${pair##*:}" || fail "an unknown enrollment probe preserves existing recovery files" +done +pass "an unknown enrollment probe preserves existing recovery files" + +# The device name and empty-enrollment prose both contain the word "finger". +TEST_FPRINTD_OUTPUT="User alice has no fingers enrolled for Goodix MOC Fingerprint Sensor." \ + run_as_root "$patched_helper" "an empty enrollment clears fingerprint authentication" +[[ -s $password_pam ]] || fail "password authentication remains configured without fingerprints" +[[ ! -e $fingerprint_pam && ! -e $test_tmp/system-sleep/fprintd-resume && ! -e $test_tmp/fprintd.service.d/10-stop-timeout.conf ]] || + fail "empty enrollment removes fingerprint PAM and resume recovery files" +pass "empty enrollment removes fingerprint authentication and its resume recovery" + +# Never enable fingerprint PAM before both recovery files are installed. +for source in "$hook_source" "$timeout_source"; do + reset_runtime_files + mv "$source" "$source.saved" + if PATH="$poison_bin:/usr/bin:/bin" OMARCHY_INSTALL_USER="$target_user" \ + "${root_runner[@]}" /bin/bash "$patched_helper" >"$test_tmp/failed-install" 2>&1; then + fail "missing recovery source must fail installation" + fi + [[ ! -e $fingerprint_pam ]] || fail "failed recovery installation cannot create fingerprint PAM" + mv "$source.saved" "$source" +done +pass "failed recovery installation cannot create fingerprint PAM" + +run_as_root "$patched_helper" "restore enrollment before removing fprintd-list" +rm -f "$trusted_fprintd" +run_as_root "$patched_helper" "a missing fingerprint package clears fingerprint authentication" +[[ ! -e $fingerprint_pam && ! -e $test_tmp/system-sleep/fprintd-resume && ! -e $test_tmp/fprintd.service.d/10-stop-timeout.conf ]] || + fail "a missing fprintd-list removes fingerprint PAM and resume recovery files" +pass "a missing fprintd-list removes fingerprint authentication and its resume recovery" diff --git a/test/shell.d/fingerprint-package-test.sh b/test/shell.d/fingerprint-package-test.sh index efc14fcc..cf67e347 100755 --- a/test/shell.d/fingerprint-package-test.sh +++ b/test/shell.d/fingerprint-package-test.sh @@ -23,6 +23,8 @@ cat > "$scratch/bin/sudo" <<'STUB' #!/bin/bash case "$1" in pacman | fprintd-enroll) exec "$@" ;; + sed) printf 'pam %s\n' "$*" >> "$CALL_LOG" ;; + tee) printf 'pam %s\n' "$*" >> "$CALL_LOG"; cat >/dev/null ;; *) echo "Unexpected privileged call: $*" >> "$CALL_LOG"; exit 99 ;; esac STUB @@ -49,22 +51,36 @@ cat > "$scratch/bin/fprintd-enroll" <<'STUB' #!/bin/bash # Stop before verification/PAM; no host authentication files may be changed. echo enroll >> "$CALL_LOG" -exit 1 +exit "${ENROLL_STATUS:-1}" STUB cat > "$scratch/bin/fprintd-verify" <<'STUB' #!/bin/bash echo verify >> "$CALL_LOG" -exit 1 +exit "${VERIFY_STATUS:-1}" STUB chmod +x "$scratch/bin/"* +cat > "$scratch/bin/omarchy-apply-lock" <<'STUB' +#!/bin/bash +echo apply-lock >> "$CALL_LOG" +if [[ ${LOCK_SETUP_UNKNOWN:-0} != "1" ]]; then + touch "$TEST_LOCK_PAM" +fi +STUB +chmod +x "$scratch/bin/omarchy-apply-lock" + +export TEST_LOCK_PAM="$scratch/omarchy-lock-fingerprint" +setup_script="$scratch/omarchy-setup-security-fingerprint" +sed "s|/etc/pam.d/omarchy-lock-fingerprint|$TEST_LOCK_PAM|g" "$ROOT/bin/omarchy-setup-security-fingerprint" > "$setup_script" +chmod +x "$setup_script" + run_setup() { : > "$CALL_LOG" - if "$ROOT/bin/omarchy-setup-security-fingerprint" > "$scratch/output" 2>&1; then + if OMARCHY_PATH="$scratch" "$setup_script" > "$scratch/output" 2>&1; then fail "setup stops on the simulated enrollment or installation failure" fi - if grep -q 'Unexpected privileged call' "$CALL_LOG"; then - fail "setup does not change PAM after failed enrollment" + if grep -Eq '^(pam |apply-lock$|Unexpected privileged call)' "$CALL_LOG"; then + fail "setup does not change PAM or lock recovery after failed enrollment" fi } @@ -98,3 +114,22 @@ pass "a failed installation stops before enrollment" HARDWARE_STATUS=1 run_setup [[ ! -s $CALL_LOG ]] || fail "missing hardware stops before package operations" pass "missing hardware performs no package operations" + +# Successful setup must reuse the same lock/recovery installer as updates. +: > "$CALL_LOG" +OMARCHY_PATH="$scratch" ENROLL_STATUS=0 VERIFY_STATUS=0 \ + "$setup_script" > "$scratch/output" 2>&1 || fail "successful enrollment configures authentication" +[[ $(grep -E '^(enroll|verify|apply-lock)$' "$CALL_LOG") == $'enroll\nverify\napply-lock' ]] || + fail "setup configures lock recovery once, after enrollment and verification" +pass "setup reuses apply-lock after enrollment and verification" + +rm -f "$TEST_LOCK_PAM" +if OMARCHY_PATH="$scratch" ENROLL_STATUS=0 VERIFY_STATUS=0 LOCK_SETUP_UNKNOWN=1 \ + "$setup_script" > "$scratch/output" 2>&1; then + fail "an inconclusive lock installer cannot report successful lock setup" +fi +grep -q 'lock-screen configuration could not be confirmed' "$scratch/output" || fail "inconclusive setup explains how to retry" +if grep -q 'Perfect!\|You can use your fingerprint' "$scratch/output"; then + fail "inconclusive setup does not promise fingerprint unlock" +fi +pass "an inconclusive lock installer cannot report successful lock setup" diff --git a/test/shell.d/fixtures/lock-fingerprint-indicator/shell.qml b/test/shell.d/fixtures/lock-fingerprint-indicator/shell.qml index 1a5f92fe..dc016133 100644 --- a/test/shell.d/fixtures/lock-fingerprint-indicator/shell.qml +++ b/test/shell.d/fixtures/lock-fingerprint-indicator/shell.qml @@ -84,6 +84,27 @@ ShellRoot { view.fingerprintConfigured = false root.assertTrue(view.fingerprintReserve === 0, "no space is reserved when no sensor is configured") + + // An unreachable reader must announce itself instead of silently + // inviting touches that can never unlock. + view.fingerprintConfigured = true + var notice = findByObjectName(view, "fingerprintUnavailableNotice") + root.assertTrue(notice !== null, "unavailable notice exists in the lock view") + + if (notice) { + var workingGlyph = indicator.text + root.assertTrue(!notice.visible, "notice stays hidden while the reader works") + + view.fingerprintUnavailable = true + root.assertTrue(notice.visible, "notice appears when the reader is unavailable") + root.assertTrue(indicator.visible, "icon stays visible when the reader is unavailable") + root.assertTrue(indicator.text !== workingGlyph, "icon crosses out when the reader is unavailable") + root.assertTrue(view.fingerprintReserve > 0, "space stays reserved for the crossed-out icon") + + view.fingerprintUnavailable = false + root.assertTrue(!notice.visible, "notice clears when the reader recovers") + root.assertTrue(indicator.text === workingGlyph, "icon restores when the reader recovers") + } } view.destroy() diff --git a/test/shell.d/fixtures/lock-fingerprint-service/shell.qml b/test/shell.d/fixtures/lock-fingerprint-service/shell.qml new file mode 100644 index 00000000..298cf040 --- /dev/null +++ b/test/shell.d/fixtures/lock-fingerprint-service/shell.qml @@ -0,0 +1,178 @@ +import QtQuick +import Quickshell +import Quickshell.Services.Pam +import qs.Commons + +ShellRoot { + id: root + + readonly property string rootPath: Quickshell.env("OMARCHY_PATH") + readonly property string resultPath: Quickshell.env("OMARCHY_QML_TEST_RESULT") + property var failures: [] + property int checks: 0 + + Item { id: host } + + function check(condition, message) { + checks += 1 + if (!condition) failures.push(message) + } + + function timer(service, interval, repeat) { + var objects = service.data || [] + for (var i = 0; i < objects.length; i++) { + if (objects[i].interval === interval && objects[i].repeat === repeat) return objects[i] + } + throw new Error("Missing timer: " + interval + ", repeat=" + repeat) + } + + function run() { + var component = Qt.createComponent("file://" + rootPath + "/shell/plugins/lock/Service.qml", Component.PreferSynchronous) + if (component.status !== Component.Ready) throw new Error(component.errorString()) + var service = component.createObject(host, { omarchyPath: rootPath }) + if (!service) throw new Error(component.errorString()) + try { + var retry = timer(service, 250, false) + var reach = timer(service, 20000, false) + var recheck = timer(service, 1000, false) + service.lockRequested = true + + service.applyFingerprintProbe("Could not activate remote peer") + check(!service.fingerprintConfigured, "unknown cannot invent an enrollment") + check(recheck.running && recheck.interval === 1000, "unknown schedules a paced probe") + + service.applyFingerprintProbe("Could not activate remote peer") + service.applyFingerprintProbe("No devices available") + check(!service.fingerprintConfigured && service.fingerprintUnavailable, "an initial outage shows unavailable without inventing enrollment") + service.nudgeFingerprint() + check(recheck.interval === 250, "input promptly retries an initial unknown probe") + + service.applyFingerprintProbe("Fingerprints for user test on Goodix:\n - #0: right-index-finger") + check(service.fingerprintConfigured, "a real enrollment row enables fingerprint") + check(!service.fingerprintUnavailable && !recheck.running, "a definitive enrollment clears probe notice and recheck") + service.applyFingerprintProbe("ListEnrolledFingers failed: Timeout was reached") + check(service.fingerprintConfigured, "unknown preserves a known enrollment") + service.applyFingerprintProbe("No devices available") + service.applyFingerprintProbe("ListEnrolledFingers failed: Timeout was reached") + check(!service.fingerprintUnavailable && !recheck.running, "known enrollment uses PAM recovery without competing probe retries") + + service.fingerprintAuthenticating = true + retry.start() + reach.start() + service.applyFingerprintProbe("User test has no fingers enrolled for Goodix MOC Fingerprint Sensor.") + check(!service.fingerprintConfigured, "empty enrollment is not a fingerprint") + check(!retry.running, "empty enrollment stops authentication retries") + check(!service.fingerprintAuthenticating && !reach.running, "empty enrollment closes the attempt and reach timer") + + service.fingerprintConfigured = true + service.fingerprintAuthenticating = true + service.fingerprintAttemptReachedDevice = false + reach.start() + service.settleFingerprintAttempt() + check(service.fingerprintUnreachedStreak === 1, "an unreached attempt advances the streak") + check(retry.running && retry.interval === 1000, "an unreached attempt retries with backoff") + check(!reach.running, "settle cancels the reach timeout") + service.handleFingerprintFinished(PamResult.Error) + check(service.fingerprintUnreachedStreak === 1, "error and completion settle an attempt only once") + check(service.lockRequested, "a PAM error never unlocks") + + service.fingerprintUnreachedStreak = 3 + service.fingerprintLastNudgeMs = 0 + retry.interval = 8000 + service.nudgeFingerprint() + check(retry.interval === 250, "input advances a backed-off PAM retry") + retry.interval = 8000 + service.nudgeFingerprint() + check(retry.interval === 8000, "continuous input cannot collapse every retry") + check(service.fingerprintUnavailable, "repeated unreached attempts report unavailable") + service.fingerprintAuthenticating = true + service.fingerprintAttemptPromptedAtMs = Date.now() - 5000 + service.noteFingerprintReachedDevice() + check(Date.now() - service.fingerprintAttemptPromptedAtMs < 2000, "the prompt resets the fast-error clock after a slow claim") + check(!service.fingerprintUnavailable, "a prompt immediately clears the unavailable notice") + service.settleFingerprintAttempt() + check(service.fingerprintUnreachedStreak === 0 && retry.interval === 250, "a reached attempt clears backoff") + + for (var i = 0; i < 3; i++) { + service.fingerprintAuthenticating = true + service.fingerprintAttemptReachedDevice = true + service.fingerprintAttemptFastError = false + service.fingerprintAttemptPromptedAtMs = Date.now() + service.settleFingerprintAttempt(true) + service.handleFingerprintFinished(PamResult.Error) + } + check(service.fingerprintUnreachedStreak === 3 && retry.interval === 4000, "fast errors after prompting back off and settle once") + check(service.fingerprintUnavailable && service.lockRequested, "a prompted but failing reader is unavailable without unlocking") + + service.fingerprintAuthenticating = true + service.fingerprintAttemptPromptedAtMs = Date.now() - 31000 + service.noteFingerprintReachedDevice() + service.handleFingerprintFinished(PamResult.Error) + check(service.fingerprintUnreachedStreak === 0 && retry.interval === 250, "a timeout message cannot restart the fast-error clock") + + service.fingerprintUnreachedStreak = 3 + service.fingerprintAuthenticating = true + service.fingerprintAttemptPromptedAtMs = Date.now() + service.handleFingerprintFinished(PamResult.Failed) + check(service.fingerprintUnreachedStreak === 0 && retry.interval === 250, "a quick mismatch is not a device error") + + service.fingerprintAuthenticating = true + service.fingerprintAttemptPromptedAtMs = Date.now() + service.handleFingerprintFinished(PamResult.Error) + check(service.fingerprintUnreachedStreak === 1, "error completion backs off even without a preceding error signal") + service.fingerprintAuthenticating = true + service.fingerprintAttemptReachedDevice = false + service.fingerprintAttemptPromptedAtMs = Date.now() - 5000 + service.noteFingerprintReachedDevice() + service.handleFingerprintFinished(PamResult.Error) + check(service.fingerprintUnreachedStreak === 2, "a slow claim followed by a fast verification error still backs off") + + for (var i = 0; i < 8; i++) { + service.fingerprintAuthenticating = true + service.fingerprintAttemptReachedDevice = false + service.settleFingerprintAttempt() + } + check(retry.interval === 40000 && service.fingerprintUnavailable, "persistent misses reach the capped wait") + service.nudgeFingerprint() + check(retry.interval === 40000, "input at the cap preserves the daemon's idle window") + + service.fingerprintAuthenticating = true + service.restartFingerprintAfterSleep() + check(!service.fingerprintAuthenticating, "resume settles an in-flight attempt") + check(service.fingerprintUnreachedStreak === 1 && !service.fingerprintUnavailable, "resume grace suppresses an unavailable notice") + check(retry.running && retry.interval === 1000, "resume retries without retaining the capped wait") + check(service.lockRequested, "resume never unlocks") + + service.fingerprintAuthenticating = true + reach.start() + service.timeoutFingerprintReach() + check(!service.fingerprintAuthenticating && !reach.running, "the reach bound closes a stuck attempt") + check(service.lockRequested, "a timeout never unlocks") + + service.lockRequested = false + service.resetAuthenticationState() + check(!retry.running && !reach.running && !recheck.running, "unlock reset stops every fingerprint timer") + service.applyFingerprintProbe("Daemon is restarting") + check(!recheck.running, "an unknown probe outside the lock cannot restart it") + } finally { + service.lockRequested = false + service.resetAuthenticationState() + service.destroy() + } + } + + Timer { + interval: 1 + running: true + onTriggered: { + try { + root.run() + } catch (error) { + root.failures.push(String(error)) + } + var payload = JSON.stringify({ ok: root.failures.length === 0, checks: root.checks, failures: root.failures }) + var quoted = "'" + payload.replace(/'/g, "'\\''") + "'" + Quickshell.execDetached(["bash", "-c", "printf '%s' " + quoted + " > \"$1\"", "_", root.resultPath]) + } + } +} diff --git a/test/shell.d/fprintd-resume-hook-test.sh b/test/shell.d/fprintd-resume-hook-test.sh new file mode 100755 index 00000000..6a9c6a87 --- /dev/null +++ b/test/shell.d/fprintd-resume-hook-test.sh @@ -0,0 +1,61 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +hook="$ROOT/default/systemd/system-sleep/fprintd-resume" + +# systemd execs the files in system-sleep/ directly, so a hook without the +# executable bit silently never runs. The installers set the mode explicitly, +# but the checkout should not ship a hook that cannot run as-is either. +[[ -x $hook ]] || + fail "the resume hook is executable" "mode: $(stat -c '%A' "$hook")" +pass "the resume hook is executable" + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +mock_bin="$tmpdir/bin" +call_log="$tmpdir/systemctl-calls" +mkdir -p "$mock_bin" + +cat >"$mock_bin/systemctl" <>"$call_log" +SH +chmod +x "$mock_bin/systemctl" + +run_hook() { + : >"$call_log" + PATH="$mock_bin:$PATH" "$hook" "$@" +} + +# Resume ("post") is the only edge that clears a claim wedged across suspend. +# The restart is enqueued, not awaited: user sessions stay frozen until the +# hook returns, and a wedged fprintd can ride out its whole stop timeout. +run_hook post suspend +[[ $(<"$call_log") == "--no-block try-restart fprintd.service" ]] || + fail "resume enqueues an fprintd restart to clear a wedged claim" "calls: $(<"$call_log")" +pass "resume enqueues an fprintd restart to clear a wedged claim" + +# Every resume path lands on "post" regardless of how the machine slept. +run_hook post hibernate +[[ $(<"$call_log") == "--no-block try-restart fprintd.service" ]] || + fail "resume from hibernate also restarts fprintd" "calls: $(<"$call_log")" +pass "resume from hibernate also restarts fprintd" + +# The drop-in installed beside the hook is what keeps a SIGTERM-ignoring +# fprintd from turning that restart into a multi-second dead reader. +dropin="$ROOT/default/systemd/system/fprintd.service.d/10-stop-timeout.conf" +grep -Eq '^TimeoutStopSec=[0-9]+s?$' "$dropin" || + fail "the stop-timeout drop-in bounds TimeoutStopSec" "content: $(<"$dropin")" +pass "the stop-timeout drop-in bounds TimeoutStopSec" + +# The claim only wedges once the verify dies under suspend, so there is +# nothing to clear before sleep; a restart there would just take the reader +# away from the verify the lock screen still has open. "pre" must do nothing. +run_hook pre suspend +[[ -z $(<"$call_log") ]] || + fail "pre-suspend leaves fprintd alone" "calls: $(<"$call_log")" +pass "pre-suspend leaves fprintd alone" diff --git a/test/shell.d/fprintd-resume-migration-test.sh b/test/shell.d/fprintd-resume-migration-test.sh new file mode 100755 index 00000000..cbcfb469 --- /dev/null +++ b/test/shell.d/fprintd-resume-migration-test.sh @@ -0,0 +1,112 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +migration=$(grep -rl 'Install the fingerprint resume hook on existing fingerprint setups' "$ROOT/migrations" | head -n 1 || true) +[[ -n $migration ]] || fail "fprintd resume hook migration exists" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +# The migration installs to a system path via sudo; stub it so the test writes +# into a temp tree instead of /usr. +stub_bin="$TMPDIR/bin" +mkdir -p "$stub_bin" +cat >"$stub_bin/sudo" <<'STUB' +#!/bin/bash +exec "$@" +STUB +chmod +x "$stub_bin/sudo" +reload_log="$TMPDIR/systemctl-calls" +cat >"$stub_bin/systemctl" <>"$reload_log" +STUB +chmod +x "$stub_bin/systemctl" + +src="$TMPDIR/fprintd-resume" +printf '#!/bin/bash\n' >"$src" +chmod +x "$src" +dst="$TMPDIR/system-sleep/fprintd-resume" +dropin_src="$TMPDIR/10-stop-timeout.conf" +printf '[Service]\nTimeoutStopSec=3s\n' >"$dropin_src" +dropin_dst="$TMPDIR/fprintd.service.d/10-stop-timeout.conf" +lock_pam="$TMPDIR/omarchy-lock-fingerprint" + +# omarchy-migrate runs each migration with `bash -euo pipefail`; match it. +run_migration() { + PATH="$stub_bin:$PATH" \ + OMARCHY_FPRINTD_RESUME_SRC="$src" \ + OMARCHY_FPRINTD_RESUME_DST="$dst" \ + OMARCHY_FPRINTD_STOP_TIMEOUT_SRC="$dropin_src" \ + OMARCHY_FPRINTD_STOP_TIMEOUT_DST="$dropin_dst" \ + OMARCHY_LOCK_FINGERPRINT_PAM="$lock_pam" \ + bash -euo pipefail "$migration" >/dev/null || + fail "migration exits clean" +} + +# The migration exits clean when its source is missing, so a hook moved +# without updating it would silently install nothing and mark itself done. +# Run once against the real default source under the repo to pin that path. +rm -rf "$TMPDIR/system-sleep" "$TMPDIR/fprintd.service.d" +: >"$lock_pam" +PATH="$stub_bin:$PATH" \ + OMARCHY_PATH="$ROOT" \ + OMARCHY_FPRINTD_RESUME_DST="$dst" \ + OMARCHY_FPRINTD_STOP_TIMEOUT_DST="$dropin_dst" \ + OMARCHY_LOCK_FINGERPRINT_PAM="$lock_pam" \ + bash -euo pipefail "$migration" >/dev/null || + fail "migration exits clean from its default sources" +[[ -x $dst ]] || fail "migration finds the hook at its default source path" "dst: $(stat -c '%A' "$dst" 2>/dev/null || echo missing)" +cmp -s "$dst" "$ROOT/default/systemd/system-sleep/fprintd-resume" || fail "migration installs the shipped hook from its default source" +cmp -s "$dropin_dst" "$ROOT/default/systemd/system/fprintd.service.d/10-stop-timeout.conf" || fail "migration installs the shipped drop-in from its default source" +pass "migration installs the shipped hook and drop-in from their default sources" + +# A machine with fingerprint configured but no hook yet gets it, executable, +# plus the stop-timeout drop-in, and systemd is told about the drop-in. +: >"$lock_pam" +rm -rf "$TMPDIR/system-sleep" "$TMPDIR/fprintd.service.d" +: >"$reload_log" +run_migration +[[ -x $dst ]] || fail "migration installs the hook, executable" "dst: $(stat -c '%A' "$dst" 2>/dev/null || echo missing)" +pass "migration installs the hook, executable" +[[ $(stat -c '%a' "$dropin_dst" 2>/dev/null) == "644" ]] || fail "migration installs the stop-timeout drop-in" "dst: $(stat -c '%A' "$dropin_dst" 2>/dev/null || echo missing)" +grep -qx "daemon-reload" "$reload_log" || fail "migration reloads systemd after installing the drop-in" "calls: $(<"$reload_log")" +pass "migration installs the stop-timeout drop-in and reloads systemd" + +# Running twice must not fail (both now exist), must not touch them, and has +# nothing to reload. +printf 'sentinel\n' >>"$dst" +printf '# sentinel\n' >>"$dropin_dst" +: >"$reload_log" +run_migration +grep -q sentinel "$dst" || fail "migration leaves an existing hook alone" +grep -q sentinel "$dropin_dst" || fail "migration leaves an existing drop-in alone" +[[ ! -s $reload_log ]] || fail "migration does not reload systemd when nothing changed" "calls: $(<"$reload_log")" +pass "migration leaves existing files alone" + +# An unnumbered drop-in may belong to the administrator; never replace it. +legacy="$TMPDIR/fprintd.service.d/stop-timeout.conf" +rm -f "$dropin_dst"; printf '[Service]\nTimeoutStopSec=15s\n' >"$legacy" +cp "$legacy" "$TMPDIR/saved-admin-timeout" +: >"$reload_log" +run_migration +cmp -s "$legacy" "$TMPDIR/saved-admin-timeout" || fail "migration preserves the administrator's unnumbered drop-in" +[[ -f $dropin_dst ]] || fail "migration installs the numbered drop-in alongside the administrator's file" +grep -qx "daemon-reload" "$reload_log" || fail "migration reloads systemd after installing the numbered drop-in" +pass "migration preserves the administrator's unnumbered drop-in" + +# The drop-in is installed on its own where only the hook is already present. +rm -rf "$TMPDIR/fprintd.service.d" +run_migration +[[ -f $dropin_dst ]] || fail "migration adds the drop-in beside an existing hook" +pass "migration adds the drop-in beside an existing hook" + +# No fingerprint configured -> nothing to fix, so nothing is installed. +rm -f "$lock_pam" +rm -rf "$TMPDIR/system-sleep" "$TMPDIR/fprintd.service.d" +run_migration +[[ ! -e $dst && ! -e $dropin_dst ]] || fail "migration skips machines without fingerprint configured" +pass "migration skips machines without fingerprint configured" diff --git a/test/shell.d/lock-fingerprint-retry-test.sh b/test/shell.d/lock-fingerprint-retry-test.sh new file mode 100755 index 00000000..eb662b4f --- /dev/null +++ b/test/shell.d/lock-fingerprint-retry-test.sh @@ -0,0 +1,182 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const model = requireFromRoot('shell/plugins/lock/FingerprintModel.js') + +// Pacing: a reached attempt (streak 0) retries fast; unreached attempts back +// off exponentially to a ceiling above fprintd's 30-second idle exit. +assertEqual(model.retryDelayMs(0), model.MATCH_RETRY_MS, 'a reached attempt retries at the fast interval') +assertEqual(model.retryDelayMs(1), model.ERROR_RETRY_BASE_MS, 'the first unreached attempt backs off') +assertEqual(model.retryDelayMs(2), model.ERROR_RETRY_BASE_MS * 2, 'consecutive unreached attempts double the delay') +assertEqual(model.retryDelayMs(3), model.ERROR_RETRY_BASE_MS * 4, 'the backoff keeps doubling') +assertEqual(model.retryDelayMs(50), model.ERROR_RETRY_CAP_MS, 'the backoff is capped') +assertEqual(model.FPRINTD_IDLE_EXIT_MS, 30000, "the model carries fprintd's 30-second idle exit") +assert(model.ERROR_RETRY_CAP_MS > model.FPRINTD_IDLE_EXIT_MS, "the cap exceeds fprintd's idle exit so a wedged claim can clear") +assert(model.IDLE_CLEAR_MS > model.FPRINTD_IDLE_EXIT_MS && model.IDLE_CLEAR_MS <= model.ERROR_RETRY_CAP_MS, + 'the idle stretch a nudge must leave at the cap covers the exit and fits inside the cap') + +let previous = 0 +let monotonic = true +for (let streak = 1; streak <= 20; streak++) { + const delay = model.retryDelayMs(streak) + if (delay < previous) monotonic = false + previous = delay +} +assert(monotonic, 'the backoff never shrinks while attempts keep failing to reach the reader') + +// The signal: reaching the device (a prompt) clears the streak; not reaching it +// advances it. This is what keeps a healthy-but-untouched reader off the notice +// while a wedged or held one climbs toward it. +assertEqual(model.nextStreak(0, true), 0, 'reaching the reader keeps the streak at zero') +assertEqual(model.nextStreak(5, true), 0, 'reaching the reader clears an accumulated streak') +assertEqual(model.nextStreak(0, false), 1, 'failing to reach the reader starts the streak') +assertEqual(model.nextStreak(2, false), 3, 'failing to reach the reader advances the streak') + +// Availability: reported unavailable only past a few consecutive misses, so a +// single transient claim conflict does not flash the notice. +assert(!model.isUnavailable(0), 'a working reader is not reported unavailable') +assert(!model.isUnavailable(model.UNAVAILABLE_AFTER - 1), 'a couple of misses do not report unavailable') +assert(model.isUnavailable(model.UNAVAILABLE_AFTER), 'enough consecutive misses report the reader unavailable') + +// End to end: repeatedly failing to reach the reader crosses the threshold, and +// the first attempt that reaches it clears both the streak and the notice. +let streak = 0 +for (let i = 0; i < model.UNAVAILABLE_AFTER; i++) streak = model.nextStreak(streak, false) +assert(model.isUnavailable(streak), 'a run of unreached attempts ends up unavailable') +streak = model.nextStreak(streak, true) +assert(!model.isUnavailable(streak), 'one reached attempt clears the unavailable state') + +// Presence nudge: it only collapses a backed-off wait, and only once per +// cooldown, so a moving cursor cannot respin the loop. +const backedOff = model.retryDelayMs(2) +assert(!model.shouldNudge(10000, 0, 0, model.MATCH_RETRY_MS), 'no nudge when the wait is already the fast interval') +assert(model.shouldNudge(10000, 0, 0, backedOff), 'a keypress collapses a backed-off wait') +assert(!model.shouldNudge(10000, 9000, 0, backedOff), 'a second nudge inside the cooldown is refused') +assert(model.shouldNudge(10000, 10000 - model.NUDGE_COOLDOWN_MS, 0, backedOff), 'a nudge one cooldown later is allowed') +assert(model.shouldNudge(10000, 11500, 0, backedOff), 'a clock stepped backwards does not refuse the nudge') +assert(model.shouldNudge(10000, 0, 11500, backedOff), 'a clock stepped backwards since the settle does not refuse it either') +assert(model.shouldNudge(10000, 0, 9990, backedOff), 'below the cap a fresh settle does not hold the nudge') + +// A cursor moving at 125 Hz raises a wake every 8 ms; over 10 s the floor caps +// the collapses at one per cooldown instead of one per event. +let last = -model.NUDGE_COOLDOWN_MS +let collapses = 0 +for (let t = 0; t <= 10000; t += 8) { + if (model.shouldNudge(t, last, 0, backedOff)) { collapses++; last = t } +} +assert(collapses <= 10000 / model.NUDGE_COOLDOWN_MS + 1, + 'a moving cursor is capped at one collapse per cooldown, got ' + collapses) + +// Once the wait has backed off past the cooldown, the tier paces the nudges: +// presence collapses each wait once, but typing at a reader that keeps failing +// cannot pull the loop under the tier's rate. +const capped = model.retryDelayMs(50) +const longAgo = -capped +assert(model.shouldNudge(capped, 0, longAgo, capped), 'a nudge one tier later is allowed') +assert(!model.shouldNudge(capped - 1, 0, longAgo, capped), 'a nudge inside the tier is refused') +last = -capped +collapses = 0 +for (let t = 0; t <= 4 * capped; t += 8) { + if (model.shouldNudge(t, last, longAgo, capped)) { collapses++; last = t } +} +assertEqual(collapses, 5, 'continuous input at the cap collapses once per tier') + +// At the cap a fresh settle holds the nudge until fprintd has had its idle +// stretch, then lets it through -- before the cap's own timer would fire. +assert(!model.shouldNudge(model.IDLE_CLEAR_MS - 1, longAgo, 0, capped), 'a nudge before the idle stretch is refused at the cap') +assert(model.shouldNudge(model.IDLE_CLEAR_MS, longAgo, 0, capped), 'a nudge after the idle stretch is allowed at the cap') +// A clock stepped back past the settle says nothing about how long fprintd +// has been idle, so at the cap it must not stand in for the idle stretch. +assert(!model.shouldNudge(10000, longAgo, 15000, capped), 'a clock stepped back past the settle does not bypass the idle guard at the cap') +assert(model.shouldNudge(10000, 15000, longAgo, capped), 'a clock stepped back past the nudge still allows the nudge at the cap once idle') + +// At the cap the idle stretch is measured from the settle: a nudged attempt +// that hangs until the reach timeout must not eat into fprintd's idle exit. +// Simulate continuous input at 125 Hz against a reader whose every attempt +// hangs for the full reach bound, and check the gap fprintd is left between +// one attempt ending and the next claiming. +let settle = 0 +let nudge = 0 +let attemptStart = model.MATCH_RETRY_MS +let minIdle = Infinity +let cycles = 0 +for (let t = attemptStart; cycles < 5; t += 8) { + const attemptEnd = attemptStart + model.REACH_TIMEOUT_MS + if (t < attemptEnd) continue + if (t === attemptEnd || settle < attemptStart) settle = attemptEnd + const timerFires = t >= settle + capped + if (timerFires || model.shouldNudge(t, nudge, settle, capped)) { + if (!timerFires) nudge = t + const claimAt = t + model.MATCH_RETRY_MS + minIdle = Math.min(minIdle, claimAt - settle) + attemptStart = claimAt + cycles++ + } +} +assert(minIdle >= model.FPRINTD_IDLE_EXIT_MS, + 'continuous input against attempts that hang to the reach bound still leaves fprintd its idle exit, got ' + minIdle + 'ms') +assert(minIdle < capped, + 'the nudge still shortens the wait at the cap rather than being dead there, got ' + minIdle + 'ms') + +// Resume grace: right after a wake the hook is restarting fprintd under the +// loop, so a miss there holds the streak at the first tier instead of +// climbing toward the notice; a reached attempt still clears it. +assertEqual(model.nextStreak(0, false, true), 1, 'a miss inside the resume grace holds the streak at the first tier') +assertEqual(model.nextStreak(5, false, true), 1, 'the grace pins any accumulated streak to the first tier') +assertEqual(model.nextStreak(5, true, true), 0, 'a reached attempt inside the grace clears the streak') +assertEqual(model.nextStreak(1, false, false), 2, 'outside the grace misses accumulate') +assert(model.RESUME_GRACE_MS >= 3000 + 1000, "the grace outlasts fprintd's 3s stop cap plus its start") +assert(model.RESUME_GRACE_MS < model.ERROR_RETRY_CAP_MS, 'the grace is shorter than the cap, so it cannot mask a reader that is really gone') +assert(!model.inResumeGrace(5000, 0), 'no resume noted means no grace') +assert(model.inResumeGrace(1000 + model.RESUME_GRACE_MS - 1, 1000), 'an attempt settling inside the window is in grace') +assert(!model.inResumeGrace(1000 + model.RESUME_GRACE_MS, 1000), 'an attempt settling at the window edge is not') +assert(!model.inResumeGrace(500, 1000), 'a clock stepped back past the resume is not grace') + +// Sleep detection: an unreached attempt cannot outlive the reach bound on the +// monotonic clock, so one that did on the wall clock spanned a suspend. +assert(!model.spannedSleep(model.REACH_TIMEOUT_MS + model.SLEEP_GAP_MS, model.REACH_TIMEOUT_MS), 'the bound plus slack is not a sleep') +assert(model.spannedSleep(model.REACH_TIMEOUT_MS + model.SLEEP_GAP_MS + 1, model.REACH_TIMEOUT_MS), 'longer than the bound plus slack is a sleep') +assert(model.spannedSleep(1000 + 8 * 3600 * 1000, 1000), 'an eight-hour wait on a one-second timer is a sleep') + +// End to end: the async resume hook lands a fresh daemon ~3.3s after wake in +// the worst case (SIGTERM-ignoring fprintd, 3s stop cap). Every attempt until +// then misses; none may show the notice, and the loop must still be retrying +// at the first tier when the daemon comes back. +const resumedAt = 1000 +let graceStreak = 0 +let attemptAt = resumedAt + model.MATCH_RETRY_MS +let attempts = 0 +while (attemptAt < resumedAt + 3300) { + graceStreak = model.nextStreak(graceStreak, false, model.inResumeGrace(attemptAt, resumedAt)) + assert(!model.isUnavailable(graceStreak), 'a miss ' + (attemptAt - resumedAt) + 'ms after resume must not show the notice') + attemptAt += model.retryDelayMs(graceStreak) + attempts++ +} +assert(attempts >= 3, 'the loop keeps retrying through the restart window, got ' + attempts) +assert(attemptAt - resumedAt < 3300 + model.ERROR_RETRY_BASE_MS + model.MATCH_RETRY_MS, + 'the first attempt after the daemon is back comes within a tier, at ' + (attemptAt - resumedAt) + 'ms') + +// Probe classification (#9453): only a definitive answer may change state. +assertEqual(model.classifyProbe('found 1 devices\nFingerprints for user g on X:\n - #0: right-index-finger'), 'yes', 'an enrolled print row reads yes') +assertEqual(model.classifyProbe('User bob has no fingers enrolled for Goodix MOC Fingerprint Sensor.'), 'no', 'a fingerprint-named reader does not turn empty enrollment into yes') +assertEqual(model.classifyProbe('Header mentions - #0: but lists nothing'), 'unknown', 'an enrollment row must start a line') +assertEqual(model.classifyProbe(' - #oops: right-index-finger'), 'unknown', 'an enrollment row requires a numeric index') +assertEqual(model.classifyProbe('\t- #12: left-index-finger'), 'yes', 'indented enrollment rows and multi-digit indexes are supported') +assertEqual(model.classifyProbe('No devices available'), 'unknown', 'a temporarily missing reader is unknown, not empty enrollment') +assertEqual(model.classifyProbe('no'), 'no', "the probe script's own no reads no") +assertEqual(model.classifyProbe('found 1 devices\nUser bob has no fingers enrolled.'), 'no', "fprintd's explicit no-prints answer reads no") +assertEqual(model.classifyProbe('Impossible to get devices: GDBus.Error:org.freedesktop.DBus.Error.NameHasNoOwner: Could not activate remote peer'), 'unknown', 'an activation failure reads unknown, not unconfigured') +assertEqual(model.classifyProbe('ListEnrolledFingers failed: Timeout was reached'), 'unknown', 'a D-Bus timeout reads unknown') +assertEqual(model.classifyProbe(''), 'unknown', 'empty output reads unknown') + +assert(model.REACH_TIMEOUT_MS < model.ERROR_RETRY_CAP_MS, + 'the reach bound is shorter than the backoff cap, so a stuck attempt is caught well before the cap') +assert(model.REACH_TIMEOUT_MS < 25000, + "the reach bound lands before GDBus fails a stuck Claim at 25s and pam_fprintd's 30s verify timeout reports as a non-error message") +assert(model.REACH_TIMEOUT_MS >= 10000, + 'the reach bound outlasts a slow device open, so a reader that takes seconds to claim is not killed mid-Claim on every attempt') +JS diff --git a/test/shell.d/lock-fingerprint-service-test.sh b/test/shell.d/lock-fingerprint-service-test.sh new file mode 100644 index 00000000..0283c7f0 --- /dev/null +++ b/test/shell.d/lock-fingerprint-service-test.sh @@ -0,0 +1,73 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +TMPDIR="" +QS_PID="" + +cleanup() { + if [[ -n $QS_PID ]] && kill -0 "$QS_PID" 2>/dev/null; then + kill "$QS_PID" 2>/dev/null || true + wait "$QS_PID" 2>/dev/null || true + fi + if [[ -n $TMPDIR && -d $TMPDIR ]]; then + rm -rf "$TMPDIR" + fi +} +trap cleanup EXIT + +require_compositor "lock fingerprint service test" + +if ! command -v quickshell >/dev/null 2>&1; then + skip "quickshell not installed; skipping lock fingerprint service test" + exit 0 +fi + +require_command jq + +TMPDIR=$(mktemp -d) +result="$TMPDIR/result.json" +log="$TMPDIR/quickshell.log" +config_dir="$TMPDIR/lock-fingerprint-service" +mkdir -p "$config_dir" "$TMPDIR/home" +cp "$SHELL_TEST_DIR/fixtures/lock-fingerprint-service/shell.qml" "$config_dir/shell.qml" +ln -s "$ROOT/shell/Ui" "$config_dir/Ui" +ln -s "$ROOT/shell/Commons" "$config_dir/Commons" + +OMARCHY_PATH="$ROOT" \ +OMARCHY_QML_TEST_RESULT="$result" \ +HOME="$TMPDIR/home" \ +XDG_CONFIG_HOME="$TMPDIR/home/.config" \ +XDG_CACHE_HOME="$TMPDIR/home/.cache" \ +XDG_STATE_HOME="$TMPDIR/home/.local/state" \ +QML2_IMPORT_PATH="$ROOT/shell${QML2_IMPORT_PATH:+:$QML2_IMPORT_PATH}" \ +QML_IMPORT_PATH="$ROOT/shell${QML_IMPORT_PATH:+:$QML_IMPORT_PATH}" \ +PATH="$ROOT/bin:$PATH" \ + quickshell -p "$config_dir" --no-color >"$log" 2>&1 & +QS_PID=$! + +for _ in {1..80}; do + [[ -s $result ]] && break + if ! kill -0 "$QS_PID" 2>/dev/null; then + sed -n '1,220p' "$log" >&2 + fail "lock fingerprint service quickshell exited before writing result" + fi + sleep 0.1 +done + +[[ -s $result ]] || { + sed -n '1,220p' "$log" >&2 + fail "lock fingerprint service test timed out" +} + +if ! jq -e '.ok == true' "$result" >/dev/null; then + printf 'Lock fingerprint service result:\n' >&2 + jq . "$result" >&2 + printf 'Lock fingerprint service log:\n' >&2 + sed -n '1,220p' "$log" >&2 + fail "fingerprint service handles enrollment, failures and recovery" +fi + +pass "fingerprint service handles enrollment, failures and recovery"