@@ -1,5 +1,5 @@ |
| 1 | 1 | use crate::polkit_helper::{ |
| 2 | | - DEFAULT_HELPER_SOCKET, HelperOutcome, HelperSocketClient, PromptProvider, |
| 2 | + DEFAULT_HELPER_SOCKET, HelperOutcome, HelperSocketClient, PromptProvider, PromptResponse, |
| 3 | 3 | }; |
| 4 | 4 | use crate::prompt::CommandPrompt; |
| 5 | 5 | use crate::state::{AuthPhase, AuthQueue, AuthState, QueueInsert}; |
@@ -56,6 +56,7 @@ pub struct PolkitBackendConfig { |
| 56 | 56 | type Subject = (String, HashMap<String, OwnedValue>); |
| 57 | 57 | type Details = HashMap<String, String>; |
| 58 | 58 | const DEFAULT_AUTH_MAX_ATTEMPTS: usize = 3; |
| 59 | +const DEFAULT_IDENTITY_SELECTION_ATTEMPTS: usize = 3; |
| 59 | 60 | |
| 60 | 61 | #[derive(Debug)] |
| 61 | 62 | struct AuthRequest { |
@@ -76,6 +77,7 @@ struct ActiveRequest { |
| 76 | 77 | details: Details, |
| 77 | 78 | cookie: String, |
| 78 | 79 | username: String, |
| 80 | + identity_options: Vec<String>, |
| 79 | 81 | } |
| 80 | 82 | |
| 81 | 83 | #[derive(Debug)] |
@@ -323,6 +325,13 @@ impl PolkitRuntime { |
| 323 | 325 | .or_else(current_username) |
| 324 | 326 | .or_else(|| std::env::var("USER").ok()) |
| 325 | 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 | 336 | tracing::debug!( |
| 328 | 337 | identity_summary = %summarize_identities(&request.identities), |
@@ -338,6 +347,7 @@ impl PolkitRuntime { |
| 338 | 347 | details: request.details.clone(), |
| 339 | 348 | cookie: request.cookie.clone(), |
| 340 | 349 | username, |
| 350 | + identity_options, |
| 341 | 351 | }) |
| 342 | 352 | } |
| 343 | 353 | |
@@ -353,6 +363,10 @@ impl PolkitRuntime { |
| 353 | 363 | ) -> HelperOutcome { |
| 354 | 364 | let prompt_context = render_prompt_context(request); |
| 355 | 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 | 371 | for attempt in 1..=max_attempts { |
| 358 | 372 | if self.is_canceled(&request.cookie) { |
@@ -371,11 +385,10 @@ impl PolkitRuntime { |
| 371 | 385 | |
| 372 | 386 | let mut cancel_aware = |
| 373 | 387 | CancellationAwarePrompt::new(prompts, self, request.cookie.as_str()); |
| 374 | | - match self.helper_client.authenticate( |
| 375 | | - &request.username, |
| 376 | | - &request.cookie, |
| 377 | | - &mut cancel_aware, |
| 378 | | - ) { |
| 388 | + match self |
| 389 | + .helper_client |
| 390 | + .authenticate(&username, &request.cookie, &mut cancel_aware) |
| 391 | + { |
| 379 | 392 | Ok(outcome) if self.is_canceled(&request.cookie) => { |
| 380 | 393 | tracing::info!( |
| 381 | 394 | action_id = %request.action_id, |
@@ -558,6 +571,123 @@ fn render_prompt_context(request: &ActiveRequest) -> String { |
| 558 | 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 | 691 | fn detail_key_label(key: &str) -> &'static str { |
| 562 | 692 | match key { |
| 563 | 693 | "program" => "Program", |
@@ -1042,6 +1172,7 @@ mod tests { |
| 1042 | 1172 | responses: VecDeque<PromptResponse>, |
| 1043 | 1173 | success_count: usize, |
| 1044 | 1174 | failure_messages: Vec<String>, |
| 1175 | + error_messages: Vec<String>, |
| 1045 | 1176 | prompt_count: usize, |
| 1046 | 1177 | } |
| 1047 | 1178 | |
@@ -1051,6 +1182,7 @@ mod tests { |
| 1051 | 1182 | responses: VecDeque::from(responses), |
| 1052 | 1183 | success_count: 0, |
| 1053 | 1184 | failure_messages: Vec::new(), |
| 1185 | + error_messages: Vec::new(), |
| 1054 | 1186 | prompt_count: 0, |
| 1055 | 1187 | } |
| 1056 | 1188 | } |
@@ -1078,6 +1210,11 @@ mod tests { |
| 1078 | 1210 | self.failure_messages.push(message.to_string()); |
| 1079 | 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 | 1220 | impl RetryPromptProvider for SequencedPrompt {} |
@@ -1323,6 +1460,7 @@ mod tests { |
| 1323 | 1460 | details: HashMap::new(), |
| 1324 | 1461 | cookie: "cookie-1".to_string(), |
| 1325 | 1462 | username: "alice".to_string(), |
| 1463 | + identity_options: vec!["alice".to_string()], |
| 1326 | 1464 | }; |
| 1327 | 1465 | let mut prompts = SequencedPrompt::new(vec![ |
| 1328 | 1466 | PromptResponse::Submitted("correct horse".to_string()), |
@@ -1380,6 +1518,7 @@ mod tests { |
| 1380 | 1518 | details: HashMap::new(), |
| 1381 | 1519 | cookie: "cookie-timeout".to_string(), |
| 1382 | 1520 | username: "alice".to_string(), |
| 1521 | + identity_options: vec!["alice".to_string()], |
| 1383 | 1522 | }; |
| 1384 | 1523 | let mut prompts = SequencedPrompt::new(vec![PromptResponse::TimedOut]); |
| 1385 | 1524 | |
@@ -1410,6 +1549,7 @@ mod tests { |
| 1410 | 1549 | details, |
| 1411 | 1550 | cookie: "cookie-ctx".to_string(), |
| 1412 | 1551 | username: "alice".to_string(), |
| 1552 | + identity_options: vec!["alice".to_string()], |
| 1413 | 1553 | }; |
| 1414 | 1554 | |
| 1415 | 1555 | let context = render_prompt_context(&request); |
@@ -1421,6 +1561,67 @@ mod tests { |
| 1421 | 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 | 1625 | #[test] |
| 1425 | 1626 | fn cancellation_aware_prompt_short_circuits_prompt_and_feedback() { |
| 1426 | 1627 | let runtime = PolkitRuntime::new_without_worker(Arc::new(AuthState::default())); |