1use std::fmt;
29use std::process::Command;
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum PrivilegeTool {
44 Sudo,
46 Doas,
48}
49
50#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
61pub enum PrivilegeMode {
62 #[default]
64 Auto,
65 Sudo,
67 Doas,
69}
70
71#[derive(Clone, Copy, Debug, PartialEq, Eq)]
82#[allow(clippy::struct_excessive_bools)]
83pub struct PrivilegeCapabilities {
84 pub supports_stdin_password: bool,
86 pub supports_credential_refresh: bool,
88 pub supports_credential_invalidation: bool,
90 pub supports_askpass: bool,
92}
93
94impl PrivilegeTool {
99 #[must_use]
107 pub const fn binary_name(self) -> &'static str {
108 match self {
109 Self::Sudo => "sudo",
110 Self::Doas => "doas",
111 }
112 }
113
114 #[must_use]
124 pub const fn capabilities(self) -> PrivilegeCapabilities {
125 match self {
126 Self::Sudo => PrivilegeCapabilities {
127 supports_stdin_password: true,
128 supports_credential_refresh: true,
129 supports_credential_invalidation: true,
130 supports_askpass: true,
131 },
132 Self::Doas => PrivilegeCapabilities {
133 supports_stdin_password: false,
134 supports_credential_refresh: false,
135 supports_credential_invalidation: false,
136 supports_askpass: false,
137 },
138 }
139 }
140
141 #[must_use]
152 pub fn is_available(self) -> bool {
153 if is_integration_test_context()
154 && let Ok(val) = std::env::var("PACSEA_TEST_PRIVILEGE_AVAILABLE")
155 {
156 if val == "none" {
157 return false;
158 }
159 return val.split(',').any(|t| t.trim() == self.binary_name());
160 }
161 which::which(self.binary_name()).is_ok()
162 }
163
164 pub fn check_passwordless(self) -> Result<bool, String> {
179 if is_integration_test_context()
180 && let Ok(val) = std::env::var("PACSEA_TEST_SUDO_PASSWORDLESS")
181 {
182 tracing::debug!(
183 tool = self.binary_name(),
184 val = %val,
185 "Using test override for passwordless check"
186 );
187 return Ok(val == "1");
188 }
189
190 let status = Command::new(self.binary_name())
191 .args(["-n", "true"])
192 .stdin(std::process::Stdio::null())
193 .stdout(std::process::Stdio::null())
194 .stderr(std::process::Stdio::null())
195 .status()
196 .map_err(|e| format!("Failed to check passwordless {}: {e}", self.binary_name()))?;
197
198 Ok(status.success())
199 }
200}
201
202impl fmt::Display for PrivilegeTool {
203 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204 f.write_str(self.binary_name())
205 }
206}
207
208impl PrivilegeMode {
213 #[must_use]
222 pub fn from_config_key(val: &str) -> Option<Self> {
223 match val.trim().to_ascii_lowercase().as_str() {
224 "auto" => Some(Self::Auto),
225 "sudo" => Some(Self::Sudo),
226 "doas" => Some(Self::Doas),
227 _ => None,
228 }
229 }
230
231 #[must_use]
239 pub const fn as_config_key(self) -> &'static str {
240 match self {
241 Self::Auto => "auto",
242 Self::Sudo => "sudo",
243 Self::Doas => "doas",
244 }
245 }
246}
247
248impl fmt::Display for PrivilegeMode {
249 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
250 f.write_str(self.as_config_key())
251 }
252}
253
254#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
274pub enum AuthMode {
275 #[default]
277 Prompt,
278 PasswordlessOnly,
280 Interactive,
282}
283
284impl AuthMode {
285 #[must_use]
294 pub fn from_config_key(val: &str) -> Option<Self> {
295 match val.trim().to_ascii_lowercase().replace('-', "_").as_str() {
296 "prompt" => Some(Self::Prompt),
297 "passwordless_only" | "passwordless" => Some(Self::PasswordlessOnly),
298 "interactive" => Some(Self::Interactive),
299 _ => None,
300 }
301 }
302
303 #[must_use]
311 pub const fn as_config_key(self) -> &'static str {
312 match self {
313 Self::Prompt => "prompt",
314 Self::PasswordlessOnly => "passwordless_only",
315 Self::Interactive => "interactive",
316 }
317 }
318
319 #[must_use]
330 pub const fn always_skips_password_modal(self) -> bool {
331 matches!(self, Self::Interactive)
332 }
333}
334
335impl fmt::Display for AuthMode {
336 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
337 f.write_str(self.as_config_key())
338 }
339}
340
341pub fn resolve_privilege_tool(mode: PrivilegeMode) -> Result<PrivilegeTool, String> {
361 match mode {
362 PrivilegeMode::Auto => {
363 let doas_ok = PrivilegeTool::Doas.is_available();
364 let sudo_ok = PrivilegeTool::Sudo.is_available();
365 tracing::debug!(
366 mode = %mode,
367 doas_available = doas_ok,
368 sudo_available = sudo_ok,
369 "Resolving privilege tool"
370 );
371 if doas_ok {
372 tracing::info!(
373 tool = "doas",
374 reason = "auto: doas preferred when available",
375 "Selected privilege tool"
376 );
377 Ok(PrivilegeTool::Doas)
378 } else if sudo_ok {
379 tracing::info!(
380 tool = "sudo",
381 reason = "auto: doas unavailable, falling back",
382 "Selected privilege tool"
383 );
384 Ok(PrivilegeTool::Sudo)
385 } else {
386 Err("Neither doas nor sudo found on $PATH. \
387 Install one to perform privileged operations: \
388 `pacman -S sudo` or `pacman -S opendoas`."
389 .to_string())
390 }
391 }
392 PrivilegeMode::Sudo => {
393 if PrivilegeTool::Sudo.is_available() {
394 tracing::info!(
395 tool = "sudo",
396 reason = "explicit config",
397 "Selected privilege tool"
398 );
399 Ok(PrivilegeTool::Sudo)
400 } else {
401 Err(
402 "sudo is not available on $PATH. Install sudo (`pacman -S sudo`) \
403 or change privilege_tool to 'auto' or 'doas' in settings.conf."
404 .to_string(),
405 )
406 }
407 }
408 PrivilegeMode::Doas => {
409 if PrivilegeTool::Doas.is_available() {
410 tracing::info!(
411 tool = "doas",
412 reason = "explicit config",
413 "Selected privilege tool"
414 );
415 Ok(PrivilegeTool::Doas)
416 } else {
417 Err(
418 "doas is not available on $PATH. Install opendoas (`pacman -S opendoas`) \
419 or change privilege_tool to 'auto' or 'sudo' in settings.conf."
420 .to_string(),
421 )
422 }
423 }
424 }
425}
426
427fn active_tool_for_mode(mode: PrivilegeMode) -> Result<PrivilegeTool, String> {
446 match resolve_privilege_tool(mode) {
447 Ok(tool) => Ok(tool),
448 Err(err) if mode == PrivilegeMode::Auto => {
449 tracing::warn!(
450 configured_mode = %mode,
451 error = %err,
452 fallback = "sudo",
453 "Privilege tool auto-resolution failed — falling back to sudo; privileged commands may fail if sudo is missing"
454 );
455 Ok(PrivilegeTool::Sudo)
456 }
457 Err(err) => Err(err),
458 }
459}
460
461#[must_use = "caller should handle missing explicit privilege tools"]
480pub fn active_tool() -> Result<PrivilegeTool, String> {
481 let settings = crate::theme::settings();
482 active_tool_for_mode(settings.privilege_mode)
483}
484
485pub fn run_interactive_auth(tool: PrivilegeTool) -> Result<bool, String> {
510 if is_integration_test_context() {
511 tracing::debug!(tool = %tool, "Skipping interactive auth in integration test context");
512 return Ok(true);
513 }
514
515 let mut cmd = Command::new(tool.binary_name());
516 if tool.capabilities().supports_credential_refresh {
517 cmd.arg("-v");
518 } else {
519 cmd.arg("true");
520 }
521
522 let status = cmd.status().map_err(|e| {
523 format!(
524 "Failed to run {} for interactive authentication: {e}",
525 tool.binary_name()
526 )
527 })?;
528
529 Ok(status.success())
530}
531
532pub fn detect_pam_fingerprint(tool: PrivilegeTool) -> bool {
551 use std::fs;
552
553 let tool_pam_path = format!("/etc/pam.d/{}", tool.binary_name());
554 let pam_files = [
555 tool_pam_path.as_str(),
556 "/etc/pam.d/system-auth",
557 "/etc/pam.d/system-local-login",
558 ];
559
560 for path in &pam_files {
561 if let Ok(contents) = fs::read_to_string(path)
562 && contents.contains("pam_fprintd")
563 {
564 tracing::debug!(path = %path, "Detected pam_fprintd in PAM configuration");
565 return true;
566 }
567 }
568
569 false
570}
571
572pub fn detect_fprintd_enrolled() -> bool {
587 let username = std::env::var("USER").unwrap_or_default();
588 if username.is_empty() {
589 return false;
590 }
591
592 let output = Command::new("fprintd-list").arg(&username).output();
593
594 match output {
595 Ok(out) if out.status.success() => {
596 let stdout = String::from_utf8_lossy(&out.stdout);
597 let has_finger = stdout.lines().any(|line| {
598 let trimmed = line.trim().to_lowercase();
599 trimmed.contains("left-") || trimmed.contains("right-")
600 });
601 if has_finger {
602 tracing::debug!("fprintd-list reports enrolled fingerprint(s)");
603 }
604 has_finger
605 }
606 _ => false,
607 }
608}
609
610static FINGERPRINT_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
616
617#[must_use]
630pub fn is_fingerprint_available() -> bool {
631 *FINGERPRINT_AVAILABLE.get_or_init(|| {
632 let Ok(tool) = active_tool() else {
633 return false;
634 };
635
636 let pam_configured = detect_pam_fingerprint(tool);
637 if !pam_configured {
638 tracing::debug!(tool = %tool, "No pam_fprintd in PAM config for tool");
639 return false;
640 }
641
642 let enrolled = detect_fprintd_enrolled();
643 if !enrolled {
644 tracing::debug!("pam_fprintd configured but no enrolled fingerprints found");
645 }
646 enrolled
647 })
648}
649
650#[must_use]
664pub fn build_privilege_command(tool: PrivilegeTool, command: &str) -> String {
665 format!("{} {command}", tool.binary_name())
666}
667
668#[must_use]
683pub fn build_password_pipe(tool: PrivilegeTool, password: &str, command: &str) -> Option<String> {
684 if !tool.capabilities().supports_stdin_password {
685 return None;
686 }
687 let escaped = crate::install::shell_single_quote(password);
688 Some(format!(
689 "printf '%s\\n' {escaped} | {} -S {command}",
690 tool.binary_name()
691 ))
692}
693
694#[must_use]
708pub fn build_credential_warmup(tool: PrivilegeTool, password: &str) -> Option<String> {
709 if !tool.capabilities().supports_credential_refresh {
710 return None;
711 }
712 let escaped = crate::install::shell_single_quote(password);
713 Some(format!(
714 "printf '%s\\n' {escaped} | {} -S -v 2>/dev/null",
715 tool.binary_name()
716 ))
717}
718
719#[must_use]
732pub fn build_credential_invalidation(tool: PrivilegeTool) -> Option<String> {
733 if !tool.capabilities().supports_credential_invalidation {
734 return None;
735 }
736 Some(format!("{} -k", tool.binary_name()))
737}
738
739pub fn validate_password(tool: PrivilegeTool, password: &str) -> Result<bool, String> {
758 if !tool.capabilities().supports_stdin_password {
759 return Err(format!(
760 "{tool} does not support password validation via stdin. \
761 Configure passwordless {tool} or switch to sudo in settings.conf."
762 ));
763 }
764
765 let escaped = crate::install::shell_single_quote(password);
766 let bin = tool.binary_name();
767 let cmd = format!("{bin} -k ; printf '%s\\n' {escaped} | {bin} -S -v 2>&1");
768
769 let output = Command::new("sh")
770 .arg("-c")
771 .arg(&cmd)
772 .output()
773 .map_err(|e| format!("Failed to execute {bin} validation: {e}"))?;
774
775 Ok(output.status.success())
776}
777
778fn is_integration_test_context() -> bool {
790 if !std::env::var("PACSEA_INTEGRATION_TEST").is_ok_and(|v| v == "1") {
791 return false;
792 }
793
794 #[cfg(debug_assertions)]
798 {
799 true
800 }
801
802 #[cfg(not(debug_assertions))]
803 {
804 is_running_cargo_test_binary()
805 }
806}
807
808#[cfg(not(debug_assertions))]
821fn is_running_cargo_test_binary() -> bool {
822 let Ok(exe) = std::env::current_exe() else {
823 return false;
824 };
825 exe.components()
826 .any(|component| component.as_os_str() == "deps")
827}
828
829#[must_use]
837pub fn is_integration_test() -> bool {
838 is_integration_test_context()
839}
840
841#[cfg(test)]
846mod tests {
847 use super::*;
848
849 #[test]
852 fn tool_binary_name_sudo() {
853 assert_eq!(PrivilegeTool::Sudo.binary_name(), "sudo");
854 }
855
856 #[test]
857 fn tool_binary_name_doas() {
858 assert_eq!(PrivilegeTool::Doas.binary_name(), "doas");
859 }
860
861 #[test]
862 fn tool_display_matches_binary_name() {
863 assert_eq!(format!("{}", PrivilegeTool::Sudo), "sudo");
864 assert_eq!(format!("{}", PrivilegeTool::Doas), "doas");
865 }
866
867 #[test]
868 fn sudo_capabilities_all_enabled() {
869 let caps = PrivilegeTool::Sudo.capabilities();
870 assert!(caps.supports_stdin_password);
871 assert!(caps.supports_credential_refresh);
872 assert!(caps.supports_credential_invalidation);
873 assert!(caps.supports_askpass);
874 }
875
876 #[test]
877 fn doas_capabilities_all_disabled() {
878 let caps = PrivilegeTool::Doas.capabilities();
879 assert!(!caps.supports_stdin_password);
880 assert!(!caps.supports_credential_refresh);
881 assert!(!caps.supports_credential_invalidation);
882 assert!(!caps.supports_askpass);
883 }
884
885 #[test]
888 fn mode_from_config_key_valid() {
889 assert_eq!(
890 PrivilegeMode::from_config_key("auto"),
891 Some(PrivilegeMode::Auto)
892 );
893 assert_eq!(
894 PrivilegeMode::from_config_key("sudo"),
895 Some(PrivilegeMode::Sudo)
896 );
897 assert_eq!(
898 PrivilegeMode::from_config_key("doas"),
899 Some(PrivilegeMode::Doas)
900 );
901 }
902
903 #[test]
904 fn mode_from_config_key_case_insensitive() {
905 assert_eq!(
906 PrivilegeMode::from_config_key("AUTO"),
907 Some(PrivilegeMode::Auto)
908 );
909 assert_eq!(
910 PrivilegeMode::from_config_key("Sudo"),
911 Some(PrivilegeMode::Sudo)
912 );
913 assert_eq!(
914 PrivilegeMode::from_config_key("DOAS"),
915 Some(PrivilegeMode::Doas)
916 );
917 }
918
919 #[test]
920 fn mode_from_config_key_with_whitespace() {
921 assert_eq!(
922 PrivilegeMode::from_config_key(" auto "),
923 Some(PrivilegeMode::Auto)
924 );
925 }
926
927 #[test]
928 fn mode_from_config_key_invalid() {
929 assert_eq!(PrivilegeMode::from_config_key(""), None);
930 assert_eq!(PrivilegeMode::from_config_key("su"), None);
931 assert_eq!(PrivilegeMode::from_config_key("runas"), None);
932 }
933
934 #[test]
935 fn mode_as_config_key_roundtrip() {
936 for mode in [
937 PrivilegeMode::Auto,
938 PrivilegeMode::Sudo,
939 PrivilegeMode::Doas,
940 ] {
941 let key = mode.as_config_key();
942 assert_eq!(PrivilegeMode::from_config_key(key), Some(mode));
943 }
944 }
945
946 #[test]
947 fn mode_default_is_auto() {
948 assert_eq!(PrivilegeMode::default(), PrivilegeMode::Auto);
949 }
950
951 #[test]
952 fn mode_display() {
953 assert_eq!(format!("{}", PrivilegeMode::Auto), "auto");
954 assert_eq!(format!("{}", PrivilegeMode::Sudo), "sudo");
955 assert_eq!(format!("{}", PrivilegeMode::Doas), "doas");
956 }
957
958 #[test]
961 fn auth_mode_from_config_key_valid() {
962 assert_eq!(AuthMode::from_config_key("prompt"), Some(AuthMode::Prompt));
963 assert_eq!(
964 AuthMode::from_config_key("passwordless_only"),
965 Some(AuthMode::PasswordlessOnly)
966 );
967 assert_eq!(
968 AuthMode::from_config_key("interactive"),
969 Some(AuthMode::Interactive)
970 );
971 }
972
973 #[test]
974 fn auth_mode_from_config_key_case_insensitive() {
975 assert_eq!(AuthMode::from_config_key("PROMPT"), Some(AuthMode::Prompt));
976 assert_eq!(
977 AuthMode::from_config_key("Interactive"),
978 Some(AuthMode::Interactive)
979 );
980 assert_eq!(
981 AuthMode::from_config_key("PASSWORDLESS_ONLY"),
982 Some(AuthMode::PasswordlessOnly)
983 );
984 }
985
986 #[test]
987 fn auth_mode_from_config_key_hyphen_alias() {
988 assert_eq!(
989 AuthMode::from_config_key("passwordless-only"),
990 Some(AuthMode::PasswordlessOnly)
991 );
992 }
993
994 #[test]
995 fn auth_mode_from_config_key_short_alias() {
996 assert_eq!(
997 AuthMode::from_config_key("passwordless"),
998 Some(AuthMode::PasswordlessOnly)
999 );
1000 }
1001
1002 #[test]
1003 fn auth_mode_from_config_key_with_whitespace() {
1004 assert_eq!(
1005 AuthMode::from_config_key(" interactive "),
1006 Some(AuthMode::Interactive)
1007 );
1008 }
1009
1010 #[test]
1011 fn auth_mode_from_config_key_invalid() {
1012 assert_eq!(AuthMode::from_config_key(""), None);
1013 assert_eq!(AuthMode::from_config_key("fingerprint"), None);
1014 assert_eq!(AuthMode::from_config_key("password"), None);
1015 }
1016
1017 #[test]
1018 fn auth_mode_as_config_key_roundtrip() {
1019 for mode in [
1020 AuthMode::Prompt,
1021 AuthMode::PasswordlessOnly,
1022 AuthMode::Interactive,
1023 ] {
1024 let key = mode.as_config_key();
1025 assert_eq!(AuthMode::from_config_key(key), Some(mode));
1026 }
1027 }
1028
1029 #[test]
1030 fn auth_mode_default_is_prompt() {
1031 assert_eq!(AuthMode::default(), AuthMode::Prompt);
1032 }
1033
1034 #[test]
1035 fn auth_mode_display() {
1036 assert_eq!(format!("{}", AuthMode::Prompt), "prompt");
1037 assert_eq!(
1038 format!("{}", AuthMode::PasswordlessOnly),
1039 "passwordless_only"
1040 );
1041 assert_eq!(format!("{}", AuthMode::Interactive), "interactive");
1042 }
1043
1044 #[test]
1045 fn auth_mode_always_skips_password_modal() {
1046 assert!(!AuthMode::Prompt.always_skips_password_modal());
1047 assert!(!AuthMode::PasswordlessOnly.always_skips_password_modal());
1048 assert!(AuthMode::Interactive.always_skips_password_modal());
1049 }
1050
1051 #[test]
1054 fn resolve_auto_prefers_doas_when_both_available() {
1055 let _guard = crate::global_test_mutex_lock();
1056 unsafe {
1057 std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1058 std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "sudo,doas");
1059 }
1060 let result = resolve_privilege_tool(PrivilegeMode::Auto);
1061 unsafe {
1062 std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1063 std::env::remove_var("PACSEA_INTEGRATION_TEST");
1064 }
1065 assert_eq!(result, Ok(PrivilegeTool::Doas));
1066 }
1067
1068 #[test]
1069 fn resolve_auto_falls_back_to_sudo() {
1070 let _guard = crate::global_test_mutex_lock();
1071 unsafe {
1072 std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1073 std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "sudo");
1074 }
1075 let result = resolve_privilege_tool(PrivilegeMode::Auto);
1076 unsafe {
1077 std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1078 std::env::remove_var("PACSEA_INTEGRATION_TEST");
1079 }
1080 assert_eq!(result, Ok(PrivilegeTool::Sudo));
1081 }
1082
1083 #[test]
1084 fn resolve_auto_fails_when_none_available() {
1085 let _guard = crate::global_test_mutex_lock();
1086 unsafe {
1087 std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1088 std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "none");
1089 }
1090 let result = resolve_privilege_tool(PrivilegeMode::Auto);
1091 unsafe {
1092 std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1093 std::env::remove_var("PACSEA_INTEGRATION_TEST");
1094 }
1095 let err = result.expect_err("should fail when no tool available");
1096 assert!(err.contains("Neither doas nor sudo found"));
1097 }
1098
1099 #[test]
1100 fn resolve_explicit_sudo_succeeds() {
1101 let _guard = crate::global_test_mutex_lock();
1102 unsafe {
1103 std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1104 std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "sudo");
1105 }
1106 let result = resolve_privilege_tool(PrivilegeMode::Sudo);
1107 unsafe {
1108 std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1109 std::env::remove_var("PACSEA_INTEGRATION_TEST");
1110 }
1111 assert_eq!(result, Ok(PrivilegeTool::Sudo));
1112 }
1113
1114 #[test]
1115 fn resolve_explicit_sudo_fails_when_missing() {
1116 let _guard = crate::global_test_mutex_lock();
1117 unsafe {
1118 std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1119 std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "doas");
1120 }
1121 let result = resolve_privilege_tool(PrivilegeMode::Sudo);
1122 unsafe {
1123 std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1124 std::env::remove_var("PACSEA_INTEGRATION_TEST");
1125 }
1126 assert!(result.is_err());
1127 let err = result.expect_err("should fail when sudo unavailable");
1128 assert!(err.contains("sudo is not available"));
1129 }
1130
1131 #[test]
1132 fn resolve_explicit_doas_succeeds() {
1133 let _guard = crate::global_test_mutex_lock();
1134 unsafe {
1135 std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1136 std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "doas");
1137 }
1138 let result = resolve_privilege_tool(PrivilegeMode::Doas);
1139 unsafe {
1140 std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1141 std::env::remove_var("PACSEA_INTEGRATION_TEST");
1142 }
1143 assert_eq!(result, Ok(PrivilegeTool::Doas));
1144 }
1145
1146 #[test]
1147 fn resolve_explicit_doas_fails_when_missing() {
1148 let _guard = crate::global_test_mutex_lock();
1149 unsafe {
1150 std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1151 std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "sudo");
1152 }
1153 let result = resolve_privilege_tool(PrivilegeMode::Doas);
1154 unsafe {
1155 std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1156 std::env::remove_var("PACSEA_INTEGRATION_TEST");
1157 }
1158 assert!(result.is_err());
1159 let err = result.expect_err("should fail when doas unavailable");
1160 assert!(err.contains("doas is not available"));
1161 }
1162
1163 #[test]
1166 fn build_privilege_command_sudo() {
1167 let cmd = build_privilege_command(PrivilegeTool::Sudo, "pacman -S foo");
1168 assert_eq!(cmd, "sudo pacman -S foo");
1169 }
1170
1171 #[test]
1172 fn build_privilege_command_doas() {
1173 let cmd = build_privilege_command(PrivilegeTool::Doas, "pacman -S foo");
1174 assert_eq!(cmd, "doas pacman -S foo");
1175 }
1176
1177 #[test]
1178 fn build_password_pipe_sudo_returns_some() {
1179 let result = build_password_pipe(PrivilegeTool::Sudo, "secret", "pacman -S foo");
1180 let cmd = result.expect("sudo should support password pipe");
1181 assert!(cmd.contains("printf "));
1182 assert!(cmd.contains("sudo -S pacman -S foo"));
1183 }
1184
1185 #[test]
1186 fn build_password_pipe_doas_returns_none() {
1187 let result = build_password_pipe(PrivilegeTool::Doas, "secret", "pacman -S foo");
1188 assert!(result.is_none());
1189 }
1190
1191 #[test]
1192 fn build_credential_warmup_sudo_returns_some() {
1193 let result = build_credential_warmup(PrivilegeTool::Sudo, "secret");
1194 let cmd = result.expect("sudo should support credential warmup");
1195 assert!(cmd.contains("sudo -S -v"));
1196 }
1197
1198 #[test]
1199 fn build_credential_warmup_doas_returns_none() {
1200 let result = build_credential_warmup(PrivilegeTool::Doas, "secret");
1201 assert!(result.is_none());
1202 }
1203
1204 #[test]
1205 fn build_credential_invalidation_sudo_returns_some() {
1206 let cmd = build_credential_invalidation(PrivilegeTool::Sudo)
1207 .expect("sudo should support credential invalidation");
1208 assert_eq!(cmd, "sudo -k");
1209 }
1210
1211 #[test]
1212 fn build_credential_invalidation_doas_returns_none() {
1213 let result = build_credential_invalidation(PrivilegeTool::Doas);
1214 assert!(result.is_none());
1215 }
1216
1217 #[test]
1220 fn validate_password_doas_returns_err() {
1221 let result = validate_password(PrivilegeTool::Doas, "any");
1222 let err = result.expect_err("doas should not support password validation");
1223 assert!(err.contains("does not support"));
1224 }
1225
1226 #[test]
1229 fn is_available_test_override_none() {
1230 let _guard = crate::global_test_mutex_lock();
1231 unsafe {
1232 std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1233 std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "none");
1234 }
1235 assert!(!PrivilegeTool::Sudo.is_available());
1236 assert!(!PrivilegeTool::Doas.is_available());
1237 unsafe {
1238 std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1239 std::env::remove_var("PACSEA_INTEGRATION_TEST");
1240 }
1241 }
1242
1243 #[test]
1244 fn is_available_test_override_sudo_only() {
1245 let _guard = crate::global_test_mutex_lock();
1246 unsafe {
1247 std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1248 std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "sudo");
1249 }
1250 assert!(PrivilegeTool::Sudo.is_available());
1251 assert!(!PrivilegeTool::Doas.is_available());
1252 unsafe {
1253 std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1254 std::env::remove_var("PACSEA_INTEGRATION_TEST");
1255 }
1256 }
1257
1258 #[test]
1259 fn is_available_test_override_both() {
1260 let _guard = crate::global_test_mutex_lock();
1261 unsafe {
1262 std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1263 std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "sudo,doas");
1264 }
1265 assert!(PrivilegeTool::Sudo.is_available());
1266 assert!(PrivilegeTool::Doas.is_available());
1267 unsafe {
1268 std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1269 std::env::remove_var("PACSEA_INTEGRATION_TEST");
1270 }
1271 }
1272
1273 #[test]
1276 fn is_integration_test_when_set() {
1277 let _guard = crate::global_test_mutex_lock();
1278 unsafe {
1279 std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1280 }
1281 assert!(is_integration_test());
1282 unsafe {
1283 std::env::remove_var("PACSEA_INTEGRATION_TEST");
1284 }
1285 }
1286
1287 #[test]
1288 fn is_integration_test_when_unset() {
1289 let _guard = crate::global_test_mutex_lock();
1290 unsafe {
1291 std::env::remove_var("PACSEA_INTEGRATION_TEST");
1292 }
1293 assert!(!is_integration_test());
1294 }
1295
1296 #[test]
1297 fn is_integration_test_wrong_value() {
1298 let _guard = crate::global_test_mutex_lock();
1299 unsafe {
1300 std::env::set_var("PACSEA_INTEGRATION_TEST", "0");
1301 }
1302 assert!(!is_integration_test());
1303 unsafe {
1304 std::env::remove_var("PACSEA_INTEGRATION_TEST");
1305 }
1306 }
1307
1308 #[test]
1311 fn active_tool_returns_ok_with_sudo_or_doas() {
1312 let tool = active_tool().expect("active_tool should resolve in this environment");
1313 assert!(
1314 tool == PrivilegeTool::Sudo || tool == PrivilegeTool::Doas,
1315 "active_tool should return Sudo or Doas"
1316 );
1317 }
1318
1319 #[test]
1320 fn active_tool_for_mode_explicit_doas_errors_when_missing() {
1321 let _guard = crate::global_test_mutex_lock();
1322 unsafe {
1323 std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1324 std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "sudo");
1325 }
1326 let result = super::active_tool_for_mode(PrivilegeMode::Doas);
1327 unsafe {
1328 std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1329 std::env::remove_var("PACSEA_INTEGRATION_TEST");
1330 }
1331 let err = result.expect_err("explicit doas without doas on PATH should error");
1332 assert!(err.contains("doas is not available"));
1333 }
1334
1335 #[test]
1336 fn active_tool_for_mode_explicit_sudo_errors_when_missing() {
1337 let _guard = crate::global_test_mutex_lock();
1338 unsafe {
1339 std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1340 std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "doas");
1341 }
1342 let result = super::active_tool_for_mode(PrivilegeMode::Sudo);
1343 unsafe {
1344 std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1345 std::env::remove_var("PACSEA_INTEGRATION_TEST");
1346 }
1347 let err = result.expect_err("explicit sudo without sudo on PATH should error");
1348 assert!(err.contains("sudo is not available"));
1349 }
1350
1351 #[test]
1352 fn active_tool_for_mode_auto_none_falls_back_to_sudo() {
1353 let _guard = crate::global_test_mutex_lock();
1354 unsafe {
1355 std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1356 std::env::set_var("PACSEA_TEST_PRIVILEGE_AVAILABLE", "none");
1357 }
1358 let result = super::active_tool_for_mode(PrivilegeMode::Auto);
1359 unsafe {
1360 std::env::remove_var("PACSEA_TEST_PRIVILEGE_AVAILABLE");
1361 std::env::remove_var("PACSEA_INTEGRATION_TEST");
1362 }
1363 assert_eq!(
1364 result,
1365 Ok(PrivilegeTool::Sudo),
1366 "Auto with no tools should still return Ok(Sudo) after fallback"
1367 );
1368 }
1369
1370 #[test]
1373 fn build_password_pipe_uses_printf_not_echo() {
1374 let result = build_password_pipe(PrivilegeTool::Sudo, "pw", "cmd");
1375 let cmd = result.expect("sudo pipe should return Some");
1376 assert!(cmd.starts_with("printf "), "should use printf, not echo");
1377 assert!(cmd.contains("%s\\n"), "should use %s\\n format");
1378 }
1379
1380 #[test]
1381 fn build_password_pipe_escapes_special_chars() {
1382 let result = build_password_pipe(PrivilegeTool::Sudo, "pa's$word", "pacman -S foo");
1383 let cmd = result.expect("sudo pipe should return Some");
1384 assert!(cmd.contains("sudo -S pacman -S foo"));
1385 assert!(!cmd.contains("pa's$word"), "password must be shell-escaped");
1386 }
1387
1388 #[test]
1389 fn build_credential_warmup_uses_printf() {
1390 let result = build_credential_warmup(PrivilegeTool::Sudo, "pw");
1391 let cmd = result.expect("sudo warmup should return Some");
1392 assert!(cmd.starts_with("printf "), "warmup should use printf");
1393 assert!(cmd.contains("sudo -S -v"));
1394 assert!(cmd.contains("2>/dev/null"));
1395 }
1396
1397 #[test]
1400 fn build_privilege_command_doas_format() {
1401 let cmd = build_privilege_command(PrivilegeTool::Doas, "pacman -Syu --noconfirm");
1402 assert_eq!(cmd, "doas pacman -Syu --noconfirm");
1403 }
1404
1405 #[test]
1406 fn doas_password_pipe_returns_none() {
1407 assert!(build_password_pipe(PrivilegeTool::Doas, "pw", "cmd").is_none());
1408 }
1409
1410 #[test]
1411 fn doas_credential_warmup_returns_none() {
1412 assert!(build_credential_warmup(PrivilegeTool::Doas, "pw").is_none());
1413 }
1414
1415 #[test]
1416 fn doas_credential_invalidation_returns_none() {
1417 assert!(build_credential_invalidation(PrivilegeTool::Doas).is_none());
1418 }
1419
1420 #[test]
1423 fn check_passwordless_test_override_true() {
1424 let _guard = crate::global_test_mutex_lock();
1425 unsafe {
1426 std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1427 std::env::set_var("PACSEA_TEST_SUDO_PASSWORDLESS", "1");
1428 }
1429 let result = PrivilegeTool::Sudo.check_passwordless();
1430 unsafe {
1431 std::env::remove_var("PACSEA_TEST_SUDO_PASSWORDLESS");
1432 std::env::remove_var("PACSEA_INTEGRATION_TEST");
1433 }
1434 assert_eq!(result, Ok(true));
1435 }
1436
1437 #[test]
1438 fn check_passwordless_test_override_false() {
1439 let _guard = crate::global_test_mutex_lock();
1440 unsafe {
1441 std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1442 std::env::set_var("PACSEA_TEST_SUDO_PASSWORDLESS", "0");
1443 }
1444 let result = PrivilegeTool::Sudo.check_passwordless();
1445 unsafe {
1446 std::env::remove_var("PACSEA_TEST_SUDO_PASSWORDLESS");
1447 std::env::remove_var("PACSEA_INTEGRATION_TEST");
1448 }
1449 assert_eq!(result, Ok(false));
1450 }
1451
1452 #[test]
1453 fn check_passwordless_doas_test_override() {
1454 let _guard = crate::global_test_mutex_lock();
1455 unsafe {
1456 std::env::set_var("PACSEA_INTEGRATION_TEST", "1");
1457 std::env::set_var("PACSEA_TEST_SUDO_PASSWORDLESS", "1");
1458 }
1459 let result = PrivilegeTool::Doas.check_passwordless();
1460 unsafe {
1461 std::env::remove_var("PACSEA_TEST_SUDO_PASSWORDLESS");
1462 std::env::remove_var("PACSEA_INTEGRATION_TEST");
1463 }
1464 assert_eq!(result, Ok(true));
1465 }
1466
1467 #[test]
1470 fn validate_password_doas_returns_unsupported() {
1471 let result = validate_password(PrivilegeTool::Doas, "any");
1472 let err = result.expect_err("doas should not support password validation");
1473 assert!(
1474 err.contains("does not support"),
1475 "unexpected error message: {err}"
1476 );
1477 }
1478
1479 #[test]
1482 fn both_tools_produce_distinct_commands() {
1483 let sudo_cmd = build_privilege_command(PrivilegeTool::Sudo, "pacman -S foo");
1484 let doas_cmd = build_privilege_command(PrivilegeTool::Doas, "pacman -S foo");
1485 assert_ne!(sudo_cmd, doas_cmd, "sudo and doas commands must differ");
1486 assert!(sudo_cmd.starts_with("sudo "));
1487 assert!(doas_cmd.starts_with("doas "));
1488 }
1489
1490 #[test]
1491 fn capabilities_are_complementary() {
1492 let sudo_caps = PrivilegeTool::Sudo.capabilities();
1493 let doas_caps = PrivilegeTool::Doas.capabilities();
1494 assert_ne!(
1495 sudo_caps.supports_stdin_password, doas_caps.supports_stdin_password,
1496 "sudo and doas should differ on stdin password support"
1497 );
1498 }
1499
1500 #[test]
1503 fn detect_pam_fingerprint_sudo_does_not_panic() {
1504 let _result = detect_pam_fingerprint(PrivilegeTool::Sudo);
1507 }
1508
1509 #[test]
1510 fn detect_pam_fingerprint_doas_does_not_panic() {
1511 let _result = detect_pam_fingerprint(PrivilegeTool::Doas);
1512 }
1513
1514 #[test]
1515 fn detect_fprintd_enrolled_does_not_panic() {
1516 let _result = detect_fprintd_enrolled();
1517 }
1518
1519 #[test]
1520 fn is_fingerprint_available_does_not_panic() {
1521 let _result = is_fingerprint_available();
1522 }
1523}