diff --git a/Cargo.lock b/Cargo.lock index afc14a1..9386f30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -193,6 +193,7 @@ dependencies = [ "thiserror", "tokio", "toml", + "zbus", ] [[package]] @@ -1814,6 +1815,7 @@ version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", diff --git a/crates/daemon/Cargo.toml b/crates/daemon/Cargo.toml index b1ffb26..473015d 100644 --- a/crates/daemon/Cargo.toml +++ b/crates/daemon/Cargo.toml @@ -30,3 +30,9 @@ workspace = true rusqlite = { version = "0.40.2", features = ["bundled"] } tempfile = "3.27.0" tokio = { version = "1.53.1", features = ["macros", "rt", "test-util"] } + +[target.'cfg(target_os = "linux")'.dependencies] +zbus = { version = "5.19.0", default-features = false, features = ["tokio"] } + +[target.'cfg(target_os = "linux")'.dev-dependencies] +zbus = { version = "5.19.0", default-features = false, features = ["tokio", "p2p"] } diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs index 4abd0e3..eb6fffd 100644 --- a/crates/daemon/src/lib.rs +++ b/crates/daemon/src/lib.rs @@ -49,6 +49,8 @@ pub mod protocol; pub mod recognition; +pub mod secrets; + pub mod supervisor; pub mod voice; diff --git a/crates/daemon/src/secrets/mod.rs b/crates/daemon/src/secrets/mod.rs new file mode 100644 index 0000000..411b025 --- /dev/null +++ b/crates/daemon/src/secrets/mod.rs @@ -0,0 +1,175 @@ +//! Where a token is kept: the secret store of the system. +//! +//! [`Secrets`] is three calls, read, write and remove, each for the token of +//! one backend, named by a [`Key`]. `SecretService`, on Linux, keeps tokens in +//! the store that answers the Secret Service API on the session bus, which is +//! `ksecretd` under KDE and `gnome-keyring` under GNOME. [`Memory`] keeps them +//! in this process, for tests. +//! +//! **A call asks the store to unlock only where it may ask a person.** +//! Unlocking takes a password, which the store asks for in a window of its +//! own: [`Asking`] says whether a call may open one, and how long it waits for +//! an answer. The daemon never asks on its own. A call made for a command a +//! person runs may ask. + +use std::collections::HashMap; +use std::future::{self, Future}; +use std::sync::{Mutex, MutexGuard}; +use std::time::Duration; + +#[cfg(target_os = "linux")] +mod service; + +#[cfg(target_os = "linux")] +pub use service::SecretService; + +/// How long one call to the store may take before it is given up on. +pub const DEADLINE: Duration = Duration::from_secs(5); + +/// How long a person is given to answer the store's window. +pub const PROMPT_WAIT: Duration = Duration::from_mins(2); + +/// What a token is kept under. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Key { + /// The backend the token is for, which the store finds it by. + pub backend: &'static str, + /// What the store shows a person the token as. + pub label: &'static str, +} + +/// The token of AniList. +pub const ANILIST: Key = Key { + backend: "anilist", + label: "AniList token for benshi", +}; + +/// Whether a call may ask a person to unlock the store. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Asking { + /// Never: a store that would have to ask is [`Error::Locked`]. + Never, + /// Yes, and this long is waited for the answer. + Within(Duration), +} + +/// A failure is boxed where its type belongs to the transport. +pub type BoxError = Box; + +/// Why a call to the store did not do what it was asked. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// No store answers, or there is no session bus to find one on. + #[error("secret store could not be reached: {0}")] + Unreachable(#[source] BoxError), + /// The store, or the person it asked, did not answer in time. + #[error("secret store did not answer within {deadline:?}")] + Timeout { + /// How long was waited. + deadline: Duration, + }, + /// The store is locked, or wanted a person where none may be asked. + #[error("secret store is locked")] + Locked, + /// The person the store asked dismissed its window. + #[error("unlocking the secret store was dismissed")] + Dismissed, + /// The store answered with an error of its own, or has no default + /// collection to write to. + #[error("secret store refused the call: {0}")] + Refused(#[source] BoxError), + /// What the store holds under the key is not UTF-8. + #[error("secret store holds a token that is not text")] + NotText, +} + +/// A place a token is kept, one for each [`Key`]. +pub trait Secrets { + /// The token kept under `key`, and nothing where none is. + fn read(&self, key: Key) -> impl Future, Error>> + Send; + + /// Keeps `token` under `key`, in place of one kept there before. + fn write(&self, key: Key, token: &str) -> impl Future> + Send; + + /// Removes the token kept under `key`. Where none is, nothing happens. + fn remove(&self, key: Key) -> impl Future> + Send; +} + +/// Tokens kept in this process and gone with it, each under the backend of +/// its key, as the store keeps them. +#[derive(Debug, Default)] +pub struct Memory { + kept: Mutex>, +} + +impl Memory { + fn kept(&self) -> MutexGuard<'_, HashMap<&'static str, String>> { + self.kept + .lock() + .expect("no call panics while it holds the tokens") + } +} + +impl Secrets for Memory { + fn read(&self, key: Key) -> impl Future, Error>> + Send { + future::ready(Ok(self.kept().get(key.backend).cloned())) + } + + fn write(&self, key: Key, token: &str) -> impl Future> + Send { + self.kept().insert(key.backend, token.to_owned()); + + future::ready(Ok(())) + } + + fn remove(&self, key: Key) -> impl Future> + Send { + self.kept().remove(key.backend); + + future::ready(Ok(())) + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::{ANILIST, DEADLINE, Error, PROMPT_WAIT}; + + #[test] + fn each_wait_is_pinned() { + assert_eq!(DEADLINE, Duration::from_secs(5)); + assert_eq!(PROMPT_WAIT, Duration::from_mins(2)); + } + + #[test] + fn the_key_of_anilist_is_pinned() { + assert_eq!(ANILIST.backend, "anilist"); + assert_eq!(ANILIST.label, "AniList token for benshi"); + } + + #[test] + fn each_line_is_said_as_decided() { + let said = [ + Error::Unreachable("no bus".into()), + Error::Timeout { + deadline: Duration::from_secs(5), + }, + Error::Locked, + Error::Dismissed, + Error::Refused("no default collection".into()), + Error::NotText, + ] + .map(|error| error.to_string()); + + assert_eq!( + said, + [ + "secret store could not be reached: no bus", + "secret store did not answer within 5s", + "secret store is locked", + "unlocking the secret store was dismissed", + "secret store refused the call: no default collection", + "secret store holds a token that is not text", + ] + ); + } +} diff --git a/crates/daemon/src/secrets/service.rs b/crates/daemon/src/secrets/service.rs new file mode 100644 index 0000000..f7dd978 --- /dev/null +++ b/crates/daemon/src/secrets/service.rs @@ -0,0 +1,407 @@ +//! The Secret Service API, spoken over the session bus. +//! +//! A token is an item of the default collection with two attributes, +//! `application` of `benshi` and `backend` of its key's backend, which is how +//! it is found again. It is sent in the clear, in a session opened with the +//! algorithm `plain`: every process of the user's session may ask the store for +//! an item that is unlocked, so an encrypted transfer on the same bus would +//! keep the token from nobody who could not ask for it. +//! +//! Every call is given up on after the deadline [`SecretService`] is made +//! with. A window a call opens to unlock the store is waited for as long as +//! its [`Asking`] says. + +use std::collections::HashMap; +use std::pin::Pin; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use zbus::export::futures_core::Stream; +use zbus::proxy::{CacheProperties, Defaults}; +use zbus::zvariant::{OwnedObjectPath, OwnedValue, Type, Value}; +use zbus::{MatchRule, MessageStream, message}; + +use super::{Asking, Error, Key, Secrets}; + +/// What the store is asked to show a token as, and holds it as. +const CONTENT_TYPE: &str = "text/plain; charset=utf8"; + +/// The attribute that names the program an item is for. +const APPLICATION: &str = "application"; + +/// The attribute that names the backend a token is for. +const BACKEND: &str = "backend"; + +/// What [`APPLICATION`] holds. +const BENSHI: &str = "benshi"; + +/// The algorithm of a session that passes a secret as it is. +const PLAIN: &str = "plain"; + +/// The alias of the collection a token is written to. +const DEFAULT: &str = "default"; + +/// The path [`DEFAULT`] reads as where no collection has the alias. +const NO_COLLECTION: &str = "/"; + +/// The path a call answers with in place of a prompt where it needs none. +const NO_PROMPT: &str = "/"; + +/// The window a prompt is shown over: none, since a daemon has none. +const NO_WINDOW: &str = ""; + +/// The property of an item that is its label. +const LABEL: &str = "org.freedesktop.Secret.Item.Label"; + +/// The property of an item that holds its attributes. +const ATTRIBUTES: &str = "org.freedesktop.Secret.Item.Attributes"; + +/// The interface of a prompt, and the signal it ends with. +const PROMPT: &str = "org.freedesktop.Secret.Prompt"; +const COMPLETED: &str = "Completed"; + +/// The error of an object that is locked. +const IS_LOCKED: &str = "org.freedesktop.Secret.Error.IsLocked"; + +/// The error of the bus where no program has the name or can be started for +/// it. +const SERVICE_UNKNOWN: &str = "org.freedesktop.DBus.Error.ServiceUnknown"; + +/// What the errors of the bus begin with where it could not start a program +/// for the name. +const SPAWN: &str = "org.freedesktop.DBus.Error.Spawn."; + +/// A secret as the Secret Service API passes it. +#[derive(Debug, Serialize, Deserialize, Type)] +#[zvariant(crate = "zbus::zvariant")] +struct Secret { + /// The session it was passed in. + session: OwnedObjectPath, + /// What the algorithm of the session needs, which for `plain` is nothing. + parameters: Vec, + /// The token. + value: Vec, + /// [`CONTENT_TYPE`]. + content_type: String, +} + +#[zbus::proxy( + interface = "org.freedesktop.Secret.Service", + default_service = "org.freedesktop.secrets", + default_path = "/org/freedesktop/secrets" +)] +trait Service { + fn open_session( + &self, + algorithm: &str, + input: &Value<'_>, + ) -> zbus::Result<(OwnedValue, OwnedObjectPath)>; + + fn search_items( + &self, + attributes: HashMap<&str, &str>, + ) -> zbus::Result<(Vec, Vec)>; + + fn unlock( + &self, + objects: &[OwnedObjectPath], + ) -> zbus::Result<(Vec, OwnedObjectPath)>; + + fn read_alias(&self, name: &str) -> zbus::Result; +} + +#[zbus::proxy( + interface = "org.freedesktop.Secret.Collection", + default_service = "org.freedesktop.secrets" +)] +trait Collection { + fn create_item( + &self, + properties: HashMap<&str, &Value<'_>>, + secret: &Secret, + replace: bool, + ) -> zbus::Result<(OwnedObjectPath, OwnedObjectPath)>; + + #[zbus(property)] + fn locked(&self) -> zbus::Result; +} + +#[zbus::proxy( + interface = "org.freedesktop.Secret.Item", + default_service = "org.freedesktop.secrets" +)] +trait Item { + fn get_secret(&self, session: &OwnedObjectPath) -> zbus::Result; + + fn delete(&self) -> zbus::Result; +} + +#[zbus::proxy( + interface = "org.freedesktop.Secret.Prompt", + default_service = "org.freedesktop.secrets" +)] +trait Prompt { + fn prompt(&self, window_id: &str) -> zbus::Result<()>; + + fn dismiss(&self) -> zbus::Result<()>; +} + +#[zbus::proxy( + interface = "org.freedesktop.Secret.Session", + default_service = "org.freedesktop.secrets" +)] +trait Session { + fn close(&self) -> zbus::Result<()>; +} + +/// Tokens kept by the store that answers the Secret Service API. +#[derive(Debug)] +pub struct SecretService { + connection: zbus::Connection, + deadline: Duration, + asking: Asking, +} + +impl SecretService { + /// The store on the session bus of this process. + /// + /// # Errors + /// + /// [`Error::Unreachable`] where there is no session bus, and + /// [`Error::Timeout`] where it does not answer within `deadline`. + pub async fn connect(deadline: Duration, asking: Asking) -> Result { + let connection = tokio::time::timeout(deadline, zbus::Connection::session()) + .await + .map_err(|_elapsed| Error::Timeout { deadline })? + .map_err(|unconnected| Error::Unreachable(Box::new(unconnected)))?; + + Ok(Self::over(connection, deadline, asking)) + } + + /// The store at the other end of `connection`. + #[must_use] + pub fn over(connection: zbus::Connection, deadline: Duration, asking: Asking) -> Self { + Self { + connection, + deadline, + asking, + } + } + + /// `call`, given up on at the deadline. + async fn call(&self, call: impl Future>) -> Result { + tokio::time::timeout(self.deadline, call) + .await + .map_err(|_elapsed| Error::Timeout { + deadline: self.deadline, + })? + .map_err(failed) + } + + /// A proxy of the service. Building one makes no call while it caches + /// no property. + async fn service(&self) -> Result, Error> { + ServiceProxy::builder(&self.connection) + .cache_properties(CacheProperties::No) + .build() + .await + .map_err(failed) + } + + /// A proxy of the object at `path`, built as [`Self::service`] is. + async fn at<'p, P>(&self, path: &'p OwnedObjectPath) -> Result + where + P: Defaults + From>, + { + zbus::proxy::Builder::

