feat: protect profiles with explicit desktop wallet selection

This commit is contained in:
LucaPisl
2026-10-01 00:54:09 +03:00
parent cec104673d
commit 04c8281532
7 changed files with 754 additions and 0 deletions

78
src/credentials/mod.rs Normal file
View File

@@ -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<PrivateProfile>>;
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<PrivateProfile>> {
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.")
})?
})
}
}

View File

@@ -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<Self> {
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<ProfileSelection> {
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())
}
}

View File

@@ -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::*;

View File

@@ -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<ProviderIdentity>,
pub recommended: bool,
}
pub(crate) fn bus() -> Result<gio::DBusConnection> {
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<glib::Variant> {
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<String> {
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<Vec<ProviderCandidate>> {
let bus = bus()?;
let names = call(&bus, "ListNames", None)?
.get::<(Vec<String>,)>()
.ok_or_else(AppError::input)?
.0;
let inactive = call(&bus, "ListActivatableNames", None)?
.get::<(Vec<String>,)>()
.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)
}

View File

@@ -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<Self> {
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<Vec<libsecret::Item>> {
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<String>,
defaults: &'a Value,
ca_path: &'a Option<std::path::PathBuf>,
transport: TransportPolicy,
unsafe_storage: bool,
}
#[derive(Deserialize)]
struct ProfileWire {
version: u8,
api_base: String,
api_key: String,
access: AccessMode,
timezone: Option<String>,
defaults: Value,
ca_path: Option<std::path::PathBuf>,
transport: TransportPolicy,
unsafe_storage: bool,
}
fn encode(p: &PrivateProfile) -> Result<Zeroizing<Vec<u8>>> {
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<PrivateProfile> {
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<PrivateProfile> {
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(())
}

View File

@@ -1,3 +1,4 @@
pub mod client; pub mod client;
pub mod credentials;
pub mod error; pub mod error;
pub mod model; pub mod model;

138
tests/credentials.rs Normal file
View File

@@ -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<zbus::zvariant::OwnedObjectPath> {
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)
);
}