use std::{
fs,
io::{BufRead, BufReader},
os::unix::{ffi::OsStrExt, fs::PermissionsExt},
path::{Path, PathBuf},
process::{Child, Command, Stdio},
};
const SOCKET_PATH_MAX: usize = 107;
const DBUS_SOCKET_PATH_MAX: usize = 99;
const DBUS_DIR_SOCKET: &str = "dbus-XXXXXXXXXX";
pub(crate) struct PrivateBus {
child: Child,
address: String,
dir: FixtureDir,
}
struct FixtureDir(PathBuf);
impl Drop for FixtureDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
impl PrivateBus {
pub(crate) fn start(label: &str) -> Self {
let dir = FixtureDir(bus_fixture(
&format!("{label}-{:016x}", getrandom::u64().unwrap()),
DBUS_DIR_SOCKET,
));
fs::create_dir(&dir.0).expect("create private bus dir");
fs::set_permissions(&dir.0, fs::Permissions::from_mode(0o700)).unwrap();
let config = dir.0.join("bus.conf");
fs::write(
&config,
format!(
"<busconfig><type>session</type><listen>unix:dir={}</listen>\
<policy context=\"default\"><allow own=\"*\"/>\
<allow send_destination=\"*\"/><allow receive_sender=\"*\"/>\
</policy></busconfig>",
dir.0.display()
),
)
.unwrap();
let child = Command::new("dbus-daemon")
.arg(format!("--config-file={}", config.display()))
.args(["--nofork", "--nopidfile", "--print-address=1"])
.env_clear()
.stdout(Stdio::piped())
.spawn()
.expect("start private dbus-daemon");
let mut bus = Self {
child,
address: String::new(),
dir,
};
BufReader::new(bus.child.stdout.take().unwrap())
.read_line(&mut bus.address)
.expect("read private bus address");
bus.address = bus.address.trim().to_string();
assert!(!bus.address.is_empty(), "private bus printed no address");
bus
}
pub(crate) fn address(&self) -> &str {
&self.address
}
pub(crate) fn dir(&self) -> &Path {
&self.dir.0
}
}
impl Drop for PrivateBus {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn dbus_plain(path: &Path) -> bool {
path.as_os_str()
.as_bytes()
.iter()
.all(|byte| byte.is_ascii_alphanumeric() || b"-_/.\\*".contains(byte))
}
pub(crate) fn socket_fixture(name: &str, deepest: &str) -> PathBuf {
fixture(name, deepest, SOCKET_PATH_MAX, false)
}
pub(crate) fn bus_fixture(name: &str, socket: &str) -> PathBuf {
fixture(name, socket, DBUS_SOCKET_PATH_MAX, true)
}
fn fixture(name: &str, deepest: &str, limit: usize, plain: bool) -> PathBuf {
let runtime = std::env::var_os("XDG_RUNTIME_DIR")
.filter(|dir| !dir.is_empty())
.map(PathBuf::from);
place(
name,
deepest,
limit,
plain,
&std::env::temp_dir(),
runtime.as_deref(),
)
.unwrap_or_else(|message| panic!("{message}"))
}
fn place(
name: &str,
deepest: &str,
limit: usize,
plain: bool,
temp: &Path,
runtime: Option<&Path>,
) -> Result<PathBuf, String> {
let socket = |base: &Path| {
let path = base.join(name);
if deepest.is_empty() {
path
} else {
path.join(deepest)
}
};
let overflow = |base: &Path| {
let path = socket(base);
let length = path.as_os_str().len();
if length > limit {
Some(format!(
"{} is {length} bytes, over the {limit}-byte limit",
path.display()
))
} else if plain && !dbus_plain(&path) {
Some(format!(
"{} has a byte that a D-Bus address escapes, and zbus does not decode escapes",
path.display()
))
} else {
None
}
};
let Some(first) = overflow(temp) else {
return Ok(temp.join(name));
};
let fallback = match runtime {
Some(dir) if dir.is_absolute() && dir.is_dir() => match overflow(dir) {
None => return Ok(dir.join(name)),
Some(second) => format!("under XDG_RUNTIME_DIR, {second}"),
},
Some(dir) => format!(
"XDG_RUNTIME_DIR ({}) is not an absolute directory",
dir.display()
),
None => "XDG_RUNTIME_DIR is unset".to_string(),
};
Err(format!(
"No usable Unix socket path: {first}; {fallback}. \
Set TMPDIR or XDG_RUNTIME_DIR to a path that fits."
))
}
mod tests {
use super::*;
#[test]
fn a_long_temp_dir_moves_to_the_runtime_dir_or_names_the_limit() {
let root = Path::new("/");
let long = PathBuf::from(format!("/{}", "t".repeat(95)));
assert_eq!(
place("f", "bus", 107, false, &long, Some(root)),
Ok(long.join("f"))
);
assert_eq!(
place("f", "bus", 99, true, &long, Some(root)),
Ok(root.join("f"))
);
assert_eq!(
place("fixture", "a.sock", 107, false, &long, Some(root)),
Ok(root.join("fixture"))
);
let error = place("fixture", "a.sock", 107, false, &long, None).unwrap_err();
let path = long.join("fixture/a.sock");
assert!(
error.contains(&format!(
"{} is 111 bytes, over the 107-byte limit;",
path.display()
)),
"{error}"
);
assert!(error.contains("XDG_RUNTIME_DIR is unset"), "{error}");
let error = place("fixture", "a.sock", 10, false, &long, Some(root)).unwrap_err();
assert!(
error.contains("under XDG_RUNTIME_DIR, /fixture/a.sock is 15 bytes"),
"{error}"
);
let error = place("f.sock", "", 10, false, &long, Some(Path::new("run"))).unwrap_err();
assert!(error.contains("/f.sock is 103 bytes"), "{error}");
assert!(
error.contains("(run) is not an absolute directory"),
"{error}"
);
}
#[test]
fn a_private_bus_keeps_its_socket_in_its_dir_and_removes_both() {
use std::os::unix::fs::FileTypeExt;
let bus = PrivateBus::start("cul-bus-test");
let dir = bus.dir().to_path_buf();
let sockets: Vec<PathBuf> = fs::read_dir(&dir)
.unwrap()
.map(|entry| entry.unwrap())
.filter(|entry| entry.file_type().unwrap().is_socket())
.map(|entry| entry.path())
.collect();
let [socket] = sockets.as_slice() else {
panic!("expected one socket in {}: {sockets:?}", dir.display());
};
let name = socket.file_name().unwrap().to_str().unwrap();
assert!(
bus.address().contains(&format!("/{name},")),
"{} does not name {name}",
bus.address()
);
let socket = socket.clone();
drop(bus);
assert!(!socket.exists(), "{} outlived its bus", socket.display());
assert!(!dir.exists(), "{} outlived its bus", dir.display());
}
#[test]
fn a_bus_moves_out_of_a_temp_dir_its_address_would_escape() {
let root = Path::new("/");
let spaced = Path::new("/t mp");
assert_eq!(
place("f", "bus", 99, false, spaced, Some(root)),
Ok(spaced.join("f"))
);
assert_eq!(
place("f", "bus", 99, true, spaced, Some(root)),
Ok(root.join("f"))
);
assert_eq!(
place("f", "bus", 99, true, Path::new("/a-b_c.d/e*"), None),
Ok(Path::new("/a-b_c.d/e*/f").to_path_buf())
);
let error = place("f", "bus", 99, true, spaced, None).unwrap_err();
assert!(
error.contains("/t mp/f/bus has a byte that a D-Bus address escapes"),
"{error}"
);
assert!(error.contains("XDG_RUNTIME_DIR is unset"), "{error}");
}
#[test]
fn every_test_bus_listens_in_its_fixture() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let tmpdir_listen = concat!("unix:", "tmp", "dir=");
let session_config = concat!("session", ".conf");
let programs = [
concat!("dbus-", "daemon"),
concat!("dbus-", "run-session"),
concat!("dbus-", "launch"),
];
let mut files = Vec::new();
let mut dirs = vec![root.join("src")];
while let Some(dir) = dirs.pop() {
for entry in fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
dirs.push(path);
} else {
files.push(path);
}
}
}
for dir in ["tests", "scripts"] {
for entry in fs::read_dir(root.join(dir)).unwrap() {
files.push(entry.unwrap().path());
}
}
files.retain(|path| {
matches!(
path.extension().and_then(|extension| extension.to_str()),
Some("rs" | "py" | "sh")
)
});
let mut offenders = Vec::new();
for path in files {
let shell = path.extension().is_some_and(|extension| extension == "sh");
let text = fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = text.lines().collect();
for (index, line) in lines.iter().enumerate() {
let code = if shell {
line.split(" #").next().unwrap_or_default()
} else {
line
};
let starts_bus = programs.iter().any(|program| {
if shell {
!code.trim_start().starts_with('#')
&& code.split_whitespace().any(|word| word == *program)
} else {
code.contains(&format!("'{program}'"))
|| code.contains(&format!("\"{program}\""))
}
});
let call = lines[index..lines.len().min(index + 3)].join(" ");
let own_config = call.contains("--config-file") && !call.contains(session_config);
if line.contains(tmpdir_listen) || (starts_bus && !own_config) {
offenders.push(format!("{}:{}", path.display(), index + 1));
}
}
}
assert!(
offenders.is_empty(),
"private buses that listen outside their fixture: {offenders:?}"
);
}
}