@@ -1,5 +1,5 @@ |
| 1 | use crate::polkit_helper::{ | 1 | use crate::polkit_helper::{ |
| 2 | - DEFAULT_HELPER_SOCKET, HelperOutcome, HelperSocketClient, PromptProvider, | 2 | + DEFAULT_HELPER_SOCKET, HelperOutcome, HelperSocketClient, PromptProvider, PromptResponse, |
| 3 | }; | 3 | }; |
| 4 | use crate::prompt::CommandPrompt; | 4 | use crate::prompt::CommandPrompt; |
| 5 | use crate::state::{AuthPhase, AuthQueue, AuthState, QueueInsert}; | 5 | use crate::state::{AuthPhase, AuthQueue, AuthState, QueueInsert}; |
@@ -56,6 +56,7 @@ pub struct PolkitBackendConfig { |
| 56 | type Subject = (String, HashMap<String, OwnedValue>); | 56 | type Subject = (String, HashMap<String, OwnedValue>); |
| 57 | type Details = HashMap<String, String>; | 57 | type Details = HashMap<String, String>; |
| 58 | const DEFAULT_AUTH_MAX_ATTEMPTS: usize = 3; | 58 | const DEFAULT_AUTH_MAX_ATTEMPTS: usize = 3; |
| | 59 | +const DEFAULT_IDENTITY_SELECTION_ATTEMPTS: usize = 3; |
| 59 | | 60 | |
| 60 | #[derive(Debug)] | 61 | #[derive(Debug)] |
| 61 | struct AuthRequest { | 62 | struct AuthRequest { |
@@ -76,6 +77,7 @@ struct ActiveRequest { |
| 76 | details: Details, | 77 | details: Details, |
| 77 | cookie: String, | 78 | cookie: String, |
| 78 | username: String, | 79 | username: String, |
| | 80 | + identity_options: Vec<String>, |
| 79 | } | 81 | } |
| 80 | | 82 | |
| 81 | #[derive(Debug)] | 83 | #[derive(Debug)] |
@@ -323,6 +325,13 @@ impl PolkitRuntime { |
| 323 | .or_else(current_username) | 325 | .or_else(current_username) |
| 324 | .or_else(|| std::env::var("USER").ok()) | 326 | .or_else(|| std::env::var("USER").ok()) |
| 325 | .unwrap_or_else(|| "unknown".to_string()); | 327 | .unwrap_or_else(|| "unknown".to_string()); |
| | 328 | + let mut identity_options = identity_options_from_subjects(&request.identities); |
| | 329 | + if !identity_options |
| | 330 | + .iter() |
| | 331 | + .any(|candidate| candidate == &username) |
| | 332 | + { |
| | 333 | + identity_options.insert(0, username.clone()); |
| | 334 | + } |
| 326 | | 335 | |
| 327 | tracing::debug!( | 336 | tracing::debug!( |
| 328 | identity_summary = %summarize_identities(&request.identities), | 337 | identity_summary = %summarize_identities(&request.identities), |
@@ -338,6 +347,7 @@ impl PolkitRuntime { |
| 338 | details: request.details.clone(), | 347 | details: request.details.clone(), |
| 339 | cookie: request.cookie.clone(), | 348 | cookie: request.cookie.clone(), |
| 340 | username, | 349 | username, |
| | 350 | + identity_options, |
| 341 | }) | 351 | }) |
| 342 | } | 352 | } |
| 343 | | 353 | |
@@ -353,6 +363,10 @@ impl PolkitRuntime { |
| 353 | ) -> HelperOutcome { | 363 | ) -> HelperOutcome { |
| 354 | let prompt_context = render_prompt_context(request); | 364 | let prompt_context = render_prompt_context(request); |
| 355 | let max_attempts = auth_max_attempts(); | 365 | let max_attempts = auth_max_attempts(); |
| | 366 | + let username = match select_identity_for_request(request, prompts) { |
| | 367 | + IdentitySelection::Selected(selected) => selected, |
| | 368 | + IdentitySelection::Terminal(outcome) => return outcome, |
| | 369 | + }; |
| 356 | | 370 | |
| 357 | for attempt in 1..=max_attempts { | 371 | for attempt in 1..=max_attempts { |
| 358 | if self.is_canceled(&request.cookie) { | 372 | if self.is_canceled(&request.cookie) { |
@@ -371,11 +385,10 @@ impl PolkitRuntime { |
| 371 | | 385 | |
| 372 | let mut cancel_aware = | 386 | let mut cancel_aware = |
| 373 | CancellationAwarePrompt::new(prompts, self, request.cookie.as_str()); | 387 | CancellationAwarePrompt::new(prompts, self, request.cookie.as_str()); |
| 374 | - match self.helper_client.authenticate( | 388 | + match self |
| 375 | - &request.username, | 389 | + .helper_client |
| 376 | - &request.cookie, | 390 | + .authenticate(&username, &request.cookie, &mut cancel_aware) |
| 377 | - &mut cancel_aware, | 391 | + { |
| 378 | - ) { | | |
| 379 | Ok(outcome) if self.is_canceled(&request.cookie) => { | 392 | Ok(outcome) if self.is_canceled(&request.cookie) => { |
| 380 | tracing::info!( | 393 | tracing::info!( |
| 381 | action_id = %request.action_id, | 394 | action_id = %request.action_id, |
@@ -558,6 +571,123 @@ fn render_prompt_context(request: &ActiveRequest) -> String { |
| 558 | lines.join("\n") | 571 | lines.join("\n") |
| 559 | } | 572 | } |
| 560 | | 573 | |
| | 574 | +enum IdentitySelection { |
| | 575 | + Selected(String), |
| | 576 | + Terminal(HelperOutcome), |
| | 577 | +} |
| | 578 | + |
| | 579 | +fn select_identity_for_request<P: PromptProvider>( |
| | 580 | + request: &ActiveRequest, |
| | 581 | + prompts: &mut P, |
| | 582 | +) -> IdentitySelection { |
| | 583 | + if request.identity_options.len() <= 1 { |
| | 584 | + return IdentitySelection::Selected(request.username.clone()); |
| | 585 | + } |
| | 586 | + |
| | 587 | + let prompt = render_identity_selection_prompt(request); |
| | 588 | + for attempt in 1..=DEFAULT_IDENTITY_SELECTION_ATTEMPTS { |
| | 589 | + match prompts.prompt_plain(&prompt) { |
| | 590 | + Ok(PromptResponse::Submitted(mut raw)) => { |
| | 591 | + if let Some(selected) = parse_identity_selection( |
| | 592 | + raw.as_str(), |
| | 593 | + &request.identity_options, |
| | 594 | + &request.username, |
| | 595 | + ) { |
| | 596 | + raw.clear(); |
| | 597 | + return IdentitySelection::Selected(selected); |
| | 598 | + } |
| | 599 | + |
| | 600 | + raw.clear(); |
| | 601 | + let _ = prompts.show_error("Invalid identity selection"); |
| | 602 | + if attempt == DEFAULT_IDENTITY_SELECTION_ATTEMPTS { |
| | 603 | + tracing::warn!( |
| | 604 | + action_id = %request.action_id, |
| | 605 | + default_identity = %request.username, |
| | 606 | + "Identity selection failed repeatedly; using default identity" |
| | 607 | + ); |
| | 608 | + return IdentitySelection::Selected(request.username.clone()); |
| | 609 | + } |
| | 610 | + } |
| | 611 | + Ok(PromptResponse::Canceled) => { |
| | 612 | + return IdentitySelection::Terminal(HelperOutcome::Canceled); |
| | 613 | + } |
| | 614 | + Ok(PromptResponse::TimedOut) => { |
| | 615 | + return IdentitySelection::Terminal(HelperOutcome::Timeout); |
| | 616 | + } |
| | 617 | + Err(err) => { |
| | 618 | + tracing::warn!( |
| | 619 | + action_id = %request.action_id, |
| | 620 | + error = %err, |
| | 621 | + default_identity = %request.username, |
| | 622 | + "Identity selection prompt failed; using default identity" |
| | 623 | + ); |
| | 624 | + return IdentitySelection::Selected(request.username.clone()); |
| | 625 | + } |
| | 626 | + } |
| | 627 | + } |
| | 628 | + |
| | 629 | + IdentitySelection::Selected(request.username.clone()) |
| | 630 | +} |
| | 631 | + |
| | 632 | +fn render_identity_selection_prompt(request: &ActiveRequest) -> String { |
| | 633 | + let mut lines = vec![ |
| | 634 | + "Select authentication identity".to_string(), |
| | 635 | + format!("Action: {}", request.action_id), |
| | 636 | + ]; |
| | 637 | + for (index, option) in request.identity_options.iter().enumerate() { |
| | 638 | + if option == &request.username { |
| | 639 | + lines.push(format!("{}: {} (default)", index + 1, option)); |
| | 640 | + } else { |
| | 641 | + lines.push(format!("{}: {}", index + 1, option)); |
| | 642 | + } |
| | 643 | + } |
| | 644 | + lines.push("Enter number or username (blank for default)".to_string()); |
| | 645 | + lines.join("\n") |
| | 646 | +} |
| | 647 | + |
| | 648 | +fn parse_identity_selection(input: &str, options: &[String], default: &str) -> Option<String> { |
| | 649 | + let trimmed = input.trim(); |
| | 650 | + if trimmed.is_empty() { |
| | 651 | + return Some(default.to_string()); |
| | 652 | + } |
| | 653 | + |
| | 654 | + if let Ok(index) = trimmed.parse::<usize>() { |
| | 655 | + if (1..=options.len()).contains(&index) { |
| | 656 | + return options.get(index - 1).cloned(); |
| | 657 | + } |
| | 658 | + } |
| | 659 | + |
| | 660 | + options |
| | 661 | + .iter() |
| | 662 | + .find(|option| option.eq_ignore_ascii_case(trimmed)) |
| | 663 | + .cloned() |
| | 664 | +} |
| | 665 | + |
| | 666 | +fn identity_options_from_subjects(identities: &[Subject]) -> Vec<String> { |
| | 667 | + let mut options = Vec::new(); |
| | 668 | + let mut seen = HashSet::new(); |
| | 669 | + |
| | 670 | + for (kind, details) in identities { |
| | 671 | + if kind != "unix-user" { |
| | 672 | + continue; |
| | 673 | + } |
| | 674 | + |
| | 675 | + let Some(name) = identity_name(details) else { |
| | 676 | + continue; |
| | 677 | + }; |
| | 678 | + let trimmed = name.trim(); |
| | 679 | + if trimmed.is_empty() { |
| | 680 | + continue; |
| | 681 | + } |
| | 682 | + let dedupe_key = trimmed.to_ascii_lowercase(); |
| | 683 | + if seen.insert(dedupe_key) { |
| | 684 | + options.push(trimmed.to_string()); |
| | 685 | + } |
| | 686 | + } |
| | 687 | + |
| | 688 | + options |
| | 689 | +} |
| | 690 | + |
| 561 | fn detail_key_label(key: &str) -> &'static str { | 691 | fn detail_key_label(key: &str) -> &'static str { |
| 562 | match key { | 692 | match key { |
| 563 | "program" => "Program", | 693 | "program" => "Program", |
@@ -1042,6 +1172,7 @@ mod tests { |
| 1042 | responses: VecDeque<PromptResponse>, | 1172 | responses: VecDeque<PromptResponse>, |
| 1043 | success_count: usize, | 1173 | success_count: usize, |
| 1044 | failure_messages: Vec<String>, | 1174 | failure_messages: Vec<String>, |
| | 1175 | + error_messages: Vec<String>, |
| 1045 | prompt_count: usize, | 1176 | prompt_count: usize, |
| 1046 | } | 1177 | } |
| 1047 | | 1178 | |
@@ -1051,6 +1182,7 @@ mod tests { |
| 1051 | responses: VecDeque::from(responses), | 1182 | responses: VecDeque::from(responses), |
| 1052 | success_count: 0, | 1183 | success_count: 0, |
| 1053 | failure_messages: Vec::new(), | 1184 | failure_messages: Vec::new(), |
| | 1185 | + error_messages: Vec::new(), |
| 1054 | prompt_count: 0, | 1186 | prompt_count: 0, |
| 1055 | } | 1187 | } |
| 1056 | } | 1188 | } |
@@ -1078,6 +1210,11 @@ mod tests { |
| 1078 | self.failure_messages.push(message.to_string()); | 1210 | self.failure_messages.push(message.to_string()); |
| 1079 | Ok(()) | 1211 | Ok(()) |
| 1080 | } | 1212 | } |
| | 1213 | + |
| | 1214 | + fn show_error(&mut self, message: &str) -> Result<()> { |
| | 1215 | + self.error_messages.push(message.to_string()); |
| | 1216 | + Ok(()) |
| | 1217 | + } |
| 1081 | } | 1218 | } |
| 1082 | | 1219 | |
| 1083 | impl RetryPromptProvider for SequencedPrompt {} | 1220 | impl RetryPromptProvider for SequencedPrompt {} |
@@ -1323,6 +1460,7 @@ mod tests { |
| 1323 | details: HashMap::new(), | 1460 | details: HashMap::new(), |
| 1324 | cookie: "cookie-1".to_string(), | 1461 | cookie: "cookie-1".to_string(), |
| 1325 | username: "alice".to_string(), | 1462 | username: "alice".to_string(), |
| | 1463 | + identity_options: vec!["alice".to_string()], |
| 1326 | }; | 1464 | }; |
| 1327 | let mut prompts = SequencedPrompt::new(vec![ | 1465 | let mut prompts = SequencedPrompt::new(vec![ |
| 1328 | PromptResponse::Submitted("correct horse".to_string()), | 1466 | PromptResponse::Submitted("correct horse".to_string()), |
@@ -1380,6 +1518,7 @@ mod tests { |
| 1380 | details: HashMap::new(), | 1518 | details: HashMap::new(), |
| 1381 | cookie: "cookie-timeout".to_string(), | 1519 | cookie: "cookie-timeout".to_string(), |
| 1382 | username: "alice".to_string(), | 1520 | username: "alice".to_string(), |
| | 1521 | + identity_options: vec!["alice".to_string()], |
| 1383 | }; | 1522 | }; |
| 1384 | let mut prompts = SequencedPrompt::new(vec![PromptResponse::TimedOut]); | 1523 | let mut prompts = SequencedPrompt::new(vec![PromptResponse::TimedOut]); |
| 1385 | | 1524 | |
@@ -1410,6 +1549,7 @@ mod tests { |
| 1410 | details, | 1549 | details, |
| 1411 | cookie: "cookie-ctx".to_string(), | 1550 | cookie: "cookie-ctx".to_string(), |
| 1412 | username: "alice".to_string(), | 1551 | username: "alice".to_string(), |
| | 1552 | + identity_options: vec!["alice".to_string()], |
| 1413 | }; | 1553 | }; |
| 1414 | | 1554 | |
| 1415 | let context = render_prompt_context(&request); | 1555 | let context = render_prompt_context(&request); |
@@ -1421,6 +1561,67 @@ mod tests { |
| 1421 | assert!(context.contains("Retains authorization: 1")); | 1561 | assert!(context.contains("Retains authorization: 1")); |
| 1422 | } | 1562 | } |
| 1423 | | 1563 | |
| | 1564 | + #[test] |
| | 1565 | + fn parse_identity_selection_accepts_blank_index_and_name() { |
| | 1566 | + let options = vec!["alice".to_string(), "root".to_string()]; |
| | 1567 | + assert_eq!( |
| | 1568 | + parse_identity_selection("", &options, "alice"), |
| | 1569 | + Some("alice".to_string()) |
| | 1570 | + ); |
| | 1571 | + assert_eq!( |
| | 1572 | + parse_identity_selection("2", &options, "alice"), |
| | 1573 | + Some("root".to_string()) |
| | 1574 | + ); |
| | 1575 | + assert_eq!( |
| | 1576 | + parse_identity_selection("ROOT", &options, "alice"), |
| | 1577 | + Some("root".to_string()) |
| | 1578 | + ); |
| | 1579 | + assert_eq!(parse_identity_selection("99", &options, "alice"), None); |
| | 1580 | + } |
| | 1581 | + |
| | 1582 | + #[test] |
| | 1583 | + fn select_identity_for_request_uses_prompted_choice() { |
| | 1584 | + let request = ActiveRequest { |
| | 1585 | + action_id: "org.gardesk.test".to_string(), |
| | 1586 | + message: "Authenticate".to_string(), |
| | 1587 | + icon_name: "dialog-password".to_string(), |
| | 1588 | + detail_count: 0, |
| | 1589 | + details: HashMap::new(), |
| | 1590 | + cookie: "cookie-identity".to_string(), |
| | 1591 | + username: "alice".to_string(), |
| | 1592 | + identity_options: vec!["alice".to_string(), "root".to_string()], |
| | 1593 | + }; |
| | 1594 | + let mut prompts = SequencedPrompt::new(vec![PromptResponse::Submitted("2".to_string())]); |
| | 1595 | + |
| | 1596 | + let selection = select_identity_for_request(&request, &mut prompts); |
| | 1597 | + assert!(matches!( |
| | 1598 | + selection, |
| | 1599 | + IdentitySelection::Selected(username) if username == "root" |
| | 1600 | + )); |
| | 1601 | + assert_eq!(prompts.prompt_count, 1); |
| | 1602 | + } |
| | 1603 | + |
| | 1604 | + #[test] |
| | 1605 | + fn select_identity_for_request_returns_canceled_outcome() { |
| | 1606 | + let request = ActiveRequest { |
| | 1607 | + action_id: "org.gardesk.test".to_string(), |
| | 1608 | + message: "Authenticate".to_string(), |
| | 1609 | + icon_name: "dialog-password".to_string(), |
| | 1610 | + detail_count: 0, |
| | 1611 | + details: HashMap::new(), |
| | 1612 | + cookie: "cookie-identity-cancel".to_string(), |
| | 1613 | + username: "alice".to_string(), |
| | 1614 | + identity_options: vec!["alice".to_string(), "root".to_string()], |
| | 1615 | + }; |
| | 1616 | + let mut prompts = SequencedPrompt::new(vec![PromptResponse::Canceled]); |
| | 1617 | + |
| | 1618 | + let selection = select_identity_for_request(&request, &mut prompts); |
| | 1619 | + assert!(matches!( |
| | 1620 | + selection, |
| | 1621 | + IdentitySelection::Terminal(HelperOutcome::Canceled) |
| | 1622 | + )); |
| | 1623 | + } |
| | 1624 | + |
| 1424 | #[test] | 1625 | #[test] |
| 1425 | fn cancellation_aware_prompt_short_circuits_prompt_and_feedback() { | 1626 | fn cancellation_aware_prompt_short_circuits_prompt_and_feedback() { |
| 1426 | let runtime = PolkitRuntime::new_without_worker(Arc::new(AuthState::default())); | 1627 | let runtime = PolkitRuntime::new_without_worker(Arc::new(AuthState::default())); |