Files
ghostty-rs/crates/ghostty-os/src/systemd.rs
T
ddidderr 05d225b800 fix-deviant(os): register and test the NOTIFY_SOCKET bounds fix
Intentional deviation from Ghostty 1.3.1 (DEVIANT_FIXES.md row 33).
Zig's os/systemd.zig:116 accepts a NOTIFY_SOCKET path of exactly 108
bytes and then writes the terminating NUL to path[108], one past the
end of sockaddr_un.sun_path (out-of-bounds write). The Rust port's
socket address construction (since f8c5ce0) accepts at most 108 bytes
without a NUL and rejects longer paths, so it never writes out of
bounds; this commit records that deviation and adds a regression test.

Test Plan:
- cargo test -p ghostty-os notify_maximum_length_socket_path_is_rejected_safely
2026-10-12 15:56:21 +02:00

279 lines
10 KiB
Rust

#[cfg(any(target_os = "linux", target_os = "android"))]
use std::io::Read;
/// Kernel `TASK_COMM_LEN`.
#[cfg(any(target_os = "linux", target_os = "android"))]
const TASK_COMM_LEN: u64 = 16;
/// Returns true if the program was launched as a systemd service.
///
/// On Linux, this returns true if the program was launched as a systemd
/// service. It will return false if Ghostty was launched any other way.
#[cfg(any(target_os = "linux", target_os = "android"))]
pub fn launched_by_systemd() -> bool {
// On Linux, systemd sets the `INVOCATION_ID` (v232+) and the
// `JOURNAL_STREAM` (v231+) environment variables. If these
// environment variables are not present we were not launched by
// systemd.
if std::env::var_os("INVOCATION_ID").is_none() {
return false;
}
if std::env::var_os("JOURNAL_STREAM").is_none() {
return false;
}
// If `INVOCATION_ID` and `JOURNAL_STREAM` are present, check to make sure
// that our parent process is actually `systemd`, not some other terminal
// emulator that doesn't clean up those environment variables.
let ppid = rustix::process::getppid().map_or(0, rustix::process::Pid::as_raw_pid);
if ppid == 1 {
return true;
}
// If the parent PID is not 1 we need to check to see if we were launched by
// a user systemd daemon. Do that by checking the `/proc/<ppid>/comm`
// to see if it ends with `systemd`.
let comm_path = format!("/proc/{ppid}/comm");
let Ok(comm_file) = std::fs::File::open(&comm_path) else {
log::error!("unable to open '{comm_path}' for reading");
return false;
};
// The maximum length of the command name is defined by
// `TASK_COMM_LEN` in the Linux kernel. This is usually 16
// bytes at the time of writing (Jun 2025) so its set to that.
// Also, since we only care to compare to "systemd", anything
// longer can be assumed to not be systemd.
let mut comm_data = Vec::new();
if comm_file
.take(TASK_COMM_LEN)
.read_to_end(&mut comm_data)
.is_err()
{
log::error!("problems reading from '{comm_path}'");
return false;
}
let end = comm_data
.iter()
.rposition(|&b| b != b'\n')
.map_or(0, |i| i + 1);
&comm_data[..end] == b"systemd"
}
/// For other platforms (macOS, ...), this returns false.
#[cfg(not(any(target_os = "linux", target_os = "android")))]
pub fn launched_by_systemd() -> bool {
false
}
/// systemd notifications. Used by Ghostty to inform systemd of the state of the
/// process. Currently only used to notify systemd that we are ready and that
/// configuration reloading has started.
///
/// See: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html
///
/// These functions are re-implemented (rather than using `libsystemd`) to
/// avoid another external dependency. Linux systems that do not use
/// `systemd` should never start Ghostty with the `NOTIFY_SOCKET` environment
/// variable set and these functions essentially become a no-op.
#[cfg(any(target_os = "linux", target_os = "android"))]
pub mod notify {
use rustix::net::{
AddressFamily,
SendFlags,
SocketAddrUnix,
SocketFlags,
SocketType,
connect,
send,
socket_with,
};
/// Send the given message to the UNIX socket specified in the NOTIFY_SOCKET
/// environment variable. If there NOTIFY_SOCKET environment variable does
/// not exist then no message is sent.
pub(super) fn send_message(message: &[u8]) {
// Get the socket address that should receive notifications.
let Some(socket_path) = std::env::var_os("NOTIFY_SOCKET") else {
return;
};
let socket_path = socket_path.as_encoded_bytes();
// If the socket address is an empty string return.
if socket_path.is_empty() {
return;
}
// The socket address must be a path or an abstract socket.
if socket_path[0] != b'/' && socket_path[0] != b'@' {
log::warn!(
"only AF_UNIX sockets with path or abstract namespace addresses are supported!"
);
return;
}
// sockaddr_un.path is 108 bytes.
if 108 < socket_path.len() {
log::warn!("NOTIFY_SOCKET path is too long!");
return;
}
// NOTE: the Zig version copies the path verbatim (including a
// leading '@'); the sd_notify protocol says a leading '@' denotes
// an abstract socket, which is what we do here.
let addr = if socket_path[0] == b'@' {
SocketAddrUnix::new_abstract_name(&socket_path[1..])
} else {
SocketAddrUnix::new(socket_path)
};
let addr = match addr {
Ok(addr) => addr,
Err(e) => {
log::warn!("invalid NOTIFY_SOCKET address: {e}");
return;
}
};
let sock = match socket_with(
AddressFamily::UNIX,
SocketType::DGRAM,
SocketFlags::CLOEXEC,
None,
) {
Ok(s) => s,
Err(e) => {
log::warn!("creating socket failed: {e}");
return;
}
};
if let Err(e) = connect(&sock, &addr) {
log::warn!("unable to connect to notify socket: {e}");
return;
}
match send(&sock, message, SendFlags::empty()) {
Ok(written) if written < message.len() => {
log::warn!(
"short write to notify socket: {written} < {}",
message.len()
);
}
Ok(_) => {}
Err(e) => log::warn!("unable to write to notify socket: {e}"),
}
}
/// Tell systemd that we are ready or that we are finished reloading.
/// See: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html#READY=1
pub fn ready() {
send_message(b"READY=1");
}
/// Tell systemd that we have started reloading our configuration.
/// See: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html#RELOADING=1
/// and: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html#MONOTONIC_USEC=%E2%80%A6
pub fn reloading() {
let ts = rustix::time::clock_gettime(rustix::time::ClockId::Monotonic);
let now = ts.tv_sec * 1_000_000 + ts.tv_nsec / 1000;
let message = format!("RELOADING=1\nMONOTONIC_USEC={now}");
send_message(message.as_bytes());
}
}
/// systemd notifications are a no-op on platforms without systemd.
#[cfg(not(any(target_os = "linux", target_os = "android")))]
pub mod notify {
pub fn ready() {}
pub fn reloading() {}
}
#[cfg(test)]
mod tests {
#[cfg(any(target_os = "linux", target_os = "android"))]
use std::os::unix::net::UnixDatagram;
use super::*;
#[test]
fn not_launched_without_env() {
temp_env::with_vars(
[("INVOCATION_ID", None::<&str>), ("JOURNAL_STREAM", None)],
|| assert!(!launched_by_systemd()),
);
temp_env::with_vars(
[("INVOCATION_ID", Some("x")), ("JOURNAL_STREAM", None)],
|| assert!(!launched_by_systemd()),
);
}
#[cfg(any(target_os = "linux", target_os = "android"))]
fn recv_with_socket(f: impl FnOnce()) -> String {
let dir = crate::TempDir::init().unwrap();
let path = dir.path().join("notify.sock");
let sock = UnixDatagram::bind(&path).unwrap();
sock.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.unwrap();
temp_env::with_var("NOTIFY_SOCKET", Some(path.to_str().unwrap()), f);
let mut buf = [0u8; 128];
let n = sock.recv(&mut buf).unwrap();
String::from_utf8(buf[..n].to_vec()).unwrap()
}
#[cfg(any(target_os = "linux", target_os = "android"))]
#[test]
fn notify_ready_sends_message() {
assert_eq!(recv_with_socket(notify::ready), "READY=1");
}
#[cfg(any(target_os = "linux", target_os = "android"))]
#[test]
fn notify_reloading_sends_message() {
let msg = recv_with_socket(notify::reloading);
let rest = msg.strip_prefix("RELOADING=1\nMONOTONIC_USEC=").unwrap();
assert!(rest.parse::<i64>().unwrap() > 0);
}
#[test]
fn notify_without_socket_is_noop() {
temp_env::with_var("NOTIFY_SOCKET", None::<&str>, notify::ready);
temp_env::with_var("NOTIFY_SOCKET", Some(""), notify::ready);
temp_env::with_var("NOTIFY_SOCKET", Some("relative"), notify::ready);
}
/// Upstream bug (`os/systemd.zig:116`): a 108-byte `NOTIFY_SOCKET` path
/// passes Zig's length check (`108 < len`) and then writes the
/// terminating NUL at `path[108]`, one past the 108-byte array. Rust's
/// socket-address constructor builds the address from the 108 bytes
/// without a terminator (valid for Linux `AF_UNIX`; the length is part of
/// the address), so there is no out-of-bounds write; longer paths are
/// rejected.
#[test]
fn notify_maximum_length_socket_path_is_rejected_safely() {
let mut max = vec![b'/'; 108];
max[1..].fill(b'a');
// 108 bytes fit exactly (no terminator needed); 109 do not.
assert!(rustix::net::SocketAddrUnix::new(&max[..107]).is_ok());
assert!(rustix::net::SocketAddrUnix::new(&max[..108]).is_ok());
let mut over = max.clone();
over.push(b'a');
assert!(rustix::net::SocketAddrUnix::new(&over[..]).is_err());
for len in [107usize, 108, 109, 4096] {
let mut p = vec![b'a'; len];
p[0] = b'/';
let p = String::from_utf8(p).unwrap();
// No panic and no out-of-bounds write (nothing listens on these
// paths, so the send just fails or is skipped).
temp_env::with_var("NOTIFY_SOCKET", Some(p.as_str()), notify::ready);
}
// '@' + 107 name bytes = 108 bytes.
let mut abs = vec![b'a'; 108];
abs[0] = b'@';
let abs = String::from_utf8(abs).unwrap();
temp_env::with_var("NOTIFY_SOCKET", Some(abs.as_str()), notify::ready);
}
}