Initial commit
This commit is contained in:
commit
9f97f2c989
37 changed files with 4871 additions and 0 deletions
250
breadgreet/src/greetd/client.rs
Normal file
250
breadgreet/src/greetd/client.rs
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
//! Thin async wrapper around `greetd_ipc`'s wire protocol — connects to
|
||||
//! `$GREETD_SOCK`, and turns greetd's `Request`/`Response` enums into a
|
||||
//! small state machine ([`Outcome`]) the UI drives.
|
||||
|
||||
use greetd_ipc::codec::TokioCodec;
|
||||
use greetd_ipc::{AuthMessageType, ErrorType, Request, Response};
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GreetdError {
|
||||
#[error("$GREETD_SOCK is not set — breadgreet must be launched by greetd")]
|
||||
NoSocketEnv,
|
||||
#[error("failed to connect to greetd socket: {0}")]
|
||||
Connect(#[source] std::io::Error),
|
||||
#[error("greetd IPC error: {0}")]
|
||||
Codec(#[from] greetd_ipc::codec::Error),
|
||||
#[error("{description}")]
|
||||
Greetd {
|
||||
description: String,
|
||||
is_auth_error: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// One step of a PAM conversation, as relayed by greetd.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AuthPrompt {
|
||||
/// Answer should be shown as typed (e.g. a username confirmation).
|
||||
Visible(String),
|
||||
/// Answer should be masked (a password).
|
||||
Secret(String),
|
||||
/// Informational message — no answer needed, just display and continue.
|
||||
Info(String),
|
||||
/// Non-fatal error message from a PAM module — display and continue.
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Outcome {
|
||||
/// Login flow complete — call [`Client::start_session`] next.
|
||||
Success,
|
||||
/// Another prompt to answer via [`Client::respond`].
|
||||
Prompt(AuthPrompt),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Client {
|
||||
stream: UnixStream,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Connects to the Unix socket greetd set in `$GREETD_SOCK` when it
|
||||
/// launched this process.
|
||||
pub async fn connect() -> Result<Self, GreetdError> {
|
||||
let path = std::env::var_os("GREETD_SOCK").ok_or(GreetdError::NoSocketEnv)?;
|
||||
Self::connect_to(path).await
|
||||
}
|
||||
|
||||
/// Connects to an explicit socket path, bypassing `$GREETD_SOCK` —
|
||||
/// used directly by tests against a mock server so they don't need to
|
||||
/// mutate process-global environment state (which parallel `cargo test`
|
||||
/// threads would race on).
|
||||
async fn connect_to(path: impl AsRef<std::path::Path>) -> Result<Self, GreetdError> {
|
||||
let stream = UnixStream::connect(path)
|
||||
.await
|
||||
.map_err(GreetdError::Connect)?;
|
||||
Ok(Self { stream })
|
||||
}
|
||||
|
||||
/// Starts a login attempt for `username`. Answer any resulting
|
||||
/// [`Outcome::Prompt`] via [`Self::respond`] until [`Outcome::Success`].
|
||||
pub async fn create_session(&mut self, username: &str) -> Result<Outcome, GreetdError> {
|
||||
let req = Request::CreateSession {
|
||||
username: username.to_string(),
|
||||
};
|
||||
self.roundtrip(req).await
|
||||
}
|
||||
|
||||
/// Answers the most recent [`AuthPrompt`]. `None` for prompts that don't
|
||||
/// need an answer (info/error messages).
|
||||
pub async fn respond(&mut self, answer: Option<String>) -> Result<Outcome, GreetdError> {
|
||||
self.roundtrip(Request::PostAuthMessageResponse { response: answer })
|
||||
.await
|
||||
}
|
||||
|
||||
/// Hands the chosen session off to greetd, which execs it and owns the
|
||||
/// VT switch away from the greeter. Only valid after an [`Outcome::Success`].
|
||||
pub async fn start_session(
|
||||
&mut self,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<(), GreetdError> {
|
||||
Request::StartSession { cmd, env }
|
||||
.write_to(&mut self.stream)
|
||||
.await?;
|
||||
match Response::read_from(&mut self.stream).await? {
|
||||
Response::Success => Ok(()),
|
||||
Response::Error {
|
||||
error_type,
|
||||
description,
|
||||
} => Err(GreetdError::Greetd {
|
||||
description,
|
||||
is_auth_error: matches!(error_type, ErrorType::AuthError),
|
||||
}),
|
||||
Response::AuthMessage { .. } => Err(GreetdError::Greetd {
|
||||
description: "greetd sent an unexpected auth message after StartSession"
|
||||
.to_string(),
|
||||
is_auth_error: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Aborts an in-progress login flow (e.g. the user hit Escape). Per the
|
||||
/// protocol this can only be called before `StartSession` — best-effort,
|
||||
/// errors here don't matter to the caller since the flow is being torn
|
||||
/// down either way.
|
||||
pub async fn cancel_session(&mut self) {
|
||||
let _ = Request::CancelSession.write_to(&mut self.stream).await;
|
||||
let _ = Response::read_from(&mut self.stream).await;
|
||||
}
|
||||
|
||||
async fn roundtrip(&mut self, req: Request) -> Result<Outcome, GreetdError> {
|
||||
req.write_to(&mut self.stream).await?;
|
||||
match Response::read_from(&mut self.stream).await? {
|
||||
Response::Success => Ok(Outcome::Success),
|
||||
Response::AuthMessage {
|
||||
auth_message_type,
|
||||
auth_message,
|
||||
} => Ok(Outcome::Prompt(match auth_message_type {
|
||||
AuthMessageType::Visible => AuthPrompt::Visible(auth_message),
|
||||
AuthMessageType::Secret => AuthPrompt::Secret(auth_message),
|
||||
AuthMessageType::Info => AuthPrompt::Info(auth_message),
|
||||
AuthMessageType::Error => AuthPrompt::Error(auth_message),
|
||||
})),
|
||||
Response::Error {
|
||||
error_type,
|
||||
description,
|
||||
} => Err(GreetdError::Greetd {
|
||||
description,
|
||||
is_auth_error: matches!(error_type, ErrorType::AuthError),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Exercises the framing/state-machine logic against a mock Unix-socket
|
||||
//! server speaking the real greetd_ipc wire format — no real greetd or
|
||||
//! PAM involved. This is the safe way to test this module: a bug here
|
||||
//! just fails a test, it can never affect a real login.
|
||||
use super::*;
|
||||
use tokio::net::UnixListener;
|
||||
|
||||
async fn mock_server(path: std::path::PathBuf, script: Vec<Response>) {
|
||||
let listener = UnixListener::bind(&path).unwrap();
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
for response in script {
|
||||
// Drain the request that prompted this response — we don't need
|
||||
// to inspect it, just keep the framing in lockstep.
|
||||
let _ = Request::read_from(&mut stream).await;
|
||||
response.write_to(&mut stream).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn socket_path(name: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"breadgreet-test-{name}-{}.sock",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_session_success_flows_straight_through() {
|
||||
let path = socket_path("success");
|
||||
std::fs::remove_file(&path).ok();
|
||||
let server = tokio::spawn(mock_server(path.clone(), vec![Response::Success]));
|
||||
|
||||
// Give the listener a moment to bind before connecting.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
let mut client = Client::connect_to(&path).await.unwrap();
|
||||
let outcome = client.create_session("bob").await.unwrap();
|
||||
assert!(matches!(outcome, Outcome::Success));
|
||||
|
||||
server.await.unwrap();
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_session_prompts_for_password_then_succeeds() {
|
||||
let path = socket_path("prompt");
|
||||
std::fs::remove_file(&path).ok();
|
||||
let server = tokio::spawn(mock_server(
|
||||
path.clone(),
|
||||
vec![
|
||||
Response::AuthMessage {
|
||||
auth_message_type: AuthMessageType::Secret,
|
||||
auth_message: "Password:".to_string(),
|
||||
},
|
||||
Response::Success,
|
||||
],
|
||||
));
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
let mut client = Client::connect_to(&path).await.unwrap();
|
||||
|
||||
let outcome = client.create_session("bob").await.unwrap();
|
||||
let Outcome::Prompt(AuthPrompt::Secret(msg)) = outcome else {
|
||||
panic!("expected a secret prompt, got {outcome:?}");
|
||||
};
|
||||
assert_eq!(msg, "Password:");
|
||||
|
||||
let outcome = client.respond(Some("hunter2".to_string())).await.unwrap();
|
||||
assert!(matches!(outcome, Outcome::Success));
|
||||
|
||||
server.await.unwrap();
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_error_is_reported_as_such() {
|
||||
let path = socket_path("autherr");
|
||||
std::fs::remove_file(&path).ok();
|
||||
let server = tokio::spawn(mock_server(
|
||||
path.clone(),
|
||||
vec![Response::Error {
|
||||
error_type: ErrorType::AuthError,
|
||||
description: "denied".to_string(),
|
||||
}],
|
||||
));
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
let mut client = Client::connect_to(&path).await.unwrap();
|
||||
|
||||
let err = client.create_session("bob").await.unwrap_err();
|
||||
match err {
|
||||
GreetdError::Greetd { is_auth_error, .. } => assert!(is_auth_error),
|
||||
other => panic!("expected a greetd auth error, got {other:?}"),
|
||||
}
|
||||
|
||||
server.await.unwrap();
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_without_greetd_sock_env_fails_cleanly() {
|
||||
std::env::remove_var("GREETD_SOCK");
|
||||
let err = Client::connect().await.unwrap_err();
|
||||
assert!(matches!(err, GreetdError::NoSocketEnv));
|
||||
}
|
||||
}
|
||||
3
breadgreet/src/greetd/mod.rs
Normal file
3
breadgreet/src/greetd/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
mod client;
|
||||
|
||||
pub use client::{AuthPrompt, Client, Outcome};
|
||||
Loading…
Add table
Add a link
Reference in a new issue