@@ -4,7 +4,7 @@ use crate::polkit_helper::{ |
| 4 | 4 | use crate::prompt::CommandPrompt; |
| 5 | 5 | use crate::state::{AuthPhase, AuthQueue, AuthState, QueueInsert}; |
| 6 | 6 | use anyhow::{Context, Result}; |
| 7 | | -use std::collections::HashMap; |
| 7 | +use std::collections::{HashMap, HashSet}; |
| 8 | 8 | use std::path::PathBuf; |
| 9 | 9 | use std::sync::atomic::{AtomicBool, Ordering}; |
| 10 | 10 | use std::sync::{Arc, Condvar, Mutex}; |
@@ -70,6 +70,7 @@ struct ActiveRequest { |
| 70 | 70 | message: String, |
| 71 | 71 | icon_name: String, |
| 72 | 72 | detail_count: usize, |
| 73 | + details: Details, |
| 73 | 74 | cookie: String, |
| 74 | 75 | username: String, |
| 75 | 76 | } |
@@ -79,6 +80,7 @@ struct PolkitRuntime { |
| 79 | 80 | auth_state: Arc<AuthState>, |
| 80 | 81 | queue: Mutex<AuthQueue<AuthRequest>>, |
| 81 | 82 | outcomes: Mutex<HashMap<String, HelperOutcome>>, |
| 83 | + canceled_cookies: Mutex<HashSet<String>>, |
| 82 | 84 | outcome_signal: Condvar, |
| 83 | 85 | helper_client: HelperSocketClient, |
| 84 | 86 | processing: AtomicBool, |
@@ -95,6 +97,70 @@ impl RetryPromptProvider for CommandPrompt { |
| 95 | 97 | } |
| 96 | 98 | } |
| 97 | 99 | |
| 100 | +struct CancellationAwarePrompt<'a, P> { |
| 101 | + inner: &'a mut P, |
| 102 | + runtime: &'a PolkitRuntime, |
| 103 | + cookie: &'a str, |
| 104 | +} |
| 105 | + |
| 106 | +impl<'a, P> CancellationAwarePrompt<'a, P> { |
| 107 | + fn new(inner: &'a mut P, runtime: &'a PolkitRuntime, cookie: &'a str) -> Self { |
| 108 | + Self { |
| 109 | + inner, |
| 110 | + runtime, |
| 111 | + cookie, |
| 112 | + } |
| 113 | + } |
| 114 | + |
| 115 | + fn canceled(&self) -> bool { |
| 116 | + self.runtime.is_canceled(self.cookie) |
| 117 | + } |
| 118 | +} |
| 119 | + |
| 120 | +impl<P: PromptProvider> PromptProvider for CancellationAwarePrompt<'_, P> { |
| 121 | + fn prompt_secret(&mut self, prompt: &str) -> Result<crate::polkit_helper::PromptResponse> { |
| 122 | + if self.canceled() { |
| 123 | + return Ok(crate::polkit_helper::PromptResponse::Canceled); |
| 124 | + } |
| 125 | + self.inner.prompt_secret(prompt) |
| 126 | + } |
| 127 | + |
| 128 | + fn prompt_plain(&mut self, prompt: &str) -> Result<crate::polkit_helper::PromptResponse> { |
| 129 | + if self.canceled() { |
| 130 | + return Ok(crate::polkit_helper::PromptResponse::Canceled); |
| 131 | + } |
| 132 | + self.inner.prompt_plain(prompt) |
| 133 | + } |
| 134 | + |
| 135 | + fn show_error(&mut self, message: &str) -> Result<()> { |
| 136 | + if self.canceled() { |
| 137 | + return Ok(()); |
| 138 | + } |
| 139 | + self.inner.show_error(message) |
| 140 | + } |
| 141 | + |
| 142 | + fn show_info(&mut self, message: &str) -> Result<()> { |
| 143 | + if self.canceled() { |
| 144 | + return Ok(()); |
| 145 | + } |
| 146 | + self.inner.show_info(message) |
| 147 | + } |
| 148 | + |
| 149 | + fn auth_succeeded(&mut self) -> Result<()> { |
| 150 | + if self.canceled() { |
| 151 | + return Ok(()); |
| 152 | + } |
| 153 | + self.inner.auth_succeeded() |
| 154 | + } |
| 155 | + |
| 156 | + fn auth_failed(&mut self, message: &str) -> Result<()> { |
| 157 | + if self.canceled() { |
| 158 | + return Ok(()); |
| 159 | + } |
| 160 | + self.inner.auth_failed(message) |
| 161 | + } |
| 162 | +} |
| 163 | + |
| 98 | 164 | impl PolkitRuntime { |
| 99 | 165 | fn new(auth_state: Arc<AuthState>) -> Self { |
| 100 | 166 | Self::new_with_worker(auth_state, true) |
@@ -113,6 +179,7 @@ impl PolkitRuntime { |
| 113 | 179 | auth_state, |
| 114 | 180 | queue: Mutex::new(AuthQueue::default()), |
| 115 | 181 | outcomes: Mutex::new(HashMap::new()), |
| 182 | + canceled_cookies: Mutex::new(HashSet::new()), |
| 116 | 183 | outcome_signal: Condvar::new(), |
| 117 | 184 | helper_client: HelperSocketClient::new(helper_socket), |
| 118 | 185 | processing: AtomicBool::new(false), |
@@ -121,6 +188,7 @@ impl PolkitRuntime { |
| 121 | 188 | } |
| 122 | 189 | |
| 123 | 190 | fn begin_authentication(self: &Arc<Self>, request: AuthRequest) -> Result<QueueInsert> { |
| 191 | + let cookie = request.cookie.clone(); |
| 124 | 192 | let (insert, active, queued) = { |
| 125 | 193 | let mut queue = self |
| 126 | 194 | .queue |
@@ -130,6 +198,7 @@ impl PolkitRuntime { |
| 130 | 198 | let (active, queued) = queue.counts(); |
| 131 | 199 | (insert, active, queued) |
| 132 | 200 | }; |
| 201 | + self.clear_canceled(&cookie); |
| 133 | 202 | |
| 134 | 203 | self.auth_state.sync_queue_counts(active, queued); |
| 135 | 204 | if matches!( |
@@ -169,6 +238,7 @@ impl PolkitRuntime { |
| 169 | 238 | }; |
| 170 | 239 | |
| 171 | 240 | if canceled_active || removed_queued { |
| 241 | + self.mark_canceled(cookie); |
| 172 | 242 | self.record_outcome(cookie, HelperOutcome::Canceled); |
| 173 | 243 | } |
| 174 | 244 | |
@@ -262,6 +332,7 @@ impl PolkitRuntime { |
| 262 | 332 | message: request.message.clone(), |
| 263 | 333 | icon_name: request.icon_name.clone(), |
| 264 | 334 | detail_count: request.details.len(), |
| 335 | + details: request.details.clone(), |
| 265 | 336 | cookie: request.cookie.clone(), |
| 266 | 337 | username, |
| 267 | 338 | }) |
@@ -277,14 +348,17 @@ impl PolkitRuntime { |
| 277 | 348 | request: &ActiveRequest, |
| 278 | 349 | prompts: &mut P, |
| 279 | 350 | ) -> HelperOutcome { |
| 280 | | - let prompt_context = if request.message.is_empty() { |
| 281 | | - request.action_id.as_str() |
| 282 | | - } else { |
| 283 | | - request.message.as_str() |
| 284 | | - }; |
| 351 | + let prompt_context = render_prompt_context(request); |
| 285 | 352 | let max_attempts = auth_max_attempts(); |
| 286 | 353 | |
| 287 | 354 | for attempt in 1..=max_attempts { |
| 355 | + if self.is_canceled(&request.cookie) { |
| 356 | + tracing::info!( |
| 357 | + action_id = %request.action_id, |
| 358 | + "Authentication request canceled before helper attempt" |
| 359 | + ); |
| 360 | + return HelperOutcome::Canceled; |
| 361 | + } |
| 288 | 362 | tracing::info!( |
| 289 | 363 | context = %prompt_context, |
| 290 | 364 | attempt, |
@@ -292,10 +366,20 @@ impl PolkitRuntime { |
| 292 | 366 | "Starting helper authentication dialog" |
| 293 | 367 | ); |
| 294 | 368 | |
| 295 | | - match self |
| 296 | | - .helper_client |
| 297 | | - .authenticate(&request.username, &request.cookie, prompts) |
| 298 | | - { |
| 369 | + let mut cancel_aware = |
| 370 | + CancellationAwarePrompt::new(prompts, self, request.cookie.as_str()); |
| 371 | + match self.helper_client.authenticate( |
| 372 | + &request.username, |
| 373 | + &request.cookie, |
| 374 | + &mut cancel_aware, |
| 375 | + ) { |
| 376 | + Ok(outcome) if self.is_canceled(&request.cookie) => { |
| 377 | + tracing::info!( |
| 378 | + action_id = %request.action_id, |
| 379 | + "Authentication request canceled during helper attempt" |
| 380 | + ); |
| 381 | + return HelperOutcome::Canceled; |
| 382 | + } |
| 299 | 383 | Ok(HelperOutcome::Denied) if attempt < max_attempts => { |
| 300 | 384 | prompts.on_retry(); |
| 301 | 385 | self.auth_state.set_phase(AuthPhase::PendingPrompt); |
@@ -352,6 +436,7 @@ impl PolkitRuntime { |
| 352 | 436 | } |
| 353 | 437 | return; |
| 354 | 438 | } |
| 439 | + self.clear_canceled(cookie); |
| 355 | 440 | self.record_outcome(cookie, outcome); |
| 356 | 441 | |
| 357 | 442 | if active > 0 { |
@@ -380,6 +465,29 @@ impl PolkitRuntime { |
| 380 | 465 | self.outcome_signal.notify_all(); |
| 381 | 466 | } |
| 382 | 467 | |
| 468 | + fn mark_canceled(&self, cookie: &str) { |
| 469 | + if let Ok(mut canceled) = self.canceled_cookies.lock() { |
| 470 | + canceled.insert(cookie.to_string()); |
| 471 | + } else { |
| 472 | + tracing::error!("auth canceled-cookie set lock poisoned"); |
| 473 | + } |
| 474 | + } |
| 475 | + |
| 476 | + fn clear_canceled(&self, cookie: &str) { |
| 477 | + if let Ok(mut canceled) = self.canceled_cookies.lock() { |
| 478 | + canceled.remove(cookie); |
| 479 | + } else { |
| 480 | + tracing::error!("auth canceled-cookie set lock poisoned"); |
| 481 | + } |
| 482 | + } |
| 483 | + |
| 484 | + fn is_canceled(&self, cookie: &str) -> bool { |
| 485 | + self.canceled_cookies |
| 486 | + .lock() |
| 487 | + .map(|canceled| canceled.contains(cookie)) |
| 488 | + .unwrap_or(false) |
| 489 | + } |
| 490 | + |
| 383 | 491 | fn wait_for_completion(&self, cookie: &str) -> Result<HelperOutcome> { |
| 384 | 492 | let mut outcomes = self |
| 385 | 493 | .outcomes |
@@ -403,12 +511,59 @@ impl PolkitRuntime { |
| 403 | 511 | if let Ok(mut outcomes) = self.outcomes.lock() { |
| 404 | 512 | outcomes.clear(); |
| 405 | 513 | } |
| 514 | + if let Ok(mut canceled) = self.canceled_cookies.lock() { |
| 515 | + canceled.clear(); |
| 516 | + } |
| 406 | 517 | self.processing.store(false, Ordering::Release); |
| 407 | 518 | self.auth_state.set_phase(AuthPhase::Idle); |
| 408 | 519 | self.auth_state.sync_queue_counts(0, 0); |
| 409 | 520 | } |
| 410 | 521 | } |
| 411 | 522 | |
| 523 | +fn render_prompt_context(request: &ActiveRequest) -> String { |
| 524 | + let mut lines = Vec::new(); |
| 525 | + let message = request.message.trim(); |
| 526 | + if !message.is_empty() { |
| 527 | + lines.push(message.to_string()); |
| 528 | + } else { |
| 529 | + lines.push("Authentication is required".to_string()); |
| 530 | + } |
| 531 | + |
| 532 | + lines.push(format!("Action: {}", request.action_id)); |
| 533 | + if !request.icon_name.trim().is_empty() { |
| 534 | + lines.push(format!("Icon: {}", request.icon_name.trim())); |
| 535 | + } |
| 536 | + |
| 537 | + let detail_keys = [ |
| 538 | + "program", |
| 539 | + "command_line", |
| 540 | + "unit", |
| 541 | + "verb", |
| 542 | + "polkit.retains_authorization_after_challenge", |
| 543 | + ]; |
| 544 | + for key in detail_keys { |
| 545 | + if let Some(value) = request.details.get(key) { |
| 546 | + let trimmed = value.trim(); |
| 547 | + if !trimmed.is_empty() { |
| 548 | + lines.push(format!("{}: {}", detail_key_label(key), trimmed)); |
| 549 | + } |
| 550 | + } |
| 551 | + } |
| 552 | + |
| 553 | + lines.join("\n") |
| 554 | +} |
| 555 | + |
| 556 | +fn detail_key_label(key: &str) -> &'static str { |
| 557 | + match key { |
| 558 | + "program" => "Program", |
| 559 | + "command_line" => "Command", |
| 560 | + "unit" => "Unit", |
| 561 | + "verb" => "Verb", |
| 562 | + "polkit.retains_authorization_after_challenge" => "Retains authorization", |
| 563 | + _ => "Detail", |
| 564 | + } |
| 565 | +} |
| 566 | + |
| 412 | 567 | fn resolve_identity_username(identities: &[Subject]) -> Option<String> { |
| 413 | 568 | let current = current_username(); |
| 414 | 569 | if let Some(current_name) = current.as_deref() { |
@@ -1108,6 +1263,7 @@ mod tests { |
| 1108 | 1263 | auth_state: Arc::new(AuthState::default()), |
| 1109 | 1264 | queue: Mutex::new(AuthQueue::default()), |
| 1110 | 1265 | outcomes: Mutex::new(HashMap::new()), |
| 1266 | + canceled_cookies: Mutex::new(HashSet::new()), |
| 1111 | 1267 | outcome_signal: Condvar::new(), |
| 1112 | 1268 | helper_client: HelperSocketClient::new(&socket_path), |
| 1113 | 1269 | processing: AtomicBool::new(false), |
@@ -1118,6 +1274,7 @@ mod tests { |
| 1118 | 1274 | message: "Authenticate".to_string(), |
| 1119 | 1275 | icon_name: "dialog-password".to_string(), |
| 1120 | 1276 | detail_count: 0, |
| 1277 | + details: HashMap::new(), |
| 1121 | 1278 | cookie: "cookie-1".to_string(), |
| 1122 | 1279 | username: "alice".to_string(), |
| 1123 | 1280 | }; |
@@ -1136,6 +1293,56 @@ mod tests { |
| 1136 | 1293 | let _ = std::fs::remove_file(&socket_path); |
| 1137 | 1294 | } |
| 1138 | 1295 | |
| 1296 | + #[test] |
| 1297 | + fn render_prompt_context_includes_policy_details() { |
| 1298 | + let mut details = HashMap::new(); |
| 1299 | + details.insert("program".to_string(), "/usr/bin/meson".to_string()); |
| 1300 | + details.insert("command_line".to_string(), "meson install".to_string()); |
| 1301 | + details.insert( |
| 1302 | + "polkit.retains_authorization_after_challenge".to_string(), |
| 1303 | + "1".to_string(), |
| 1304 | + ); |
| 1305 | + let request = ActiveRequest { |
| 1306 | + action_id: "com.mesonbuild.install.run".to_string(), |
| 1307 | + message: "Authentication is required to install this project".to_string(), |
| 1308 | + icon_name: "preferences-system".to_string(), |
| 1309 | + detail_count: details.len(), |
| 1310 | + details, |
| 1311 | + cookie: "cookie-ctx".to_string(), |
| 1312 | + username: "alice".to_string(), |
| 1313 | + }; |
| 1314 | + |
| 1315 | + let context = render_prompt_context(&request); |
| 1316 | + assert!(context.contains("Authentication is required to install this project")); |
| 1317 | + assert!(context.contains("Action: com.mesonbuild.install.run")); |
| 1318 | + assert!(context.contains("Icon: preferences-system")); |
| 1319 | + assert!(context.contains("Program: /usr/bin/meson")); |
| 1320 | + assert!(context.contains("Command: meson install")); |
| 1321 | + assert!(context.contains("Retains authorization: 1")); |
| 1322 | + } |
| 1323 | + |
| 1324 | + #[test] |
| 1325 | + fn cancellation_aware_prompt_short_circuits_prompt_and_feedback() { |
| 1326 | + let runtime = PolkitRuntime::new_without_worker(Arc::new(AuthState::default())); |
| 1327 | + runtime.mark_canceled("cookie-1"); |
| 1328 | + |
| 1329 | + let mut prompts = |
| 1330 | + SequencedPrompt::new(vec![PromptResponse::Submitted("correct horse".to_string())]); |
| 1331 | + { |
| 1332 | + let mut wrapped = CancellationAwarePrompt::new(&mut prompts, &runtime, "cookie-1"); |
| 1333 | + let response = wrapped |
| 1334 | + .prompt_secret("Password:") |
| 1335 | + .expect("prompt response should be canceled"); |
| 1336 | + assert_eq!(response, PromptResponse::Canceled); |
| 1337 | + wrapped |
| 1338 | + .auth_succeeded() |
| 1339 | + .expect("suppressed success callback"); |
| 1340 | + } |
| 1341 | + |
| 1342 | + assert_eq!(prompts.prompt_count, 0); |
| 1343 | + assert_eq!(prompts.success_count, 0); |
| 1344 | + } |
| 1345 | + |
| 1139 | 1346 | #[test] |
| 1140 | 1347 | fn resolve_identity_username_uses_uid_detail() { |
| 1141 | 1348 | let mut details = HashMap::new(); |