::new(&self.connection) + .path(path.as_str()) + .map_err(failed)? + .cache_properties(CacheProperties::No) + .build() + .await + .map_err(failed) + } + + /// Runs `during` in a session of its own, which is closed after it. + /// + /// A session the store does not close, in time or at all, is left to it: + /// what was read in the session has been read, and the store closes it + /// when this connection goes. + async fn in_session( + &self, + during: impl AsyncFnOnce(&OwnedObjectPath) -> Result, + ) -> Result { + let service = self.service().await?; + let (_output, session) = self + .call(service.open_session(PLAIN, &Value::from(""))) + .await?; + let outcome = during(&session).await; + if let Ok(closing) = self.at::>(&session).await { + drop(self.call(closing.close()).await); + } + + outcome + } + + /// Asks the store to unlock `objects`, and is [`Error::Locked`] with no + /// call where no person may be asked, so that nothing the store does to + /// unlock can open a window. + async fn unlock(&self, objects: &[OwnedObjectPath]) -> Result<(), Error> { + if self.asking == Asking::Never { + return Err(Error::Locked); + } + let service = self.service().await?; + let (_unlocked, prompt) = self.call(service.unlock(objects)).await?; + + self.prompted(&prompt).await + } + + /// Waits for the person the store asks through `prompt`, where it asks + /// at all and this store may. + async fn prompted(&self, prompt: &OwnedObjectPath) -> Result<(), Error> { + if prompt.as_str() == NO_PROMPT { + return Ok(()); + } + let asked = self.at::>(prompt).await?; + let Asking::Within(wait) = self.asking else { + self.dismiss(&asked).await; + return Err(Error::Locked); + }; + + // No sender is named, so any peer of the bus can send `Completed` on + // this path. A forged one ends the wait, as `Dismissed` or as the + // `IsLocked` of the next call. + let completion = MatchRule::builder() + .msg_type(message::Type::Signal) + .path(prompt.as_str()) + .and_then(|rule| rule.interface(PROMPT)) + .and_then(|rule| rule.member(COMPLETED)) + .map_err(failed)? + .build(); + let mut completed = self + .call(MessageStream::for_match_rule( + completion, + &self.connection, + None, + )) + .await?; + self.call(asked.prompt(NO_WINDOW)).await?; + + let answer = tokio::time::timeout( + wait, + std::future::poll_fn(|context| Pin::new(&mut completed).poll_next(context)), + ) + .await; + let Ok(answer) = answer else { + self.dismiss(&asked).await; + return Err(Error::Timeout { deadline: wait }); + }; + let message = answer + .ok_or_else(|| Error::Unreachable("connection closed".into()))? + .map_err(failed)?; + let (dismissed, _result): (bool, OwnedValue) = + message.body().deserialize().map_err(failed)?; + if dismissed { + return Err(Error::Dismissed); + } + + Ok(()) + } + + /// Dismisses `asked`. The call that made the prompt fails either way, so + /// what the store answers to the dismissal is not read. + async fn dismiss(&self, asked: &PromptProxy<'_>) { + drop(self.call(asked.dismiss()).await); + } +} + +/// The attributes the token of `key` is found by. +fn attributes(key: Key) -> HashMap<&'static str, &'static str> { + HashMap::from([(APPLICATION, BENSHI), (BACKEND, key.backend)]) +} + +/// Which of [`Error::Locked`], [`Error::Unreachable`] and [`Error::Refused`] +/// a failed call is. +fn failed(error: zbus::Error) -> Error { + match &error { + zbus::Error::MethodError(name, _, _) if name.as_str() == IS_LOCKED => Error::Locked, + zbus::Error::MethodError(name, _, _) + if name.as_str() == SERVICE_UNKNOWN || name.starts_with(SPAWN) => + { + Error::Unreachable(Box::new(error)) + } + // The connection itself failed, which the store answered nothing to. + zbus::Error::InputOutput(_) => Error::Unreachable(Box::new(error)), + _ => Error::Refused(Box::new(error)), + } +} + +impl Secrets for SecretService { + async fn read(&self, key: Key) -> Result, Error> { + self.in_session(async |session| { + let service = self.service().await?; + let (unlocked, locked) = self.call(service.search_items(attributes(key))).await?; + let item = match (unlocked.into_iter().next(), locked.into_iter().next()) { + (Some(item), _) => item, + (None, Some(item)) => { + self.unlock(std::slice::from_ref(&item)).await?; + item + } + (None, None) => return Ok(None), + }; + let secret = self + .call(self.at::>(&item).await?.get_secret(session)) + .await?; + + String::from_utf8(secret.value) + .map(Some) + .map_err(|_not_text| Error::NotText) + }) + .await + } + + async fn write(&self, key: Key, token: &str) -> Result<(), Error> { + self.in_session(async |session| { + let service = self.service().await?; + let collection = self.call(service.read_alias(DEFAULT)).await?; + if collection.as_str() == NO_COLLECTION { + return Err(Error::Refused("no default collection".into())); + } + let held = self.at::>(&collection).await?; + if self.call(held.locked()).await? { + self.unlock(std::slice::from_ref(&collection)).await?; + } + let label = Value::from(key.label); + let attributes = Value::from(attributes(key)); + let properties = HashMap::from([(LABEL, &label), (ATTRIBUTES, &attributes)]); + let secret = Secret { + session: session.clone(), + parameters: Vec::new(), + value: token.as_bytes().to_vec(), + content_type: CONTENT_TYPE.to_owned(), + }; + let (_item, prompt) = self + .call(held.create_item(properties, &secret, true)) + .await?; + + self.prompted(&prompt).await + }) + .await + } + + async fn remove(&self, key: Key) -> Result<(), Error> { + let service = self.service().await?; + let (unlocked, locked) = self.call(service.search_items(attributes(key))).await?; + if !locked.is_empty() { + self.unlock(&locked).await?; + } + for item in unlocked.iter().chain(&locked) { + let prompt = self + .call(self.at::>(item).await?.delete()) + .await?; + self.prompted(&prompt).await?; + } + + Ok(()) + } +} diff --git a/crates/daemon/tests/fake_secret_service/mod.rs b/crates/daemon/tests/fake_secret_service/mod.rs new file mode 100644 index 0000000..6a14122 --- /dev/null +++ b/crates/daemon/tests/fake_secret_service/mod.rs @@ -0,0 +1,612 @@ +//! A Secret Service of the test's own, served at one end of a socket pair. +//! +//! It keeps items in one collection, which the alias `default` names unless +//! a test takes the alias away, and answers as the specification says: an +//! object that is locked is unlocked through a prompt, and a prompt is +//! answered as the test says a person would. Every call is written down, in +//! the order it came. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Duration; + +use benshi_daemon::secrets::{Asking, SecretService}; +use serde::{Deserialize, Serialize}; +use tokio::net::UnixStream; +use zbus::object_server::SignalEmitter; +use zbus::zvariant::{OwnedObjectPath, OwnedValue, Type, Value}; +use zbus::{Connection, DBusError, Guid, ObjectServer, connection, interface}; + +const SERVICE: &str = "/org/freedesktop/secrets"; + +/// The one collection. +pub const COLLECTION: &str = "/org/freedesktop/secrets/collection/fake"; + +/// What the person the store asks does with its window. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Person { + /// Types the password. + Unlocks, + /// Closes the window. + Dismisses, + /// Is not there. + Away, +} + +/// How the store answers a call, where it does not answer as it should. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Fault { + /// `org.freedesktop.Secret.Error.IsLocked`. + IsLocked, + /// `org.freedesktop.Secret.Error.NoSession`. + NoSession, + /// `org.freedesktop.DBus.Error.ServiceUnknown`, which the bus answers + /// where no program owns the name and none can be started for it. + ServiceUnknown, + /// `org.freedesktop.DBus.Error.Spawn.ChildExited`, which the bus answers + /// where the program it started for the name ended before it took it. + SpawnFailed, + /// No answer at all. + Silent, +} + +/// One item, as the store keeps it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Item { + pub label: String, + pub attributes: HashMap, + pub parameters: Vec, + pub value: Vec, + pub content_type: String, + pub locked: bool, +} + +impl Item { + /// An item of benshi's for `backend`, holding `value`. + pub fn of(backend: &str, value: &[u8]) -> Self { + Self { + label: format!("{backend} token for benshi"), + attributes: HashMap::from([ + ("application".to_owned(), "benshi".to_owned()), + ("backend".to_owned(), backend.to_owned()), + ]), + parameters: Vec::new(), + value: value.to_vec(), + content_type: "text/plain; charset=utf8".to_owned(), + locked: false, + } + } +} + +/// What the store holds and how it behaves. +#[derive(Debug)] +pub struct State { + pub items: BTreeMap, + pub collection_locked: bool, + /// Whether `default` names the collection. + pub aliased: bool, + pub person: Person, + /// The methods that ask the person before they do what they were asked. + pub asking: BTreeSet<&'static str>, + /// The calls that fail, by the name of their method. + pub faults: HashMap<&'static str, Fault>, + /// Every call, as `Method` and what it was handed where that matters. + pub calls: Vec, + /// The sessions open now. + pub sessions: BTreeSet, + /// What `replace` was, at each `CreateItem`. + pub replaced: Vec, + next: u32, +} + +impl Default for State { + fn default() -> Self { + Self { + items: BTreeMap::new(), + collection_locked: false, + aliased: true, + person: Person::Unlocks, + asking: BTreeSet::new(), + faults: HashMap::new(), + calls: Vec::new(), + sessions: BTreeSet::new(), + replaced: Vec::new(), + next: 1, + } + } +} + +impl State { + fn next(&mut self) -> u32 { + let next = self.next; + self.next += 1; + next + } + + /// The names of the methods called, in order. + pub fn methods(&self) -> Vec<&str> { + self.calls + .iter() + .map(|call| call.split(' ').next().unwrap_or_default()) + .collect() + } +} + +/// A store, shared between the objects that serve it and the test. +#[derive(Debug, Clone, Default)] +pub struct Fake(Arc>); + +impl Fake { + pub fn state(&self) -> MutexGuard<'_, State> { + self.0.lock().expect("no test panicked holding the store") + } + + /// The store served at one end of a socket pair, and the adapter at the + /// other. The connection that serves it is handed back to be kept for as + /// long as the adapter is used. + pub async fn serve(&self, deadline: Duration, asking: Asking) -> (SecretService, Connection) { + let (theirs, ours) = UnixStream::pair().expect("a socket pair"); + let serving = connection::Builder::unix_stream(theirs) + .server(Guid::generate()) + .expect("a server of this guid") + .p2p() + .serve_at(SERVICE, ServiceObject(self.clone())) + .expect("the service object") + .serve_at(COLLECTION, CollectionObject(self.clone())) + .expect("the collection object") + .build(); + let using = connection::Builder::unix_stream(ours).p2p().build(); + let (serving, using) = tokio::try_join!(serving, using).expect("the two ends connect"); + + let held: Vec = self.state().items.keys().copied().collect(); + for id in held { + serving + .object_server() + .at( + item_path(id), + ItemObject { + fake: self.clone(), + id, + }, + ) + .await + .expect("an item object"); + } + + (SecretService::over(using, deadline, asking), serving) + } + + /// Writes the call down, and answers with its fault where it has one. + async fn called(&self, method: &'static str, call: String) -> Result<(), Failure> { + let fault = { + let mut state = self.state(); + state.calls.push(call); + state.faults.get(method).copied() + }; + + match fault { + None => Ok(()), + Some(Fault::Silent) => std::future::pending().await, + Some(Fault::IsLocked) => Err(Failure::IsLocked(format!("{method} on a locked object"))), + Some(Fault::NoSession) => Err(Failure::NoSession("no such session".to_owned())), + Some(Fault::SpawnFailed) => Err(Failure::SpawnChildExited( + "Process org.freedesktop.secrets exited with status 1".to_owned(), + )), + Some(Fault::ServiceUnknown) => Err(Failure::ServiceUnknown( + "The name org.freedesktop.secrets was not provided by any .service files" + .to_owned(), + )), + } + } + + /// Files `item` and serves it, in place of one of the same attributes + /// where `replace` is set. + async fn file( + &self, + item: Item, + replace: bool, + server: &ObjectServer, + ) -> Result { + let (id, new) = { + let mut state = self.state(); + let same = state + .items + .iter() + .find(|(_, held)| held.attributes == item.attributes) + .map(|(id, _)| *id); + if let Some(id) = same.filter(|_| replace) { + state.items.insert(id, item); + (id, false) + } else { + let id = state.next(); + state.items.insert(id, item); + (id, true) + } + }; + if new { + server + .at( + item_path(id), + ItemObject { + fake: self.clone(), + id, + }, + ) + .await?; + } + + path(item_path(id)) + } + + /// A prompt that does `action` once the person unlocks. + async fn prompt( + &self, + action: Action, + server: &ObjectServer, + ) -> Result { + let id = self.state().next(); + let at = format!("/org/freedesktop/secrets/prompt/{id}"); + server + .at( + at.as_str(), + PromptObject { + fake: self.clone(), + action, + }, + ) + .await?; + + path(at) + } + + fn is_locked(&self, object: &str) -> bool { + let state = self.state(); + if object == COLLECTION { + return state.collection_locked; + } + state + .items + .iter() + .any(|(id, item)| item_path(*id) == object && item.locked) + } +} + +fn item_path(id: u32) -> String { + format!("{COLLECTION}/{id}") +} + +fn path(text: String) -> Result { + OwnedObjectPath::try_from(text).map_err(|wrong| Failure::ZBus(wrong.into())) +} + +fn nothing() -> OwnedObjectPath { + OwnedObjectPath::try_from("/").expect("`/` is a path") +} + +/// The errors the store answers with. +#[derive(Debug, DBusError)] +#[zbus(prefix = "org.freedesktop")] +pub enum Failure { + #[zbus(error)] + ZBus(zbus::Error), + #[zbus(name = "Secret.Error.IsLocked")] + IsLocked(String), + #[zbus(name = "Secret.Error.NoSession")] + NoSession(String), + #[zbus(name = "Secret.Error.NoSuchObject")] + NoSuchObject(String), + #[zbus(name = "DBus.Error.ServiceUnknown")] + ServiceUnknown(String), + #[zbus(name = "DBus.Error.Spawn.ChildExited")] + SpawnChildExited(String), + #[zbus(name = "DBus.Error.NotSupported")] + NotSupported(String), +} + +/// A secret as the Secret Service API passes it. +#[derive(Debug, Serialize, Deserialize, Type)] +#[zvariant(crate = "zbus::zvariant")] +pub struct Secret { + session: OwnedObjectPath, + parameters: Vec, + value: Vec, + content_type: String, +} + +struct ServiceObject(Fake); + +#[interface(name = "org.freedesktop.Secret.Service")] +impl ServiceObject { + async fn open_session( + &self, + algorithm: String, + input: Value<'_>, + #[zbus(object_server)] server: &ObjectServer, + ) -> Result<(Value<'static>, OwnedObjectPath), Failure> { + let input = String::try_from(input).unwrap_or_else(|_| "".to_owned()); + self.0 + .called("OpenSession", format!("OpenSession {algorithm} {input:?}")) + .await?; + if algorithm != "plain" { + return Err(Failure::NotSupported(algorithm)); + } + let id = self.0.state().next(); + let at = format!("/org/freedesktop/secrets/session/{id}"); + server + .at( + at.as_str(), + SessionObject { + fake: self.0.clone(), + at: at.clone(), + }, + ) + .await?; + self.0.state().sessions.insert(at.clone()); + + Ok((Value::from(""), path(at)?)) + } + + async fn search_items( + &self, + attributes: HashMap, + ) -> Result<(Vec, Vec), Failure> { + self.0 + .called("SearchItems", "SearchItems".to_owned()) + .await?; + let (mut unlocked, mut locked) = (Vec::new(), Vec::new()); + let found: Vec<(u32, bool)> = self + .0 + .state() + .items + .iter() + .filter(|(_, item)| { + attributes + .iter() + .all(|(name, value)| item.attributes.get(name) == Some(value)) + }) + .map(|(id, item)| (*id, item.locked)) + .collect(); + for (id, is_locked) in found { + if is_locked { + locked.push(path(item_path(id))?); + } else { + unlocked.push(path(item_path(id))?); + } + } + + Ok((unlocked, locked)) + } + + async fn unlock( + &self, + objects: Vec, + #[zbus(object_server)] server: &ObjectServer, + ) -> Result<(Vec, OwnedObjectPath), Failure> { + self.0.called("Unlock", "Unlock".to_owned()).await?; + if objects + .iter() + .any(|object| self.0.is_locked(object.as_str())) + { + let prompt = self.0.prompt(Action::Unlock(objects), server).await?; + return Ok((Vec::new(), prompt)); + } + + Ok((objects, nothing())) + } + + async fn read_alias(&self, name: String) -> Result { + self.0 + .called("ReadAlias", format!("ReadAlias {name}")) + .await?; + if name == "default" && self.0.state().aliased { + return path(COLLECTION.to_owned()); + } + + Ok(nothing()) + } +} + +struct CollectionObject(Fake); + +#[interface(name = "org.freedesktop.Secret.Collection")] +impl CollectionObject { + async fn create_item( + &self, + properties: HashMap, + secret: Secret, + replace: bool, + #[zbus(object_server)] server: &ObjectServer, + ) -> Result<(OwnedObjectPath, OwnedObjectPath), Failure> { + self.0.called("CreateItem", "CreateItem".to_owned()).await?; + if self.0.state().collection_locked { + return Err(Failure::IsLocked("the collection is locked".to_owned())); + } + let property = |name: &str| { + properties + .get(name) + .and_then(|value| value.try_clone().ok()) + .ok_or_else(|| Failure::NoSuchObject(format!("no {name}"))) + }; + let label = String::try_from(property("org.freedesktop.Secret.Item.Label")?) + .map_err(|wrong| Failure::ZBus(wrong.into()))?; + let attributes = HashMap::::try_from(property( + "org.freedesktop.Secret.Item.Attributes", + )?) + .map_err(|wrong| Failure::ZBus(wrong.into()))?; + if !self.0.state().sessions.contains(secret.session.as_str()) { + return Err(Failure::NoSession("no such session".to_owned())); + } + self.0.state().replaced.push(replace); + let item = Item { + label, + attributes, + parameters: secret.parameters, + value: secret.value, + content_type: secret.content_type, + locked: false, + }; + + if self.0.state().asking.contains("CreateItem") { + let prompt = self.0.prompt(Action::Create(item, replace), server).await?; + return Ok((nothing(), prompt)); + } + + Ok((self.0.file(item, replace, server).await?, nothing())) + } + + #[zbus(property)] + async fn locked(&self) -> zbus::fdo::Result { + self.0 + .called("Locked", "Locked".to_owned()) + .await + .map_err(|failure| zbus::fdo::Error::Failed(failure.to_string()))?; + + Ok(self.0.state().collection_locked) + } +} + +struct ItemObject { + fake: Fake, + id: u32, +} + +#[interface(name = "org.freedesktop.Secret.Item")] +impl ItemObject { + async fn get_secret(&self, session: OwnedObjectPath) -> Result { + self.fake + .called("GetSecret", "GetSecret".to_owned()) + .await?; + let state = self.fake.state(); + let item = state + .items + .get(&self.id) + .ok_or_else(|| Failure::NoSuchObject("the item is gone".to_owned()))?; + if item.locked { + return Err(Failure::IsLocked("the item is locked".to_owned())); + } + if !state.sessions.contains(session.as_str()) { + return Err(Failure::NoSession("no such session".to_owned())); + } + + Ok(Secret { + session, + parameters: item.parameters.clone(), + value: item.value.clone(), + content_type: item.content_type.clone(), + }) + } + + async fn delete( + &self, + #[zbus(object_server)] server: &ObjectServer, + ) -> Result { + self.fake.called("Delete", "Delete".to_owned()).await?; + if self.fake.is_locked(&item_path(self.id)) { + return Err(Failure::IsLocked("the item is locked".to_owned())); + } + if self.fake.state().asking.contains("Delete") { + return self.fake.prompt(Action::Delete(self.id), server).await; + } + self.fake.state().items.remove(&self.id); + + Ok(nothing()) + } +} + +struct SessionObject { + fake: Fake, + at: String, +} + +#[interface(name = "org.freedesktop.Secret.Session")] +impl SessionObject { + async fn close(&self) -> Result<(), Failure> { + self.fake.called("Close", "Close".to_owned()).await?; + self.fake.state().sessions.remove(&self.at); + + Ok(()) + } +} + +/// What a prompt does once the person unlocks. +#[derive(Debug)] +enum Action { + Unlock(Vec), + Create(Item, bool), + Delete(u32), +} + +struct PromptObject { + fake: Fake, + action: Action, +} + +#[interface(name = "org.freedesktop.Secret.Prompt")] +impl PromptObject { + async fn prompt( + &self, + window_id: String, + #[zbus(signal_emitter)] emitter: SignalEmitter<'_>, + #[zbus(object_server)] server: &ObjectServer, + ) -> Result<(), Failure> { + self.fake + .called("Prompt", format!("Prompt {window_id:?}")) + .await?; + let person = self.fake.state().person; + match person { + Person::Unlocks => { + self.carry_out(server).await?; + Self::completed(&emitter, false, Value::from("")).await?; + } + Person::Dismisses => Self::completed(&emitter, true, Value::from("")).await?, + Person::Away => {} + } + + Ok(()) + } + + async fn dismiss( + &self, + #[zbus(signal_emitter)] emitter: SignalEmitter<'_>, + ) -> Result<(), Failure> { + self.fake.called("Dismiss", "Dismiss".to_owned()).await?; + Self::completed(&emitter, true, Value::from("")).await?; + + Ok(()) + } + + #[zbus(signal)] + async fn completed( + emitter: &SignalEmitter<'_>, + dismissed: bool, + result: Value<'_>, + ) -> zbus::Result<()>; +} + +impl PromptObject { + async fn carry_out(&self, server: &ObjectServer) -> Result<(), Failure> { + match &self.action { + Action::Unlock(objects) => { + let mut state = self.fake.state(); + for object in objects { + if object.as_str() == COLLECTION { + state.collection_locked = false; + } + for (id, item) in &mut state.items { + if item_path(*id) == object.as_str() { + item.locked = false; + } + } + } + } + Action::Create(item, replace) => { + self.fake.file(item.clone(), *replace, server).await?; + } + Action::Delete(id) => { + self.fake.state().items.remove(id); + } + } + + Ok(()) + } +} diff --git a/crates/daemon/tests/secret_store_live.rs b/crates/daemon/tests/secret_store_live.rs new file mode 100644 index 0000000..22336a3 --- /dev/null +++ b/crates/daemon/tests/secret_store_live.rs @@ -0,0 +1,49 @@ +//! The secret store of the session the tests run in, written to, read and +//! cleared. +//! +//! Ignored: it changes the store of whoever runs it. Run it by hand, in a +//! desktop session: +//! +//! ```sh +//! cargo test -p benshi-daemon --test secret_store_live -- --ignored +//! ``` +//! +//! It keeps one item under a label of its own and removes it at the end. An +//! item a failed run leaves is removed at the start of the next. Where the +//! store is locked it opens the store's window to unlock it. + +#![cfg(target_os = "linux")] + +use benshi_daemon::secrets::{Asking, DEADLINE, Key, PROMPT_WAIT, SecretService, Secrets}; + +/// An item a person who finds it knows to be a test's. +const LIVE: Key = Key { + backend: "live-test", + label: "benshi live test, safe to delete", +}; + +#[tokio::test] +#[ignore = "writes to the secret store of the session it runs in"] +async fn a_token_goes_through_the_secret_store_of_this_session() { + let store = SecretService::connect(DEADLINE, Asking::Within(PROMPT_WAIT)) + .await + .expect("a secret store on the session bus"); + store + .remove(LIVE) + .await + .expect("an item an earlier run left is removed"); + + assert_eq!(store.read(LIVE).await.expect("a read"), None); + store.write(LIVE, "not a token").await.expect("a write"); + assert_eq!( + store.read(LIVE).await.expect("a read").as_deref(), + Some("not a token") + ); + store.write(LIVE, "written again").await.expect("a write"); + assert_eq!( + store.read(LIVE).await.expect("a read").as_deref(), + Some("written again") + ); + store.remove(LIVE).await.expect("a removal"); + assert_eq!(store.read(LIVE).await.expect("a read"), None); +} diff --git a/crates/daemon/tests/secrets.rs b/crates/daemon/tests/secrets.rs new file mode 100644 index 0000000..29c3f29 --- /dev/null +++ b/crates/daemon/tests/secrets.rs @@ -0,0 +1,663 @@ +//! Where a token is kept: the store in memory, and the Secret Service API +//! against a service of the test's own over a socket pair. +//! +//! The clauses every store keeps are written once and run against each. What +//! is particular to the Secret Service follows them. + +#[cfg(target_os = "linux")] +mod fake_secret_service; + +use benshi_daemon::secrets::{ANILIST, Key, Memory, Secrets}; + +/// A key of another backend, for the clause that keeps the two apart. +const OTHER: Key = Key { + backend: "other", + label: "other token for benshi", +}; + +/// A token built to the shape of none in particular. +const TOKEN: &str = "eyJ0eXAiOiJKV1QifQ.c2lnbmVk.dG9rZW4"; + +/// The clauses every store keeps, each a test of its own for each store. +macro_rules! every_store_keeps { + ($store:ident, $made:ident) => { + mod $store { + use super::*; + + #[tokio::test] + async fn nothing_kept_is_read_as_nothing() { + $made!(store); + + assert_eq!(store.read(ANILIST).await.expect("a read"), None); + } + + #[tokio::test] + async fn a_token_written_is_read_back() { + $made!(store); + + store.write(ANILIST, TOKEN).await.expect("a write"); + + assert_eq!( + store.read(ANILIST).await.expect("a read").as_deref(), + Some(TOKEN) + ); + } + + #[tokio::test] + async fn a_token_written_again_takes_the_place_of_the_first() { + $made!(store); + + store.write(ANILIST, "the first").await.expect("a write"); + store.write(ANILIST, TOKEN).await.expect("a second write"); + + assert_eq!( + store.read(ANILIST).await.expect("a read").as_deref(), + Some(TOKEN) + ); + } + + #[tokio::test] + async fn a_token_removed_is_read_as_nothing() { + $made!(store); + store.write(ANILIST, TOKEN).await.expect("a write"); + + store.remove(ANILIST).await.expect("a removal"); + + assert_eq!(store.read(ANILIST).await.expect("a read"), None); + } + + #[tokio::test] + async fn removing_what_is_not_kept_does_nothing() { + $made!(store); + store.write(OTHER, "of the other").await.expect("a write"); + + store.remove(ANILIST).await.expect("a removal of nothing"); + + assert_eq!( + store.read(OTHER).await.expect("a read").as_deref(), + Some("of the other") + ); + } + + #[tokio::test] + async fn the_tokens_of_two_backends_are_kept_apart() { + $made!(store); + + store.write(ANILIST, TOKEN).await.expect("a write"); + store.write(OTHER, "of the other").await.expect("a write"); + + assert_eq!( + store.read(ANILIST).await.expect("a read").as_deref(), + Some(TOKEN) + ); + assert_eq!( + store.read(OTHER).await.expect("a read").as_deref(), + Some("of the other") + ); + } + } + }; +} + +macro_rules! in_memory { + ($store:ident) => { + let $store = Memory::default(); + }; +} + +every_store_keeps!(memory, in_memory); + +#[cfg(target_os = "linux")] +macro_rules! over_the_secret_service { + ($store:ident) => { + let fake = crate::fake_secret_service::Fake::default(); + let ($store, _serving) = fake + .serve( + benshi_daemon::secrets::DEADLINE, + benshi_daemon::secrets::Asking::Never, + ) + .await; + }; +} + +#[cfg(target_os = "linux")] +every_store_keeps!(secret_service, over_the_secret_service); + +#[cfg(target_os = "linux")] +mod the_secret_service { + use std::collections::HashMap; + use std::future::Future; + use std::time::Duration; + + use benshi_daemon::secrets::{ANILIST, Asking, DEADLINE, Error, Secrets}; + + use super::TOKEN; + use crate::fake_secret_service::{Fake, Fault, Item, Person}; + + /// A deadline, or a wait for a person, that a test which waits for it + /// spends whole. + const SHORT: Duration = Duration::from_millis(200); + + /// A bound on a test whose call gives up on its own, so that one which + /// does not ends the test. + const BOUND: Duration = Duration::from_secs(5); + + async fn bounded(call: impl Future) -> T { + tokio::time::timeout(BOUND, call) + .await + .expect("the call gives up on its own") + } + + fn locked(value: &[u8]) -> Item { + Item { + locked: true, + ..Item::of("anilist", value) + } + } + + fn asking() -> Asking { + Asking::Within(Duration::from_secs(5)) + } + + #[tokio::test] + async fn a_token_is_written_to_the_default_collection_as_agreed() { + let fake = Fake::default(); + let (store, _serving) = fake.serve(DEADLINE, Asking::Never).await; + + store.write(ANILIST, TOKEN).await.expect("a write"); + + let state = fake.state(); + let items: Vec<&Item> = state.items.values().collect(); + assert_eq!( + items, + [&Item { + label: "AniList token for benshi".to_owned(), + attributes: HashMap::from([ + ("application".to_owned(), "benshi".to_owned()), + ("backend".to_owned(), "anilist".to_owned()), + ]), + parameters: Vec::new(), + value: TOKEN.as_bytes().to_vec(), + content_type: "text/plain; charset=utf8".to_owned(), + locked: false, + }] + ); + assert_eq!(state.replaced, [true]); + } + + #[tokio::test] + async fn a_session_is_opened_in_the_clear_and_closed_after_each_call() { + let fake = Fake::default(); + let (store, _serving) = fake.serve(DEADLINE, Asking::Never).await; + + store.write(ANILIST, TOKEN).await.expect("a write"); + store.read(ANILIST).await.expect("a read"); + + let state = fake.state(); + let opened: Vec<&String> = state + .calls + .iter() + .filter(|call| call.starts_with("OpenSession")) + .collect(); + assert_eq!(opened, ["OpenSession plain \"\"", "OpenSession plain \"\""]); + assert_eq!( + state + .methods() + .iter() + .filter(|method| **method == "Close") + .count(), + 2 + ); + assert!(state.sessions.is_empty(), "{:?} left open", state.sessions); + } + + #[tokio::test] + async fn a_removal_opens_no_session() { + let fake = Fake::default(); + fake.state() + .items + .insert(1, Item::of("anilist", TOKEN.as_bytes())); + let (store, _serving) = fake.serve(DEADLINE, Asking::Never).await; + + store.remove(ANILIST).await.expect("a removal"); + + assert_eq!(fake.state().methods(), ["SearchItems", "Delete"]); + } + + #[tokio::test] + async fn a_locked_token_is_not_read_where_nobody_may_be_asked() { + let fake = Fake::default(); + fake.state().items.insert( + 1, + Item { + locked: true, + ..Item::of("anilist", TOKEN.as_bytes()) + }, + ); + let (store, _serving) = fake.serve(DEADLINE, Asking::Never).await; + + let read = store.read(ANILIST).await; + + assert!(matches!(read, Err(Error::Locked)), "{read:?}"); + let methods = fake.state().methods().join(" "); + assert!(!methods.contains("Unlock"), "{methods}"); + assert!(!methods.contains("GetSecret"), "{methods}"); + } + + #[tokio::test] + async fn a_locked_token_is_read_once_the_person_asked_unlocks() { + let fake = Fake::default(); + fake.state().items.insert( + 1, + Item { + locked: true, + ..Item::of("anilist", TOKEN.as_bytes()) + }, + ); + let (store, _serving) = fake.serve(DEADLINE, asking()).await; + + let read = store.read(ANILIST).await.expect("a read"); + + assert_eq!(read.as_deref(), Some(TOKEN)); + let state = fake.state(); + let prompted: Vec<&String> = state + .calls + .iter() + .filter(|call| call.starts_with("Prompt")) + .collect(); + assert_eq!(prompted, ["Prompt \"\""]); + } + + #[tokio::test] + async fn a_window_the_person_dismisses_is_said_as_dismissed() { + let fake = Fake::default(); + fake.state().items.insert( + 1, + Item { + locked: true, + ..Item::of("anilist", TOKEN.as_bytes()) + }, + ); + fake.state().person = Person::Dismisses; + let (store, _serving) = fake.serve(DEADLINE, asking()).await; + + let read = store.read(ANILIST).await; + + assert!(matches!(read, Err(Error::Dismissed)), "{read:?}"); + } + + #[tokio::test] + async fn a_window_nobody_answers_is_given_up_on_and_dismissed() { + let fake = Fake::default(); + fake.state().items.insert( + 1, + Item { + locked: true, + ..Item::of("anilist", TOKEN.as_bytes()) + }, + ); + fake.state().person = Person::Away; + let (store, _serving) = fake.serve(DEADLINE, Asking::Within(SHORT)).await; + + let read = bounded(store.read(ANILIST)).await; + + assert!( + matches!(read, Err(Error::Timeout { deadline }) if deadline == SHORT), + "{read:?}" + ); + assert_eq!(fake.state().methods().last(), Some(&"Close")); + assert!(fake.state().methods().contains(&"Dismiss")); + } + + #[tokio::test] + async fn a_locked_collection_is_unlocked_before_a_token_is_written() { + let fake = Fake::default(); + fake.state().collection_locked = true; + let (store, _serving) = fake.serve(DEADLINE, asking()).await; + + store.write(ANILIST, TOKEN).await.expect("a write"); + + assert_eq!(fake.state().items.len(), 1); + let methods = fake.state().methods().join(" "); + assert!(methods.contains("Unlock Prompt CreateItem"), "{methods}"); + } + + #[tokio::test] + async fn a_locked_collection_is_not_written_to_where_nobody_may_be_asked() { + let fake = Fake::default(); + fake.state().collection_locked = true; + let (store, _serving) = fake.serve(DEADLINE, Asking::Never).await; + + let written = store.write(ANILIST, TOKEN).await; + + assert!(matches!(written, Err(Error::Locked)), "{written:?}"); + assert!(fake.state().items.is_empty()); + assert!(!fake.state().methods().contains(&"Unlock")); + } + + #[tokio::test] + async fn a_write_the_store_asks_about_is_done_once_the_person_agrees() { + let fake = Fake::default(); + fake.state().asking.insert("CreateItem"); + let (store, _serving) = fake.serve(DEADLINE, asking()).await; + + store.write(ANILIST, TOKEN).await.expect("a write"); + + assert_eq!(fake.state().items.len(), 1); + } + + #[tokio::test] + async fn a_removal_the_store_asks_about_is_done_once_the_person_agrees() { + let fake = Fake::default(); + fake.state() + .items + .insert(1, Item::of("anilist", TOKEN.as_bytes())); + fake.state().asking.insert("Delete"); + let (store, _serving) = fake.serve(DEADLINE, asking()).await; + + store.remove(ANILIST).await.expect("a removal"); + + assert!(fake.state().items.is_empty()); + } + + #[tokio::test] + async fn a_locked_token_is_unlocked_before_it_is_removed() { + let fake = Fake::default(); + fake.state().items.insert( + 1, + Item { + locked: true, + ..Item::of("anilist", TOKEN.as_bytes()) + }, + ); + let (store, _serving) = fake.serve(DEADLINE, asking()).await; + + store.remove(ANILIST).await.expect("a removal"); + + assert!(fake.state().items.is_empty()); + } + + #[tokio::test] + async fn a_store_with_no_default_collection_refuses_a_write() { + let fake = Fake::default(); + fake.state().aliased = false; + let (store, _serving) = fake.serve(DEADLINE, Asking::Never).await; + + let written = store.write(ANILIST, TOKEN).await; + + assert_eq!( + written.map_err(|refused| refused.to_string()), + Err("secret store refused the call: no default collection".to_owned()) + ); + } + + #[tokio::test] + async fn no_store_on_the_bus_is_unreachable() { + let fake = Fake::default(); + fake.state() + .faults + .insert("OpenSession", Fault::ServiceUnknown); + let (store, _serving) = fake.serve(DEADLINE, Asking::Never).await; + + let read = store.read(ANILIST).await; + + assert_eq!( + read.map_err(|unreached| unreached.to_string()), + Err( + "secret store could not be reached: org.freedesktop.DBus.Error.ServiceUnknown: \ + The name org.freedesktop.secrets was not provided by any .service files" + .to_owned() + ) + ); + } + + #[tokio::test] + async fn an_object_the_store_says_is_locked_is_locked() { + let fake = Fake::default(); + fake.state() + .items + .insert(1, Item::of("anilist", TOKEN.as_bytes())); + fake.state().faults.insert("GetSecret", Fault::IsLocked); + let (store, _serving) = fake.serve(DEADLINE, asking()).await; + + let read = store.read(ANILIST).await; + + assert!(matches!(read, Err(Error::Locked)), "{read:?}"); + } + + #[tokio::test] + async fn another_error_of_the_store_is_a_refusal_in_its_words() { + let fake = Fake::default(); + fake.state() + .items + .insert(1, Item::of("anilist", TOKEN.as_bytes())); + fake.state().faults.insert("GetSecret", Fault::NoSession); + let (store, _serving) = fake.serve(DEADLINE, Asking::Never).await; + + let read = store.read(ANILIST).await; + + assert_eq!( + read.map_err(|refused| refused.to_string()), + Err( + "secret store refused the call: org.freedesktop.Secret.Error.NoSession: \ + no such session" + .to_owned() + ) + ); + } + + #[tokio::test] + async fn a_token_that_is_not_utf_8_is_not_text() { + let fake = Fake::default(); + fake.state().items.insert(1, Item::of("anilist", b"\xff")); + let (store, _serving) = fake.serve(DEADLINE, Asking::Never).await; + + let read = store.read(ANILIST).await; + + assert!(matches!(read, Err(Error::NotText)), "{read:?}"); + } + + /// What a test asks the store to do. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum Asked { + Read, + ReadLocked, + Write, + Remove, + } + + #[tokio::test] + async fn a_call_the_store_does_not_answer_is_given_up_on_at_the_deadline() { + for (method, asked) in [ + ("OpenSession", Asked::Read), + ("SearchItems", Asked::Read), + ("GetSecret", Asked::Read), + ("Unlock", Asked::ReadLocked), + ("Prompt", Asked::ReadLocked), + ("ReadAlias", Asked::Write), + ("Locked", Asked::Write), + ("CreateItem", Asked::Write), + ("SearchItems", Asked::Remove), + ("Delete", Asked::Remove), + ] { + let fake = Fake::default(); + let item = if asked == Asked::ReadLocked { + locked(TOKEN.as_bytes()) + } else { + Item::of("anilist", TOKEN.as_bytes()) + }; + fake.state().items.insert(1, item); + fake.state().faults.insert(method, Fault::Silent); + let (store, _serving) = fake.serve(SHORT, asking()).await; + + let outcome = bounded(async { + match asked { + Asked::Read | Asked::ReadLocked => store.read(ANILIST).await.map(drop), + Asked::Write => store.write(ANILIST, TOKEN).await, + Asked::Remove => store.remove(ANILIST).await, + } + }) + .await; + + assert!( + matches!(outcome, Err(Error::Timeout { deadline }) if deadline == SHORT), + "{method}: {outcome:?}" + ); + } + } + + #[tokio::test] + async fn a_dismissal_the_store_does_not_answer_still_ends_as_locked() { + let fake = Fake::default(); + fake.state().asking.insert("CreateItem"); + fake.state().faults.insert("Dismiss", Fault::Silent); + let (store, _serving) = fake.serve(SHORT, Asking::Never).await; + + let written = bounded(store.write(ANILIST, TOKEN)).await; + + assert!(matches!(written, Err(Error::Locked)), "{written:?}"); + } + + #[tokio::test] + async fn a_write_the_store_asks_about_is_not_made_where_nobody_may_be_asked() { + let fake = Fake::default(); + fake.state().asking.insert("CreateItem"); + let (store, _serving) = fake.serve(DEADLINE, Asking::Never).await; + + let written = store.write(ANILIST, TOKEN).await; + + assert!(matches!(written, Err(Error::Locked)), "{written:?}"); + assert!(fake.state().items.is_empty()); + let methods = fake.state().methods().join(" "); + assert!(methods.contains("Dismiss"), "{methods}"); + assert!(!methods.contains("Prompt"), "{methods}"); + } + + #[tokio::test] + async fn a_removal_the_store_asks_about_is_not_made_where_nobody_may_be_asked() { + let fake = Fake::default(); + fake.state() + .items + .insert(1, Item::of("anilist", TOKEN.as_bytes())); + fake.state().asking.insert("Delete"); + let (store, _serving) = fake.serve(DEADLINE, Asking::Never).await; + + let removed = store.remove(ANILIST).await; + + assert!(matches!(removed, Err(Error::Locked)), "{removed:?}"); + assert_eq!(fake.state().items.len(), 1); + assert!(fake.state().methods().contains(&"Dismiss")); + } + + #[tokio::test] + async fn a_locked_token_is_not_removed_where_nobody_may_be_asked() { + let fake = Fake::default(); + fake.state().items.insert(1, locked(TOKEN.as_bytes())); + let (store, _serving) = fake.serve(DEADLINE, Asking::Never).await; + + let removed = store.remove(ANILIST).await; + + assert!(matches!(removed, Err(Error::Locked)), "{removed:?}"); + assert_eq!(fake.state().items.len(), 1); + assert!(!fake.state().methods().contains(&"Unlock")); + } + + #[tokio::test] + async fn a_copy_that_is_unlocked_is_read_before_one_that_is_locked() { + let fake = Fake::default(); + fake.state().items.insert(1, locked(b"the locked copy")); + fake.state() + .items + .insert(2, Item::of("anilist", TOKEN.as_bytes())); + let (store, _serving) = fake.serve(DEADLINE, Asking::Never).await; + + let read = store.read(ANILIST).await.expect("a read"); + + assert_eq!(read.as_deref(), Some(TOKEN)); + } + + #[tokio::test] + async fn every_copy_of_a_token_is_removed() { + let fake = Fake::default(); + fake.state() + .items + .insert(1, Item::of("anilist", b"the first copy")); + fake.state() + .items + .insert(2, Item::of("anilist", TOKEN.as_bytes())); + let (store, _serving) = fake.serve(DEADLINE, Asking::Never).await; + + store.remove(ANILIST).await.expect("a removal"); + + assert!(fake.state().items.is_empty()); + } + + #[tokio::test] + async fn a_store_the_bus_cannot_start_is_unreachable() { + let fake = Fake::default(); + fake.state() + .faults + .insert("OpenSession", Fault::SpawnFailed); + let (store, _serving) = fake.serve(DEADLINE, Asking::Never).await; + + let read = store.read(ANILIST).await; + + assert!(matches!(read, Err(Error::Unreachable(_))), "{read:?}"); + } + + #[tokio::test] + async fn a_session_the_store_does_not_close_keeps_the_token_read() { + let fake = Fake::default(); + fake.state() + .items + .insert(1, Item::of("anilist", TOKEN.as_bytes())); + fake.state().faults.insert("Close", Fault::Silent); + let (store, _serving) = fake.serve(SHORT, Asking::Never).await; + + let read = bounded(store.read(ANILIST)).await.expect("a read"); + + assert_eq!(read.as_deref(), Some(TOKEN)); + } + + #[tokio::test] + async fn a_store_that_is_gone_is_unreachable() { + let fake = Fake::default(); + let (store, serving) = fake.serve(DEADLINE, Asking::Never).await; + drop(serving); + + let read = store.read(ANILIST).await; + + assert!(matches!(read, Err(Error::Unreachable(_))), "{read:?}"); + } + + #[tokio::test] + async fn a_store_that_goes_while_the_person_is_asked_is_unreachable() { + let fake = Fake::default(); + fake.state().items.insert( + 1, + Item { + locked: true, + ..Item::of("anilist", TOKEN.as_bytes()) + }, + ); + fake.state().person = Person::Away; + let (store, serving) = fake.serve(DEADLINE, asking()).await; + let asked = async { + while !fake.state().methods().contains(&"Prompt") { + tokio::task::yield_now().await; + } + }; + // Bounded, so that a read that never asks ends the test. + let going = async { + let asked = tokio::time::timeout(Duration::from_secs(5), asked).await; + drop(serving); + + asked.is_ok() + }; + + let (read, asked) = tokio::join!(store.read(ANILIST), going); + + assert!(asked, "the person was never asked: {read:?}"); + assert!(matches!(read, Err(Error::Unreachable(_))), "{read:?}"); + } +} diff --git a/crates/daemon/tests/the_session_bus_is_found.rs b/crates/daemon/tests/the_session_bus_is_found.rs new file mode 100644 index 0000000..520c2e3 --- /dev/null +++ b/crates/daemon/tests/the_session_bus_is_found.rs @@ -0,0 +1,102 @@ +//! Where the secret store finds the session bus, and what it says where there +//! is none to find. +//! +//! A process cannot set its own environment without `unsafe`, so each test +//! here runs this binary again in a session it chose, with +//! `DBUS_SESSION_BUS_ADDRESS` naming a bus of the test's making. +//! +//! The tests are in a binary of their own: a child holds a copy of every +//! descriptor of the process that started it until it runs its own program, +//! and a listener a test beside it let go of would go on answering for that +//! long. + +#![cfg(target_os = "linux")] + +use std::env; +use std::os::unix::net::UnixListener; +use std::process::Command; +use std::time::Duration; + +use benshi_daemon::secrets::{Asking, Error, SecretService}; + +/// Set in a run a test started, and in no other. +const AGAIN: &str = "BENSHI_TEST_AGAIN"; + +/// A deadline that a test which waits for it spends whole. +const SHORT: Duration = Duration::from_millis(200); + +/// A bound on a test whose call gives up on its own, so that one which does +/// not ends the test. +const BOUND: Duration = Duration::from_secs(5); + +/// Run one test of this binary again, with this address for the session bus +/// and nothing else of this session. +fn again(name: &str, address: &str) { + let run = Command::new(env::current_exe().expect("the tests know what runs them")) + .args(["--exact", name]) + .env_clear() + .env(AGAIN, "1") + .env("DBUS_SESSION_BUS_ADDRESS", address) + .output() + .expect("the tests can be run again"); + let said = String::from_utf8_lossy(&run.stdout); + + assert!(run.status.success(), "{address}: {said}"); + // A name that matches no test is a run that passes. + assert!(said.contains("1 passed"), "{address}: {said}"); +} + +/// What connecting in this session answers. +fn connected() -> Result { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("a runtime") + .block_on(async { + tokio::time::timeout(BOUND, SecretService::connect(SHORT, Asking::Never)) + .await + .expect("connecting gives up on its own") + }) +} + +#[test] +fn a_session_with_no_bus_has_no_store_to_reach() { + if env::var_os(AGAIN).is_some() { + let connected = connected(); + + assert!( + matches!(&connected, Err(Error::Unreachable(_))), + "{connected:?}" + ); + return; + } + + again( + "a_session_with_no_bus_has_no_store_to_reach", + "unix:path=/nowhere/bus", + ); +} + +#[test] +fn a_bus_that_does_not_answer_is_given_up_on_at_the_deadline() { + if env::var_os(AGAIN).is_some() { + let connected = connected(); + + assert!( + matches!(&connected, Err(Error::Timeout { deadline }) if *deadline == SHORT), + "{connected:?}" + ); + return; + } + + // A socket that takes a connection into its backlog and never reads it, + // so the bus's greeting is never answered. + let directory = tempfile::tempdir().expect("a directory"); + let bus = directory.path().join("bus"); + let _listening = UnixListener::bind(&bus).expect("a socket that listens"); + + again( + "a_bus_that_does_not_answer_is_given_up_on_at_the_deadline", + &format!("unix:path={}", bus.display()), + ); +}