diff --git a/Cargo.toml b/Cargo.toml index e9b5037..42f8032 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,9 @@ include = [ "Cargo.toml", "Cargo.lock", "README.md", + "docs/install.md", + "docs/cli.md", + "docs/troubleshooting.md", "LICENSE", "NOTICE", "config.example.toml", diff --git a/scripts/build-release-bundle.sh b/scripts/build-release-bundle.sh index ae72dd3..85b3793 100755 --- a/scripts/build-release-bundle.sh +++ b/scripts/build-release-bundle.sh @@ -46,6 +46,7 @@ mkdir -p \ "$stage_dir/$bundle_name/share/bash-completion/completions" \ "$stage_dir/$bundle_name/share/zsh/site-functions" \ "$stage_dir/$bundle_name/share/fish/vendor_completions.d" \ + "$stage_dir/$bundle_name/share/doc/whispers/docs" \ "$stage_dir/$bundle_name/share/doc/whispers" \ "$stage_dir/$bundle_name/share/licenses/whispers" @@ -65,6 +66,12 @@ install -Dm755 "${target_dir}/whispers-rewrite-worker" \ install -Dm644 README.md \ "$stage_dir/$bundle_name/share/doc/whispers/README.md" +install -Dm644 docs/install.md \ + "$stage_dir/$bundle_name/share/doc/whispers/docs/install.md" +install -Dm644 docs/cli.md \ + "$stage_dir/$bundle_name/share/doc/whispers/docs/cli.md" +install -Dm644 docs/troubleshooting.md \ + "$stage_dir/$bundle_name/share/doc/whispers/docs/troubleshooting.md" install -Dm644 config.example.toml \ "$stage_dir/$bundle_name/share/doc/whispers/config.example.toml" install -Dm644 LICENSE \ diff --git a/src/inject/mod.rs b/src/inject/mod.rs index 97b19e1..fc6cc87 100644 --- a/src/inject/mod.rs +++ b/src/inject/mod.rs @@ -6,6 +6,8 @@ mod preflight; mod tests; use crate::error::{Result, WhsprError}; +#[cfg(test)] +pub(crate) use preflight::InjectionReadinessIssue; pub use preflight::{InjectionReadinessReport, validate_injection_prerequisites}; const DEVICE_READY_DELAY: std::time::Duration = std::time::Duration::from_millis(120); diff --git a/src/inject/preflight.rs b/src/inject/preflight.rs index f182ab5..c63da8f 100644 --- a/src/inject/preflight.rs +++ b/src/inject/preflight.rs @@ -129,7 +129,10 @@ impl InjectionReadinessIssue { "Log out and back in after changing group membership.".into(), ], Self::UinputUnavailable(_) => { - vec!["Check that `/dev/uinput` exists and is writable by the current user.".into()] + vec![ + "Check that `/dev/uinput` exists and is readable and writable by the current user." + .into(), + ] } } } @@ -141,7 +144,7 @@ impl InjectionReadinessIssue { "`/dev/uinput` is missing; load the `uinput` kernel module".into() } Self::UinputPermissionDenied => { - "`/dev/uinput` is present but not writable by the current user; create a dedicated `uinput` group, add your user to it, install a `udev` rule, then log out and back in".into() + "`/dev/uinput` is present but not readable and writable by the current user; create a dedicated `uinput` group, add your user to it, install a `udev` rule, then log out and back in".into() } Self::UinputUnavailable(detail) => { format!("`/dev/uinput` could not be opened: {detail}") @@ -159,12 +162,15 @@ pub fn validate_injection_prerequisites() -> Result<()> { } fn probe_uinput() -> Option { - let path = Path::new(UINPUT_PATH); + probe_uinput_path(Path::new(UINPUT_PATH)) +} + +pub(super) fn probe_uinput_path(path: &Path) -> Option { if !path.exists() { return Some(InjectionReadinessIssue::MissingUinputDevice); } - match OpenOptions::new().write(true).open(path) { + match OpenOptions::new().read(true).write(true).open(path) { Ok(_) => None, Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { Some(InjectionReadinessIssue::UinputPermissionDenied) diff --git a/src/inject/tests.rs b/src/inject/tests.rs index a655058..f1bc9d5 100644 --- a/src/inject/tests.rs +++ b/src/inject/tests.rs @@ -1,3 +1,5 @@ +use std::os::unix::fs::PermissionsExt; + use crate::error::WhsprError; use super::preflight::InjectionReadinessIssue; @@ -19,7 +21,7 @@ fn run_wl_copy_reports_spawn_failure() { fn run_wl_copy_reports_non_zero_exit() { let err = clipboard::run_wl_copy( "/bin/sh", - &[String::from("-c"), String::from("exit 7")], + &[String::from("-c"), String::from("cat >/dev/null; exit 7")], "hello", ) .expect_err("non-zero exit should fail"); @@ -112,3 +114,22 @@ fn readiness_report_only_requires_relogin_when_permission_is_last_blocker() { ]); assert!(!additional_steps.only_requires_relogin()); } + +#[test] +fn probe_uinput_requires_read_and_write_access() { + let dir = crate::test_support::unique_temp_dir("uinput-readwrite-probe"); + let path = dir.join("uinput"); + std::fs::write(&path, []).expect("create probe file"); + + let mut perms = std::fs::metadata(&path).expect("metadata").permissions(); + perms.set_mode(0o200); + std::fs::set_permissions(&path, perms).expect("set write-only permissions"); + + let issue = preflight::probe_uinput_path(&path).expect("write-only file should fail"); + assert_eq!(issue, InjectionReadinessIssue::UinputPermissionDenied); + + let mut cleanup_perms = std::fs::metadata(&path).expect("metadata").permissions(); + cleanup_perms.set_mode(0o600); + std::fs::set_permissions(&path, cleanup_perms).expect("restore permissions"); + std::fs::remove_dir_all(&dir).expect("cleanup temp dir"); +} diff --git a/src/setup.rs b/src/setup.rs index 3d7028d..047c7c4 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -54,7 +54,13 @@ impl CloudSetup { } } +fn validate_setup_invoker(uid: libc::uid_t) -> Result<()> { + side_effects::validate_setup_user(uid) +} + pub async fn run_setup(config_path_override: Option<&Path>) -> Result<()> { + validate_setup_invoker(unsafe { libc::geteuid() })?; + let ui = SetupUi::new(); ui.print_header("whispers setup"); ui.blank(); diff --git a/src/setup/report.rs b/src/setup/report.rs index d6a1dfd..7f0823c 100644 --- a/src/setup/report.rs +++ b/src/setup/report.rs @@ -87,17 +87,37 @@ pub(super) fn print_injection_readiness( println!(" - {line}"); } - if setup.changed_groups { - ui.print_info( - "If you were just added to the `uinput` group, log out and back in before testing.", - ); + if let Some(message) = injection_readiness_info_message(readiness, setup) { + ui.print_info(message); } - for line in readiness.fix_lines() { + for line in injection_readiness_fix_lines(readiness, setup) { println!(" - {line}"); } } +pub(super) fn injection_readiness_info_message( + readiness: &InjectionReadinessReport, + setup: &InjectionSetupOutcome, +) -> Option<&'static str> { + if setup.can_finish_with_relogin_only(readiness.only_requires_relogin()) { + Some("Log out and back in before testing.") + } else { + setup.report_group_change_message() + } +} + +pub(super) fn injection_readiness_fix_lines( + readiness: &InjectionReadinessReport, + setup: &InjectionSetupOutcome, +) -> Vec { + if setup.can_finish_with_relogin_only(readiness.only_requires_relogin()) { + Vec::new() + } else { + readiness.fix_lines() + } +} + pub(super) fn print_setup_complete( ui: &SetupUi, readiness: &InjectionReadinessReport, @@ -109,7 +129,7 @@ pub(super) fn print_setup_complete( setup_complete_message( readiness.is_ready(), setup.changed_groups, - readiness.only_requires_relogin(), + setup.can_finish_with_relogin_only(readiness.only_requires_relogin()), ) ); ui.print_section("Example keybind"); @@ -120,11 +140,11 @@ pub(super) fn print_setup_complete( pub(super) fn setup_complete_message( is_ready: bool, changed_groups: bool, - only_requires_relogin: bool, + can_finish_with_relogin_only: bool, ) -> &'static str { if is_ready { "You can now use whispers." - } else if changed_groups && only_requires_relogin { + } else if can_finish_with_relogin_only { "Log out and back in, then use whispers." } else if changed_groups { "Log out and back in, then finish any remaining paste injection steps above before using whispers." diff --git a/src/setup/side_effects.rs b/src/setup/side_effects.rs index ca58900..efbdd8e 100644 --- a/src/setup/side_effects.rs +++ b/src/setup/side_effects.rs @@ -1,4 +1,4 @@ -use std::ffi::CStr; +use std::ffi::{CStr, CString}; use std::io::Write; use std::path::Path; use std::process::{Command, Stdio}; @@ -14,10 +14,52 @@ const MODULES_LOAD_PATH: &str = "/etc/modules-load.d/whispers-uinput.conf"; const UDEV_RULE_PATH: &str = "/etc/udev/rules.d/70-whispers-uinput.rules"; const UDEV_RULE_CONTENT: &str = "KERNEL==\"uinput\", SUBSYSTEM==\"misc\", GROUP=\"uinput\", MODE=\"0660\", OPTIONS+=\"static_node=uinput\"\n"; const MODULES_LOAD_CONTENT: &str = "uinput\n"; +pub(super) const UDEV_TRIGGER_ARGS: &[&str] = &[ + "udevadm", + "trigger", + "--subsystem-match=misc", + "--sysname-match=uinput", + "--settle", +]; #[derive(Debug, Clone, Copy, Default)] pub(super) struct InjectionSetupOutcome { pub changed_groups: bool, + pub group_membership_ready: bool, + pub uinput_rule_ready: bool, + pub udev_reload_succeeded: bool, +} + +impl InjectionSetupOutcome { + pub(super) fn setup_group_change_message(self) -> Option<&'static str> { + if !self.changed_groups { + None + } else if self.udev_reload_succeeded { + Some("Group membership changed. Log out and back in before testing dictation.") + } else { + Some( + "Group membership changed. Log out and back in after finishing the remaining paste injection steps.", + ) + } + } + + pub(super) fn report_group_change_message(self) -> Option<&'static str> { + if !self.changed_groups { + None + } else { + Some( + "If you were just added to the `uinput` group, log out and back in after finishing the remaining paste injection steps.", + ) + } + } + + pub(super) fn can_finish_with_relogin_only(self, only_requires_relogin: bool) -> bool { + self.should_reload_udev() && self.udev_reload_succeeded && only_requires_relogin + } + + pub(super) fn should_reload_udev(self) -> bool { + self.group_membership_ready && self.uinput_rule_ready + } } pub(super) async fn download_asr_model( @@ -101,6 +143,7 @@ pub(super) fn maybe_prewarm_experimental_nemo( } pub(super) fn maybe_setup_injection_access(ui: &SetupUi) -> Result { + validate_setup_user(unsafe { libc::geteuid() })?; let readiness = crate::inject::InjectionReadinessReport::collect(); if readiness.is_ready() || !readiness.has_uinput_issue() { return Ok(InjectionSetupOutcome::default()); @@ -124,10 +167,11 @@ pub(super) fn maybe_setup_injection_access(ui: &SetupUi) -> Result outcome.uinput_rule_ready = true, + Err(err) => ui.print_warn(format!( "Failed to install the `/dev/uinput` udev rule: {err}" - )); + )), } if let Err(err) = ensure_group_exists(ui, UINPUT_GROUP) { ui.print_warn(format!( @@ -141,14 +185,25 @@ pub(super) fn maybe_setup_injection_access(ui: &SetupUi) -> Result outcome.udev_reload_succeeded = true, + Err(err) => ui.print_warn(format!( + "Failed to reload `udev` after updating `/dev/uinput`: {err}" + )), + } + } else if !outcome.group_membership_ready { + ui.print_warn( + "Skipping `/dev/uinput` rule activation until `uinput` group membership is in place.", + ); + } else if !outcome.uinput_rule_ready { + ui.print_warn( + "Skipping `/dev/uinput` rule activation until the `uinput` udev rule is installed.", + ); } - if outcome.changed_groups { - ui.print_info("Group membership changed. Log out and back in before testing dictation."); + if let Some(message) = outcome.setup_group_change_message() { + ui.print_info(message); } Ok(outcome) @@ -162,9 +217,13 @@ pub(super) fn record_group_membership_change_result( match result { Ok(true) => { outcome.changed_groups = true; + outcome.group_membership_ready = true; + None + } + Ok(false) => { + outcome.group_membership_ready = true; None } - Ok(false) => None, Err(err) => Some(format!( "Failed to add the current user to the `{group}` group: {err}" )), @@ -185,12 +244,7 @@ fn ensure_uinput_module_loaded(ui: &SetupUi) -> Result<()> { fn reload_udev(ui: &SetupUi) -> Result<()> { ui.print_info("Reloading `udev` rules for `/dev/uinput`..."); run_sudo(&["udevadm", "control", "--reload"])?; - run_sudo(&[ - "udevadm", - "trigger", - "--subsystem-match=misc", - "--sysname-match=uinput", - ]) + run_sudo(UDEV_TRIGGER_ARGS) } fn add_user_to_group(ui: &SetupUi, group: &str) -> Result { @@ -219,15 +273,52 @@ fn ensure_group_exists(ui: &SetupUi, group: &str) -> Result<()> { run_sudo(&["groupadd", "--system", group]) } -fn group_exists(group: &str) -> Result { - let group_file = std::fs::read_to_string("/etc/group").map_err(|err| { - crate::error::WhsprError::Config(format!("failed to read /etc/group: {err}")) +pub(super) fn group_exists(group: &str) -> Result { + let c_group = CString::new(group).map_err(|_| { + crate::error::WhsprError::Config(format!( + "group name `{group}` contains an interior NUL byte" + )) })?; - Ok(group_file - .lines() - .filter_map(|line| line.split(':').next()) - .any(|entry| entry == group)) + let mut buffer_len = match unsafe { libc::sysconf(libc::_SC_GETGR_R_SIZE_MAX) } { + value if value > 0 => value as usize, + _ => 1024, + }; + + loop { + let mut buffer = vec![0u8; buffer_len]; + let mut group_entry = std::mem::MaybeUninit::::uninit(); + let mut result = std::ptr::null_mut(); + + let status = unsafe { + libc::getgrnam_r( + c_group.as_ptr(), + group_entry.as_mut_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + &mut result, + ) + }; + + if status == 0 { + return Ok(!result.is_null()); + } + + if status == libc::ERANGE { + buffer_len = buffer_len.saturating_mul(2); + if buffer_len > 1 << 20 { + return Err(crate::error::WhsprError::Config(format!( + "failed to resolve group `{group}` via NSS: lookup buffer exceeded 1 MiB" + ))); + } + continue; + } + + return Err(crate::error::WhsprError::Config(format!( + "failed to resolve group `{group}` via NSS: {}", + std::io::Error::from_raw_os_error(status) + ))); + } } fn current_user_in_group(username: &str, group: &str) -> Result { @@ -249,41 +340,79 @@ fn current_user_in_group(username: &str, group: &str) -> Result { fn current_username() -> Result { let uid = unsafe { libc::geteuid() }; + validate_setup_user(uid)?; + + current_username_for_uid_with(uid, |uid, passwd, buffer, result| unsafe { + libc::getpwuid_r( + uid, + passwd, + buffer.as_mut_ptr().cast(), + buffer.len(), + result, + ) + }) +} + +pub(super) fn validate_setup_user(uid: libc::uid_t) -> Result<()> { if uid == 0 { return Err(crate::error::WhsprError::Config( "run `whispers setup` as your normal user, not as root".into(), )); } - let buffer_len = match unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) } { + Ok(()) +} + +pub(super) fn current_username_for_uid_with(uid: libc::uid_t, mut lookup: F) -> Result +where + F: FnMut(libc::uid_t, *mut libc::passwd, &mut Vec, *mut *mut libc::passwd) -> libc::c_int, +{ + let mut buffer_len = match unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) } { value if value > 0 => value as usize, _ => 1024, }; - let mut buffer = vec![0u8; buffer_len]; - let mut passwd = std::mem::MaybeUninit::::uninit(); - let mut result = std::ptr::null_mut(); - let status = unsafe { - libc::getpwuid_r( - uid, - passwd.as_mut_ptr(), - buffer.as_mut_ptr().cast(), - buffer.len(), - &mut result, - ) - }; - if status != 0 || result.is_null() { + loop { + let mut buffer = vec![0u8; buffer_len]; + let mut passwd = std::mem::MaybeUninit::::uninit(); + let mut result = std::ptr::null_mut(); + + let status = lookup(uid, passwd.as_mut_ptr(), &mut buffer, &mut result); + if status == 0 && !result.is_null() { + let passwd = unsafe { passwd.assume_init() }; + let name = unsafe { CStr::from_ptr(passwd.pw_name) }; + return Ok(name.to_string_lossy().into_owned()); + } + + if status == libc::ERANGE { + buffer_len = buffer_len.saturating_mul(2); + if buffer_len > 1 << 20 { + return Err(crate::error::WhsprError::Config(format!( + "failed to resolve current username for uid {uid}: lookup buffer exceeded 1 MiB" + ))); + } + continue; + } + + if status == 0 { + return Err(crate::error::WhsprError::Config(format!( + "failed to resolve current username for uid {uid}" + ))); + } + return Err(crate::error::WhsprError::Config(format!( - "failed to resolve current username for uid {uid}" + "failed to resolve current username for uid {uid}: {}", + std::io::Error::from_raw_os_error(status) ))); } - - let passwd = unsafe { passwd.assume_init() }; - let name = unsafe { CStr::from_ptr(passwd.pw_name) }; - Ok(name.to_string_lossy().into_owned()) } fn install_root_file(ui: &SetupUi, target: &str, contents: &str) -> Result<()> { + if root_file_has_contents(Path::new(target), contents) { + ui.print_info(format!("`{target}` already has the expected contents.")); + return Ok(()); + } + let Some(parent) = Path::new(target).parent().and_then(|path| path.to_str()) else { return Err(crate::error::WhsprError::Config(format!( "failed to determine parent directory for `{target}`" @@ -296,6 +425,21 @@ fn install_root_file(ui: &SetupUi, target: &str, contents: &str) -> Result<()> { Ok(()) } +fn root_file_has_contents(path: &Path, expected: &str) -> bool { + match std::fs::read_to_string(path) { + Ok(contents) => contents == expected, + Err(err) + if matches!( + err.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied + ) => + { + false + } + Err(_) => false, + } +} + fn run_sudo(args: &[&str]) -> Result<()> { let status = Command::new("sudo").args(args).status().map_err(|err| { crate::error::WhsprError::Config(format!("failed to run sudo {:?}: {err}", args)) diff --git a/src/setup/tests.rs b/src/setup/tests.rs index e167206..2e25ed4 100644 --- a/src/setup/tests.rs +++ b/src/setup/tests.rs @@ -1,5 +1,6 @@ use crate::config::Config; use crate::error::WhsprError; +use crate::inject::{InjectionReadinessIssue, InjectionReadinessReport}; use super::{CloudSetup, apply, report, side_effects}; use crate::config::{self, TranscriptionBackend, TranscriptionFallback}; @@ -63,10 +64,34 @@ fn group_membership_failures_become_warnings_without_marking_success() { .expect("errors should become warnings"); assert!(!outcome.changed_groups); + assert!(!outcome.group_membership_ready); + assert!(!outcome.uinput_rule_ready); assert!(warning.contains("Failed to add the current user")); assert!(warning.contains("group add failed")); } +#[test] +fn setup_rejects_root_before_uinput_readiness_short_circuit() { + let err = side_effects::validate_setup_user(0).expect_err("root should be rejected"); + match err { + WhsprError::Config(message) => { + assert!(message.contains("run `whispers setup` as your normal user, not as root")); + } + other => panic!("unexpected error variant: {other:?}"), + } +} + +#[test] +fn setup_rejects_root_before_any_setup_side_effects() { + let err = super::validate_setup_invoker(0).expect_err("root should be rejected up front"); + match err { + WhsprError::Config(message) => { + assert!(message.contains("run `whispers setup` as your normal user, not as root")); + } + other => panic!("unexpected error variant: {other:?}"), + } +} + #[test] fn group_membership_success_marks_logout_as_needed() { let mut outcome = side_effects::InjectionSetupOutcome::default(); @@ -75,6 +100,199 @@ fn group_membership_success_marks_logout_as_needed() { assert!(warning.is_none()); assert!(outcome.changed_groups); + assert!(outcome.group_membership_ready); + assert!(!outcome.uinput_rule_ready); + assert!(!outcome.udev_reload_succeeded); +} + +#[test] +fn group_exists_uses_nss_sources() { + assert!(side_effects::group_exists("root").expect("resolve root group")); + + let missing = format!("whispers-missing-group-{}", std::process::id()); + assert!(!side_effects::group_exists(&missing).expect("resolve missing group")); +} + +#[test] +fn existing_group_membership_marks_relogin_as_possible_without_new_group_change() { + let mut outcome = side_effects::InjectionSetupOutcome::default(); + let warning = + side_effects::record_group_membership_change_result(&mut outcome, "uinput", Ok(false)); + + assert!(warning.is_none()); + assert!(!outcome.changed_groups); + assert!(outcome.group_membership_ready); + assert!(!outcome.uinput_rule_ready); +} + +#[test] +fn group_change_messages_follow_recorded_reload_status() { + let success = side_effects::InjectionSetupOutcome { + changed_groups: true, + group_membership_ready: true, + uinput_rule_ready: true, + udev_reload_succeeded: true, + }; + let failed_reload = side_effects::InjectionSetupOutcome { + changed_groups: true, + group_membership_ready: true, + uinput_rule_ready: true, + udev_reload_succeeded: false, + }; + + assert_eq!( + success.setup_group_change_message(), + Some("Group membership changed. Log out and back in before testing dictation."), + ); + assert_eq!( + failed_reload.setup_group_change_message(), + Some( + "Group membership changed. Log out and back in after finishing the remaining paste injection steps.", + ), + ); + assert_eq!( + success.report_group_change_message(), + Some( + "If you were just added to the `uinput` group, log out and back in after finishing the remaining paste injection steps.", + ), + ); + assert_eq!( + failed_reload.report_group_change_message(), + Some( + "If you were just added to the `uinput` group, log out and back in after finishing the remaining paste injection steps.", + ), + ); +} + +#[test] +fn relogin_only_readiness_collapses_to_logout_instruction() { + let readiness = InjectionReadinessReport::from_issues(vec![ + InjectionReadinessIssue::UinputPermissionDenied, + ]); + let setup = side_effects::InjectionSetupOutcome { + changed_groups: true, + group_membership_ready: true, + uinput_rule_ready: true, + udev_reload_succeeded: true, + }; + + assert_eq!( + report::injection_readiness_info_message(&readiness, &setup), + Some("Log out and back in before testing.") + ); + assert!(report::injection_readiness_fix_lines(&readiness, &setup).is_empty()); +} + +#[test] +fn relogin_only_completion_allows_reruns_when_group_is_already_configured() { + let rerun = side_effects::InjectionSetupOutcome { + changed_groups: false, + group_membership_ready: true, + uinput_rule_ready: true, + udev_reload_succeeded: true, + }; + let failed_group_update = side_effects::InjectionSetupOutcome { + changed_groups: false, + group_membership_ready: false, + uinput_rule_ready: true, + udev_reload_succeeded: true, + }; + let missing_rule = side_effects::InjectionSetupOutcome { + changed_groups: false, + group_membership_ready: true, + uinput_rule_ready: false, + udev_reload_succeeded: true, + }; + + assert!(rerun.can_finish_with_relogin_only(true)); + assert!(!failed_group_update.can_finish_with_relogin_only(true)); + assert!(!missing_rule.can_finish_with_relogin_only(true)); + assert!(!rerun.can_finish_with_relogin_only(false)); +} + +#[test] +fn udev_reload_requires_group_membership_and_rule() { + let ready = side_effects::InjectionSetupOutcome { + changed_groups: false, + group_membership_ready: true, + uinput_rule_ready: true, + udev_reload_succeeded: false, + }; + let missing_group = side_effects::InjectionSetupOutcome { + changed_groups: false, + group_membership_ready: false, + uinput_rule_ready: true, + udev_reload_succeeded: false, + }; + let missing_rule = side_effects::InjectionSetupOutcome { + changed_groups: false, + group_membership_ready: true, + uinput_rule_ready: false, + udev_reload_succeeded: false, + }; + + assert!(ready.should_reload_udev()); + assert!(!missing_group.should_reload_udev()); + assert!(!missing_rule.should_reload_udev()); +} + +#[test] +fn incomplete_setup_keeps_manual_fix_lines_visible() { + let readiness = InjectionReadinessReport::from_issues(vec![ + InjectionReadinessIssue::UinputPermissionDenied, + ]); + let setup = side_effects::InjectionSetupOutcome { + changed_groups: true, + group_membership_ready: true, + uinput_rule_ready: false, + udev_reload_succeeded: true, + }; + + assert_eq!( + report::injection_readiness_info_message(&readiness, &setup), + Some( + "If you were just added to the `uinput` group, log out and back in after finishing the remaining paste injection steps.", + ) + ); + assert!(!report::injection_readiness_fix_lines(&readiness, &setup).is_empty()); +} + +#[test] +fn current_username_lookup_retries_after_erange() { + let expected_name = std::ffi::CString::new("whispers-test-user").expect("c string"); + let mut attempts = 0; + + let username = + side_effects::current_username_for_uid_with(4242, |uid, passwd, _buffer, result| { + attempts += 1; + if attempts == 1 { + return libc::ERANGE; + } + + unsafe { + *passwd = libc::passwd { + pw_name: expected_name.as_ptr() as *mut _, + pw_passwd: std::ptr::null_mut(), + pw_uid: uid, + pw_gid: 0, + pw_gecos: std::ptr::null_mut(), + pw_dir: std::ptr::null_mut(), + pw_shell: std::ptr::null_mut(), + }; + *result = passwd; + } + + 0 + }) + .expect("retry should succeed"); + + assert_eq!(username, "whispers-test-user"); + assert_eq!(attempts, 2); +} + +#[test] +fn udev_trigger_waits_for_settle_before_rechecking() { + assert!(side_effects::UDEV_TRIGGER_ARGS.contains(&"--settle")); } #[test] @@ -87,6 +305,14 @@ fn setup_complete_message_stays_aligned_with_remaining_steps() { report::setup_complete_message(false, true, false), "Log out and back in, then finish any remaining paste injection steps above before using whispers." ); + assert_eq!( + report::setup_complete_message(false, true, false), + "Log out and back in, then finish any remaining paste injection steps above before using whispers." + ); + assert_eq!( + report::setup_complete_message(false, false, true), + "Log out and back in, then use whispers." + ); assert_eq!( report::setup_complete_message(false, false, false), "Finish the paste injection steps above, then use whispers."