1use dbus::Message;
31use dbus::channel::Channel;
32use std::sync::Mutex;
33use std::sync::atomic::{AtomicU64, Ordering};
34use std::time::{Duration, Instant};
35
36pub const SETUP: Duration = Duration::from_secs(5);
39
40static UNDER_WAY: Mutex<Vec<Setup>> = Mutex::new(Vec::new());
42
43#[derive(Debug, Clone)]
45struct Setup {
46 address: String,
47 socket: Option<(u64, u64)>,
50 began: Instant,
51 limit: Duration,
52 number: u64,
53}
54
55fn refused(under_way: &[Setup], address: &str, socket: Option<(u64, u64)>, now: Instant) -> bool {
61 under_way.iter().any(|setup| setup.address == address && setup.socket == socket && now.saturating_duration_since(setup.began) >= setup.limit)
62}
63
64fn socket_of(address: &str) -> Option<(u64, u64)> {
68 use std::os::unix::fs::MetadataExt;
69 let found = std::fs::metadata(unix_path(address)?).ok()?;
70 Some((found.dev(), found.ino()))
71}
72
73pub(crate) fn unix_path(address: &str) -> Option<std::path::PathBuf> {
75 use std::os::unix::ffi::OsStringExt;
76 let first = address.split(';').next()?.strip_prefix("unix:")?;
77 let value = first.split(',').find_map(|pair| pair.strip_prefix("path="))?;
78 let mut bytes = Vec::with_capacity(value.len());
79 let mut rest = value.as_bytes();
80 while let Some((&byte, after)) = rest.split_first() {
81 if byte == b'%' {
82 let hex = std::str::from_utf8(after.get(..2)?).ok()?;
83 bytes.push(u8::from_str_radix(hex, 16).ok()?);
84 rest = &after[2..];
85 } else {
86 bytes.push(byte);
87 rest = after;
88 }
89 }
90 Some(std::path::PathBuf::from(std::ffi::OsString::from_vec(bytes)))
91}
92
93pub fn connect(address: &str) -> Result<Channel, dbus::Error> {
96 connect_within(address, SETUP)
97}
98
99pub fn connect_within(address: &str, limit: Duration) -> Result<Channel, dbus::Error> {
103 static NEXT: AtomicU64 = AtomicU64::new(0);
104 let number = NEXT.fetch_add(1, Ordering::Relaxed);
105 let socket = socket_of(address);
106 {
107 let mut under_way = UNDER_WAY.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
108 if refused(&under_way, address, socket, Instant::now()) {
109 return Err(dbus::Error::new_failed("the bus did not answer a connection in time"));
110 }
111 under_way.push(Setup { address: address.to_string(), socket, began: Instant::now(), limit, number });
112 }
113 let (sender, answer) = std::sync::mpsc::channel();
114 let target = address.to_string();
115 let started = std::thread::Builder::new().name("sioul-bus-setup".into()).spawn(move || {
116 let made = set_up(&target, limit);
117 finished(number);
118 let _ = sender.send(made);
120 });
121 if started.is_err() {
122 finished(number);
123 return Err(dbus::Error::new_failed("no thread for the connection"));
124 }
125 answer.recv_timeout(limit).unwrap_or_else(|_| Err(dbus::Error::new_failed("the bus did not answer the connection in time")))
126}
127
128fn finished(number: u64) {
130 UNDER_WAY.lock().unwrap_or_else(std::sync::PoisonError::into_inner).retain(|setup| setup.number != number);
131}
132
133fn set_up(address: &str, limit: Duration) -> Result<Channel, dbus::Error> {
137 let channel = Channel::open_private(address)?;
138 let hello = Message::new_method_call("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "Hello").map_err(|e| dbus::Error::new_failed(&e))?;
139 channel.send_with_reply_and_block(hello, limit)?;
140 Ok(channel)
141}
142
143pub fn session() -> Result<Channel, dbus::Error> {
145 let address = session_address().ok_or_else(|| dbus::Error::new_failed("no session bus"))?;
146 connect(&address)
147}
148
149pub fn system() -> Result<Channel, dbus::Error> {
151 connect(&system_address())
152}
153
154pub fn session_address() -> Option<String> {
157 if let Some(address) = std::env::var("DBUS_SESSION_BUS_ADDRESS").ok().filter(|address| !address.is_empty()) {
158 return Some(address);
159 }
160 let bus = std::path::PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR")?).join("bus");
161 bus.exists().then(|| format!("unix:path={}", escaped(&bus)))
162}
163
164pub fn system_address() -> String {
166 std::env::var("DBUS_SYSTEM_BUS_ADDRESS").ok().filter(|address| !address.is_empty()).unwrap_or_else(|| "unix:path=/var/run/dbus/system_bus_socket".to_string())
167}
168
169pub fn escaped(path: &std::path::Path) -> String {
172 use std::os::unix::ffi::OsStrExt;
173 path.as_os_str().as_bytes().iter().map(|&byte| if byte.is_ascii_alphanumeric() || b"-_/.\\*".contains(&byte) { char::from(byte).to_string() } else { format!("%{byte:02x}") }).collect()
174}
175
176#[cfg(test)]
179pub(crate) static NOTIFICATION_SERVER: Mutex<()> = Mutex::new(());
180
181#[cfg(test)]
185pub(crate) struct StandIns {
186 dir: std::path::PathBuf,
187 daemons: Vec<std::process::Child>,
188}
189
190#[cfg(test)]
191impl StandIns {
192 pub(crate) fn new(name: &str) -> StandIns {
194 let dir = std::env::temp_dir().join(format!("sioul-{name}-{}", std::process::id()));
195 let _ = std::fs::remove_dir_all(&dir);
196 std::fs::create_dir_all(&dir).unwrap();
197 StandIns { dir, daemons: Vec::new() }
198 }
199
200 pub(crate) fn start(&mut self, name: &str) -> (u32, String) {
204 use std::process::{Command, Stdio};
205 let socket = self.dir.join(name);
206 let _ = std::fs::remove_file(&socket);
207 let address = format!("unix:path={}", escaped(&socket));
208 let config = self.dir.join(format!("{name}.conf"));
209 let policy = "<allow send_destination=\"*\" eavesdrop=\"true\"/><allow eavesdrop=\"true\"/><allow own=\"*\"/>";
210 std::fs::write(&config, format!("<!DOCTYPE busconfig PUBLIC \"-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN\" \"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd\">\n<busconfig><type>session</type><listen>{address}</listen><auth>EXTERNAL</auth><policy context=\"default\">{policy}</policy></busconfig>\n")).unwrap();
211 let daemon = Command::new("dbus-daemon").arg(format!("--config-file={}", config.display())).arg("--nofork").stdout(Stdio::null()).stderr(Stdio::null()).spawn().expect("dbus-daemon");
212 let pid = daemon.id();
213 self.daemons.push(daemon);
214 let until = Instant::now() + Duration::from_secs(5);
215 while !socket.exists() && Instant::now() < until {
216 std::thread::sleep(Duration::from_millis(20));
217 }
218 assert!(socket.exists(), "{name}: no socket");
219 (pid, address)
220 }
221
222 pub(crate) fn signal(pid: u32, signal: &str) {
224 let sent = std::process::Command::new("kill").args([signal, &pid.to_string()]).status().is_ok_and(|status| status.success());
225 assert!(sent, "kill {signal} {pid}");
226 }
227}
228
229#[cfg(test)]
230impl Drop for StandIns {
231 fn drop(&mut self) {
232 for daemon in &mut self.daemons {
233 let _ = daemon.kill();
234 let _ = daemon.wait();
235 }
236 let _ = std::fs::remove_dir_all(&self.dir);
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
250 fn a_setup_stuck_past_its_limit_refuses_the_next_ones() {
251 let began = Instant::now();
252 let old = Some((44, 1001));
253 let stuck = Setup { address: "unix:path=/run/a".into(), socket: old, began, limit: SETUP, number: 1 };
254 let under_way = [stuck];
255 assert!(!refused(&under_way, "unix:path=/run/a", old, began + SETUP - Duration::from_millis(1)), "within its limit: another may go");
256 assert!(refused(&under_way, "unix:path=/run/a", old, began + SETUP), "past it: refused");
257 assert!(!refused(&under_way, "unix:path=/run/b", None, began + SETUP * 3), "another bus goes ahead");
258 assert!(!refused(&under_way, "unix:path=/run/a", Some((44, 2002)), began + SETUP * 3), "a bus started again at the same path: tried");
259 assert!(!refused(&under_way, "unix:path=/run/a", None, began + SETUP * 3), "no socket there now: tried (and fails at once)");
260 assert!(!refused(&[], "unix:path=/run/a", old, began));
261 let abstract_bus = [Setup { address: "unix:abstract=sioul".into(), socket: None, began, limit: SETUP, number: 2 }];
263 assert!(refused(&abstract_bus, "unix:abstract=sioul", None, began + SETUP));
264 }
265
266 #[test]
269 fn addresses_are_written_as_d_bus_writes_them() {
270 assert_eq!(escaped(std::path::Path::new("/run/user/1000/bus")), "/run/user/1000/bus");
271 assert_eq!(escaped(std::path::Path::new("/tmp/a b,c;é")), "/tmp/a%20b%2cc%3b%c3%a9");
272 let odd = std::path::Path::new("/tmp/a b,c;é");
273 assert_eq!(unix_path(&format!("unix:path={}", escaped(odd))).as_deref(), Some(odd));
274 assert_eq!(unix_path("unix:path=/run/user/1000/bus,guid=0123abcd;unix:path=/other").as_deref(), Some(std::path::Path::new("/run/user/1000/bus")));
275 assert_eq!(unix_path("unix:abstract=/tmp/dbus-abc"), None);
276 assert_eq!(unix_path("tcp:host=localhost,port=4444"), None);
277 assert_eq!(unix_path("unix:path=/tmp/broken%2"), None, "a broken escape reads as no path");
278 }
279
280 #[test]
282 fn a_bus_that_is_not_there_is_refused_at_once() {
283 let start = Instant::now();
284 assert!(connect("unix:path=/nonexistent/sioul-no-bus").is_err());
285 assert!(start.elapsed() < Duration::from_secs(2), "{:?}", start.elapsed());
286 assert!(UNDER_WAY.lock().unwrap().iter().all(|setup| setup.address != "unix:path=/nonexistent/sioul-no-bus"), "nothing left under way");
287 }
288
289 fn named(text: &str, name: &str) -> bool {
293 let part = |c: char| c.is_alphanumeric() || c == '_';
294 text.match_indices(name).any(|(at, _)| !text[..at].chars().next_back().is_some_and(part) && !text[at + name.len()..].chars().next().is_some_and(part))
295 }
296
297 fn reached(text: &str, name: &str) -> bool {
300 let part = |c: char| c.is_alphanumeric() || c == '_';
301 text.match_indices(name).any(|(at, _)| {
302 let before = text[..at].trim_end();
303 !text[at + name.len()..].chars().next().is_some_and(part) && (before.ends_with('.') || before.ends_with("::"))
304 })
305 }
306
307 #[test]
324 fn nothing_but_this_file_reaches_a_bus() {
325 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
326 let (mut files, mut manifests) = (Vec::new(), Vec::new());
327 let mut folders = vec![root.join("crates")];
328 while let Some(folder) = folders.pop() {
329 for entry in std::fs::read_dir(&folder).unwrap().flatten() {
330 let path = entry.path();
331 if path.is_dir() {
332 folders.push(path);
333 } else if path.ends_with("Cargo.toml") {
334 manifests.push(path);
335 } else if !path.to_string_lossy().ends_with("_rc.py") {
336 files.push(path);
337 }
338 }
339 }
340 assert!(files.len() > 500 && manifests.len() >= 5, "{} files, {} manifests", files.len(), manifests.len());
341 let words = ["new_session", "new_system", "new_address", "get_private", "open_private", "dbus_bus_get", "dbus_bus_get_private", "dbus_bus_register", "dbus_connection_open", "dbus_connection_open_private", "ffidisp", "libdbus_sys", "dbus_secret_service", "secret_service", "zbus"];
342 let binary = ["ttf", "otf", "woff", "woff2", "png", "jpg", "jpeg", "gif", "ico", "icns", "qsb", "qm", "bin", "gz", "zst"];
346 for file in &files {
347 if file.ends_with("sioul-sync/src/bus.rs") || file.extension().and_then(|e| e.to_str()).is_some_and(|e| binary.contains(&e)) {
348 continue;
349 }
350 let bytes = std::fs::read(file).unwrap();
351 let text = String::from_utf8_lossy(&bytes);
352 for word in words {
353 assert!(!named(&text, word), "{}: {word} (connections through sioul_sync::bus alone)", file.display());
354 }
355 assert!(!reached(&text, "register"), "{}: a register call (libdbus's own registration)", file.display());
356 if !file.ends_with("sioul-sync/src/notify.rs") {
357 assert!(!named(&text, "notify_rust"), "{}: notify-rust (registers through libdbus)", file.display());
358 }
359 assert!(!text.contains("QDBus") && !text.contains("QtDBus"), "{}: Qt's D-Bus", file.display());
361 if file.ends_with("build.rs") {
362 assert!(!text.contains("\"DBus\""), "{}: Qt's D-Bus module", file.display());
363 }
364 }
365 manifests.push(root.join("Cargo.toml"));
367 for manifest in &manifests {
368 let table: toml::Table = std::fs::read_to_string(manifest).unwrap().parse().unwrap();
369 let own = manifest.ends_with("sioul-sync/Cargo.toml");
370 let workspace = *manifest == root.join("Cargo.toml");
371 let mut declared: Vec<(String, String)> = Vec::new();
372 let mut sections: Vec<(String, &toml::Table)> = Vec::new();
373 for kind in ["dependencies", "dev-dependencies", "build-dependencies"] {
374 if let Some(deps) = table.get(kind).and_then(toml::Value::as_table) {
375 sections.push((format!("[{kind}]"), deps));
376 }
377 if let Some(deps) = table.get("workspace").and_then(|w| w.get(kind)).and_then(toml::Value::as_table) {
378 sections.push((format!("[workspace.{kind}]"), deps));
379 }
380 }
381 if let Some(targets) = table.get("target").and_then(toml::Value::as_table) {
382 for (target, inner) in targets {
383 for kind in ["dependencies", "dev-dependencies", "build-dependencies"] {
384 if let Some(deps) = inner.get(kind).and_then(toml::Value::as_table) {
385 sections.push((format!("[target.'{target}'.{kind}]"), deps));
386 }
387 }
388 }
389 }
390 for (section, deps) in sections {
391 for (name, spec) in deps {
392 let package = spec.get("package").and_then(toml::Value::as_str).unwrap_or(name);
393 declared.push((section.clone(), package.to_string()));
394 }
395 }
396 for (section, package) in &declared {
397 match package.as_str() {
398 "notify-rust" => assert!((own && section == "[target.'cfg(any(windows, target_os = \"macos\"))'.dependencies]") || (workspace && section == "[workspace.dependencies]"), "{}: notify-rust under {section}", manifest.display()),
399 "dbus" => assert!(own && section.contains("not(any(target_os = \"macos\", target_os = \"android\"))"), "{}: dbus under {section}", manifest.display()),
400 "zbus" | "secret-service" | "dbus-secret-service" | "libdbus-sys" | "dbus-tokio" | "dbus-crossroads" | "zbus_names" | "ashpd" => panic!("{}: {package} under {section} (connections through sioul_sync::bus alone)", manifest.display()),
401 _ => {}
402 }
403 }
404 }
405 let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap();
407 let mut reaching: Vec<(String, String)> = Vec::new();
408 for package in lock.split("[[package]]").skip(1) {
409 let name = package.lines().find_map(|line| line.strip_prefix("name = \"")).and_then(|rest| rest.strip_suffix('"')).unwrap_or_default().to_string();
410 for line in package.lines() {
411 let dependency = line.trim().trim_end_matches(',').trim_matches('"');
412 let dependency = dependency.split(' ').next().unwrap_or_default();
413 if ["dbus", "libdbus-sys"].contains(&dependency) {
414 reaching.push((dependency.to_string(), name.clone()));
415 }
416 }
417 }
418 reaching.sort();
419 let expected: Vec<(String, String)> = [("dbus", "dbus-secret-service"), ("dbus", "notify-rust"), ("dbus", "sioul-sync"), ("libdbus-sys", "dbus")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect();
420 assert_eq!(reaching, expected, "the crates that reach libdbus (the keyring's, notify-rust's for Windows and macOS, and this one)");
421 let mut depends: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
425 let lock: toml::Table = lock.parse().unwrap();
426 for package in lock.get("package").and_then(toml::Value::as_array).unwrap() {
427 let name = package.get("name").and_then(toml::Value::as_str).unwrap_or_default().to_string();
428 let on: Vec<String> = package.get("dependencies").and_then(toml::Value::as_array).map(|all| all.iter().filter_map(toml::Value::as_str).map(|d| d.split(' ').next().unwrap_or_default().to_string()).collect()).unwrap_or_default();
429 depends.entry(name).or_default().extend(on);
430 }
431 let reach = |target: &str| -> Vec<String> {
432 let mut found: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
433 loop {
434 let more: Vec<String> = depends.iter().filter(|(name, on)| !found.contains(*name) && *name != target && on.iter().any(|d| d == target || found.contains(d))).map(|(name, _)| name.clone()).collect();
435 if more.is_empty() {
436 break;
437 }
438 found.extend(more);
439 }
440 found.into_iter().collect()
441 };
442 assert_eq!(reach("zbus"), ["android-keyring", "keyring", "secret-service", "sioul-app", "sioul-cli", "sioul-learn", "sioul-sync"], "who reaches zbus");
443 assert_eq!(reach("dbus"), ["android-keyring", "dbus-secret-service", "keyring", "notify-rust", "sioul-app", "sioul-cli", "sioul-learn", "sioul-sync"], "who reaches dbus");
444 }
445
446 #[test]
453 #[ignore = "starts a stand-in dbus-daemon: run alone, by name"]
454 fn a_bus_started_again_at_the_same_path_is_tried() {
455 let mut stand_ins = StandIns::new("stand-in-again");
456 let path = stand_ins.dir.join("bus");
457 let address = format!("unix:path={}", escaped(&path));
458 let listener = std::os::unix::net::UnixListener::bind(&path).unwrap();
460 let held: std::sync::Arc<Mutex<Vec<std::os::unix::net::UnixStream>>> = std::sync::Arc::default();
461 let keep = std::sync::Arc::clone(&held);
462 std::thread::spawn(move || {
463 for stream in listener.incoming().flatten() {
464 keep.lock().unwrap().push(stream);
465 }
466 });
467 let limit = Duration::from_secs(1);
468 assert!(connect_within(&address, limit).is_err(), "the old bus never answers");
469 assert!(connect_within(&address, limit).is_err(), "and, its setup stuck past its limit, refuses the next at once");
470 std::fs::remove_file(&path).unwrap();
472 let (_, again) = stand_ins.start("bus");
473 assert_eq!(again, address);
474 let start = Instant::now();
475 let made = connect_within(&address, limit);
476 assert!(made.is_ok(), "the bus started again is tried, and answers: {:?}", made.err());
477 assert!(start.elapsed() < limit, "{:?}", start.elapsed());
478 held.lock().unwrap().clear();
479 }
480
481 #[test]
494 #[ignore = "starts two stand-in dbus-daemons: run alone, by name"]
495 fn a_bus_stuck_at_setup_holds_nothing_else() {
496 use dbus::blocking::Connection;
497 let mut stand_ins = StandIns::new("stand-in-setup");
498 let (stopped_pid, stopped) = stand_ins.start("stopped");
499 let (_, other) = stand_ins.start("other");
500 StandIns::signal(stopped_pid, "-STOP");
501 let watched = |limit: Duration, work: Box<dyn FnOnce() + Send>| -> Option<Duration> {
502 let (sender, came) = std::sync::mpsc::channel();
503 let start = Instant::now();
504 std::thread::spawn(move || {
505 work();
506 let _ = sender.send(start.elapsed());
507 });
508 came.recv_timeout(limit).ok()
509 };
510 let limit = Duration::from_secs(2);
512 let target = stopped.clone();
513 let first = std::thread::spawn(move || {
514 let start = Instant::now();
515 let made = connect_within(&target, limit);
516 (made.is_ok(), start.elapsed())
517 });
518 std::thread::sleep(Duration::from_millis(500));
519 let elsewhere = other.clone();
520 let registered = watched(Duration::from_secs(3), Box::new(move || drop(Connection::new_address(&elsewhere).unwrap())));
521 let (made, took) = first.join().unwrap();
522 assert!(registered.is_some(), "libdbus's own connection to the other bus waited behind the stopped one");
523 assert!(!made && took < limit + Duration::from_secs(1), "refused within its limit: {made} {took:?}");
524 let start = Instant::now();
526 assert!(connect_within(&stopped, limit).is_err());
527 assert!(start.elapsed() < Duration::from_millis(200), "{:?}", start.elapsed());
528 StandIns::signal(stopped_pid, "-CONT");
530 let until = Instant::now() + Duration::from_secs(5);
531 while UNDER_WAY.lock().unwrap().iter().any(|setup| setup.address == stopped) && Instant::now() < until {
532 std::thread::sleep(Duration::from_millis(50));
533 }
534 let again = connect(&stopped).map(Connection::from).expect("made again");
535 let (names,): (Vec<String>,) = again.with_proxy("org.freedesktop.DBus", "/org/freedesktop/DBus", Duration::from_secs(2)).method_call("org.freedesktop.DBus", "ListNames", ()).unwrap();
536 assert!(names.iter().any(|name| name.starts_with(':')), "{names:?}");
537 }
538}