use std::ffi::OsStr;
use std::fs::{self, DirBuilder, File, OpenOptions};
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::os::fd::{FromRawFd, OwnedFd};
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
use crate::cli::Xwayland;
pub const MAX_LOG_BYTES: u64 = 1_048_576;
pub const ERROR_LOG_BYTES: usize = 8_192;
pub struct RuntimeDirectory {
pub path: PathBuf,
}
pub const MAX_UNIX_SOCKET_PATH: usize = 107;
const SHORT_NAME_LENGTH: usize = 9;
impl RuntimeDirectory {
pub fn create() -> io::Result<Self> {
Self::create_in(&default_base(), "vvland-", 8)
}
pub fn create_short(reserve: usize) -> io::Result<Self> {
let mut bases = vec![default_base()];
if bases[0] != Path::new("/tmp") {
bases.push(PathBuf::from("/tmp"));
}
for base in &bases {
if base.as_os_str().len() + SHORT_NAME_LENGTH + reserve <= MAX_UNIX_SOCKET_PATH {
return Self::create_in(base, "vv", 3);
}
}
Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"no runtime directory is short enough to hold a {reserve}-byte socket path; \
tried {}",
bases
.iter()
.map(|base| base.display().to_string())
.collect::<Vec<_>>()
.join(", ")
),
))
}
fn create_in(base: &Path, prefix: &str, random_bytes: usize) -> io::Result<Self> {
for _ in 0..32 {
let mut random = [0_u8; 8];
getrandom::fill(&mut random[..random_bytes])
.map_err(|error| io::Error::other(error.to_string()))?;
let width = random_bytes * 2;
let path = base.join(format!(
"{prefix}{:0width$x}",
u64::from_be_bytes(random) >> (64 - random_bytes * 8)
));
match DirBuilder::new().mode(0o700).create(&path) {
Ok(()) => return Ok(Self { path }),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
}
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"could not allocate a private vvland runtime directory",
))
}
}
fn default_base() -> PathBuf {
std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.filter(|path| path.is_dir())
.unwrap_or_else(std::env::temp_dir)
}
impl Drop for RuntimeDirectory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
pub fn write_private_file(path: &Path, bytes: &[u8], mode: u32) -> io::Result<()> {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(mode)
.open(path)?;
file.write_all(bytes)?;
file.flush()
}
pub const MAX_EXTRA_CONFIG_BYTES: u64 = 65_536;
pub fn read_extra_config(path: &Path) -> io::Result<String> {
let describe = |error: io::Error| {
io::Error::new(
error.kind(),
format!("--extra-config {}: {error}", path.display()),
)
};
let metadata = fs::metadata(path).map_err(describe)?;
if !metadata.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("--extra-config {} is not a regular file", path.display()),
));
}
if metadata.len() > MAX_EXTRA_CONFIG_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"--extra-config {} is {} bytes; the limit is {MAX_EXTRA_CONFIG_BYTES}",
path.display(),
metadata.len()
),
));
}
let text = fs::read_to_string(path).map_err(describe)?;
if let Some(offending) = text
.chars()
.find(|character| character.is_control() && !matches!(character, '\n' | '\t'))
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"--extra-config {} contains {offending:?}, which a compositor configuration \
cannot carry",
path.display()
),
));
}
Ok(text)
}
pub fn push_extra_config(config: &mut String, extra: Option<&str>) {
let Some(extra) = extra else {
return;
};
if !config.ends_with('\n') {
config.push('\n');
}
config
.push_str("# --extra-config, appended verbatim; it overrides the generated lines above.\n");
config.push_str(extra);
if !extra.ends_with('\n') {
config.push('\n');
}
}
pub fn pipe() -> io::Result<(OwnedFd, OwnedFd)> {
let mut descriptors = [-1; 2];
if unsafe { libc::pipe2(descriptors.as_mut_ptr(), libc::O_CLOEXEC) } < 0 {
return Err(io::Error::last_os_error());
}
Ok(unsafe {
(
OwnedFd::from_raw_fd(descriptors[0]),
OwnedFd::from_raw_fd(descriptors[1]),
)
})
}
pub fn socketpair() -> io::Result<(OwnedFd, OwnedFd)> {
let mut descriptors = [-1; 2];
let result = unsafe {
libc::socketpair(
libc::AF_UNIX,
libc::SOCK_SEQPACKET | libc::SOCK_CLOEXEC,
0,
descriptors.as_mut_ptr(),
)
};
if result < 0 {
return Err(io::Error::last_os_error());
}
Ok(unsafe {
(
OwnedFd::from_raw_fd(descriptors[0]),
OwnedFd::from_raw_fd(descriptors[1]),
)
})
}
pub fn start_bounded_log(
thread_name: &str,
read_fd: OwnedFd,
path: PathBuf,
) -> io::Result<thread::JoinHandle<()>> {
thread::Builder::new()
.name(thread_name.to_owned())
.spawn(move || {
let mut input = File::from(read_fd);
let Ok(mut output) = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.mode(0o600)
.open(path)
else {
return;
};
let mut written = 0_u64;
let mut buffer = [0_u8; 8192];
while let Ok(count) = input.read(&mut buffer) {
if count == 0 {
break;
}
if written.saturating_add(count as u64) > MAX_LOG_BYTES {
if output.set_len(0).is_err() || output.seek(SeekFrom::Start(0)).is_err() {
break;
}
written = 0;
}
if output.write_all(&buffer[..count]).is_err() {
break;
}
written = written.saturating_add(count as u64);
}
})
}
pub fn startup_error(summary: String, compositor_name: &str, log_path: &Path) -> io::Error {
let Ok(log) = fs::read(log_path) else {
return io::Error::other(summary);
};
let start = log.len().saturating_sub(ERROR_LOG_BYTES);
let tail = String::from_utf8_lossy(&log[start..]);
let tail = tail.trim();
if tail.is_empty() {
io::Error::other(summary)
} else {
io::Error::other(format!("{summary}; {compositor_name} log:\n{tail}"))
}
}
pub fn terminate_group(group: i32, child: &mut Child) {
unsafe {
libc::kill(-group, libc::SIGTERM);
}
let deadline = Instant::now() + Duration::from_secs(2);
while Instant::now() < deadline {
let _ = child.try_wait();
if !process_group_exists(group) {
return;
}
thread::sleep(Duration::from_millis(20));
}
unsafe {
libc::kill(-group, libc::SIGKILL);
}
let _ = child.wait();
}
pub fn process_group_exists(group: i32) -> bool {
if unsafe { libc::kill(-group, 0) } == 0 {
return true;
}
io::Error::last_os_error().kind() == io::ErrorKind::PermissionDenied
}
pub fn command_in_path(program: &str) -> bool {
std::env::var_os("PATH").is_some_and(|paths| {
std::env::split_paths(&paths).any(|path| {
let candidate = path.join(program);
candidate.is_file()
&& candidate
.metadata()
.is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0)
})
})
}
pub fn xwayland_enabled(policy: Xwayland) -> bool {
match policy {
Xwayland::On => true,
Xwayland::Off => false,
Xwayland::Auto => command_in_path("Xwayland"),
}
}
pub fn sanitize_child_environment(command: &mut Command) {
for (name, _) in std::env::vars_os() {
if remove_child_environment(&name) {
command.env_remove(name);
}
}
}
pub fn remove_child_environment(name: &OsStr) -> bool {
let name = name.to_string_lossy();
name.starts_with("VIVID_")
|| name.starts_with("WLR_")
|| matches!(
name.as_ref(),
"WAYLAND_DISPLAY"
| "WAYLAND_SOCKET"
| "DISPLAY"
| "SWAYSOCK"
| "I3SOCK"
| "HYPRLAND_INSTANCE_SIGNATURE"
| "HYPRLAND_CMD"
| "PULSE_SERVER"
| "PULSE_SINK"
| "PULSE_SOURCE"
)
}
const LIVENESS_PROBE: Duration = Duration::from_millis(500);
pub fn child_logs_enabled() -> bool {
std::env::var_os("VVLAND_CHILD_LOGS").is_some_and(|value| !value.is_empty())
}
pub fn child_output() -> (Stdio, Stdio) {
if child_logs_enabled() {
(Stdio::inherit(), Stdio::inherit())
} else {
(Stdio::null(), Stdio::null())
}
}
pub fn confirm_started(name: &str, child: &mut Child) -> io::Result<()> {
thread::sleep(LIVENESS_PROBE);
match child.try_wait()? {
Some(status) => Err(io::Error::other(format!(
"{name} exited immediately with status {status}"
))),
None => Ok(()),
}
}
pub fn set_client_environment(
command: &mut Command,
runtime: &Path,
wayland_display: &str,
pulse_server: Option<&OsStr>,
pulse_sink: Option<&OsStr>,
) {
command
.env("XDG_RUNTIME_DIR", runtime)
.env("WAYLAND_DISPLAY", wayland_display);
set_pulse_environment(command, pulse_server, pulse_sink);
}
pub fn set_pulse_environment(
command: &mut Command,
pulse_server: Option<&OsStr>,
pulse_sink: Option<&OsStr>,
) {
if let Some(server) = pulse_server {
command.env("PULSE_SERVER", server);
}
if let Some(sink) = pulse_sink {
command.env("PULSE_SINK", sink);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extra_config_is_read_bounded_and_single_line_safe() {
let directory = RuntimeDirectory::create().expect("private runtime directory");
let path = directory.path.join("extra.conf");
fs::write(&path, "exec-once = nwg-dock-hyprland\n").expect("write extra config");
assert_eq!(
read_extra_config(&path).expect("readable extra config"),
"exec-once = nwg-dock-hyprland\n"
);
fs::write(&path, "exec-once = dock\u{1b}[2J\n").expect("write hostile extra config");
let error = read_extra_config(&path).expect_err("control characters are refused");
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
fs::write(&path, vec![b'#'; MAX_EXTRA_CONFIG_BYTES as usize + 1]).expect("write oversize");
let error = read_extra_config(&path).expect_err("oversize files are refused");
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
let error =
read_extra_config(&directory.path).expect_err("a directory is not a config file");
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
let error = read_extra_config(&directory.path.join("absent.conf"))
.expect_err("a missing file is reported, not ignored");
assert_eq!(error.kind(), io::ErrorKind::NotFound);
}
#[test]
fn extra_config_is_appended_last_and_newline_terminated() {
let mut config = "monitor = , disable\n".to_owned();
push_extra_config(&mut config, None);
assert_eq!(config, "monitor = , disable\n");
push_extra_config(&mut config, Some("exec-once = waybar"));
assert!(config.starts_with("monitor = , disable\n"), "{config}");
assert!(config.ends_with("exec-once = waybar\n"), "{config}");
assert!(config.contains("# --extra-config"), "{config}");
}
#[test]
fn child_environment_filter_excludes_credentials_and_host_display() {
for name in [
"VIVID_ENDPOINT_CONTROL",
"VIVID_ENDPOINT_INTERACTIVE",
"VIVID_ENDPOINT_REALTIME",
"VIVID_ENDPOINT_BULK",
"VIVID_ROOT_SECRET",
"VIVID_TOKEN",
"WLR_RENDERER",
"WAYLAND_DISPLAY",
"WAYLAND_SOCKET",
"DISPLAY",
"SWAYSOCK",
"I3SOCK",
"HYPRLAND_INSTANCE_SIGNATURE",
"HYPRLAND_CMD",
"PULSE_SERVER",
"PULSE_SINK",
"PULSE_SOURCE",
] {
assert!(remove_child_environment(OsStr::new(name)), "{name}");
}
assert!(!remove_child_environment(OsStr::new("PATH")));
assert!(!remove_child_environment(OsStr::new("HOME")));
assert!(!remove_child_environment(OsStr::new(
"PIPEWIRE_RUNTIME_DIR"
)));
}
#[test]
fn sanitized_child_environment_strips_every_vivid_secret() {
let _guard = crate::cli::tests::TEST_ENV_LOCK.lock().unwrap();
unsafe {
std::env::set_var("VIVID_ROOT_SECRET", "0123456789abcdef0123456789abcdef");
std::env::set_var("VIVID_ENDPOINT_CONTROL", "unix:/tmp/vivid.sock");
std::env::set_var("SWAYSOCK", "/run/user/1000/sway.sock");
}
let mut command = Command::new("true");
sanitize_child_environment(&mut command);
for (name, value) in command.get_envs() {
if value.is_none() {
continue;
}
let name = name.to_string_lossy();
assert!(
!name.starts_with("VIVID_") && !name.starts_with("SWAYSOCK"),
"child environment leaked {name}"
);
}
assert!(std::env::var_os("PATH").is_some());
}
#[test]
fn child_logs_are_off_unless_explicitly_enabled() {
let _guard = crate::cli::tests::TEST_ENV_LOCK.lock().unwrap();
unsafe { std::env::remove_var("VVLAND_CHILD_LOGS") };
assert!(!child_logs_enabled());
unsafe { std::env::set_var("VVLAND_CHILD_LOGS", "") };
assert!(!child_logs_enabled(), "an empty value stays off");
unsafe { std::env::set_var("VVLAND_CHILD_LOGS", "1") };
assert!(child_logs_enabled());
unsafe { std::env::remove_var("VVLAND_CHILD_LOGS") };
}
#[test]
fn an_application_that_exits_immediately_is_reported() {
let mut failing = Command::new("sh")
.args(["-c", "exit 3"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
let error = confirm_started("google-chrome", &mut failing).unwrap_err();
let message = error.to_string();
assert!(message.contains("google-chrome"), "{message}");
assert!(message.contains("exited immediately"), "{message}");
let mut living = Command::new("sleep")
.arg("30")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
assert!(confirm_started("google-chrome", &mut living).is_ok());
let _ = living.kill();
let _ = living.wait();
}
#[test]
fn runtime_directory_is_private_and_removed() {
let runtime = RuntimeDirectory::create().unwrap();
let path = runtime.path.clone();
assert_eq!(
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o700
);
drop(runtime);
assert!(!path.exists());
}
#[test]
fn bounded_log_never_exceeds_its_cap() {
let runtime = RuntimeDirectory::create().unwrap();
let log = runtime.path.join("bounded.log");
let (read, write) = pipe().unwrap();
let logger = start_bounded_log("vvland-test-log", read, log.clone()).unwrap();
let mut writer = File::from(write);
writer
.write_all(&vec![b'x'; MAX_LOG_BYTES as usize + 1])
.unwrap();
drop(writer);
logger.join().unwrap();
assert!(fs::metadata(log).unwrap().len() <= MAX_LOG_BYTES);
}
#[test]
fn termination_cleans_descendants_after_the_compositor_exits() {
use std::io::BufRead;
use std::os::unix::process::CommandExt;
use std::process::Stdio;
let mut command = Command::new("sh");
command
.args(["-c", "sleep 30 & printf '%s\n' \"$!\""])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
unsafe {
command.pre_exec(|| {
if libc::setpgid(0, 0) < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
});
}
let mut child = command.spawn().unwrap();
let group = i32::try_from(child.id()).unwrap();
let mut descendant = String::new();
std::io::BufReader::new(child.stdout.take().unwrap())
.read_line(&mut descendant)
.unwrap();
let descendant = descendant.trim().parse::<i32>().unwrap();
assert!(child.wait().unwrap().success());
assert!(process_group_exists(group));
terminate_group(group, &mut child);
assert!(!process_group_exists(group));
assert_eq!(unsafe { libc::kill(descendant, 0) }, -1);
}
}