use std::collections::HashMap;
use std::os::unix::net::UnixStream;
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
use zbus::blocking::{Connection, connection};
use zbus::object_server::SignalEmitter;
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
use zbus::{Guid, ObjectServer, interface};
use super::service::Keyring;
use super::{SecretError, Secrets, Store};
const COLLECTION_PATH: &str = "/org/freedesktop/secrets/collection/login";
#[derive(Default)]
struct Held {
items: Vec<Kept>,
locked: bool,
has_default: bool,
dismiss: bool,
prompts: usize,
next: usize,
}
struct Kept {
path: String,
label: String,
attributes: HashMap<String, String>,
secret: Vec<u8>,
}
type Shared = Arc<Mutex<Held>>;
fn held(shared: &Shared) -> MutexGuard<'_, Held> {
shared.lock().unwrap_or_else(PoisonError::into_inner)
}
#[derive(Debug, zbus::DBusError)]
#[zbus(prefix = "org.freedesktop.Secret.Error")]
enum Refusal {
#[zbus(error)]
ZBus(zbus::Error),
IsLocked(String),
NoSuchObject(String),
}
fn path(text: &str) -> OwnedObjectPath {
OwnedObjectPath::try_from(text.to_owned()).expect("a valid object path")
}
struct Service(Shared);
#[interface(name = "org.freedesktop.Secret.Service")]
impl Service {
fn open_session(&self, algorithm: &str, _input: Value<'_>) -> Result<(OwnedValue, OwnedObjectPath), Refusal> {
if algorithm != "plain" {
return Err(Refusal::ZBus(zbus::Error::Unsupported));
}
Ok((OwnedValue::from(0_u8), path("/org/freedesktop/secrets/session/1")))
}
#[allow(clippy::needless_pass_by_value)]
fn search_items(&self, attributes: HashMap<String, String>) -> (Vec<OwnedObjectPath>, Vec<OwnedObjectPath>) {
let held = held(&self.0);
let found = held
.items
.iter()
.filter(|kept| attributes.iter().all(|(key, value)| kept.attributes.get(key) == Some(value)))
.map(|kept| path(&kept.path))
.collect();
if held.locked { (Vec::new(), found) } else { (found, Vec::new()) }
}
async fn unlock(
&self,
objects: Vec<OwnedObjectPath>,
#[zbus(object_server)] server: &ObjectServer,
) -> zbus::fdo::Result<(Vec<OwnedObjectPath>, OwnedObjectPath)> {
let prompt = {
let mut held = held(&self.0);
if !held.locked {
return Ok((objects, path("/")));
}
held.next += 1;
format!("/org/freedesktop/secrets/prompt/{}", held.next)
};
server.at(prompt.as_str(), Prompt { shared: Arc::clone(&self.0), objects }).await?;
Ok((Vec::new(), path(&prompt)))
}
fn read_alias(&self, name: &str) -> OwnedObjectPath {
path(if name == "default" && held(&self.0).has_default { COLLECTION_PATH } else { "/" })
}
fn create_collection(
&self,
_properties: HashMap<String, OwnedValue>,
alias: &str,
) -> (OwnedObjectPath, OwnedObjectPath) {
held(&self.0).has_default |= alias == "default";
(path(COLLECTION_PATH), path("/"))
}
}
struct Collection(Shared);
#[interface(name = "org.freedesktop.Secret.Collection")]
impl Collection {
async fn create_item(
&self,
properties: HashMap<String, OwnedValue>,
secret: (OwnedObjectPath, Vec<u8>, Vec<u8>, String),
replace: bool,
#[zbus(object_server)] server: &ObjectServer,
) -> Result<(OwnedObjectPath, OwnedObjectPath), Refusal> {
let text = |value: &OwnedValue| String::try_from(value.try_clone().expect("a plain value")).ok();
let label = properties.get("org.freedesktop.Secret.Item.Label").and_then(text).unwrap_or_default();
let attributes: HashMap<String, String> = properties
.get("org.freedesktop.Secret.Item.Attributes")
.and_then(|value| HashMap::try_from(value.try_clone().expect("a plain value")).ok())
.unwrap_or_default();
let made = {
let mut held = held(&self.0);
if held.locked {
return Err(Refusal::IsLocked("the collection is locked".to_owned()));
}
if replace {
held.items.retain(|kept| kept.attributes != attributes);
}
held.next += 1;
let made = format!("{COLLECTION_PATH}/{}", held.next);
held.items.push(Kept { path: made.clone(), label, attributes, secret: secret.2 });
made
};
server.at(made.as_str(), Item(Arc::clone(&self.0))).await.map_err(Refusal::ZBus)?;
Ok((path(&made), path("/")))
}
}
struct Item(Shared);
#[interface(name = "org.freedesktop.Secret.Item")]
impl Item {
#[allow(clippy::needless_pass_by_value)]
fn get_secret(
&self,
session: OwnedObjectPath,
#[zbus(header)] header: zbus::message::Header<'_>,
) -> Result<(OwnedObjectPath, Vec<u8>, Vec<u8>, String), Refusal> {
let held = held(&self.0);
if held.locked {
return Err(Refusal::IsLocked("the item is locked".to_owned()));
}
let me = header.path().map(ToString::to_string).unwrap_or_default();
let kept = held.items.iter().find(|kept| kept.path == me).ok_or_else(|| Refusal::NoSuchObject(me.clone()))?;
Ok((session, Vec::new(), kept.secret.clone(), "application/octet-stream".to_owned()))
}
#[allow(clippy::needless_pass_by_value)]
fn delete(&self, #[zbus(header)] header: zbus::message::Header<'_>) -> Result<OwnedObjectPath, Refusal> {
let mut held = held(&self.0);
if held.locked {
return Err(Refusal::IsLocked("the item is locked".to_owned()));
}
let me = header.path().map(ToString::to_string).unwrap_or_default();
held.items.retain(|kept| kept.path != me);
Ok(path("/"))
}
}
struct Prompt {
shared: Shared,
objects: Vec<OwnedObjectPath>,
}
#[interface(name = "org.freedesktop.Secret.Prompt")]
impl Prompt {
async fn prompt(
&self,
_window_id: &str,
#[zbus(signal_emitter)] emitter: SignalEmitter<'_>,
) -> zbus::fdo::Result<()> {
let dismissed = {
let mut held = held(&self.shared);
held.prompts += 1;
if !held.dismiss {
held.locked = false;
}
held.dismiss
};
let result = if dismissed { Value::from("") } else { Value::from(self.objects.clone()) };
Self::completed(&emitter, dismissed, result).await?;
Ok(())
}
fn dismiss(&self) {}
#[zbus(signal)]
async fn completed(emitter: &SignalEmitter<'_>, dismissed: bool, result: Value<'_>) -> zbus::Result<()>;
}
fn keyring(start: Held) -> (Secrets, Shared, Connection) {
let shared: Shared = Arc::new(Mutex::new(start));
let (ours, theirs) = UnixStream::pair().expect("a socket pair");
let serving = Arc::clone(&shared);
let service = std::thread::spawn(move || {
connection::Builder::async_io_unix_stream(theirs)
.server(Guid::generate())
.and_then(|builder| builder.p2p().serve_at("/org/freedesktop/secrets", Service(Arc::clone(&serving))))
.and_then(|builder| builder.serve_at(COLLECTION_PATH, Collection(Arc::clone(&serving))))
.and_then(connection::Builder::build)
.expect("the stand-in keyring serves")
});
let client = connection::Builder::async_io_unix_stream(ours).p2p().build().expect("the client connects");
let service = service.join().expect("the stand-in keyring started");
for kept in &held(&shared).items {
service.object_server().at(kept.path.as_str(), Item(Arc::clone(&shared))).expect("an item is served");
}
let keyring = Keyring::over(client).expect("a session opens");
let secrets = Secrets { application: "quvyta-test".to_owned(), store: Store::System(keyring) };
(secrets, shared, service)
}
fn kept(path: &str, account: &str, secret: &[u8]) -> Kept {
Kept {
path: format!("{COLLECTION_PATH}/{path}"),
label: format!("Test: {account}"),
attributes: HashMap::from([
("application".to_owned(), "quvyta-test".to_owned()),
("account".to_owned(), account.to_owned()),
]),
secret: secret.to_vec(),
}
}
fn bytes(found: Option<zeroize::Zeroizing<Vec<u8>>>) -> Option<Vec<u8>> {
found.map(|secret| secret.to_vec())
}
#[test]
fn memory_keeps_finds_replaces_and_forgets_a_secret() {
let secrets = Secrets::memory("quvyta-test");
assert_eq!(bytes(secrets.lookup("home").expect("memory answers")), None, "nothing is kept yet");
secrets.store("home", "Test: home", b"first").expect("memory keeps");
secrets.store("home", "Test: home", b"second").expect("memory replaces");
secrets.store("work", "Test: work", b"other").expect("memory keeps another");
assert_eq!(bytes(secrets.lookup("home").expect("memory answers")), Some(b"second".to_vec()));
let clone = secrets.clone();
clone.delete("home").expect("memory forgets");
assert_eq!(bytes(secrets.lookup("home").expect("memory answers")), None);
assert_eq!(bytes(secrets.lookup("work").expect("memory answers")), Some(b"other".to_vec()));
clone.delete("home").expect("forgetting twice is harmless");
assert!(!secrets.is_system());
}
#[test]
fn the_keyring_keeps_a_secret_under_the_application_and_the_account_with_the_label() {
let (secrets, shared, _service) = keyring(Held { has_default: true, ..Held::default() });
secrets.store("home-server", "Music: Home server", b"correct horse").expect("the keyring keeps it");
let held = held(&shared);
assert_eq!(held.items.len(), 1);
let item = &held.items[0];
assert_eq!(item.label, "Music: Home server");
assert_eq!(item.attributes.get("application").map(String::as_str), Some("quvyta-test"));
assert_eq!(item.attributes.get("account").map(String::as_str), Some("home-server"));
assert_eq!(item.secret, b"correct horse");
assert!(secrets.is_system());
}
#[test]
fn the_keyring_finds_replaces_and_forgets_a_secret() {
let (secrets, shared, _service) = keyring(Held { has_default: true, ..Held::default() });
assert_eq!(bytes(secrets.lookup("home").expect("the keyring answers")), None, "an account with nothing is None");
secrets.store("home", "Test: home", b"first").expect("the keyring keeps it");
secrets.store("home", "Test: home", b"second").expect("the keyring replaces it");
secrets.store("work", "Test: work", b"other").expect("the keyring keeps another");
assert_eq!(held(&shared).items.len(), 2, "storing again replaces rather than adds");
assert_eq!(bytes(secrets.lookup("home").expect("the keyring answers")), Some(b"second".to_vec()));
secrets.delete("home").expect("the keyring forgets it");
assert_eq!(bytes(secrets.lookup("home").expect("the keyring answers")), None);
assert_eq!(bytes(secrets.lookup("work").expect("the keyring answers")), Some(b"other".to_vec()));
secrets.delete("home").expect("forgetting twice is harmless");
}
#[test]
fn another_applications_secret_under_the_same_account_is_not_found() {
let mut theirs = kept("1", "home", b"not yours");
theirs.attributes.insert("application".to_owned(), "quvyta-other".to_owned());
let (secrets, shared, _service) =
keyring(Held { has_default: true, items: vec![theirs], next: 1, ..Held::default() });
assert_eq!(bytes(secrets.lookup("home").expect("the keyring answers")), None);
secrets.delete("home").expect("nothing of ours to forget");
assert_eq!(held(&shared).items.len(), 1, "the other application's secret stays");
}
#[test]
fn a_keyring_with_no_default_collection_gets_one_before_the_first_secret() {
let (secrets, shared, _service) = keyring(Held::default());
secrets.store("home", "Test: home", b"first").expect("the keyring keeps it");
assert!(held(&shared).has_default, "the default collection was made");
assert_eq!(bytes(secrets.lookup("home").expect("the keyring answers")), Some(b"first".to_vec()));
}
#[test]
fn a_locked_keyring_is_opened_through_its_own_prompt_to_read_and_to_keep() {
let (secrets, shared, _service) = keyring(Held {
has_default: true,
locked: true,
items: vec![kept("1", "home", b"kept")],
next: 1,
..Held::default()
});
assert_eq!(bytes(secrets.lookup("home").expect("the prompt opens it")), Some(b"kept".to_vec()));
assert_eq!(held(&shared).prompts, 1);
held(&shared).locked = true;
secrets.store("work", "Test: work", b"new").expect("the prompt opens it again");
assert_eq!(held(&shared).prompts, 2);
assert_eq!(held(&shared).items.len(), 2);
}
#[test]
fn a_dismissed_prompt_is_denied_and_leaves_the_keyring_as_it_was() {
let (secrets, shared, _service) = keyring(Held {
has_default: true,
locked: true,
dismiss: true,
items: vec![kept("1", "home", b"kept")],
next: 1,
..Held::default()
});
assert_eq!(secrets.lookup("home").map(bytes), Err(SecretError::Denied));
assert_eq!(secrets.store("work", "Test: work", b"new"), Err(SecretError::Denied));
assert_eq!(secrets.delete("home"), Err(SecretError::Denied));
let held = held(&shared);
assert_eq!(held.items.len(), 1, "nothing was added or removed");
assert!(held.locked);
}
#[test]
fn no_session_bus_is_unavailable_at_once() {
let started = Instant::now();
assert!(matches!(Keyring::open(""), Err(SecretError::Unavailable)), "an empty address is no bus");
let gone = std::env::temp_dir().join(format!("quvyta-secrets-no-bus-{}", std::process::id()));
let address = format!("unix:path={}", gone.display());
assert!(matches!(Keyring::open(&address), Err(SecretError::Unavailable)), "a bus that is not there");
assert!(started.elapsed() < Duration::from_secs(10), "it did not wait: {:?}", started.elapsed());
}
const CHILD: &str = "QFRAME_SECRETS_NO_BUS_CHILD";
#[test]
fn system_answers_unavailable_in_the_environment_it_was_given() {
if std::env::var_os(CHILD).is_none() {
return;
}
let started = Instant::now();
assert!(matches!(Secrets::system("quvyta-test"), Err(SecretError::Unavailable)));
assert!(started.elapsed() < Duration::from_secs(10), "it did not wait: {:?}", started.elapsed());
}
#[test]
fn a_session_without_its_bus_address_never_reaches_a_bus() {
let runtime = std::env::temp_dir().join(format!("quvyta-secrets-runtime-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&runtime);
std::fs::create_dir_all(&runtime).expect("a runtime folder");
let bus = std::os::unix::net::UnixListener::bind(runtime.join("bus")).expect("a bus socket");
bus.set_nonblocking(true).expect("a socket that does not wait");
let test = std::env::current_exe().expect("this test binary");
for empty in [false, true] {
let mut child = std::process::Command::new(&test);
child
.args(["--exact", "secrets::tests::system_answers_unavailable_in_the_environment_it_was_given", "-q"])
.env(CHILD, "1")
.env("XDG_RUNTIME_DIR", &runtime)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
if empty {
child.env("DBUS_SESSION_BUS_ADDRESS", "");
} else {
child.env_remove("DBUS_SESSION_BUS_ADDRESS");
}
let mut child = child.spawn().expect("the child test starts");
let deadline = Instant::now() + Duration::from_secs(60);
let status = loop {
if let Some(status) = child.try_wait().expect("the child test is there") {
break Some(status);
}
if Instant::now() > deadline {
let _ = child.kill();
break None;
}
std::thread::sleep(Duration::from_millis(20));
};
let knocked = bus.accept().is_ok();
assert!(
!knocked,
"with the address {}, the bus at its usual place was reached",
if empty { "empty" } else { "missing" }
);
assert!(status.is_some_and(|status| status.success()), "the child answered Unavailable: {status:?}");
}
let _ = std::fs::remove_dir_all(&runtime);
}
#[test]
fn no_error_and_no_debug_output_carries_the_secret() {
let phrase = "s3cret-Pa55-0f-the-test";
let memory = Secrets::memory("quvyta-test");
memory.store("home", "Test: home", phrase.as_bytes()).expect("memory keeps");
let (system, _shared, _service) =
keyring(Held { has_default: true, locked: true, dismiss: true, ..Held::default() });
let refused = system.store("home", "Test: home", phrase.as_bytes()).expect_err("the prompt was dismissed");
let mut printed = vec![format!("{memory:?}"), format!("{system:?}"), refused.to_string(), format!("{refused:?}")];
printed.extend(
[
SecretError::Unavailable,
SecretError::Locked,
SecretError::Denied,
SecretError::Other("NoSession".to_owned()),
]
.iter()
.map(ToString::to_string),
);
for text in printed {
assert!(!text.contains(phrase), "{text:?} carries the secret");
assert!(!text.contains("home"), "{text:?} names the account");
}
}