diff --git a/shell/ferese-polkit-agent/Cargo.toml b/shell/ferese-polkit-agent/Cargo.toml index 139d72d..6b247f1 100644 --- a/shell/ferese-polkit-agent/Cargo.toml +++ b/shell/ferese-polkit-agent/Cargo.toml @@ -14,6 +14,6 @@ libcosmic = { workspace = true, features = ["wgpu"] } libloading = "0.8.9" serde.workspace = true serde_json.workspace = true -tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } zbus = "5.19.0" zeroize = "1.9" diff --git a/shell/ferese-polkit-agent/src/main.rs b/shell/ferese-polkit-agent/src/main.rs index b3bf68a..9f2d6fc 100644 --- a/shell/ferese-polkit-agent/src/main.rs +++ b/shell/ferese-polkit-agent/src/main.rs @@ -1,7 +1,7 @@ mod prompt; mod session; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; @@ -16,33 +16,110 @@ const AUTHORITY_INTERFACE: &str = "org.freedesktop.PolicyKit1.Authority"; #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] enum PromptEvent { - Start { message: String, user: String }, - Request { prompt: String, echo: bool }, - Info { text: String }, - Error { text: String }, - Response { value: String }, + Start(AuthenticationRequest), + Request { + generation: Generation, + prompt: String, + echo: bool, + }, + Info { + generation: Generation, + text: String, + }, + Error { + generation: Generation, + text: String, + }, + Response { + generation: Generation, + uid: u32, + value: String, + }, + IdentityChanged { + generation: Generation, + uid: u32, + }, + SelectIdentity { + generation: Generation, + uid: u32, + }, Cancel, } type Identity = (String, HashMap); -#[derive(Default)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +struct Generation { + selection: u64, + attempt: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct IdentityChoice { + uid: u32, + name: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct AuthenticationRequest { + generation: Generation, + message: String, + icon_name: String, + details: BTreeMap, + vendor_name: String, + vendor_url: String, + identities: Vec, + selected_uid: u32, +} + +#[derive(Serialize, Deserialize, zbus::zvariant::Type)] +struct ActionDescription { + action_id: String, + description: String, + message: String, + vendor_name: String, + vendor_url: String, + icon_name: String, + implicit_any: u32, + implicit_inactive: u32, + implicit_active: u32, + annotations: HashMap, +} + struct Agent { + connection: zbus::Connection, active: Mutex>>, } +impl Agent { + async fn vendor(&self, action_id: &str) -> (String, String) { + let lookup = async { + let authority = zbus::Proxy::new(&self.connection, AUTHORITY, AUTHORITY_PATH, AUTHORITY_INTERFACE).await?; + let locale = std::env::var("LANG").unwrap_or_default(); + let actions: Vec = authority.call("EnumerateActions", &(locale,)).await?; + Ok::<_, zbus::Error>(actions.into_iter().find(|action| action.action_id == action_id)) + }; + // Missing metadata must never prevent authentication or cancellation. + match tokio::time::timeout(std::time::Duration::from_secs(2), lookup).await { + Ok(Ok(Some(action))) => (action.vendor_name, action.vendor_url), + _ => (String::new(), String::new()), + } + } +} + #[zbus::interface(name = "org.freedesktop.PolicyKit1.AuthenticationAgent")] impl Agent { async fn begin_authentication( &self, - _action_id: String, + action_id: String, message: String, - _icon_name: String, - _details: HashMap, + icon_name: String, + details: HashMap, cookie: String, identities: Vec, ) -> zbus::fdo::Result<()> { - let uid = choose_identity(&identities) + let allowed = available_identities(&identities); + let uid = choose_identity(&allowed) .ok_or_else(|| zbus::fdo::Error::Failed("No supported authentication identity".into()))?; let cancelled = Arc::new(AtomicBool::new(false)); @@ -54,15 +131,31 @@ impl Agent { active.insert(cookie.clone(), cancelled.clone()); } + let (vendor_name, vendor_url) = self.vendor(&action_id).await; + let request = AuthenticationRequest { + generation: Generation::default(), + message, + icon_name, + details: details.into_iter().collect(), + vendor_name, + vendor_url, + identities: allowed + .into_iter() + .map(|uid| IdentityChoice { + uid, + name: session::username(uid), + }) + .collect(), + selected_uid: uid, + }; let result = tokio::task::spawn_blocking({ let cookie = cookie.clone(); - move || session::authenticate(uid, message.chars().take(240).collect(), cookie, cancelled) + move || session::authenticate(request, cookie, cancelled) }) - .await - .map_err(|error| zbus::fdo::Error::Failed(error.to_string()))?; + .await; self.active.lock().unwrap().remove(&cookie); - match result { + match result.map_err(|error| zbus::fdo::Error::Failed(error.to_string()))? { Ok(true) => Ok(()), Ok(false) => Err(zbus::fdo::Error::Failed("Authentication cancelled".into())), Err(error) => Err(zbus::fdo::Error::Failed(error)), @@ -76,19 +169,28 @@ impl Agent { } } -fn choose_identity(identities: &[Identity]) -> Option { - let available: Vec<_> = identities +const MAX_IDENTITIES: usize = 16; + +fn available_identities(identities: &[Identity]) -> Vec { + let mut available = Vec::new(); + for uid in identities .iter() .filter(|(kind, _)| kind == "unix-user") - .filter_map(|(_, details)| { - details - .get("uid") - .and_then(|uid| u32::try_from(uid.clone()).ok()) - .filter(|uid| *uid <= i32::MAX as u32) - }) - .collect(); - let current = unsafe { libc::geteuid() }; + .filter_map(|(_, details)| details.get("uid").and_then(|uid| u32::try_from(uid.clone()).ok())) + .filter(|uid| *uid <= i32::MAX as u32) + { + if !available.contains(&uid) { + available.push(uid); + if available.len() == MAX_IDENTITIES { + break; + } + } + } + available +} +fn choose_identity(available: &[u32]) -> Option { + let current = unsafe { libc::geteuid() }; available .iter() .copied() @@ -157,9 +259,16 @@ fn login_session_subject(id: String) -> (String, HashMap> } async fn run_agent() -> Result<(), Box> { - let connection = zbus::connection::Builder::system()? - .serve_at(AGENT_PATH, Agent::default())? - .build() + let connection = zbus::Connection::system().await?; + connection + .object_server() + .at( + AGENT_PATH, + Agent { + connection: connection.clone(), + active: Mutex::new(HashMap::new()), + }, + ) .await?; let subject = session_subject(&connection).await?; let authority = zbus::Proxy::new(&connection, AUTHORITY, AUTHORITY_PATH, AUTHORITY_INTERFACE).await?; @@ -193,6 +302,205 @@ fn main() -> Result<(), Box> { mod tests { use super::*; + pub(super) fn request_fixture() -> AuthenticationRequest { + AuthenticationRequest { + generation: Generation::default(), + message: "Authorize this complete request ".repeat(30), + icon_name: "system-software-install".into(), + details: BTreeMap::from([ + ("program".into(), "/usr/bin/example".into()), + ("command_line".into(), "example --long-detail".into()), + ]), + vendor_name: "Example Vendor".into(), + vendor_url: "https://example.test/vendor".into(), + identities: vec![ + IdentityChoice { + uid: 1000, + name: "current".into(), + }, + IdentityChoice { + uid: 1001, + name: "other".into(), + }, + ], + selected_uid: 1000, + } + } + + #[test] + fn complete_request_survives_the_prompt_protocol() { + let request = request_fixture(); + let encoded = serde_json::to_string(&PromptEvent::Start(request.clone())).unwrap(); + let PromptEvent::Start(decoded) = serde_json::from_str(&encoded).unwrap() else { + panic!("Missing Start") + }; + assert_eq!(decoded, request); + assert!(decoded.message.len() > 240); + assert!(encoded.contains("\"type\":\"start\"")); + } + + #[test] + fn prompt_protocol_keeps_selection_response_and_echo_fields() { + let selection: PromptEvent = + serde_json::from_str(r#"{"type":"select_identity","generation":{"selection":2,"attempt":0},"uid":1001}"#) + .unwrap(); + assert!(matches!( + selection, + PromptEvent::SelectIdentity { + uid: 1001, + generation: Generation { + selection: 2, + attempt: 0 + } + } + )); + let response = PromptEvent::Response { + generation: Generation { + selection: 2, + attempt: 1, + }, + uid: 1001, + value: "test-response".into(), + }; + let decoded = serde_json::from_str(&serde_json::to_string(&response).unwrap()).unwrap(); + assert!( + matches!(decoded, PromptEvent::Response { uid: 1001, generation: Generation { selection: 2, attempt: 1 }, value } if value == "test-response") + ); + for echo in [true, false] { + let event = PromptEvent::Request { + generation: Generation { + selection: 2, + attempt: 1, + }, + prompt: "Password:".into(), + echo, + }; + let decoded = serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap(); + assert!( + matches!(decoded, PromptEvent::Request { generation: Generation { selection: 2, attempt: 1 }, prompt, echo: actual } if prompt == "Password:" && actual == echo) + ); + } + } + + #[test] + fn only_supported_unique_user_identities_are_offered() { + let identities = vec![ + ( + "unix-user".into(), + HashMap::from([("uid".into(), OwnedValue::from(1000u32))]), + ), + ( + "unix-user".into(), + HashMap::from([("uid".into(), OwnedValue::from(1000u32))]), + ), + ( + "unix-group".into(), + HashMap::from([("gid".into(), OwnedValue::from(1001u32))]), + ), + ( + "unix-user".into(), + HashMap::from([("uid".into(), OwnedValue::from(u32::MAX))]), + ), + ("unix-user".into(), HashMap::new()), + ]; + assert_eq!(available_identities(&identities), vec![1000]); + assert_eq!(choose_identity(&[]), None); + assert_eq!(choose_identity(&[1001]), Some(1001)); + } + + #[test] + fn offered_identities_are_capped_before_username_lookups() { + let identities: Vec<_> = (0..4096u32) + .map(|uid| { + ( + "unix-user".into(), + HashMap::from([("uid".into(), OwnedValue::from(uid))]), + ) + }) + .collect(); + assert_eq!( + available_identities(&identities), + (0..MAX_IDENTITIES as u32).collect::>() + ); + } + + #[test] + fn metadata_signature_matches_polkit_action_descriptions() { + use zbus::zvariant::Type; + assert_eq!(ActionDescription::SIGNATURE.to_string(), "(ssssssuuua{ss})"); + } + + struct PrivateBus(std::process::Child); + + impl Drop for PrivateBus { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + struct MockAuthority; + + #[zbus::interface(name = "org.freedesktop.PolicyKit1.Authority")] + impl MockAuthority { + fn enumerate_actions(&self, _locale: String) -> Vec { + vec![ActionDescription { + action_id: "test.action".into(), + description: "Test action".into(), + message: "Authorize test".into(), + vendor_name: "Test vendor".into(), + vendor_url: "https://example.test/vendor".into(), + icon_name: "system-software-install".into(), + implicit_any: 0, + implicit_inactive: 0, + implicit_active: 1, + annotations: HashMap::new(), + }] + } + } + + #[tokio::test] + async fn vendor_metadata_uses_real_dbus_encoding_and_is_optional() { + use std::io::{BufRead, BufReader}; + use std::process::{Command, Stdio}; + let mut bus = PrivateBus( + Command::new("dbus-daemon") + .args(["--session", "--nofork", "--print-address=1"]) + .stdout(Stdio::piped()) + .spawn() + .unwrap(), + ); + let mut address = String::new(); + BufReader::new(bus.0.stdout.take().unwrap()) + .read_line(&mut address) + .unwrap(); + let server = zbus::connection::Builder::address(address.trim()) + .unwrap() + .name(AUTHORITY) + .unwrap() + .serve_at(AUTHORITY_PATH, MockAuthority) + .unwrap() + .build() + .await + .unwrap(); + let connection = zbus::connection::Builder::address(address.trim()) + .unwrap() + .build() + .await + .unwrap(); + let agent = Agent { + connection, + active: Mutex::new(HashMap::new()), + }; + assert_eq!( + agent.vendor("test.action").await, + ("Test vendor".into(), "https://example.test/vendor".into()) + ); + assert_eq!(agent.vendor("missing.action").await, (String::new(), String::new())); + server.close().await.unwrap(); + assert_eq!(agent.vendor("test.action").await, (String::new(), String::new())); + } + #[test] fn desktop_agent_registers_for_the_login_session() { let (kind, details) = login_session_subject("test-session".into()); @@ -219,6 +527,9 @@ mod tests { HashMap::from([("uid".to_owned(), OwnedValue::from(uid))]), ) }; - assert_eq!(choose_identity(&[identity(0), identity(current)]), Some(current)); + assert_eq!( + choose_identity(&available_identities(&[identity(0), identity(current)])), + Some(current) + ); } } diff --git a/shell/ferese-polkit-agent/src/prompt.rs b/shell/ferese-polkit-agent/src/prompt.rs index 61afc2d..7b14e40 100644 --- a/shell/ferese-polkit-agent/src/prompt.rs +++ b/shell/ferese-polkit-agent/src/prompt.rs @@ -5,11 +5,11 @@ use std::time::Duration; use cosmic::app::{Core, Settings, Task}; use cosmic::iced::{Alignment, Color, Length, Size, Subscription, window}; -use cosmic::widget::{button, column, container, row}; +use cosmic::widget::{button, column, container, row, scrollable}; use cosmic::{Element, theme, widget}; use zeroize::{Zeroize, Zeroizing}; -use crate::PromptEvent; +use crate::{AuthenticationRequest, Generation, PromptEvent}; #[derive(Clone)] struct Appearance { @@ -69,12 +69,14 @@ impl Appearance { #[derive(Clone)] enum Message { - ThemeChanged(ferese_config::theme::Snapshot), + ThemeChanged(Box), WindowOpened(cosmic::iced::window::Id), MaterialAttached(Result), Tick, Input(String), Submit, + SelectIdentity(usize), + ToggleDetails, Cancel, } @@ -89,12 +91,15 @@ struct Prompt { material: Option, appearance: Appearance, incoming: Arc>>, - description: String, - user: String, + request: AuthenticationRequest, + identity_names: Vec, + details_expanded: bool, + action_icon: Option, question: String, echo: bool, answer: Zeroizing, waiting: bool, + awaiting_ack: bool, error: Option, info: Option, height: f32, @@ -109,28 +114,56 @@ fn content_height(description: &str, status: Option<&str>) -> f32 { (214 + 18 * lines.saturating_sub(1) + status_height).min(360) as f32 } +fn dialog_height(request: &AuthenticationRequest, status: Option<&str>, expanded: bool) -> f32 { + let extra = if request.identities.len() > 1 { 54. } else { 0. } + + if has_details(request) { 40. } else { 0. } + + if expanded { 96. } else { 0. }; + (content_height(&request.message, status) + extra).min(360.) +} + +fn has_details(request: &AuthenticationRequest) -> bool { + !request.details.is_empty() || !request.vendor_name.is_empty() || !request.vendor_url.is_empty() +} + +fn action_icon(name: &str) -> Option { + if name.is_empty() { + return None; + } + // Resolve once; a missing icon uses Ferese's embedded lock, never a blank header. + widget::icon::from_name(name.to_owned()) + .size(20) + .fallback(None) + .path() + .map(widget::icon::from_path) +} + +fn read_events(lines: impl Iterator>, queue: &Mutex>) { + for line in lines { + let Ok(line) = line else { break }; + if let Ok(event) = serde_json::from_str(&line) { + queue.lock().unwrap().push_back(event); + } + } + // The agent owns this pipe. EOF (including agent death) closes the prompt. + queue.lock().unwrap().push_back(PromptEvent::Cancel); +} + pub fn run() -> Result<(), Box> { let stdin = std::io::stdin(); let mut lines = BufReader::new(stdin).lines(); let Some(Ok(first)) = lines.next() else { return Err("Missing authentication request".into()); }; - let PromptEvent::Start { message, user } = serde_json::from_str(&first)? else { + let PromptEvent::Start(request) = serde_json::from_str(&first)? else { return Err("Invalid authentication request".into()); }; let incoming = Arc::new(Mutex::new(VecDeque::new())); let queue = incoming.clone(); std::thread::spawn(move || { - for line in lines { - let Ok(line) = line else { break }; - if let Ok(event) = serde_json::from_str(&line) { - queue.lock().unwrap().push_back(event); - } - } - queue.lock().unwrap().push_back(PromptEvent::Cancel); + read_events(lines, &queue); }); let appearance = Appearance::load(); - let height = content_height(&message, None); + let height = dialog_height(&request, None, false); cosmic::app::run::( Settings::default() .size(Size::new(434., height)) @@ -140,7 +173,7 @@ pub fn run() -> Result<(), Box> { .default_font(appearance.font) .default_text_size(14.) .antialiasing(true), - (incoming, message, user, appearance), + (incoming, request, appearance), )?; Ok(()) } @@ -152,7 +185,7 @@ fn reply(mut event: PromptEvent) { let _ = stdout.flush(); line.zeroize(); } - if let PromptEvent::Response { value } = &mut event { + if let PromptEvent::Response { value, .. } = &mut event { value.zeroize(); } } @@ -162,8 +195,36 @@ fn focus() -> Task { } impl Prompt { + fn reset_authentication(&mut self, uid: u32) { + self.request.selected_uid = uid; + self.answer.zeroize(); + self.error = None; + self.info = None; + self.waiting = true; + self.echo = false; + self.question = "Waiting for authentication…".into(); + } + + fn select_identity(&mut self, index: usize) -> Option { + let uid = self.request.identities.get(index)?.uid; + if uid == self.request.selected_uid { + return None; + } + self.request.generation = Generation { + selection: self.request.generation.selection.checked_add(1)?, + attempt: 0, + }; + self.reset_authentication(uid); + self.awaiting_ack = true; + Some(uid) + } + fn resize_to_content(&mut self) -> Task { - let height = content_height(&self.description, self.error.as_deref().or(self.info.as_deref())); + let height = dialog_height( + &self.request, + self.error.as_deref().or(self.info.as_deref()), + self.details_expanded, + ); if (height - self.height).abs() < 1.0 { return Task::none(); } @@ -177,7 +238,7 @@ impl Prompt { impl cosmic::Application for Prompt { type Executor = cosmic::executor::Default; - type Flags = (Arc>>, String, String, Appearance); + type Flags = (Arc>>, AuthenticationRequest, Appearance); type Message = Message; const APP_ID: &'static str = "dev.ferese.Authentication"; @@ -197,12 +258,18 @@ impl cosmic::Application for Prompt { })) } - fn init(mut core: Core, (incoming, description, user, appearance): Self::Flags) -> (Self, Task) { + fn init(mut core: Core, (incoming, request, appearance): Self::Flags) -> (Self, Task) { core.window.show_headerbar = false; core.window.border_padding = Some(0); core.window.content_container = false; core.window.use_template = false; - let height = content_height(&description, None); + let height = dialog_height(&request, None, false); + let identity_names = request + .identities + .iter() + .map(|identity| identity.name.clone()) + .collect(); + let action_icon = action_icon(&request.icon_name); let bounds = core .main_window_id() .map(|id| window::set_max_size(id, Some(Size::new(434., 360.)))) @@ -213,12 +280,15 @@ impl cosmic::Application for Prompt { material: None, appearance, incoming, - description, - user, + request, + identity_names, + details_expanded: false, + action_icon, question: "Waiting for authentication…".into(), echo: false, answer: Zeroizing::new(String::new()), waiting: true, + awaiting_ack: true, error: None, info: None, height, @@ -229,7 +299,7 @@ impl cosmic::Application for Prompt { fn subscription(&self) -> Subscription { Subscription::batch([ - ferese_theme::service::subscription().map(Message::ThemeChanged), + ferese_theme::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))), cosmic::iced::time::every(Duration::from_millis(30)).map(|_| Message::Tick), cosmic::iced::event::listen_with(|event, _, id| match event { cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => { @@ -258,16 +328,42 @@ impl cosmic::Application for Prompt { let mut refocus = false; for event in events { match event { - PromptEvent::Request { prompt, echo } => { + PromptEvent::IdentityChanged { generation, uid } + if uid == self.request.selected_uid + && generation.selection == self.request.generation.selection + && (generation.attempt > self.request.generation.attempt + || (generation == self.request.generation && self.awaiting_ack)) => + { + self.request.generation = generation; + self.reset_authentication(uid); + self.awaiting_ack = false; + } + PromptEvent::Request { + generation, + prompt, + echo, + } if generation == self.request.generation && !self.awaiting_ack => { self.answer.zeroize(); self.question = prompt; self.echo = echo; self.waiting = false; refocus = true; } - PromptEvent::Info { text } => self.info = Some(text), - PromptEvent::Error { text } => self.error = Some(text), - PromptEvent::Cancel => return cosmic::iced::exit(), + PromptEvent::Info { generation, text } + if generation == self.request.generation && !self.awaiting_ack => + { + self.info = Some(text) + } + PromptEvent::Error { generation, text } + if generation == self.request.generation && !self.awaiting_ack => + { + self.error = Some(text) + } + PromptEvent::Cancel => { + self.answer.zeroize(); + self.waiting = true; + return cosmic::iced::exit(); + } _ => {} } } @@ -287,9 +383,25 @@ impl cosmic::Application for Prompt { } Message::Submit if !self.waiting && !self.answer.is_empty() => { let value = std::mem::take(&mut *self.answer); - reply(PromptEvent::Response { value }); + reply(PromptEvent::Response { + generation: self.request.generation, + uid: self.request.selected_uid, + value, + }); self.waiting = true; } + Message::SelectIdentity(index) => { + if let Some(uid) = self.select_identity(index) { + reply(PromptEvent::SelectIdentity { + generation: self.request.generation, + uid, + }); + } + } + Message::ToggleDetails => { + self.details_expanded = !self.details_expanded; + return self.resize_to_content(); + } Message::Cancel => { self.answer.zeroize(); reply(PromptEvent::Cancel); @@ -325,7 +437,19 @@ impl cosmic::Application for Prompt { .height(Length::Fixed(36.)) .padding([8, 16]) .on_press_maybe((!self.waiting && !self.answer.is_empty()).then_some(Message::Submit)); - let lock_icon = ferese_theme::icons::tinted(ferese_theme::icons::LOCK, 20, palette.accent); + let lock_icon = if let Some(icon) = &self.action_icon { + widget::icon(icon.clone()).size(20) + } else { + ferese_theme::icons::tinted(ferese_theme::icons::LOCK, 20, palette.accent) + }; + let selected = self + .request + .identities + .iter() + .position(|identity| identity.uid == self.request.selected_uid); + let user = selected + .map(|index| self.request.identities[index].name.as_str()) + .unwrap_or("Unknown account"); let heading = row![ container(lock_icon) .width(40) @@ -335,7 +459,7 @@ impl cosmic::Application for Prompt { .class(ferese_theme::controls::surface(palette.accent.scale_alpha(0.13), 12.,)), column![ ferese_theme::text("Authentication required", palette.font).size(18), - ferese_theme::text(format!("Confirm as {}", self.user), palette.font) + ferese_theme::text(format!("Confirm as {user}"), palette.font) .size(12) .class(theme::Text::Color(palette.muted)), ] @@ -343,24 +467,81 @@ impl cosmic::Application for Prompt { ] .spacing(12) .align_y(Alignment::Center); - let mut body = column![ - heading, - ferese_theme::text(&self.description, palette.font).size(13), - input - ] - .spacing(14) - .width(Length::Fill); - if let Some(error) = &self.error { - body = body.push( - ferese_theme::text(error, palette.font) - .size(12) - .class(theme::Text::Color(Color::from_rgb8(235, 98, 98))), + let mut information = column![ferese_theme::text(&self.request.message, palette.font).size(13)] + .spacing(10) + .width(Length::Fill); + if has_details(&self.request) { + information = information.push( + button::custom( + ferese_theme::text( + if self.details_expanded { + "Hide details" + } else { + "Show details" + }, + palette.font, + ) + .size(12), + ) + .class(ferese_theme::controls::button_style(palette.palette(), false)) + .padding([6, 10]) + .on_press(Message::ToggleDetails), ); - } else if let Some(info) = &self.info { + if self.details_expanded { + let mut details = column![].spacing(6).width(Length::Fill); + for (key, value) in &self.request.details { + details = details.push(detail_row(key, value, palette)); + } + for (label, value) in [ + ("Vendor", &self.request.vendor_name), + ("Vendor URL", &self.request.vendor_url), + ] { + if !value.is_empty() { + details = details.push(detail_row(label, value, palette)); + } + } + information = information.push(details); + } + } + let mut body = column![heading, scrollable(information).height(Length::Fill)] + .spacing(14) + .width(Length::Fill); + if self.request.identities.len() > 1 { body = body.push( - ferese_theme::text(info, palette.font) - .size(12) - .class(theme::Text::Color(palette.muted)), + container( + row![ + ferese_theme::text("Account", palette.font) + .size(12) + .class(theme::Text::Color(palette.muted)), + widget::dropdown(&self.identity_names[..], selected, Message::SelectIdentity) + .width(Length::Fill), + ferese_theme::icons::tinted(ferese_theme::icons::CHEVRON_DOWN, 16, palette.muted), + ] + .spacing(10) + .align_y(Alignment::Center), + ) + .padding([4, 10]) + .class(ferese_theme::controls::surface(palette.accent.scale_alpha(0.08), 8.)), + ); + } + body = body.push(input); + if let Some((status, color)) = self + .error + .as_deref() + .map(|text| (text, Color::from_rgb8(235, 98, 98))) + .or_else(|| self.info.as_deref().map(|text| (text, palette.muted))) + { + let lines: usize = status + .lines() + .map(|line| line.chars().count().div_ceil(48).max(1)) + .sum(); + body = body.push( + scrollable( + ferese_theme::text(status, palette.font) + .size(12) + .class(theme::Text::Color(color)), + ) + .height(Length::Fixed(18. * lines.clamp(1, 3) as f32)), ); } body = body.push( @@ -382,9 +563,202 @@ impl cosmic::Application for Prompt { } } +fn detail_row<'a>(key: &'a str, value: &'a str, palette: &Appearance) -> Element<'a, Message> { + row![ + ferese_theme::text(format!("{key}:"), palette.font) + .size(12) + .class(theme::Text::Color(palette.muted)) + .width(Length::Fixed(106.)), + ferese_theme::text(value, palette.font).size(12).width(Length::Fill), + ] + .spacing(8) + .into() +} + #[cfg(test)] mod tests { - use super::content_height; + use std::collections::VecDeque; + use std::sync::{Arc, Mutex}; + + use cosmic::app::Core; + + use super::{Appearance, Message, Prompt, action_icon, content_height, dialog_height, has_details}; + use crate::tests::request_fixture; + + #[test] + fn agent_pipe_eof_cancels_the_prompt_and_clears_the_password() { + use std::io::{BufRead, BufReader, Write}; + use std::os::unix::net::UnixStream; + + use cosmic::Application; + + use crate::PromptEvent; + + let (reader, mut writer) = UnixStream::pair().unwrap(); + let incoming = Arc::new(Mutex::new(VecDeque::new())); + let queue = incoming.clone(); + let reader_thread = std::thread::spawn(move || super::read_events(BufReader::new(reader).lines(), &queue)); + writeln!( + writer, + "{{\"type\":\"request\",\"generation\":{{\"selection\":0,\"attempt\":0}},\"prompt\":\"Password:\",\"echo\":false}}" + ) + .unwrap(); + drop(writer); + reader_thread.join().unwrap(); + assert!(matches!(incoming.lock().unwrap().back(), Some(PromptEvent::Cancel))); + let appearance = Appearance::from_resolved(&ferese_config::theme::ResolvedTheme::default()); + let (mut prompt, _) = Prompt::init(Core::default(), (incoming, request_fixture(), appearance)); + *prompt.answer = "test-password".into(); + prompt.waiting = false; + let _ = prompt.update(Message::Tick); + assert!(prompt.answer.is_empty()); + assert!(prompt.waiting); + } + + #[test] + fn stale_prompts_and_acknowledgements_cannot_undo_a_newer_selection() { + use cosmic::Application; + + use crate::{Generation, PromptEvent}; + + let incoming = Arc::new(Mutex::new(VecDeque::new())); + let appearance = Appearance::from_resolved(&ferese_config::theme::ResolvedTheme::default()); + let (mut prompt, _) = Prompt::init(Core::default(), (incoming.clone(), request_fixture(), appearance)); + assert_eq!(prompt.select_identity(1), Some(1001)); + let older = prompt.request.generation; + assert_eq!(prompt.select_identity(0), Some(1000)); + let current = prompt.request.generation; + incoming.lock().unwrap().extend([ + PromptEvent::IdentityChanged { + generation: older, + uid: 1001, + }, + PromptEvent::Request { + generation: older, + prompt: "Old visible prompt".into(), + echo: true, + }, + PromptEvent::IdentityChanged { + generation: Generation::default(), + uid: 1000, + }, + PromptEvent::Request { + generation: Generation::default(), + prompt: "Original visible prompt".into(), + echo: true, + }, + PromptEvent::Info { + generation: older, + text: "Old info".into(), + }, + PromptEvent::Error { + generation: older, + text: "Old error".into(), + }, + ]); + let _ = prompt.update(Message::Tick); + assert_eq!(prompt.request.selected_uid, 1000); + assert_eq!(prompt.request.generation, current); + assert!(prompt.waiting && prompt.awaiting_ack); + assert!(!prompt.echo); + assert!(prompt.info.is_none() && prompt.error.is_none()); + incoming.lock().unwrap().extend([ + PromptEvent::IdentityChanged { + generation: current, + uid: 1000, + }, + PromptEvent::Request { + generation: current, + prompt: "Current password".into(), + echo: false, + }, + PromptEvent::IdentityChanged { + generation: current, + uid: 1000, + }, + PromptEvent::Request { + generation: older, + prompt: "Delayed visible prompt".into(), + echo: true, + }, + ]); + let _ = prompt.update(Message::Tick); + assert!(!prompt.waiting && !prompt.awaiting_ack && !prompt.echo); + assert_eq!(prompt.question, "Current password"); + + // A fresh retry gets its own generation, even for the same account. + let retry = Generation { attempt: 1, ..current }; + incoming.lock().unwrap().extend([ + PromptEvent::IdentityChanged { + generation: retry, + uid: 1000, + }, + PromptEvent::Request { + generation: retry, + prompt: "Retry password".into(), + echo: false, + }, + PromptEvent::Request { + generation: current, + prompt: "Old retry prompt".into(), + echo: true, + }, + PromptEvent::IdentityChanged { + generation: current, + uid: 1000, + }, + ]); + let _ = prompt.update(Message::Tick); + assert_eq!(prompt.request.generation, retry); + assert_eq!(prompt.question, "Retry password"); + assert!(!prompt.waiting && !prompt.echo); + } + + #[test] + fn selecting_another_account_clears_password_and_waits_for_a_fresh_prompt() { + let appearance = Appearance::from_resolved(&ferese_config::theme::ResolvedTheme::default()); + let (mut prompt, _) = ::init( + Core::default(), + (Arc::new(Mutex::new(VecDeque::new())), request_fixture(), appearance), + ); + *prompt.answer = "test-response".into(); + prompt.waiting = false; + prompt.echo = true; + prompt.error = Some("Old account error".into()); + assert_eq!(prompt.select_identity(1), Some(1001)); + assert!(prompt.answer.is_empty()); + assert!(prompt.error.is_none()); + assert!(prompt.waiting); + assert!(!prompt.echo); + assert_eq!(prompt.request.selected_uid, 1001); + assert_eq!(prompt.select_identity(1), None); + assert_eq!(prompt.select_identity(20), None); + let _ = ::update(&mut prompt, Message::ToggleDetails); + assert!(prompt.details_expanded); + assert!(prompt.height <= 360.); + } + + #[test] + fn full_requests_and_expanded_details_keep_the_height_cap() { + let request = request_fixture(); + assert_eq!(dialog_height(&request, None, false), 360.); + assert_eq!(dialog_height(&request, Some("Password not accepted"), true), 360.); + assert!(has_details(&request)); + let mut short = request; + short.message = "Short request".into(); + short.identities.truncate(1); + assert!(dialog_height(&short, None, true) > dialog_height(&short, None, false)); + short.details.clear(); + short.vendor_name.clear(); + short.vendor_url.clear(); + assert!(!has_details(&short)); + } + + #[test] + fn absent_or_unknown_action_icons_use_the_embedded_lock() { + assert!(action_icon("").is_none()); + assert!(action_icon("ferese-nonexistent-action-icon-for-test").is_none()); + } #[test] fn dialog_grows_for_long_messages_and_caps_its_height() { diff --git a/shell/ferese-polkit-agent/src/session.rs b/shell/ferese-polkit-agent/src/session.rs index aa56289..3353947 100644 --- a/shell/ferese-polkit-agent/src/session.rs +++ b/shell/ferese-polkit-agent/src/session.rs @@ -8,9 +8,9 @@ use std::time::Duration; use glib::gobject_ffi; use libloading::Library; -use zeroize::Zeroize; +use zeroize::{Zeroize, Zeroizing}; -use crate::PromptEvent; +use crate::{AuthenticationRequest, Generation, PromptEvent}; enum SessionEvent { Request(String, bool), @@ -63,7 +63,7 @@ pub fn check() -> Result<(), String> { Api::load().map(|_| ()) } -fn username(uid: u32) -> String { +pub(crate) fn username(uid: u32) -> String { let output = Command::new("getent").args(["passwd", &uid.to_string()]).output(); output .ok() @@ -80,10 +80,20 @@ fn send(writer: &mut BufWriter, event: &PromptEvent) -> Result<(), S writer.flush().map_err(|e| e.to_string()) } -fn prompt_process( - message: String, - user: String, -) -> Result<(Child, BufWriter, mpsc::Receiver), String> { +struct PromptProcess { + child: Child, + writer: BufWriter, + input: mpsc::Receiver, +} + +impl Drop for PromptProcess { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn prompt_process(request: AuthenticationRequest) -> Result { let mut child = Command::new(std::env::current_exe().map_err(|e| e.to_string())?) .arg("--prompt") .stdin(Stdio::piped()) @@ -93,8 +103,7 @@ fn prompt_process( .map_err(|e| e.to_string())?; let stdin = child.stdin.take().ok_or("Prompt has no input")?; let stdout = child.stdout.take().ok_or("Prompt has no output")?; - let mut writer = BufWriter::new(stdin); - send(&mut writer, &PromptEvent::Start { message, user })?; + let writer = BufWriter::new(stdin); let (tx, rx) = mpsc::channel(); std::thread::spawn(move || { for line in BufReader::new(stdout).lines() { @@ -109,10 +118,16 @@ fn prompt_process( } } }); - Ok((child, writer, rx)) + let mut process = PromptProcess { + child, + writer, + input: rx, + }; + send(&mut process.writer, &PromptEvent::Start(request))?; + Ok(process) } -unsafe extern "C" fn request(_session: Object, prompt: *const c_char, echo: c_int, data: *mut c_void) { +unsafe extern "C" fn request_signal(_session: Object, prompt: *const c_char, echo: c_int, data: *mut c_void) { let tx = unsafe { &*(data as *const Sender) }; let prompt = unsafe { CStr::from_ptr(prompt) }.to_string_lossy().into_owned(); let _ = tx.send(SessionEvent::Request(prompt, echo != 0)); @@ -148,17 +163,74 @@ unsafe fn connect(session: Object, name: &'static CStr, callback: gobject_ffi::G } } +#[derive(Debug, PartialEq, Eq)] +enum SessionOutcome { + Completed(bool), + Cancelled, + IdentityChanged { uid: u32, generation: Generation }, +} + +struct SessionGuard { + object: Object, + signal_data: *mut Sender, + cancel: unsafe extern "C" fn(Object), + completed: bool, +} + +impl Drop for SessionGuard { + fn drop(&mut self) { + unsafe { + if !self.completed { + (self.cancel)(self.object); + } + gobject_ffi::g_object_unref(self.object.cast()); + drop(Box::from_raw(self.signal_data)); + } + } +} + +fn permitted_selection(request: &AuthenticationRequest, uid: u32) -> bool { + request.identities.iter().any(|identity| identity.uid == uid) +} + +fn response_buffer(value: &str) -> Option>> { + if value.as_bytes().contains(&0) { + return None; + } + // Allocate room for the terminator up front so appending it cannot leave + // a password copy in an old allocation. Drop wipes the entire FFI buffer. + let mut response = Zeroizing::new(Vec::with_capacity(value.len() + 1)); + response.extend_from_slice(value.as_bytes()); + response.push(0); + Some(response) +} + fn run_session( api: &Api, - uid: u32, + request: &AuthenticationRequest, cookie: &CString, context: &glib::MainContext, - writer: &mut BufWriter, - input: &mpsc::Receiver, + process: &mut PromptProcess, cancelled: &AtomicBool, - child: &mut Child, -) -> Result, String> { - let identity = unsafe { (api.user_new)(uid as c_int) }; +) -> Result { + let generation = request.generation; + send( + &mut process.writer, + &PromptEvent::IdentityChanged { + generation, + uid: request.selected_uid, + }, + )?; + if generation.attempt > 0 { + send( + &mut process.writer, + &PromptEvent::Error { + generation, + text: "Password not accepted. Try again.".into(), + }, + )?; + } + let identity = unsafe { (api.user_new)(request.selected_uid as c_int) }; if identity.is_null() { return Err("Could not create polkit identity".into()); } @@ -169,7 +241,14 @@ fn run_session( } let (tx, rx) = mpsc::channel(); - let signal_data = Box::into_raw(Box::new(tx)).cast(); + let signal_data = Box::into_raw(Box::new(tx)); + let mut session_guard = SessionGuard { + object: session, + signal_data, + cancel: api.cancel, + completed: false, + }; + let signal_data = signal_data.cast(); unsafe { connect( session, @@ -178,7 +257,7 @@ fn run_session( unsafe extern "C" fn(Object, *const c_char, c_int, *mut c_void), unsafe extern "C" fn(), >( - request as unsafe extern "C" fn(Object, *const c_char, c_int, *mut c_void), + request_signal as unsafe extern "C" fn(Object, *const c_char, c_int, *mut c_void), )), signal_data, ); @@ -221,6 +300,7 @@ fn run_session( let mut outcome = None; let mut stopped = false; + let mut selected_uid = None; while outcome.is_none() { while context.pending() { context.iteration(false); @@ -228,78 +308,381 @@ fn run_session( while let Ok(event) = rx.try_recv() { match event { SessionEvent::Request(prompt, echo) => { - send(writer, &PromptEvent::Request { prompt, echo })?; + send( + &mut process.writer, + &PromptEvent::Request { + generation, + prompt, + echo, + }, + )?; } - SessionEvent::Info(text) => send(writer, &PromptEvent::Info { text })?, - SessionEvent::Error(text) => send(writer, &PromptEvent::Error { text })?, + SessionEvent::Info(text) => send(&mut process.writer, &PromptEvent::Info { generation, text })?, + SessionEvent::Error(text) => send(&mut process.writer, &PromptEvent::Error { generation, text })?, SessionEvent::Completed(success) => outcome = Some(success), } } if outcome.is_some() { break; } - while let Ok(event) = input.try_recv() { + while let Ok(event) = process.input.try_recv() { match event { - PromptEvent::Response { mut value } => { - if let Ok(response) = CString::new(value.as_str()) { - unsafe { (api.response)(session, response.as_ptr()) }; + PromptEvent::Response { + generation: response_generation, + uid, + mut value, + } => { + if uid == request.selected_uid + && response_generation == generation + && !stopped + && selected_uid.is_none() + && let Some(response) = response_buffer(&value) + { + value.zeroize(); + unsafe { (api.response)(session, response.as_ptr().cast()) }; } value.zeroize(); } + PromptEvent::SelectIdentity { + generation: selection_generation, + uid, + } if permitted_selection(request, uid) + && selection_generation.attempt == 0 + && selection_generation.selection > generation.selection + && selected_uid.is_none_or(|(_, pending): (u32, Generation)| { + selection_generation.selection > pending.selection + }) + && (!stopped || selected_uid.is_some()) => + { + selected_uid = Some((uid, selection_generation)); + } PromptEvent::Cancel => cancelled.store(true, Ordering::Release), _ => {} } } - if !stopped && (cancelled.load(Ordering::Acquire) || child.try_wait().map_err(|e| e.to_string())?.is_some()) { + if !stopped + && (selected_uid.is_some() + || cancelled.load(Ordering::Acquire) + || process.child.try_wait().map_err(|e| e.to_string())?.is_some()) + { unsafe { (api.cancel)(session) }; stopped = true; } std::thread::sleep(Duration::from_millis(10)); } - unsafe { - gobject_ffi::g_object_unref(session.cast()); - drop(Box::from_raw(signal_data as *mut Sender)); + session_guard.completed = true; + if cancelled.load(Ordering::Acquire) { + Ok(SessionOutcome::Cancelled) + } else if let Some((uid, generation)) = selected_uid { + Ok(SessionOutcome::IdentityChanged { uid, generation }) + } else if stopped { + Ok(SessionOutcome::Cancelled) + } else { + Ok(SessionOutcome::Completed(outcome.unwrap())) } - Ok(outcome.filter(|_| !stopped)) } -pub fn authenticate(uid: u32, message: String, cookie: String, cancelled: Arc) -> Result { +pub fn authenticate( + mut request: AuthenticationRequest, + cookie: String, + cancelled: Arc, +) -> Result { + if cancelled.load(Ordering::Acquire) { + return Ok(false); + } let api = Api::load()?; let cookie = CString::new(cookie).map_err(|_| "Invalid authentication cookie")?; let context = glib::MainContext::new(); context .with_thread_default(|| { - let (mut child, mut writer, input) = prompt_process(message, username(uid))?; - let result = loop { - match run_session( - &api, - uid, - &cookie, - &context, - &mut writer, - &input, - &cancelled, - &mut child, - ) { - Ok(Some(true)) => break Ok(true), - Ok(Some(false)) if !cancelled.load(Ordering::Acquire) => { - if let Err(error) = send( - &mut writer, - &PromptEvent::Error { - text: "Password not accepted. Try again.".into(), - }, - ) { - break Err(error); - } - } - Ok(_) => break Ok(false), - Err(error) => break Err(error), - } - }; - let _ = child.kill(); - let _ = child.wait(); - result + let mut process = prompt_process(request.clone())?; + authenticate_sessions(&api, &mut request, &cookie, &context, &mut process, &cancelled) }) .map_err(|error| error.to_string())? } + +const MAX_AUTH_ATTEMPTS: usize = 3; + +fn authenticate_sessions( + api: &Api, + request: &mut AuthenticationRequest, + cookie: &CString, + context: &glib::MainContext, + process: &mut PromptProcess, + cancelled: &AtomicBool, +) -> Result { + let mut failures = 0; + loop { + match run_session(api, request, cookie, context, process, cancelled) { + Ok(SessionOutcome::Completed(true)) => break Ok(true), + Ok(SessionOutcome::IdentityChanged { uid, generation }) if !cancelled.load(Ordering::Acquire) => { + request.selected_uid = uid; + request.generation = generation; + } + Ok(SessionOutcome::Completed(false)) if !cancelled.load(Ordering::Acquire) => { + failures += 1; + // Switching accounts never resets this request's attempt budget. + if failures >= MAX_AUTH_ATTEMPTS { + break Err(format!("Authentication failed after {MAX_AUTH_ATTEMPTS} attempts")); + } + request.generation.attempt += 1; + } + Ok(_) => break Ok(false), + Err(error) => break Err(error), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tests::request_fixture; + + static RESPONSES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + + unsafe extern "C" fn fake_initiate(session: Object) { + unsafe { + gobject_ffi::g_signal_emit_by_name(session.cast(), c"request".as_ptr(), c"Password:".as_ptr(), 0 as c_int); + } + } + + unsafe extern "C" fn fake_cancel(session: Object) { + unsafe { + gobject_ffi::g_signal_emit_by_name(session.cast(), c"completed".as_ptr(), 0 as c_int); + } + } + + unsafe extern "C" fn fake_response(session: Object, _value: *const c_char) { + RESPONSES.fetch_add(1, Ordering::SeqCst); + unsafe { + gobject_ffi::g_signal_emit_by_name(session.cast(), c"completed".as_ptr(), 1 as c_int); + } + } + + #[test] + fn retries_stop_after_three_failures_even_when_the_account_changes() { + let mut api = Api::load().unwrap(); + api.initiate = fake_initiate; + api.response = fake_failed_response; + api.cancel = fake_cancel; + let context = glib::MainContext::new(); + context + .with_thread_default(|| { + let mut child = Command::new("cat") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let writer = BufWriter::new(child.stdin.take().unwrap()); + let output = child.stdout.take().unwrap(); + let (tx, input) = mpsc::channel(); + let starts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = starts.clone(); + let prompt = std::thread::spawn(move || { + let mut uid = 1000; + for line in BufReader::new(output).lines() { + let Ok(line) = line else { break }; + if let Ok(PromptEvent::Request { generation, .. }) = serde_json::from_str::(&line) + { + let count = counter.fetch_add(1, Ordering::SeqCst) + 1; + let event = if count == 3 { + uid = 1001; + PromptEvent::SelectIdentity { + uid, + generation: Generation { + selection: generation.selection + 1, + attempt: 0, + }, + } + } else { + PromptEvent::Response { + generation, + uid, + value: "incorrect".into(), + } + }; + if tx.send(event).is_err() { + break; + } + } + } + }); + let mut process = PromptProcess { child, writer, input }; + let mut request = request_fixture(); + let result = authenticate_sessions( + &api, + &mut request, + &CString::new("test-cookie").unwrap(), + &context, + &mut process, + &AtomicBool::new(false), + ); + drop(process); + prompt.join().unwrap(); + assert_eq!(result, Err("Authentication failed after 3 attempts".into())); + assert_eq!(starts.load(Ordering::SeqCst), 4); + assert_eq!(request.selected_uid, 1001); + }) + .unwrap(); + } + + unsafe extern "C" fn fake_failed_response(session: Object, _value: *const c_char) { + unsafe { + gobject_ffi::g_signal_emit_by_name(session.cast(), c"completed".as_ptr(), 0 as c_int); + } + } + + #[test] + fn account_changes_cancel_old_sessions_and_reject_stale_passwords() { + // Use actual GLib session objects and callbacks, with no PAM helper or + // authority contacted. Only the session's initiate/response/cancel calls are fake. + let mut api = Api::load().unwrap(); + api.initiate = fake_initiate; + api.response = fake_response; + api.cancel = fake_cancel; + let context = glib::MainContext::new(); + context + .with_thread_default(|| { + let mut child = Command::new("sleep").arg("5").stdin(Stdio::piped()).spawn().unwrap(); + let writer = BufWriter::new(child.stdin.take().unwrap()); + let cookie = CString::new("test-cookie").unwrap(); + let cancelled = AtomicBool::new(false); + let mut request = request_fixture(); + let (tx, input) = mpsc::channel(); + let mut process = PromptProcess { child, writer, input }; + tx.send(PromptEvent::SelectIdentity { + uid: 1001, + generation: Generation { + selection: 1, + attempt: 0, + }, + }) + .unwrap(); + tx.send(PromptEvent::Response { + generation: Generation::default(), + uid: 1000, + value: "stale-response".into(), + }) + .unwrap(); + assert_eq!( + run_session(&api, &request, &cookie, &context, &mut process, &cancelled).unwrap(), + SessionOutcome::IdentityChanged { + uid: 1001, + generation: Generation { + selection: 1, + attempt: 0 + } + } + ); + assert_eq!(RESPONSES.load(Ordering::SeqCst), 0); + request.selected_uid = 1001; + request.generation = Generation { + selection: 1, + attempt: 0, + }; + tx.send(PromptEvent::Response { + generation: Generation::default(), + uid: 1000, + value: "stale-response".into(), + }) + .unwrap(); + // Matching UID alone is insufficient: reject responses from a + // previous account selection and from an earlier retry. + tx.send(PromptEvent::Response { + generation: Generation::default(), + uid: 1001, + value: "same-user-old-generation".into(), + }) + .unwrap(); + request.generation.attempt = 1; + tx.send(PromptEvent::Response { + generation: Generation { + attempt: 0, + ..request.generation + }, + uid: 1001, + value: "same-user-old-attempt".into(), + }) + .unwrap(); + tx.send(PromptEvent::Response { + generation: request.generation, + uid: 1001, + value: "new-response".into(), + }) + .unwrap(); + assert_eq!( + run_session(&api, &request, &cookie, &context, &mut process, &cancelled).unwrap(), + SessionOutcome::Completed(true) + ); + assert_eq!(RESPONSES.load(Ordering::SeqCst), 1); + tx.send(PromptEvent::Cancel).unwrap(); + assert_eq!( + run_session(&api, &request, &cookie, &context, &mut process, &cancelled).unwrap(), + SessionOutcome::Cancelled + ); + }) + .unwrap(); + } + + #[test] + fn account_switches_keep_the_latest_generation() { + let mut api = Api::load().unwrap(); + api.initiate = fake_initiate; + api.cancel = fake_cancel; + let context = glib::MainContext::new(); + context + .with_thread_default(|| { + let mut child = Command::new("sleep").arg("5").stdin(Stdio::piped()).spawn().unwrap(); + let writer = BufWriter::new(child.stdin.take().unwrap()); + let (tx, input) = mpsc::channel(); + let mut process = PromptProcess { child, writer, input }; + for (selection, uid) in [(2, 1001), (3, 1000), (1, 1001), (2, 1001)] { + tx.send(PromptEvent::SelectIdentity { + generation: Generation { selection, attempt: 0 }, + uid, + }) + .unwrap(); + } + assert_eq!( + run_session( + &api, + &request_fixture(), + &CString::new("test-cookie").unwrap(), + &context, + &mut process, + &AtomicBool::new(false), + ) + .unwrap(), + SessionOutcome::IdentityChanged { + uid: 1000, + generation: Generation { + selection: 3, + attempt: 0 + } + } + ); + }) + .unwrap(); + } + + #[test] + fn ffi_response_is_nul_terminated_and_owned_by_a_zeroizing_buffer() { + let response = response_buffer("test-password").unwrap(); + assert_eq!( + CStr::from_bytes_with_nul(&response).unwrap().to_bytes(), + b"test-password" + ); + assert!(response_buffer("invalid\0password").is_none()); + assert_eq!(response_buffer("").unwrap().as_slice(), &[0]); + } + + #[test] + fn account_switches_accept_only_offered_identities() { + let request = request_fixture(); + assert!(permitted_selection(&request, 1001)); + assert!(permitted_selection(&request, 1000)); + assert!(!permitted_selection(&request, 0)); + assert!(!permitted_selection(&request, u32::MAX)); + } +}