diff --git a/src/credentials/mod.rs b/src/credentials/mod.rs new file mode 100644 index 0000000..7dff6d3 --- /dev/null +++ b/src/credentials/mod.rs @@ -0,0 +1,78 @@ +use crate::{ + error::{AppError, Result}, + model::{PrivateProfile, ProfileSelection}, +}; +use futures_util::future::BoxFuture; +pub mod preferences; +pub mod protection; +pub mod providers; +mod secret_service; +pub struct DesktopStore; +#[derive(Clone, Copy)] +pub enum EnrollmentConsent { + Verified, + ProtectedByUser, + UnsafeBackendAccepted, +} +pub trait CredentialStore: Send + Sync { + fn load(&self, selection: ProfileSelection) -> BoxFuture<'static, Result>; + fn put( + &self, + selection: ProfileSelection, + profile: PrivateProfile, + consent: EnrollmentConsent, + ) -> BoxFuture<'static, Result<()>>; + fn forget(&self, selection: ProfileSelection) -> BoxFuture<'static, Result<()>>; +} +impl CredentialStore for DesktopStore { + fn load(&self, s: ProfileSelection) -> BoxFuture<'static, Result> { + Box::pin(async move { + tokio::task::spawn_blocking(move || secret_service::load(s)) + .await + .map_err(|_| { + AppError::new("WALLET_UNAVAILABLE", "Wallet operation did not complete.") + })? + }) + } + fn put( + &self, + s: ProfileSelection, + mut p: PrivateProfile, + consent: EnrollmentConsent, + ) -> BoxFuture<'static, Result<()>> { + Box::pin(async move { + tokio::task::spawn_blocking(move || { + let protection = protection::inspect_protection(&s.provider); + if protection == protection::ProtectionEvidence::Unprotected + && !matches!(consent, EnrollmentConsent::UnsafeBackendAccepted) + { + return Err(AppError::new( + "UNPROTECTED_WALLET", + "Protect the wallet in its native manager first.", + )); + } + if matches!(consent, EnrollmentConsent::Verified) + && protection != protection::ProtectionEvidence::Protected + { + return Err(AppError::new( + "UNVERIFIED_WALLET", + "Review wallet protection in setup.", + )); + } + p.unsafe_storage = matches!(consent, EnrollmentConsent::UnsafeBackendAccepted); + secret_service::put(s, p) + }) + .await + .map_err(|_| AppError::new("WALLET_UNAVAILABLE", "Wallet storage did not complete."))? + }) + } + fn forget(&self, s: ProfileSelection) -> BoxFuture<'static, Result<()>> { + Box::pin(async move { + tokio::task::spawn_blocking(move || secret_service::forget(s)) + .await + .map_err(|_| { + AppError::new("WALLET_UNAVAILABLE", "Wallet removal did not complete.") + })? + }) + } +} diff --git a/src/credentials/preferences.rs b/src/credentials/preferences.rs new file mode 100644 index 0000000..6517396 --- /dev/null +++ b/src/credentials/preferences.rs @@ -0,0 +1,119 @@ +use crate::{ + error::{AppError, Result}, + model::ProfileSelection, +}; +use std::{ + fs::{self, OpenOptions}, + io::{Read, Write}, + os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}, + path::PathBuf, +}; +pub struct PreferenceStore { + pub directory: PathBuf, +} +pub fn validate_profile(name: &str) -> Result<()> { + if name.is_empty() + || name.len() > 64 + || !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + return Err(AppError::input()); + } + Ok(()) +} +impl PreferenceStore { + pub fn default_location() -> Result { + let root = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) + .ok_or_else(|| { + AppError::new( + "CONFIG_UNAVAILABLE", + "Cannot locate your user configuration directory.", + ) + })?; + Ok(Self { + directory: root.join("hermes-grocy-mcp"), + }) + } + fn ensure_directory(&self) -> Result<()> { + if !self.directory.exists() { + fs::create_dir_all(&self.directory) + .map_err(|_| AppError::new("CONFIG_UNAVAILABLE", "Cannot create preferences."))? + } + let m = fs::symlink_metadata(&self.directory).map_err(|_| AppError::input())?; + if !m.is_dir() || m.uid() != unsafe { libc::geteuid() } { + return Err(AppError::new( + "UNSAFE_PREFERENCES", + "Preferences must be in your owned directory.", + )); + } + fs::set_permissions(&self.directory, fs::Permissions::from_mode(0o700)) + .map_err(|_| AppError::input())?; + Ok(()) + } + pub fn save(&self, s: &ProfileSelection) -> Result<()> { + validate_profile(&s.profile)?; + self.ensure_directory()?; + let tmp = self + .directory + .join(format!(".{}.{}.tmp", s.profile, std::process::id())); + let mut f = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(&tmp) + .map_err(|_| { + AppError::new("CONFIG_UNAVAILABLE", "Cannot atomically write preferences.") + })?; + let result = (|| { + f.write_all(&serde_json::to_vec(s).map_err(|_| AppError::input())?) + .map_err(|_| AppError::input())?; + f.sync_all().map_err(|_| AppError::input())?; + fs::rename(&tmp, self.directory.join(format!("{}.json", s.profile))) + .map_err(|_| AppError::input())?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(tmp); + } + result + } + pub fn load(&self, name: &str) -> Result { + validate_profile(name)?; + let mut f = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(self.directory.join(format!("{name}.json"))) + .map_err(|_| AppError::new("PROFILE_NOT_FOUND", "Run setup to enroll this profile."))?; + let m = f.metadata().map_err(|_| AppError::input())?; + if !m.is_file() + || m.uid() != unsafe { libc::geteuid() } + || m.permissions().mode() & 0o077 != 0 + { + return Err(AppError::new( + "UNSAFE_PREFERENCES", + "Preferences must be owned by you and accessible only to you.", + )); + } + let mut bytes = Vec::new(); + Read::by_ref(&mut f) + .take(16385) + .read_to_end(&mut bytes) + .map_err(|_| AppError::input())?; + if bytes.len() > 16384 { + return Err(AppError::input()); + } + let s: ProfileSelection = serde_json::from_slice(&bytes).map_err(|_| AppError::input())?; + if s.profile != name { + return Err(AppError::input()); + } + Ok(s) + } + pub fn remove(&self, name: &str) -> Result<()> { + self.load(name)?; + fs::remove_file(self.directory.join(format!("{name}.json"))).map_err(|_| AppError::input()) + } +} diff --git a/src/credentials/protection.rs b/src/credentials/protection.rs new file mode 100644 index 0000000..a853ff3 --- /dev/null +++ b/src/credentials/protection.rs @@ -0,0 +1,66 @@ +use crate::model::ProviderIdentity; +use std::{ + fs::OpenOptions, + io::Read, + os::unix::fs::{MetadataExt, OpenOptionsExt}, + path::Path, +}; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProtectionEvidence { + Protected, + Unprotected, + Unverified, +} +pub fn inspect_header(path: &Path) -> ProtectionEvidence { + let Ok(mut f) = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path) + else { + return ProtectionEvidence::Unverified; + }; + let Ok(meta) = f.metadata() else { + return ProtectionEvidence::Unverified; + }; + if !meta.is_file() || meta.uid() != unsafe { libc::geteuid() } { + return ProtectionEvidence::Unverified; + } + let mut buf = [0u8; 20]; + let Ok(n) = f.read(&mut buf) else { + return ProtectionEvidence::Unverified; + }; + let h = &buf[..n]; + if h.starts_with(b"GnomeKeyring\n\r\0\n\0\0\0\0") { + ProtectionEvidence::Protected + } else if h.starts_with(b"[keyring]") { + ProtectionEvidence::Unprotected + } else { + ProtectionEvidence::Unverified + } +} +pub fn inspect_protection(identity: &ProviderIdentity) -> ProtectionEvidence { + // Only inspect a recognized provider's actual default collection label; no unrelated secret reads. + let Ok(owner) = super::providers::bind_selected(identity) else { + return ProtectionEvidence::Unverified; + }; + let Ok(session) = super::secret_service::WalletSession::open(&owner) else { + return ProtectionEvidence::Unverified; + }; + let label = session.collection.label(); + if label.is_empty() || label.contains(['/', '\\']) || label == "." || label == ".." { + return ProtectionEvidence::Unverified; + } + let Some(home) = std::env::var_os("HOME") else { + return ProtectionEvidence::Unverified; + }; + let root = std::env::var_os("XDG_DATA_HOME") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(home).join(".local/share")); + let exe = identity.executable.to_string_lossy(); + if exe.contains("gnome-keyring") { + inspect_header(&root.join("keyrings").join(format!("{label}.keyring"))) + } else { + ProtectionEvidence::Unverified + } +} +use libsecret::prelude::*; diff --git a/src/credentials/providers.rs b/src/credentials/providers.rs new file mode 100644 index 0000000..8ef30d3 --- /dev/null +++ b/src/credentials/providers.rs @@ -0,0 +1,126 @@ +use crate::{ + error::{AppError, Result}, + model::ProviderIdentity, +}; +use glib::variant::ToVariant; +use std::{collections::HashSet, path::PathBuf}; +pub const ALIASES: &[(&str, &str)] = &[ + ("KDE KWallet", "org.kde.secretservicecompat"), + ("KDE KWallet", "org.kde.kwalletd6"), + ("KDE KWallet", "org.kde.kwalletd5"), + ("GNOME Keyring", "org.gnome.keyring"), + ("System Secret Service", "org.freedesktop.secrets"), +]; +#[derive(Clone)] +pub struct ProviderCandidate { + pub label: String, + pub identity: Option, + pub recommended: bool, +} +pub(crate) fn bus() -> Result { + gio::bus_get_sync(gio::BusType::Session, gio::Cancellable::NONE).map_err(|_| { + AppError::new( + "WALLET_UNAVAILABLE", + "No desktop session bus. Run setup from your desktop session.", + ) + }) +} +pub(crate) fn call( + bus: &gio::DBusConnection, + method: &str, + params: Option<&glib::Variant>, +) -> Result { + bus.call_sync( + Some("org.freedesktop.DBus"), + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + method, + params, + None, + gio::DBusCallFlags::NO_AUTO_START, + 5000, + gio::Cancellable::NONE, + ) + .map_err(|_| { + AppError::new( + "WALLET_UNAVAILABLE", + "The selected wallet is not running. Open its native manager and retry.", + ) + }) +} +pub(crate) fn owner_identity(bus: &gio::DBusConnection, name: &str) -> Result<(String, PathBuf)> { + let owner = call(bus, "GetNameOwner", Some(&(name,).to_variant()))? + .get::<(String,)>() + .ok_or_else(AppError::input)? + .0; + let pid = call( + bus, + "GetConnectionUnixProcessID", + Some(&(owner.as_str(),).to_variant()), + )? + .get::<(u32,)>() + .ok_or_else(AppError::input)? + .0; + let exe = std::fs::read_link(format!("/proc/{pid}/exe")).map_err(|_| { + AppError::new( + "WALLET_UNAVAILABLE", + "Cannot identify the selected wallet process.", + ) + })?; + Ok((owner, exe)) +} +pub(crate) fn bind_selected(identity: &ProviderIdentity) -> Result { + let bus = bus()?; + let (owner, exe) = owner_identity(&bus, &identity.bus_name)?; + if exe != identity.executable { + return Err(AppError::new( + "WALLET_PROVIDER_CHANGED", + "The wallet provider changed. Run setup to review its selection.", + )); + } + Ok(owner) +} +pub fn discover_providers() -> Result> { + let bus = bus()?; + let names = call(&bus, "ListNames", None)? + .get::<(Vec,)>() + .ok_or_else(AppError::input)? + .0; + let inactive = call(&bus, "ListActivatableNames", None)? + .get::<(Vec,)>() + .ok_or_else(AppError::input)? + .0; + let desktop = std::env::var("XDG_CURRENT_DESKTOP") + .unwrap_or_default() + .to_lowercase(); + let mut seen = HashSet::new(); + let mut output = Vec::new(); + for (label, name) in ALIASES { + if names.iter().any(|n| n == name) { + if let Ok((owner, exe)) = owner_identity(&bus, name) { + if seen.insert(owner) { + let recommended = (desktop.contains("kde") + && exe.to_string_lossy().contains("kwallet")) + || (desktop.contains("gnome") + && exe.to_string_lossy().contains("gnome-keyring")); + output.push(ProviderCandidate { + label: label.to_string(), + identity: Some(ProviderIdentity { + bus_name: name.to_string(), + executable: exe, + }), + recommended, + }); + } + } + } else if inactive.iter().any(|n| n == name) && !output.iter().any(|p| p.label == *label) { + output.push(ProviderCandidate { + label: format!("{label} (not running; open its manager)"), + identity: None, + recommended: false, + }) + } + } + output.sort_by_key(|p| (!p.recommended, p.identity.is_none())); + Ok(output) +} diff --git a/src/credentials/secret_service.rs b/src/credentials/secret_service.rs new file mode 100644 index 0000000..a716cd8 --- /dev/null +++ b/src/credentials/secret_service.rs @@ -0,0 +1,226 @@ +use crate::{ + client::ApiBase, + error::{AppError, Result}, + model::{AccessMode, PrivateProfile, ProfileSelection, TransportPolicy}, +}; +use gio::prelude::*; +use libsecret::prelude::*; +use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{collections::HashMap, sync::mpsc, time::Duration}; +use zeroize::Zeroizing; +pub const APP_ID: &str = "io.github.hermes-grocy-mcp"; +fn wallet_error() -> AppError { + AppError::new( + "WALLET_UNAVAILABLE", + "The selected wallet is unavailable or did not finish. Open its native manager and retry; no fallback was used.", + ) +} +pub(crate) struct WalletSession { + pub collection: libsecret::Collection, + pub cancel: gio::Cancellable, + done: mpsc::Sender<()>, +} +impl WalletSession { + pub(crate) fn open(owner: &str) -> Result { + let cancel = gio::Cancellable::new(); + let timer = cancel.clone(); + let (done, rx) = mpsc::channel(); + std::thread::spawn(move || { + if matches!( + rx.recv_timeout(Duration::from_secs(30)), + Err(mpsc::RecvTimeoutError::Timeout) + ) { + timer.cancel(); + } + }); + let service = libsecret::Service::open_sync( + libsecret::Service::static_type(), + Some(owner), + libsecret::ServiceFlags::OPEN_SESSION, + Some(&cancel), + ) + .map_err(|_| wallet_error())?; + if service.session_algorithms().as_deref() != Some("dh-ietf1024-sha256-aes128-cbc-pkcs7") { + return Err(AppError::new( + "UNENCRYPTED_SECRET_SESSION", + "This wallet cannot provide encrypted communication. Choose a compatible desktop wallet; plaintext communication is refused.", + )); + } + let collection = libsecret::Collection::for_alias_sync( + Some(&service), + "default", + libsecret::CollectionFlags::NONE, + Some(&cancel), + ) + .map_err(|_| wallet_error())? + .ok_or_else(|| { + AppError::new( + "NO_DEFAULT_WALLET", + "Create a protected default wallet using the native manager and retry.", + ) + })?; + if collection.is_locked() { + return Err(AppError::new( + "WALLET_LOCKED", + "Unlock your default wallet in its native manager, then retry.", + )); + } + Ok(Self { + collection, + cancel, + done, + }) + } + fn items(&self, name: &str) -> Result> { + self.collection + .search_sync( + None, + attributes(name), + libsecret::SearchFlags::ALL, + Some(&self.cancel), + ) + .map_err(|_| wallet_error()) + } +} +impl Drop for WalletSession { + fn drop(&mut self) { + let _ = self.done.send(()); + } +} +fn attributes(name: &str) -> HashMap<&str, &str> { + HashMap::from([("application", APP_ID), ("profile", name)]) +} +#[derive(Serialize)] +struct ProfileRef<'a> { + version: u8, + api_base: &'a str, + api_key: &'a str, + access: AccessMode, + timezone: &'a Option, + defaults: &'a Value, + ca_path: &'a Option, + transport: TransportPolicy, + unsafe_storage: bool, +} +#[derive(Deserialize)] +struct ProfileWire { + version: u8, + api_base: String, + api_key: String, + access: AccessMode, + timezone: Option, + defaults: Value, + ca_path: Option, + transport: TransportPolicy, + unsafe_storage: bool, +} +fn encode(p: &PrivateProfile) -> Result>> { + serde_json::to_vec(&ProfileRef { + version: 1, + api_base: p.api_base.as_str(), + api_key: p.api_key.expose_secret(), + access: p.access, + timezone: &p.timezone, + defaults: &p.defaults, + ca_path: &p.ca_path, + transport: p.transport, + unsafe_storage: p.unsafe_storage, + }) + .map(Zeroizing::new) + .map_err(|_| AppError::input()) +} +fn decode(bytes: &[u8]) -> Result { + if bytes.len() > 256 * 1024 { + return Err(AppError::new( + "INVALID_PROFILE", + "The wallet profile is invalid. Run setup again.", + )); + } + let mut w: ProfileWire = serde_json::from_slice(bytes).map_err(|_| { + AppError::new( + "INVALID_PROFILE", + "The wallet profile is invalid. Run setup again.", + ) + })?; + let key = SecretString::from(std::mem::take(&mut w.api_key)); + if w.version != 1 { + return Err(AppError::new( + "INVALID_PROFILE", + "Run setup to refresh this profile.", + )); + } + Ok(PrivateProfile { + api_base: ApiBase::parse(&w.api_base, w.transport)?, + api_key: key, + access: w.access, + timezone: w.timezone, + defaults: w.defaults, + ca_path: w.ca_path, + transport: w.transport, + unsafe_storage: w.unsafe_storage, + }) +} +pub(crate) fn load(s: ProfileSelection) -> Result { + super::preferences::validate_profile(&s.profile)?; + let owner = super::providers::bind_selected(&s.provider)?; + let session = WalletSession::open(&owner)?; + let items = session.items(&s.profile)?; + if items.len() != 1 { + return Err(AppError::new( + "PROFILE_NOT_FOUND", + "The wallet profile is missing or ambiguous. Run setup to review it.", + )); + } + let item = &items[0]; + if item.is_locked() { + return Err(AppError::new( + "WALLET_LOCKED", + "Unlock the wallet item in its native manager and retry.", + )); + } + item.load_secret_sync(Some(&session.cancel)) + .map_err(|_| wallet_error())?; + let secret = item.secret().ok_or_else(wallet_error)?; + let bytes = Zeroizing::new(secret.get()); + decode(&bytes) +} +pub(crate) fn put(s: ProfileSelection, p: PrivateProfile) -> Result<()> { + super::preferences::validate_profile(&s.profile)?; + let owner = super::providers::bind_selected(&s.provider)?; + let session = WalletSession::open(&owner)?; + let bytes = encode(&p)?; + if bytes.len() > 256 * 1024 { + return Err(AppError::input()); + } + let text = std::str::from_utf8(&bytes).map_err(|_| AppError::input())?; + let value = libsecret::Value::new(text, "application/json"); + libsecret::Item::create_sync( + &session.collection, + None, + attributes(&s.profile), + &format!("Grocy MCP ({})", s.profile), + &value, + libsecret::ItemCreateFlags::REPLACE, + Some(&session.cancel), + ) + .map_err(|_| wallet_error())?; + Ok(()) +} +pub(crate) fn forget(s: ProfileSelection) -> Result<()> { + super::preferences::validate_profile(&s.profile)?; + let owner = super::providers::bind_selected(&s.provider)?; + let session = WalletSession::open(&owner)?; + for item in session.items(&s.profile)? { + if item.is_locked() { + return Err(AppError::new( + "WALLET_LOCKED", + "Unlock the wallet item and retry.", + )); + } + item.delete_sync(Some(&session.cancel)) + .map_err(|_| wallet_error())?; + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 90e171e..4cfd796 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ pub mod client; +pub mod credentials; pub mod error; pub mod model; diff --git a/tests/credentials.rs b/tests/credentials.rs new file mode 100644 index 0000000..98ae4ae --- /dev/null +++ b/tests/credentials.rs @@ -0,0 +1,138 @@ +use hermes_grocy_mcp::{ + credentials::{ + CredentialStore, DesktopStore, + preferences::PreferenceStore, + protection::{ProtectionEvidence, inspect_header}, + }, + model::{ProfileSelection, ProviderIdentity}, +}; +use std::{ + io::{BufRead, BufReader}, + os::unix::fs::PermissionsExt, + process::{Command, Stdio}, +}; +#[test] +fn protected_preferences() { + let dir = tempfile::tempdir().unwrap(); + let selection = ProfileSelection { + profile: "default".into(), + provider: ProviderIdentity { + bus_name: "org.freedesktop.secrets".into(), + executable: "/usr/bin/fixture-wallet".into(), + }, + }; + let prefs = PreferenceStore { + directory: dir.path().join("prefs"), + }; + prefs.save(&selection).unwrap(); + assert_eq!(prefs.load("default").unwrap(), selection); + assert_eq!( + std::fs::metadata(prefs.directory.join("default.json")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + let header = dir.path().join("header"); + std::fs::write(&header, b"[keyring]\nplaintext").unwrap(); + assert_eq!(inspect_header(&header), ProtectionEvidence::Unprotected); + std::fs::write(&header, b"GnomeKeyring\n\r\0\n\0\0\0\0").unwrap(); + assert_eq!(inspect_header(&header), ProtectionEvidence::Protected); + std::fs::write(&header, b"KWALLET\n\r\0\r\n").unwrap(); + assert_eq!(inspect_header(&header), ProtectionEvidence::Unverified); + let link = dir.path().join("link"); + std::os::unix::fs::symlink(&header, &link).unwrap(); + assert_eq!(inspect_header(&link), ProtectionEvidence::Unverified); + assert!(prefs.load("../escape").is_err()); +} +struct PlainService; +#[zbus::interface(name = "org.freedesktop.Secret.Service")] +impl PlainService { + fn open_session( + &self, + algorithm: &str, + _input: zbus::zvariant::OwnedValue, + ) -> zbus::fdo::Result<(zbus::zvariant::OwnedValue, zbus::zvariant::OwnedObjectPath)> { + if algorithm != "plain" { + return Err(zbus::fdo::Error::NotSupported("Only plain".into())); + } + Ok(( + zbus::zvariant::Value::from("").try_into().unwrap(), + "/org/freedesktop/secrets/session/plain".try_into().unwrap(), + )) + } + #[zbus(property)] + fn collections(&self) -> Vec { + vec![] + } +} +#[test] +fn selected_wallet_only() { + if std::env::var_os("GROCY_WALLET_TEST_CHILD").is_some() { + tokio::runtime::Runtime::new().unwrap().block_on(async { + let _conn = zbus::connection::Builder::address( + std::env::var("DBUS_SESSION_BUS_ADDRESS").unwrap().as_str(), + ) + .unwrap() + .name("org.freedesktop.secrets") + .unwrap() + .serve_at("/org/freedesktop/secrets", PlainService) + .unwrap() + .build() + .await + .unwrap(); + let selection = ProfileSelection { + profile: "default".into(), + provider: ProviderIdentity { + bus_name: "org.freedesktop.secrets".into(), + executable: std::fs::read_link("/proc/self/exe").unwrap(), + }, + }; + assert_eq!( + DesktopStore + .load(selection.clone()) + .await + .err() + .unwrap() + .code, + "UNENCRYPTED_SECRET_SESSION" + ); + let mut wrong = selection; + wrong.provider.executable = "/not-the-selected-provider".into(); + assert_eq!( + DesktopStore.load(wrong).await.err().unwrap().code, + "WALLET_PROVIDER_CHANGED" + ); + }); + return; + } + let mut daemon = Command::new("dbus-daemon") + .args(["--session", "--nofork", "--print-address"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let mut address = String::new(); + BufReader::new(daemon.stdout.take().unwrap()) + .read_line(&mut address) + .unwrap(); + let result = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "selected_wallet_only", + "--nocapture", + "--test-threads=1", + ]) + .env("GROCY_WALLET_TEST_CHILD", "1") + .env("DBUS_SESSION_BUS_ADDRESS", address.trim()) + .output() + .unwrap(); + let _ = daemon.kill(); + let _ = daemon.wait(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stdout) + ); +}