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
279 lines
10 KiB
Rust
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);
|
|
}
|
|
}
|