Complete polkit authentication dialog and harden session handling
This commit is contained in:
1 parent
4137f787bf
commit
cb6fe73e44
4 files changed
+1207
-139
No files matched your search
@@ -14,6 +14,6 @@ libcosmic = { workspace = true, features = ["wgpu"] }
|
|||||||
libloading = "0.8.9"
|
libloading = "0.8.9"
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.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"
|
zbus = "5.19.0"
|
||||||
zeroize = "1.9"
|
zeroize = "1.9"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
mod prompt;
|
mod prompt;
|
||||||
mod session;
|
mod session;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
@@ -16,33 +16,110 @@ const AUTHORITY_INTERFACE: &str = "org.freedesktop.PolicyKit1.Authority";
|
|||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
enum PromptEvent {
|
enum PromptEvent {
|
||||||
Start { message: String, user: String },
|
Start(AuthenticationRequest),
|
||||||
Request { prompt: String, echo: bool },
|
Request {
|
||||||
Info { text: String },
|
generation: Generation,
|
||||||
Error { text: String },
|
prompt: String,
|
||||||
Response { value: 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,
|
Cancel,
|
||||||
}
|
}
|
||||||
|
|
||||||
type Identity = (String, HashMap<String, OwnedValue>);
|
type Identity = (String, HashMap<String, OwnedValue>);
|
||||||
|
|
||||||
#[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<String, String>,
|
||||||
|
vendor_name: String,
|
||||||
|
vendor_url: String,
|
||||||
|
identities: Vec<IdentityChoice>,
|
||||||
|
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<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
struct Agent {
|
struct Agent {
|
||||||
|
connection: zbus::Connection,
|
||||||
active: Mutex<HashMap<String, Arc<AtomicBool>>>,
|
active: Mutex<HashMap<String, Arc<AtomicBool>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<ActionDescription> = 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")]
|
#[zbus::interface(name = "org.freedesktop.PolicyKit1.AuthenticationAgent")]
|
||||||
impl Agent {
|
impl Agent {
|
||||||
async fn begin_authentication(
|
async fn begin_authentication(
|
||||||
&self,
|
&self,
|
||||||
_action_id: String,
|
action_id: String,
|
||||||
message: String,
|
message: String,
|
||||||
_icon_name: String,
|
icon_name: String,
|
||||||
_details: HashMap<String, String>,
|
details: HashMap<String, String>,
|
||||||
cookie: String,
|
cookie: String,
|
||||||
identities: Vec<Identity>,
|
identities: Vec<Identity>,
|
||||||
) -> zbus::fdo::Result<()> {
|
) -> 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()))?;
|
.ok_or_else(|| zbus::fdo::Error::Failed("No supported authentication identity".into()))?;
|
||||||
let cancelled = Arc::new(AtomicBool::new(false));
|
let cancelled = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
@@ -54,15 +131,31 @@ impl Agent {
|
|||||||
active.insert(cookie.clone(), cancelled.clone());
|
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 result = tokio::task::spawn_blocking({
|
||||||
let cookie = cookie.clone();
|
let cookie = cookie.clone();
|
||||||
move || session::authenticate(uid, message.chars().take(240).collect(), cookie, cancelled)
|
move || session::authenticate(request, cookie, cancelled)
|
||||||
})
|
})
|
||||||
.await
|
.await;
|
||||||
.map_err(|error| zbus::fdo::Error::Failed(error.to_string()))?;
|
|
||||||
|
|
||||||
self.active.lock().unwrap().remove(&cookie);
|
self.active.lock().unwrap().remove(&cookie);
|
||||||
match result {
|
match result.map_err(|error| zbus::fdo::Error::Failed(error.to_string()))? {
|
||||||
Ok(true) => Ok(()),
|
Ok(true) => Ok(()),
|
||||||
Ok(false) => Err(zbus::fdo::Error::Failed("Authentication cancelled".into())),
|
Ok(false) => Err(zbus::fdo::Error::Failed("Authentication cancelled".into())),
|
||||||
Err(error) => Err(zbus::fdo::Error::Failed(error)),
|
Err(error) => Err(zbus::fdo::Error::Failed(error)),
|
||||||
@@ -76,19 +169,28 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn choose_identity(identities: &[Identity]) -> Option<u32> {
|
const MAX_IDENTITIES: usize = 16;
|
||||||
let available: Vec<_> = identities
|
|
||||||
|
fn available_identities(identities: &[Identity]) -> Vec<u32> {
|
||||||
|
let mut available = Vec::new();
|
||||||
|
for uid in identities
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(kind, _)| kind == "unix-user")
|
.filter(|(kind, _)| kind == "unix-user")
|
||||||
.filter_map(|(_, details)| {
|
.filter_map(|(_, details)| details.get("uid").and_then(|uid| u32::try_from(uid.clone()).ok()))
|
||||||
details
|
.filter(|uid| *uid <= i32::MAX as u32)
|
||||||
.get("uid")
|
{
|
||||||
.and_then(|uid| u32::try_from(uid.clone()).ok())
|
if !available.contains(&uid) {
|
||||||
.filter(|uid| *uid <= i32::MAX as u32)
|
available.push(uid);
|
||||||
})
|
if available.len() == MAX_IDENTITIES {
|
||||||
.collect();
|
break;
|
||||||
let current = unsafe { libc::geteuid() };
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
available
|
||||||
|
}
|
||||||
|
|
||||||
|
fn choose_identity(available: &[u32]) -> Option<u32> {
|
||||||
|
let current = unsafe { libc::geteuid() };
|
||||||
available
|
available
|
||||||
.iter()
|
.iter()
|
||||||
.copied()
|
.copied()
|
||||||
@@ -157,9 +259,16 @@ fn login_session_subject(id: String) -> (String, HashMap<String, Value<'static>>
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn run_agent() -> Result<(), Box<dyn std::error::Error>> {
|
async fn run_agent() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let connection = zbus::connection::Builder::system()?
|
let connection = zbus::Connection::system().await?;
|
||||||
.serve_at(AGENT_PATH, Agent::default())?
|
connection
|
||||||
.build()
|
.object_server()
|
||||||
|
.at(
|
||||||
|
AGENT_PATH,
|
||||||
|
Agent {
|
||||||
|
connection: connection.clone(),
|
||||||
|
active: Mutex::new(HashMap::new()),
|
||||||
|
},
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let subject = session_subject(&connection).await?;
|
let subject = session_subject(&connection).await?;
|
||||||
let authority = zbus::Proxy::new(&connection, AUTHORITY, AUTHORITY_PATH, AUTHORITY_INTERFACE).await?;
|
let authority = zbus::Proxy::new(&connection, AUTHORITY, AUTHORITY_PATH, AUTHORITY_INTERFACE).await?;
|
||||||
@@ -193,6 +302,205 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<ActionDescription> {
|
||||||
|
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]
|
#[test]
|
||||||
fn desktop_agent_registers_for_the_login_session() {
|
fn desktop_agent_registers_for_the_login_session() {
|
||||||
let (kind, details) = login_session_subject("test-session".into());
|
let (kind, details) = login_session_subject("test-session".into());
|
||||||
@@ -219,6 +527,9 @@ mod tests {
|
|||||||
HashMap::from([("uid".to_owned(), OwnedValue::from(uid))]),
|
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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,11 +5,11 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use cosmic::app::{Core, Settings, Task};
|
use cosmic::app::{Core, Settings, Task};
|
||||||
use cosmic::iced::{Alignment, Color, Length, Size, Subscription, window};
|
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 cosmic::{Element, theme, widget};
|
||||||
use zeroize::{Zeroize, Zeroizing};
|
use zeroize::{Zeroize, Zeroizing};
|
||||||
|
|
||||||
use crate::PromptEvent;
|
use crate::{AuthenticationRequest, Generation, PromptEvent};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct Appearance {
|
struct Appearance {
|
||||||
@@ -69,12 +69,14 @@ impl Appearance {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
enum Message {
|
enum Message {
|
||||||
ThemeChanged(ferese_config::theme::Snapshot),
|
ThemeChanged(Box<ferese_config::theme::Snapshot>),
|
||||||
WindowOpened(cosmic::iced::window::Id),
|
WindowOpened(cosmic::iced::window::Id),
|
||||||
MaterialAttached(Result<ferese_theme::material::ModalMaterial, String>),
|
MaterialAttached(Result<ferese_theme::material::ModalMaterial, String>),
|
||||||
Tick,
|
Tick,
|
||||||
Input(String),
|
Input(String),
|
||||||
Submit,
|
Submit,
|
||||||
|
SelectIdentity(usize),
|
||||||
|
ToggleDetails,
|
||||||
Cancel,
|
Cancel,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,12 +91,15 @@ struct Prompt {
|
|||||||
material: Option<ferese_theme::material::ModalMaterial>,
|
material: Option<ferese_theme::material::ModalMaterial>,
|
||||||
appearance: Appearance,
|
appearance: Appearance,
|
||||||
incoming: Arc<Mutex<VecDeque<PromptEvent>>>,
|
incoming: Arc<Mutex<VecDeque<PromptEvent>>>,
|
||||||
description: String,
|
request: AuthenticationRequest,
|
||||||
user: String,
|
identity_names: Vec<String>,
|
||||||
|
details_expanded: bool,
|
||||||
|
action_icon: Option<widget::icon::Handle>,
|
||||||
question: String,
|
question: String,
|
||||||
echo: bool,
|
echo: bool,
|
||||||
answer: Zeroizing<String>,
|
answer: Zeroizing<String>,
|
||||||
waiting: bool,
|
waiting: bool,
|
||||||
|
awaiting_ack: bool,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
info: Option<String>,
|
info: Option<String>,
|
||||||
height: f32,
|
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
|
(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<widget::icon::Handle> {
|
||||||
|
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<Item = std::io::Result<String>>, queue: &Mutex<VecDeque<PromptEvent>>) {
|
||||||
|
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<dyn std::error::Error>> {
|
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let stdin = std::io::stdin();
|
let stdin = std::io::stdin();
|
||||||
let mut lines = BufReader::new(stdin).lines();
|
let mut lines = BufReader::new(stdin).lines();
|
||||||
let Some(Ok(first)) = lines.next() else {
|
let Some(Ok(first)) = lines.next() else {
|
||||||
return Err("Missing authentication request".into());
|
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());
|
return Err("Invalid authentication request".into());
|
||||||
};
|
};
|
||||||
let incoming = Arc::new(Mutex::new(VecDeque::new()));
|
let incoming = Arc::new(Mutex::new(VecDeque::new()));
|
||||||
let queue = incoming.clone();
|
let queue = incoming.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
for line in lines {
|
read_events(lines, &queue);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
let appearance = Appearance::load();
|
let appearance = Appearance::load();
|
||||||
let height = content_height(&message, None);
|
let height = dialog_height(&request, None, false);
|
||||||
cosmic::app::run::<Prompt>(
|
cosmic::app::run::<Prompt>(
|
||||||
Settings::default()
|
Settings::default()
|
||||||
.size(Size::new(434., height))
|
.size(Size::new(434., height))
|
||||||
@@ -140,7 +173,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.default_font(appearance.font)
|
.default_font(appearance.font)
|
||||||
.default_text_size(14.)
|
.default_text_size(14.)
|
||||||
.antialiasing(true),
|
.antialiasing(true),
|
||||||
(incoming, message, user, appearance),
|
(incoming, request, appearance),
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -152,7 +185,7 @@ fn reply(mut event: PromptEvent) {
|
|||||||
let _ = stdout.flush();
|
let _ = stdout.flush();
|
||||||
line.zeroize();
|
line.zeroize();
|
||||||
}
|
}
|
||||||
if let PromptEvent::Response { value } = &mut event {
|
if let PromptEvent::Response { value, .. } = &mut event {
|
||||||
value.zeroize();
|
value.zeroize();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,8 +195,36 @@ fn focus() -> Task<Message> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Prompt {
|
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<u32> {
|
||||||
|
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<Message> {
|
fn resize_to_content(&mut self) -> Task<Message> {
|
||||||
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 {
|
if (height - self.height).abs() < 1.0 {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
@@ -177,7 +238,7 @@ impl Prompt {
|
|||||||
|
|
||||||
impl cosmic::Application for Prompt {
|
impl cosmic::Application for Prompt {
|
||||||
type Executor = cosmic::executor::Default;
|
type Executor = cosmic::executor::Default;
|
||||||
type Flags = (Arc<Mutex<VecDeque<PromptEvent>>>, String, String, Appearance);
|
type Flags = (Arc<Mutex<VecDeque<PromptEvent>>>, AuthenticationRequest, Appearance);
|
||||||
type Message = Message;
|
type Message = Message;
|
||||||
const APP_ID: &'static str = "dev.ferese.Authentication";
|
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<Message>) {
|
fn init(mut core: Core, (incoming, request, appearance): Self::Flags) -> (Self, Task<Message>) {
|
||||||
core.window.show_headerbar = false;
|
core.window.show_headerbar = false;
|
||||||
core.window.border_padding = Some(0);
|
core.window.border_padding = Some(0);
|
||||||
core.window.content_container = false;
|
core.window.content_container = false;
|
||||||
core.window.use_template = 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
|
let bounds = core
|
||||||
.main_window_id()
|
.main_window_id()
|
||||||
.map(|id| window::set_max_size(id, Some(Size::new(434., 360.))))
|
.map(|id| window::set_max_size(id, Some(Size::new(434., 360.))))
|
||||||
@@ -213,12 +280,15 @@ impl cosmic::Application for Prompt {
|
|||||||
material: None,
|
material: None,
|
||||||
appearance,
|
appearance,
|
||||||
incoming,
|
incoming,
|
||||||
description,
|
request,
|
||||||
user,
|
identity_names,
|
||||||
|
details_expanded: false,
|
||||||
|
action_icon,
|
||||||
question: "Waiting for authentication…".into(),
|
question: "Waiting for authentication…".into(),
|
||||||
echo: false,
|
echo: false,
|
||||||
answer: Zeroizing::new(String::new()),
|
answer: Zeroizing::new(String::new()),
|
||||||
waiting: true,
|
waiting: true,
|
||||||
|
awaiting_ack: true,
|
||||||
error: None,
|
error: None,
|
||||||
info: None,
|
info: None,
|
||||||
height,
|
height,
|
||||||
@@ -229,7 +299,7 @@ impl cosmic::Application for Prompt {
|
|||||||
|
|
||||||
fn subscription(&self) -> Subscription<Message> {
|
fn subscription(&self) -> Subscription<Message> {
|
||||||
Subscription::batch([
|
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::time::every(Duration::from_millis(30)).map(|_| Message::Tick),
|
||||||
cosmic::iced::event::listen_with(|event, _, id| match event {
|
cosmic::iced::event::listen_with(|event, _, id| match event {
|
||||||
cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => {
|
cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => {
|
||||||
@@ -258,16 +328,42 @@ impl cosmic::Application for Prompt {
|
|||||||
let mut refocus = false;
|
let mut refocus = false;
|
||||||
for event in events {
|
for event in events {
|
||||||
match event {
|
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.answer.zeroize();
|
||||||
self.question = prompt;
|
self.question = prompt;
|
||||||
self.echo = echo;
|
self.echo = echo;
|
||||||
self.waiting = false;
|
self.waiting = false;
|
||||||
refocus = true;
|
refocus = true;
|
||||||
}
|
}
|
||||||
PromptEvent::Info { text } => self.info = Some(text),
|
PromptEvent::Info { generation, text }
|
||||||
PromptEvent::Error { text } => self.error = Some(text),
|
if generation == self.request.generation && !self.awaiting_ack =>
|
||||||
PromptEvent::Cancel => return cosmic::iced::exit(),
|
{
|
||||||
|
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() => {
|
Message::Submit if !self.waiting && !self.answer.is_empty() => {
|
||||||
let value = std::mem::take(&mut *self.answer);
|
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;
|
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 => {
|
Message::Cancel => {
|
||||||
self.answer.zeroize();
|
self.answer.zeroize();
|
||||||
reply(PromptEvent::Cancel);
|
reply(PromptEvent::Cancel);
|
||||||
@@ -325,7 +437,19 @@ impl cosmic::Application for Prompt {
|
|||||||
.height(Length::Fixed(36.))
|
.height(Length::Fixed(36.))
|
||||||
.padding([8, 16])
|
.padding([8, 16])
|
||||||
.on_press_maybe((!self.waiting && !self.answer.is_empty()).then_some(Message::Submit));
|
.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![
|
let heading = row![
|
||||||
container(lock_icon)
|
container(lock_icon)
|
||||||
.width(40)
|
.width(40)
|
||||||
@@ -335,7 +459,7 @@ impl cosmic::Application for Prompt {
|
|||||||
.class(ferese_theme::controls::surface(palette.accent.scale_alpha(0.13), 12.,)),
|
.class(ferese_theme::controls::surface(palette.accent.scale_alpha(0.13), 12.,)),
|
||||||
column![
|
column![
|
||||||
ferese_theme::text("Authentication required", palette.font).size(18),
|
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)
|
.size(12)
|
||||||
.class(theme::Text::Color(palette.muted)),
|
.class(theme::Text::Color(palette.muted)),
|
||||||
]
|
]
|
||||||
@@ -343,24 +467,81 @@ impl cosmic::Application for Prompt {
|
|||||||
]
|
]
|
||||||
.spacing(12)
|
.spacing(12)
|
||||||
.align_y(Alignment::Center);
|
.align_y(Alignment::Center);
|
||||||
let mut body = column![
|
let mut information = column![ferese_theme::text(&self.request.message, palette.font).size(13)]
|
||||||
heading,
|
.spacing(10)
|
||||||
ferese_theme::text(&self.description, palette.font).size(13),
|
.width(Length::Fill);
|
||||||
input
|
if has_details(&self.request) {
|
||||||
]
|
information = information.push(
|
||||||
.spacing(14)
|
button::custom(
|
||||||
.width(Length::Fill);
|
ferese_theme::text(
|
||||||
if let Some(error) = &self.error {
|
if self.details_expanded {
|
||||||
body = body.push(
|
"Hide details"
|
||||||
ferese_theme::text(error, palette.font)
|
} else {
|
||||||
.size(12)
|
"Show details"
|
||||||
.class(theme::Text::Color(Color::from_rgb8(235, 98, 98))),
|
},
|
||||||
|
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(
|
body = body.push(
|
||||||
ferese_theme::text(info, palette.font)
|
container(
|
||||||
.size(12)
|
row![
|
||||||
.class(theme::Text::Color(palette.muted)),
|
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(
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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, _) = <Prompt as cosmic::Application>::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 _ = <Prompt as cosmic::Application>::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]
|
#[test]
|
||||||
fn dialog_grows_for_long_messages_and_caps_its_height() {
|
fn dialog_grows_for_long_messages_and_caps_its_height() {
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use glib::gobject_ffi;
|
use glib::gobject_ffi;
|
||||||
use libloading::Library;
|
use libloading::Library;
|
||||||
use zeroize::Zeroize;
|
use zeroize::{Zeroize, Zeroizing};
|
||||||
|
|
||||||
use crate::PromptEvent;
|
use crate::{AuthenticationRequest, Generation, PromptEvent};
|
||||||
|
|
||||||
enum SessionEvent {
|
enum SessionEvent {
|
||||||
Request(String, bool),
|
Request(String, bool),
|
||||||
@@ -63,7 +63,7 @@ pub fn check() -> Result<(), String> {
|
|||||||
Api::load().map(|_| ())
|
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();
|
let output = Command::new("getent").args(["passwd", &uid.to_string()]).output();
|
||||||
output
|
output
|
||||||
.ok()
|
.ok()
|
||||||
@@ -80,10 +80,20 @@ fn send(writer: &mut BufWriter<ChildStdin>, event: &PromptEvent) -> Result<(), S
|
|||||||
writer.flush().map_err(|e| e.to_string())
|
writer.flush().map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prompt_process(
|
struct PromptProcess {
|
||||||
message: String,
|
child: Child,
|
||||||
user: String,
|
writer: BufWriter<ChildStdin>,
|
||||||
) -> Result<(Child, BufWriter<ChildStdin>, mpsc::Receiver<PromptEvent>), String> {
|
input: mpsc::Receiver<PromptEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PromptProcess {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.child.kill();
|
||||||
|
let _ = self.child.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_process(request: AuthenticationRequest) -> Result<PromptProcess, String> {
|
||||||
let mut child = Command::new(std::env::current_exe().map_err(|e| e.to_string())?)
|
let mut child = Command::new(std::env::current_exe().map_err(|e| e.to_string())?)
|
||||||
.arg("--prompt")
|
.arg("--prompt")
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
@@ -93,8 +103,7 @@ fn prompt_process(
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let stdin = child.stdin.take().ok_or("Prompt has no input")?;
|
let stdin = child.stdin.take().ok_or("Prompt has no input")?;
|
||||||
let stdout = child.stdout.take().ok_or("Prompt has no output")?;
|
let stdout = child.stdout.take().ok_or("Prompt has no output")?;
|
||||||
let mut writer = BufWriter::new(stdin);
|
let writer = BufWriter::new(stdin);
|
||||||
send(&mut writer, &PromptEvent::Start { message, user })?;
|
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
for line in BufReader::new(stdout).lines() {
|
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<SessionEvent>) };
|
let tx = unsafe { &*(data as *const Sender<SessionEvent>) };
|
||||||
let prompt = unsafe { CStr::from_ptr(prompt) }.to_string_lossy().into_owned();
|
let prompt = unsafe { CStr::from_ptr(prompt) }.to_string_lossy().into_owned();
|
||||||
let _ = tx.send(SessionEvent::Request(prompt, echo != 0));
|
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<SessionEvent>,
|
||||||
|
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<Zeroizing<Vec<u8>>> {
|
||||||
|
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(
|
fn run_session(
|
||||||
api: &Api,
|
api: &Api,
|
||||||
uid: u32,
|
request: &AuthenticationRequest,
|
||||||
cookie: &CString,
|
cookie: &CString,
|
||||||
context: &glib::MainContext,
|
context: &glib::MainContext,
|
||||||
writer: &mut BufWriter<ChildStdin>,
|
process: &mut PromptProcess,
|
||||||
input: &mpsc::Receiver<PromptEvent>,
|
|
||||||
cancelled: &AtomicBool,
|
cancelled: &AtomicBool,
|
||||||
child: &mut Child,
|
) -> Result<SessionOutcome, String> {
|
||||||
) -> Result<Option<bool>, String> {
|
let generation = request.generation;
|
||||||
let identity = unsafe { (api.user_new)(uid as c_int) };
|
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() {
|
if identity.is_null() {
|
||||||
return Err("Could not create polkit identity".into());
|
return Err("Could not create polkit identity".into());
|
||||||
}
|
}
|
||||||
@@ -169,7 +241,14 @@ fn run_session(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel();
|
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 {
|
unsafe {
|
||||||
connect(
|
connect(
|
||||||
session,
|
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(Object, *const c_char, c_int, *mut c_void),
|
||||||
unsafe extern "C" fn(),
|
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,
|
signal_data,
|
||||||
);
|
);
|
||||||
@@ -221,6 +300,7 @@ fn run_session(
|
|||||||
|
|
||||||
let mut outcome = None;
|
let mut outcome = None;
|
||||||
let mut stopped = false;
|
let mut stopped = false;
|
||||||
|
let mut selected_uid = None;
|
||||||
while outcome.is_none() {
|
while outcome.is_none() {
|
||||||
while context.pending() {
|
while context.pending() {
|
||||||
context.iteration(false);
|
context.iteration(false);
|
||||||
@@ -228,78 +308,381 @@ fn run_session(
|
|||||||
while let Ok(event) = rx.try_recv() {
|
while let Ok(event) = rx.try_recv() {
|
||||||
match event {
|
match event {
|
||||||
SessionEvent::Request(prompt, echo) => {
|
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::Info(text) => send(&mut process.writer, &PromptEvent::Info { generation, text })?,
|
||||||
SessionEvent::Error(text) => send(writer, &PromptEvent::Error { text })?,
|
SessionEvent::Error(text) => send(&mut process.writer, &PromptEvent::Error { generation, text })?,
|
||||||
SessionEvent::Completed(success) => outcome = Some(success),
|
SessionEvent::Completed(success) => outcome = Some(success),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if outcome.is_some() {
|
if outcome.is_some() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
while let Ok(event) = input.try_recv() {
|
while let Ok(event) = process.input.try_recv() {
|
||||||
match event {
|
match event {
|
||||||
PromptEvent::Response { mut value } => {
|
PromptEvent::Response {
|
||||||
if let Ok(response) = CString::new(value.as_str()) {
|
generation: response_generation,
|
||||||
unsafe { (api.response)(session, response.as_ptr()) };
|
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();
|
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),
|
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) };
|
unsafe { (api.cancel)(session) };
|
||||||
stopped = true;
|
stopped = true;
|
||||||
}
|
}
|
||||||
std::thread::sleep(Duration::from_millis(10));
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe {
|
session_guard.completed = true;
|
||||||
gobject_ffi::g_object_unref(session.cast());
|
if cancelled.load(Ordering::Acquire) {
|
||||||
drop(Box::from_raw(signal_data as *mut Sender<SessionEvent>));
|
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<AtomicBool>) -> Result<bool, String> {
|
pub fn authenticate(
|
||||||
|
mut request: AuthenticationRequest,
|
||||||
|
cookie: String,
|
||||||
|
cancelled: Arc<AtomicBool>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
if cancelled.load(Ordering::Acquire) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
let api = Api::load()?;
|
let api = Api::load()?;
|
||||||
let cookie = CString::new(cookie).map_err(|_| "Invalid authentication cookie")?;
|
let cookie = CString::new(cookie).map_err(|_| "Invalid authentication cookie")?;
|
||||||
let context = glib::MainContext::new();
|
let context = glib::MainContext::new();
|
||||||
context
|
context
|
||||||
.with_thread_default(|| {
|
.with_thread_default(|| {
|
||||||
let (mut child, mut writer, input) = prompt_process(message, username(uid))?;
|
let mut process = prompt_process(request.clone())?;
|
||||||
let result = loop {
|
authenticate_sessions(&api, &mut request, &cookie, &context, &mut process, &cancelled)
|
||||||
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
|
|
||||||
})
|
})
|
||||||
.map_err(|error| error.to_string())?
|
.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<bool, String> {
|
||||||
|
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::<PromptEvent>(&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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user