feat(os): port Ghostty's Linux OS helpers

Ports ZIG/src/os for Linux into ghostty-os: XDG dirs, home dir, hostname,
env/PATH helpers, passwd lookup (via nix), shell detection, resources
dir discovery, string encodings (printf-q, URL percent), systemd
sd_notify, i18n via gettextrs, xdg-open, desktop/cgroup/kernel info,
temp dirs, URI parsing (port of std.Uri incl. Ghostty's extra options),
and a zbus-based Flatpak HostCommand implementation.

The crate stays free of unsafe. Consequences, all tracked as
TODO(integration) in docs/rewrite/notes/ghostty-os.md:
- locale::ensure_locale is a no-op (setlocale/setenv are unsafe; GTK
  calls setlocale itself); setenv/unsetenv are not exposed.
- passwd's Flatpak branch needs the termio pty and falls back to the
  sandbox entry for now.
- FlatpakHostCommand's D-Bus path is untested (no portal here).
Intentional fix: a leading '@' in NOTIFY_SOCKET is treated as an
abstract socket.

macOS/Windows/WASM modules are out of scope.

Test Plan:
- cargo test -p ghostty-os (88 tests)
- cargo clippy -p ghostty-os --all-targets -- -D warnings
This commit is contained in:
ddidderr committed 2026-10-11 21:54:11 +02:00
1 parent 1388f117d3
commit f8c5ce06d6
30 files changed
+3736 -1

No files matched your search

+9
View File
@@ -5,7 +5,16 @@ edition.workspace = true
license.workspace = true license.workspace = true
[dependencies] [dependencies]
gettext-rs = { version = "0.8.0", features = ["gettext-system"] }
log = "0.4.34"
nix = { version = "0.31.3", features = ["user"] }
rustix = { version = "1.1.5", features = ["fs", "process", "system", "pipe", "net", "time", "rand"] }
thiserror = "2.0.21"
zbus = "5.19.0"
[lints] [lints]
workspace = true workspace = true
[dev-dependencies]
temp-env = "0.3.6"
+21
View File
@@ -0,0 +1,21 @@
//! Command line argument iteration.
//!
//! The macOS NSProcessInfo-based iterator is skipped; on Linux this is the
//! standard process argument iterator (as `os_str`s so that non-UTF-8 args
//! don't panic).
/// Returns an iterator over the command line arguments.
pub fn iterator() -> std::env::ArgsOs {
std::env::args_os()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn args() {
let mut iter = iterator();
assert!(!iter.next().unwrap().is_empty());
}
}
+40
View File
@@ -0,0 +1,40 @@
use std::io::Read;
/// Maximum number of bytes read from the cgroup file (the Zig version reads
/// into a caller-provided buffer; callers there use path-sized buffers).
const READ_LIMIT: u64 = 4096;
/// Returns the path to the cgroup for the given pid.
pub fn current(pid: u32) -> Option<String> {
// Read our cgroup by opening /proc/<pid>/cgroup and reading the first
// line. The first line will look something like this:
// 0::/user.slice/user-1000.slice/session-1.scope
// The cgroup path is the third field.
let file = std::fs::File::open(format!("/proc/{pid}/cgroup")).ok()?;
let mut contents = Vec::new();
file.take(READ_LIMIT).read_to_end(&mut contents).ok()?;
// Find the last ':'
let idx = contents.iter().rposition(|&b| b == b':')?;
let rest = &contents[idx + 1..];
let end = rest
.iter()
.rposition(|b| !matches!(b, b' ' | b'\r' | b'\n'))
.map_or(0, |i| i + 1);
Some(String::from_utf8_lossy(&rest[..end]).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn current_cgroup_of_self() {
// Not part of the Zig tests; sanity check that parsing works when
// /proc is available.
if let Some(p) = current(std::process::id()) {
assert!(!p.ends_with('\n'));
}
assert!(current(u32::MAX).is_none());
}
}
+36
View File
@@ -0,0 +1,36 @@
/// Returns true if the program was launched by D-Bus activation.
///
/// On Linux GTK, this returns true if the program was launched using D-Bus
/// activation. It will return false if Ghostty was launched any other way.
pub fn launched_by_dbus_activation() -> bool {
// On Linux, D-Bus activation sets `DBUS_STARTER_ADDRESS` and
// `DBUS_STARTER_BUS_TYPE`. If these environment variables are present
// (no matter the value) we were launched by D-Bus activation.
std::env::var_os("DBUS_STARTER_ADDRESS").is_some()
&& std::env::var_os("DBUS_STARTER_BUS_TYPE").is_some()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::with_env_lock;
#[test]
fn dbus_activation_env() {
temp_env::with_vars(
[
("DBUS_STARTER_ADDRESS", Some("x")),
("DBUS_STARTER_BUS_TYPE", Some("session")),
],
|| assert!(launched_by_dbus_activation()),
);
temp_env::with_vars(
[
("DBUS_STARTER_ADDRESS", Some("x")),
("DBUS_STARTER_BUS_TYPE", None),
],
|| assert!(!launched_by_dbus_activation()),
);
with_env_lock(|| {});
}
}
+112
View File
@@ -0,0 +1,112 @@
/// Returns true if the program was launched from a desktop environment.
///
/// On Linux GTK, this returns true if the program was launched using the
/// desktop file. This also includes when `gtk-launch` is used because I
/// can't find a way to distinguish the two scenarios.
pub fn launched_from_desktop() -> bool {
// On Linux and BSD, GTK sets GIO_LAUNCHED_DESKTOP_FILE and
// GIO_LAUNCHED_DESKTOP_FILE_PID. We only check the latter to see if
// we match the PID and assume that if we do, we were launched from
// the desktop file. Pid comparing catches the scenario where
// another terminal was launched from a desktop file and then launches
// Ghostty and Ghostty inherits the env.
let Some(gio_pid_str) = std::env::var_os("GIO_LAUNCHED_DESKTOP_FILE_PID") else {
return false;
};
let pid = rustix::process::getpid().as_raw_nonzero().get();
let Ok(gio_pid) = gio_pid_str.to_string_lossy().parse::<i32>() else {
return false;
};
gio_pid == pid
}
/// The list of desktop environments that we detect. New Linux desktop
/// environments should only be added to this list if there's a specific reason
/// to differentiate between `gnome` and `other`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DesktopEnvironment {
Gnome,
Macos,
Other,
Windows,
}
/// Detect what desktop environment we are running under. This is mainly used
/// on Linux and BSD to enable or disable certain features but there may be more uses in
/// the future.
pub fn desktop_environment() -> DesktopEnvironment {
// Use $XDG_SESSION_DESKTOP to determine what DE we are using on Linux
// https://www.freedesktop.org/software/systemd/man/latest/pam_systemd.html#desktop=
if let Some(sd) = std::env::var_os("XDG_SESSION_DESKTOP") {
let sd = sd.to_string_lossy();
if sd.eq_ignore_ascii_case("gnome") {
return DesktopEnvironment::Gnome;
}
if sd.eq_ignore_ascii_case("gnome-xorg") {
return DesktopEnvironment::Gnome;
}
}
// If $XDG_SESSION_DESKTOP is not set, or doesn't match any known
// DE, check $XDG_CURRENT_DESKTOP. $XDG_CURRENT_DESKTOP is a
// colon-separated list of up to three desktop names, although we
// only look at the first.
// https://specifications.freedesktop.org/desktop-entry-spec/latest/recognized-keys.html
if let Some(cd) = std::env::var_os("XDG_CURRENT_DESKTOP") {
let cd = cd.to_string_lossy();
let first = cd.split(':').next().unwrap_or("");
if first.eq_ignore_ascii_case("gnome") {
return DesktopEnvironment::Gnome;
}
}
DesktopEnvironment::Other
}
#[cfg(test)]
mod tests {
use super::*;
fn de(session: Option<&str>, current: Option<&str>) -> DesktopEnvironment {
temp_env::with_vars(
[
("XDG_SESSION_DESKTOP", session),
("XDG_CURRENT_DESKTOP", current),
],
desktop_environment,
)
}
#[test]
fn desktop_environment_detection() {
use DesktopEnvironment::{Gnome, Other};
assert_eq!(de(Some("gnome"), None), Gnome);
assert_eq!(de(Some("gnome-xorg"), None), Gnome);
assert_eq!(de(Some("foobar"), None), Other);
assert_eq!(de(None, None), Other);
assert_eq!(de(None, Some("GNOME")), Gnome);
assert_eq!(de(None, Some("FOOBAR")), Other);
assert_eq!(de(None, Some("GNOME:Unity")), Gnome);
assert_eq!(de(None, Some("Unity:GNOME")), Other);
assert_eq!(de(Some("foobar"), Some("GNOME")), Gnome);
}
#[test]
fn launched_from_desktop_pid_match() {
let pid = std::process::id().to_string();
temp_env::with_var("GIO_LAUNCHED_DESKTOP_FILE_PID", Some(pid), || {
assert!(launched_from_desktop());
});
temp_env::with_var("GIO_LAUNCHED_DESKTOP_FILE_PID", Some("1"), || {
assert!(!launched_from_desktop());
});
temp_env::with_var("GIO_LAUNCHED_DESKTOP_FILE_PID", Some("abc"), || {
assert!(!launched_from_desktop());
});
temp_env::with_var("GIO_LAUNCHED_DESKTOP_FILE_PID", None::<&str>, || {
assert!(!launched_from_desktop());
});
}
}
+106
View File
@@ -0,0 +1,106 @@
use std::collections::HashMap;
use crate::flatpak::is_flatpak;
/// PATH-style delimiter (`std.fs.path.delimiter` on POSIX).
const DELIMITER: char = ':';
/// Get the environment map. Inside Flatpak this is empty (the sandbox
/// environment is not meaningful for host commands).
pub fn get_env_map() -> HashMap<String, String> {
if is_flatpak() {
return HashMap::new();
}
std::env::vars_os()
.map(|(k, v)| {
(
k.to_string_lossy().into_owned(),
v.to_string_lossy().into_owned(),
)
})
.collect()
}
/// Append a value to an environment variable such as PATH.
pub fn append_env(current: &str, value: &str) -> String {
// If there is no prior value, we return it as-is
if current.is_empty() {
return value.to_owned();
}
// Otherwise we must prefix.
append_env_always(current, value)
}
/// Always append value to environment, even when it is empty.
/// This is useful because some env vars (like MANPATH) want there
/// to be an empty prefix to preserve existing values.
pub fn append_env_always(current: &str, value: &str) -> String {
format!("{current}{DELIMITER}{value}")
}
/// Prepend a value to an environment variable such as PATH.
pub fn prepend_env(current: &str, value: &str) -> String {
// If there is no prior value, we return it as-is
if current.is_empty() {
return value.to_owned();
}
format!("{value}{DELIMITER}{current}")
}
/// Gets the value of an environment variable, or None if not found
/// (or not valid unicode).
pub fn getenv(key: &str) -> Option<String> {
std::env::var(key).ok()
}
/// Gets the value of an environment variable. Returns None if not found or
/// the value is empty.
pub fn getenv_not_empty(key: &str) -> Option<String> {
getenv(key).filter(|v| !v.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn append_env_empty() {
assert_eq!(append_env("", "foo"), "foo");
}
#[test]
fn append_env_existing() {
assert_eq!(append_env("a:b", "foo"), "a:b:foo");
}
#[test]
fn prepend_env_empty() {
assert_eq!(prepend_env("", "foo"), "foo");
}
#[test]
fn prepend_env_existing() {
assert_eq!(prepend_env("a:b", "foo"), "foo:a:b");
}
#[test]
fn append_env_always_empty_value() {
assert_eq!(append_env_always("a", ""), "a:");
assert_eq!(append_env_always("", "x"), ":x");
}
#[test]
fn getenv_not_empty_filters() {
temp_env::with_vars(
[("GHOSTTY_RS_T1", Some("")), ("GHOSTTY_RS_T2", Some("v"))],
|| {
assert_eq!(getenv("GHOSTTY_RS_T1").as_deref(), Some(""));
assert_eq!(getenv_not_empty("GHOSTTY_RS_T1"), None);
assert_eq!(getenv_not_empty("GHOSTTY_RS_T2").as_deref(), Some("v"));
assert_eq!(getenv_not_empty("GHOSTTY_RS_NOPE"), None);
},
);
}
}
+101
View File
@@ -0,0 +1,101 @@
use rustix::process::{Resource, Rlimit as RustixRlimit, getrlimit, setrlimit};
/// Resource limit (`rlimit` in Zig). `None` means infinity.
pub type Rlimit = RustixRlimit;
/// rlim_t value for infinity in the Zig implementation.
const INFINITY: u64 = u64::MAX;
fn cur(l: &Rlimit) -> u64 {
l.current.unwrap_or(INFINITY)
}
fn max(l: &Rlimit) -> u64 {
l.maximum.unwrap_or(INFINITY)
}
fn mk(cur: u64, max: Option<u64>) -> Rlimit {
Rlimit {
current: if cur == INFINITY { None } else { Some(cur) },
maximum: max,
}
}
/// This maximizes the number of file descriptors we can have open. We
/// need to do this because each window consumes at least a handful of fds.
/// This is extracted from the Zig compiler source code.
pub fn fix_max_files() -> Option<Rlimit> {
let old = getrlimit(Resource::Nofile);
// If we're already at the max, we're done.
if cur(&old) >= max(&old) {
log::debug!("file handle limit already maximized value={}", cur(&old));
return Some(old);
}
// Do a binary search for the limit.
let mut lim = old;
let mut min_v: u64 = cur(&lim);
let mut max_v: u64 = 1 << 20;
// But if there's a defined upper bound, don't search, just set it.
if max(&lim) != INFINITY {
min_v = max(&lim);
max_v = max(&lim);
}
loop {
let c = min_v + (max_v - min_v) / 2;
lim = mk(c, lim.maximum);
if setrlimit(Resource::Nofile, lim).is_ok() {
min_v = c;
} else {
max_v = c;
}
if min_v + 1 >= max_v {
break;
}
}
log::debug!("file handle limit raised value={}", cur(&lim));
Some(old)
}
pub fn restore_max_files(lim: Rlimit) {
let _ = setrlimit(Resource::Nofile, lim);
}
/// Return the recommended path for temporary files.
pub fn alloc_tmp_dir() -> Option<String> {
if let Some(v) = std::env::var_os("TMPDIR") {
return Some(v.to_string_lossy().into_owned());
}
if let Some(v) = std::env::var_os("TMP") {
return Some(v.to_string_lossy().into_owned());
}
Some("/tmp".to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tmp_dir_env_precedence() {
temp_env::with_vars([("TMPDIR", Some("/a")), ("TMP", Some("/b"))], || {
assert_eq!(alloc_tmp_dir().as_deref(), Some("/a"));
});
temp_env::with_vars([("TMPDIR", None), ("TMP", Some("/b"))], || {
assert_eq!(alloc_tmp_dir().as_deref(), Some("/b"));
});
temp_env::with_vars([("TMPDIR", None::<&str>), ("TMP", None)], || {
assert_eq!(alloc_tmp_dir().as_deref(), Some("/tmp"));
});
}
#[test]
fn fix_and_restore_max_files() {
let old = fix_max_files().unwrap();
let now = getrlimit(Resource::Nofile);
assert!(cur(&now) >= cur(&old));
restore_max_files(old);
assert_eq!(getrlimit(Resource::Nofile), old);
}
}
+194
View File
@@ -0,0 +1,194 @@
//! Port of `src/file_type.zig`.
struct TypeDetails {
typ: FileType,
sigs: &'static [&'static [Option<u8>]],
exts: &'static [&'static str],
}
#[allow(clippy::unnecessary_wraps)]
const fn b(v: u8) -> Option<u8> {
Some(v)
}
const TYPE_DETAILS: &[TypeDetails] = &[
TypeDetails {
typ: FileType::Jpeg,
sigs: &[
&[b(0xFF), b(0xD8), b(0xFF), b(0xDB)],
&[
b(0xFF),
b(0xD8),
b(0xFF),
b(0xE0),
b(0x00),
b(0x10),
b(0x4A),
b(0x46),
b(0x49),
b(0x46),
b(0x00),
b(0x01),
],
&[b(0xFF), b(0xD8), b(0xFF), b(0xEE)],
&[
b(0xFF),
b(0xD8),
b(0xFF),
b(0xE1),
None,
None,
b(0x45),
b(0x78),
b(0x69),
b(0x66),
b(0x00),
b(0x00),
],
&[b(0xFF), b(0xD8), b(0xFF), b(0xE0)],
],
exts: &[".jpg", ".jpeg", ".jfif"],
},
TypeDetails {
typ: FileType::Png,
sigs: &[&[
b(0x89),
b(0x50),
b(0x4E),
b(0x47),
b(0x0D),
b(0x0A),
b(0x1A),
b(0x0A),
]],
exts: &[".png"],
},
TypeDetails {
typ: FileType::Gif,
sigs: &[
&[b(b'G'), b(b'I'), b(b'F'), b(b'8'), b(b'7'), b(b'a')],
&[b(b'G'), b(b'I'), b(b'F'), b(b'8'), b(b'9'), b(b'a')],
],
exts: &[".gif"],
},
TypeDetails {
typ: FileType::Bmp,
sigs: &[&[b(b'B'), b(b'M')]],
exts: &[".bmp"],
},
TypeDetails {
typ: FileType::Qoi,
sigs: &[&[b(b'q'), b(b'o'), b(b'i'), b(b'f')]],
exts: &[".qoi"],
},
TypeDetails {
typ: FileType::Webp,
sigs: &[&[
b(0x52),
b(0x49),
b(0x46),
b(0x46),
None,
None,
None,
None,
b(0x57),
b(0x45),
b(0x42),
b(0x50),
]],
exts: &[".webp"],
},
];
/// This is a helper for detecting file types based on magic bytes.
///
/// Ref: https://en.wikipedia.org/wiki/List_of_file_signatures
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileType {
/// JPEG image file.
Jpeg,
/// PNG image file.
Png,
/// GIF image file.
Gif,
/// BMP image file.
Bmp,
/// QOI image file.
Qoi,
/// WebP image file.
Webp,
/// Unknown file format.
Unknown,
}
impl FileType {
/// Detect file type based on the magic bytes
/// at the start of the provided file contents.
pub fn detect(contents: &[u8]) -> FileType {
for typ in TYPE_DETAILS {
for signature in typ.sigs {
if contents.len() >= signature.len()
&& contents
.iter()
.zip(signature.iter())
.all(|(f, sig)| sig.is_none_or(|s| *f == s))
{
return typ.typ;
}
}
}
FileType::Unknown
}
/// Guess file type from its extension.
pub fn guess_from_extension(extension: &str) -> FileType {
for typ in TYPE_DETAILS {
for ext in typ.exts {
if extension.eq_ignore_ascii_case(ext) {
return typ.typ;
}
}
}
FileType::Unknown
}
}
#[cfg(test)]
mod tests {
use super::*;
// The Zig file has no tests; these cover the detection logic.
#[test]
fn detect_magic_bytes() {
assert_eq!(
FileType::detect(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1]),
FileType::Png
);
assert_eq!(FileType::detect(b"GIF89a...."), FileType::Gif);
assert_eq!(FileType::detect(b"BM"), FileType::Bmp);
assert_eq!(FileType::detect(b"qoif"), FileType::Qoi);
assert_eq!(
FileType::detect(b"RIFF\x01\x02\x03\x04WEBP"),
FileType::Webp
);
assert_eq!(
FileType::detect(b"RIFF\x01\x02\x03\x04WEBX"),
FileType::Unknown
);
assert_eq!(
FileType::detect(&[0xFF, 0xD8, 0xFF, 0xE1, 9, 9, 0x45, 0x78, 0x69, 0x66, 0, 0]),
FileType::Jpeg
);
assert_eq!(FileType::detect(&[0xFF, 0xD8, 0xFF, 0xE0]), FileType::Jpeg);
assert_eq!(FileType::detect(b""), FileType::Unknown);
}
#[test]
fn guess_from_extension_case_insensitive() {
assert_eq!(FileType::guess_from_extension(".JPG"), FileType::Jpeg);
assert_eq!(FileType::guess_from_extension(".jfif"), FileType::Jpeg);
assert_eq!(FileType::guess_from_extension(".webp"), FileType::Webp);
assert_eq!(FileType::guess_from_extension(".txt"), FileType::Unknown);
}
}
+400
View File
@@ -0,0 +1,400 @@
use std::collections::HashMap;
use std::os::fd::{AsFd, OwnedFd};
use std::sync::{Arc, Condvar, Mutex};
use zbus::blocking::{Connection, MessageIterator};
use zbus::zvariant::Fd;
use zbus::{MatchRule, message::Type as MessageType};
const BUS_NAME: &str = "org.freedesktop.Flatpak";
const OBJECT_PATH: &str = "/org/freedesktop/Flatpak/Development";
const INTERFACE: &str = "org.freedesktop.Flatpak.Development";
/// Returns true if we're running in a Flatpak environment.
pub fn is_flatpak() -> bool {
std::path::Path::new("/.flatpak-info").exists()
}
/// Errors that are possible from us.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum Error {
#[error("flatpak host command must be started")]
FlatpakMustBeStarted,
#[error("flatpak spawn failed")]
FlatpakSpawnFail,
#[error("flatpak setup failed")]
FlatpakSetupFail,
#[error("flatpak rpc failed")]
FlatpakRPCFail,
}
/// State the process is in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
/// Initial state
Init,
/// Error starting. The error message is only available via logs.
Err,
/// Process started with the given pid on the host.
Started { pid: u32 },
/// Process exited
Exited { pid: u32, status: u8 },
}
/// State shared with the dedicated thread, protected by the lock and
/// condition variable.
struct Shared {
state: Mutex<State>,
cv: Condvar,
}
impl Shared {
fn update(&self, state: State) {
let mut guard = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*guard = state;
self.cv.notify_all();
}
}
/// A struct to help execute commands on the host via the
/// org.freedesktop.Flatpak.Development DBus module. This uses `zbus`
/// (blocking API) rather than GIO/GLib.
///
/// This always spawns its own thread to wait for the exit signal. This makes
/// it easy for the command to behave synchronously similar to
/// `std::process::Child`.
///
/// There are lots of chances for low-hanging improvements here (automatic
/// pipes, /dev/null, etc.) but this was purpose built for a specific need so
/// it doesn't have all of those.
///
/// NOTE: this can't be exercised in the test environment (needs a Flatpak
/// session bus with the Development interface); it is a best-effort port.
pub struct FlatpakHostCommand {
/// Argv are the arguments to call on the host with argv[0] being
/// the command to execute.
pub argv: Vec<String>,
/// The cwd for the new process. If this is not set then it will use
/// the current cwd of the calling process.
pub cwd: Option<String>,
/// Environment variables for the child process. If this is None, this
/// does not send any environment variables.
pub env: Option<HashMap<String, String>>,
/// File descriptors to send to the child process. It is up to the
/// caller to create the file descriptors and set them up. They are
/// duplicated when spawning so the caller keeps ownership.
pub stdin: OwnedFd,
pub stdout: OwnedFd,
pub stderr: OwnedFd,
/// Connection kept for sending signals.
shared: Arc<Shared>,
}
impl FlatpakHostCommand {
pub fn new(argv: Vec<String>, stdin: OwnedFd, stdout: OwnedFd, stderr: OwnedFd) -> Self {
Self {
argv,
cwd: None,
env: None,
stdin,
stdout,
stderr,
shared: Arc::new(Shared {
state: Mutex::new(State::Init),
cv: Condvar::new(),
}),
}
}
fn state(&self) -> State {
*self
.shared
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Spawn the command. This will start the host command. On return,
/// the pid will be available. This must only be called with the
/// state in "init".
pub fn spawn(&mut self) -> Result<u32, Error> {
let request = Request {
argv: self.argv.clone(),
cwd: self.cwd.clone(),
env: self.env.clone().unwrap_or_default(),
fds: [
self.stdin
.try_clone()
.map_err(|_| Error::FlatpakSetupFail)?,
self.stdout
.try_clone()
.map_err(|_| Error::FlatpakSetupFail)?,
self.stderr
.try_clone()
.map_err(|_| Error::FlatpakSetupFail)?,
],
};
let shared = Arc::clone(&self.shared);
// We don't track this thread, it will terminate on its own on
// command exit.
std::thread::Builder::new()
.name("flatpak-host-command".into())
.spawn(move || thread_main(&shared, request))
.map_err(|_| Error::FlatpakSpawnFail)?;
// Wait for the process to start or error.
let mut guard = self
.shared
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
while *guard == State::Init {
guard = self
.shared
.cv
.wait(guard)
.unwrap_or_else(std::sync::PoisonError::into_inner);
}
match *guard {
State::Init => unreachable!(),
State::Err => Err(Error::FlatpakSpawnFail),
State::Started { pid } | State::Exited { pid, .. } => Ok(pid),
}
}
/// Wait for the process to end and return the exit status. This
/// can only be called ONCE. Once this returns, the state is reset.
pub fn wait(&mut self) -> Result<u8, Error> {
let mut guard = self
.shared
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
loop {
match *guard {
State::Init => return Err(Error::FlatpakMustBeStarted),
State::Err => return Err(Error::FlatpakSpawnFail),
State::Started { .. } => {}
State::Exited { status, .. } => {
*guard = State::Init;
self.shared.cv.notify_all();
return Ok(status);
}
}
guard = self
.shared
.cv
.wait(guard)
.unwrap_or_else(std::sync::PoisonError::into_inner);
}
}
/// Wait for the process to end asynchronously. The callback is invoked
/// from a helper thread with the result. This can only be called ONCE.
///
/// This replaces `waitXev` (libxev completion) of the Zig version.
pub fn wait_callback(
mut self,
callback: impl FnOnce(Result<u8, Error>) + Send + 'static,
) -> std::io::Result<()> {
std::thread::Builder::new()
.name("flatpak-host-wait".into())
.spawn(move || callback(self.wait()))?;
Ok(())
}
/// Send a signal to the started command. This does nothing if the
/// command is not in the started state.
pub fn signal(&self, sig: u8, pg: bool) -> Result<(), Error> {
let State::Started { pid } = self.state() else {
return Ok(());
};
// Get our bus connection.
let bus = Connection::session().map_err(|e| {
log::warn!("signal error getting bus: {e}");
Error::FlatpakSetupFail
})?;
if let Err(e) = bus.call_method(
Some(BUS_NAME),
OBJECT_PATH,
Some(INTERFACE),
"HostCommandSignal",
&(pid, u32::from(sig), pg),
) {
log::warn!("signal send error: {e}");
}
Ok(())
}
}
struct Request {
argv: Vec<String>,
cwd: Option<String>,
env: HashMap<String, String>,
fds: [OwnedFd; 3],
}
fn thread_main(shared: &Shared, request: Request) {
// Get our bus connection. This has to remain active until we exit
// the thread otherwise our signals won't be called.
let bus = match Connection::session() {
Ok(b) => b,
Err(e) => {
log::warn!("spawn error getting bus: {e}");
shared.update(State::Err);
return;
}
};
// Spawn the command first. This will setup all our IO.
match start(shared, &bus, request) {
Ok(iter_and_pid) => {
let (iter, pid) = iter_and_pid;
wait_for_exit(shared, iter, pid);
}
Err(err) => {
log::warn!("error starting host command: {err}");
shared.update(State::Err);
}
}
}
/// Start the command. This will start the host command and set the
/// pid on success. This will not wait for completion.
fn start(
shared: &Shared,
bus: &Connection,
request: Request,
) -> Result<(MessageIterator, u32), Error> {
// Build our file descriptor map: 0 -> stdin, 1 -> stdout, 2 -> stderr.
let mut fds: HashMap<u32, Fd<'_>> = HashMap::new();
for (i, fd) in request.fds.iter().enumerate() {
fds.insert(i as u32, Fd::from(fd.as_fd()));
}
// Build our args (arrays of NUL-terminated byte strings).
let args: Vec<Vec<u8>> = request
.argv
.iter()
.map(|a| {
let mut v = a.as_bytes().to_vec();
v.push(0);
v
})
.collect();
// Get the cwd in case we don't have ours set.
let cwd = match request.cwd {
Some(c) => c,
None => std::env::current_dir()
.map_err(|_| Error::FlatpakSetupFail)?
.to_string_lossy()
.into_owned(),
};
let mut cwd = cwd.into_bytes();
cwd.push(0);
// Subscribe to exit notifications (before the call so we can't miss it).
let rule = MatchRule::builder()
.msg_type(MessageType::Signal)
.sender(BUS_NAME)
.and_then(|b| b.interface(INTERFACE))
.and_then(|b| b.member("HostCommandExited"))
.and_then(|b| b.path(OBJECT_PATH))
.map(zbus::match_rule::Builder::build)
.map_err(|e| {
log::warn!("error building match rule: {e}");
Error::FlatpakSetupFail
})?;
let iter = MessageIterator::for_match_rule(rule, bus, None).map_err(|e| {
log::warn!("error subscribing to HostCommandExited: {e}");
Error::FlatpakSetupFail
})?;
// Go!
let reply = bus
.call_method(
Some(BUS_NAME),
OBJECT_PATH,
Some(INTERFACE),
"HostCommand",
&(cwd, args, fds, request.env, 0u32),
)
.map_err(|e| {
log::warn!("Flatpak.HostCommand failed: {e}");
Error::FlatpakRPCFail
})?;
let pid: u32 = reply.body().deserialize().map_err(|e| {
log::warn!("Flatpak.HostCommand bad reply: {e}");
Error::FlatpakRPCFail
})?;
log::debug!("HostCommand started pid={pid}");
shared.update(State::Started { pid });
Ok((iter, pid))
}
/// Block on the exit signal for our pid and publish the exit status.
fn wait_for_exit(shared: &Shared, iter: MessageIterator, pid: u32) {
for msg in iter {
let Ok(msg) = msg else { continue };
let Ok((exit_pid, exit_status_raw)) = msg.body().deserialize::<(u32, u32)>() else {
continue;
};
if exit_pid != pid {
continue;
}
// posix.W.EXITSTATUS
let status = ((exit_status_raw >> 8) & 0xff) as u8;
shared.update(State::Exited { pid, status });
log::debug!("HostCommand exited pid={pid} status={status}");
return;
}
// The connection closed without an exit signal.
log::warn!("host command signal stream ended without exit pid={pid}");
shared.update(State::Err);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_flatpak_matches_flatpak_info() {
assert_eq!(
is_flatpak(),
std::path::Path::new("/.flatpak-info").exists()
);
}
#[test]
fn wait_before_spawn_errors() {
let (r, w) = crate::pipe::pipe().unwrap();
let w2 = w.try_clone().unwrap();
let mut cmd = FlatpakHostCommand::new(vec!["true".into()], r, w, w2);
assert_eq!(cmd.wait(), Err(Error::FlatpakMustBeStarted));
// Signalling an unstarted command is a no-op.
assert_eq!(cmd.signal(15, false), Ok(()));
}
#[test]
fn exit_status_extraction() {
// Mirrors posix.W.EXITSTATUS on a raw wait status.
assert_eq!(((3u32 << 8) >> 8) & 0xff, 3);
}
}
+152
View File
@@ -0,0 +1,152 @@
use std::process::{Command, Stdio};
use crate::passwd;
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum Error {
/// The buffer used for output is not large enough to store the value.
#[error("buffer too small")]
BufferTooSmall,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ExpandError {
#[error("home detection failed")]
HomeDetectionFailed,
#[error("buffer too small")]
BufferTooSmall,
}
/// Equivalent of `std.fs.max_path_bytes` on Linux.
const MAX_PATH_BYTES: usize = 4096;
/// Determine the home directory for the currently executing user. This
/// is generally an expensive process so the value should be cached.
pub fn home() -> Result<Option<String>, Error> {
home_with_capacity(MAX_PATH_BYTES)
}
/// Like [`home`] but emulates the fixed output buffer of the Zig version:
/// errors with `BufferTooSmall` if the result is longer than `cap` bytes.
pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
let check = |s: String| {
if cap < s.len() {
Err(Error::BufferTooSmall)
} else {
Ok(Some(s))
}
};
// First: if we have a HOME env var, then we use that.
if let Some(result) = std::env::var_os("HOME") {
return check(result.to_string_lossy().into_owned());
}
// We try passwd. This doesn't work on multi-user mac but we try it anyways.
if let Ok(pw) = passwd::get()
&& let Some(result) = pw.home
{
return check(result);
}
// If all else fails, have the shell tell us...
let run = Command::new("/bin/sh")
.args(["-c", "cd && pwd"])
.stdin(Stdio::null())
.stderr(Stdio::null())
.output();
if let Ok(run) = run
&& run.status.success()
{
let out = String::from_utf8_lossy(&run.stdout);
return check(trim_space(&out).to_owned());
}
Ok(None)
}
fn trim_space(input: &str) -> &str {
input.trim_matches([' ', '\n', '\t'])
}
/// Expands a path that starts with a tilde (~) to the home directory of
/// the current user.
///
/// Errors if `home` fails or if the size of the expanded path is larger
/// than the maximum path length.
pub fn expand_home(path: &str) -> Result<String, ExpandError> {
expand_home_with_capacity(path, MAX_PATH_BYTES)
}
/// Like [`expand_home`] with an explicit output capacity (emulating the
/// caller-provided buffer of the Zig version).
pub fn expand_home_with_capacity(path: &str, cap: usize) -> Result<String, ExpandError> {
if !path.starts_with("~/") {
return Ok(path.to_owned());
}
let Ok(Some(home_dir)) = home_with_capacity(cap) else {
return Err(ExpandError::HomeDetectionFailed);
};
let rest = &path[1..]; // Skip the ~
let expanded_len = home_dir.len() + rest.len();
if expanded_len > cap {
return Err(ExpandError::BufferTooSmall);
}
Ok(format!("{home_dir}{rest}"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::with_env_lock;
#[test]
fn expand_home_unix() {
with_env_lock(|| {
let cap = MAX_PATH_BYTES;
let home_dir = expand_home_with_capacity("~/", cap).unwrap();
// Joining the home directory `~` with the path `/`
// the result should end with a separator here. (e.g. `/home/user/`)
assert!(home_dir.ends_with('/'));
let downloads = expand_home_with_capacity("~/Downloads/shader.glsl", cap).unwrap();
let expected_downloads = format!("{home_dir}Downloads/shader.glsl");
assert_eq!(expected_downloads, downloads);
assert_eq!("~", expand_home_with_capacity("~", cap).unwrap());
assert_eq!("~abc/", expand_home_with_capacity("~abc/", cap).unwrap());
assert_eq!(
"/home/user",
expand_home_with_capacity("/home/user", cap).unwrap()
);
assert_eq!("", expand_home_with_capacity("", cap).unwrap());
// Expect an error if the buffer is large enough to hold the home directory,
// but not the expanded path
assert_eq!(
Err(ExpandError::BufferTooSmall),
expand_home_with_capacity("~/Downloads", home_dir.len())
);
});
}
#[test]
fn home_is_detected() {
with_env_lock(|| {
let result = home().unwrap();
assert!(result.is_some());
assert!(!result.unwrap().is_empty());
});
}
#[test]
fn home_prefers_env() {
temp_env::with_var("HOME", Some("/some/home"), || {
assert_eq!(home().unwrap().as_deref(), Some("/some/home"));
assert_eq!(expand_home("~/x").unwrap(), "/some/home/x");
assert_eq!(home_with_capacity(3), Err(Error::BufferTooSmall));
});
}
}
+158
View File
@@ -0,0 +1,158 @@
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum LocalHostnameValidationError {
#[error("permission denied")]
PermissionDenied,
#[error("unexpected error")]
Unexpected,
}
/// Validates a hostname according to [RFC 1123](https://www.rfc-editor.org/rfc/rfc1123)
///
/// A permissive validator would consider strings like ".example.com",
/// "exa..mple.com", and "-example.com" to be valid hostnames, which
/// is incorrect.
pub fn is_valid(hostname: &str) -> bool {
let hostname = hostname.as_bytes();
if hostname.is_empty() {
return false;
}
if hostname[0] == b'.' {
return false;
}
// Ignore trailing dot (FQDN). It doesn't count toward our length.
let end = if hostname[hostname.len() - 1] == b'.' {
if hostname.len() == 1 {
return false;
}
hostname.len() - 1
} else {
hostname.len()
};
if end > 253 {
return false;
}
// Hostnames are divided into dot-separated "labels", which:
//
// - Start with a letter or digit
// - Can contain letters, digits, or hyphens
// - Must end with a letter or digit
// - Have a minimum of 1 character and a maximum of 63
let mut label_start = 0usize;
let mut label_len = 0usize;
for (i, &c) in hostname[..end].iter().enumerate() {
match c {
b'.' => {
if label_len == 0 || label_len > 63 {
return false;
}
if !hostname[label_start].is_ascii_alphanumeric() {
return false;
}
if !hostname[i - 1].is_ascii_alphanumeric() {
return false;
}
label_start = i + 1;
label_len = 0;
}
b'-' => {
label_len += 1;
}
_ => {
if !c.is_ascii_alphanumeric() {
return false;
}
label_len += 1;
}
}
}
// Validate the final label
if label_len == 0 || label_len > 63 {
return false;
}
if !hostname[label_start].is_ascii_alphanumeric() {
return false;
}
if !hostname[end - 1].is_ascii_alphanumeric() {
return false;
}
true
}
/// The current machine's hostname (`gethostname`).
fn our_hostname() -> String {
rustix::system::uname()
.nodename()
.to_string_lossy()
.into_owned()
}
/// Checks if a hostname is local to the current machine. This matches
/// both "localhost" and the current hostname of the machine (as returned
/// by `gethostname`).
pub fn is_local(hostname: &str) -> Result<bool, LocalHostnameValidationError> {
// A 'localhost' hostname is always considered local.
if hostname == "localhost" {
return Ok(true);
}
// If hostname is not "localhost" it must match our hostname.
Ok(hostname == our_hostname())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_valid_hostnames() {
// Valid hostnames
assert!(is_valid("example"));
assert!(is_valid("example.com"));
assert!(is_valid("www.example.com"));
assert!(is_valid("sub.domain.example.com"));
assert!(is_valid("example.com."));
assert!(is_valid("host-name.example.com."));
assert!(is_valid("123.example.com."));
assert!(is_valid("a-b.com"));
assert!(is_valid("a.b.c.d.e.f.g"));
assert!(is_valid("127.0.0.1")); // Also a valid hostname
assert!(is_valid(&format!("{}.com", "a".repeat(63)))); // Label exactly 63 chars (valid)
assert!(is_valid(&format!("{}a", "a.".repeat(126)))); // Total length 253 (valid)
// Invalid hostnames
assert!(!is_valid(""));
assert!(!is_valid(".example.com"));
assert!(!is_valid("example.com.."));
assert!(!is_valid("host..domain"));
assert!(!is_valid("-hostname"));
assert!(!is_valid("hostname-"));
assert!(!is_valid("a.-.b"));
assert!(!is_valid("host_name.com"));
assert!(!is_valid("."));
assert!(!is_valid(".."));
assert!(!is_valid(&format!("{}.com", "a".repeat(64)))); // Label length 64 (too long)
assert!(!is_valid(&format!("{}ab", "a.".repeat(126)))); // Total length 254 (too long)
}
#[test]
fn is_local_returns_true_when_provided_hostname_is_localhost() {
assert!(is_local("localhost").unwrap());
}
#[test]
fn is_local_returns_true_when_hostname_is_local() {
let local = our_hostname();
assert!(is_local(&local).unwrap());
}
#[test]
fn is_local_returns_false_when_hostname_is_not_local() {
assert!(!is_local("not-the-local-hostname").unwrap());
}
}
+194
View File
@@ -0,0 +1,194 @@
use std::path::Path;
use crate::i18n_locales::LOCALES;
/// Application bundle ID, used as the gettext domain
/// (`build_config.bundle_id`).
// TODO(integration): take from a shared build-config crate.
pub const BUNDLE_ID: &str = "com.mitchellh.ghostty";
/// Membership test for supported locales (Zig: `locales_map`).
pub fn is_supported_locale(locale: &str) -> bool {
LOCALES.contains(&locale)
}
#[derive(Debug, thiserror::Error)]
pub enum InitError {
#[error("invalid resources dir")]
InvalidResourcesDir,
#[error("out of memory")]
OutOfMemory,
}
/// Initialize i18n support for the application. This should be
/// called automatically by the global state initialization.
///
/// This calls `bindtextdomain` for gettext with the proper directory
/// of translations. This does NOT call `textdomain` as we don't
/// want to set the domain for the entire application since this is also
/// used by libghostty.
pub fn init(resources_dir: &str) -> Result<(), InitError> {
// Our resources dir is always nested below the share dir that
// is standard for translations.
// (`std.fs.path.dirname` returns null for a bare relative name, where
// `Path::parent` returns an empty path.)
let share_dir = Path::new(resources_dir)
.parent()
.filter(|p| !p.as_os_str().is_empty())
.ok_or(InitError::InvalidResourcesDir)?;
// Build our locale path
let path = format!("{}/locale", share_dir.display());
// Bind our bundle ID to the given locale path
log::debug!("binding domain={BUNDLE_ID} path={path}");
gettextrs::bindtextdomain(BUNDLE_ID, path).map_err(|_| InitError::OutOfMemory)?;
Ok(())
}
/// Set the global gettext domain to our bundle ID, allowing unqualified
/// `gettext` calls to look up translations for our application.
///
/// This should only be called for apprts that are fully owning the
/// Ghostty application. This should not be called for libghostty users.
pub fn init_global_domain() -> Result<(), InitError> {
gettextrs::textdomain(BUNDLE_ID).map_err(|_| InitError::OutOfMemory)?;
Ok(())
}
/// Translate a message for the Ghostty domain (Zig: `_`).
pub fn tr(msgid: &str) -> String {
gettextrs::dgettext(BUNDLE_ID, msgid)
}
/// Canonicalize a locale name from a platform-specific value to
/// a POSIX-compliant value.
///
/// The Zig version is a thin layer over gnulib's
/// `_libintl_locale_name_canonicalize` (only linked when building libintl
/// from source, i.e. macOS). That function isn't available here, so this is
/// a pure-Rust approximation of the cases that matter: BCP-47 style tags
/// have `-` replaced with `_` and the Chinese script tags are mapped to
/// their POSIX region equivalents. Encodings are NOT parsed out (like the
/// original, `en_US.UTF-8` becomes `en_US.UTF_8`).
pub fn canonicalize_locale(locale: &str) -> String {
// Fix zh locales
if let Some(fixed) = fix_zh_locale(locale) {
return fixed.to_owned();
}
// Emulate gnulib's mapping of bare zh script tags.
match locale {
"zh-Hans" => return "zh_CN".to_owned(),
"zh-Hant" => return "zh_TW".to_owned(),
_ => {}
}
locale.replace('-', "_")
}
/// Handles some zh locales canonicalization because internal libintl
/// canonicalization function doesn't handle correctly in these cases.
fn fix_zh_locale(locale: &str) -> Option<&'static str> {
let mut it = locale.split('-');
let name = it.next()?;
if name != "zh" {
return None;
}
let script = it.next()?;
let region = it.next()?;
if script == "Hans" {
if region == "SG" {
return Some("zh_SG");
}
return Some("zh_CN");
}
if script == "Hant" {
if region == "MO" {
return Some("zh_MO");
}
if region == "HK" {
return Some("zh_HK");
}
return Some("zh_TW");
}
None
}
/// This can be called at any point a compile-time-known locale is
/// available. In a `const` context this verifies the locale is supported
/// (panics at compile time otherwise).
pub const fn static_locale(v: &'static str) -> &'static str {
let vb = v.as_bytes();
let mut i = 0;
while i < LOCALES.len() {
let lb = LOCALES[i].as_bytes();
if lb.len() == vb.len() {
let mut j = 0;
let mut eq = true;
while j < lb.len() {
if lb[j] != vb[j] {
eq = false;
break;
}
j += 1;
}
if eq {
return LOCALES[i];
}
}
i += 1;
}
panic!("unsupported locale");
}
#[cfg(test)]
mod tests {
use super::*;
// The Zig test is darwin-only; the canonicalization here is portable.
#[test]
fn canonicalize_locale_darwin() {
assert_eq!("en_US", canonicalize_locale("en_US"));
assert_eq!("zh_CN", canonicalize_locale("zh-Hans"));
assert_eq!("zh_TW", canonicalize_locale("zh-Hant"));
assert_eq!("zh_CN", canonicalize_locale("zh-Hans-CN"));
assert_eq!("zh_SG", canonicalize_locale("zh-Hans-SG"));
assert_eq!("zh_TW", canonicalize_locale("zh-Hant-TW"));
assert_eq!("zh_HK", canonicalize_locale("zh-Hant-HK"));
assert_eq!("zh_MO", canonicalize_locale("zh-Hant-MO"));
// This is just an edge case I want to make sure we're aware of:
// canonicalizeLocale does not handle encodings and will turn them into
// underscores. We should parse them out before calling this function.
assert_eq!("en_US.UTF_8", canonicalize_locale("en_US.UTF-8"));
}
#[test]
fn locales_map_and_static_locale() {
assert!(is_supported_locale("zh_CN"));
assert!(is_supported_locale("kk"));
assert!(!is_supported_locale("xx"));
const L: &str = static_locale("de");
assert_eq!(L, "de");
}
#[test]
fn init_rejects_dirless_resources_dir() {
assert!(matches!(
init("ghostty"),
Err(InitError::InvalidResourcesDir)
));
assert!(matches!(init("/"), Err(InitError::InvalidResourcesDir)));
}
#[test]
fn tr_falls_back_to_msgid() {
assert_eq!(tr("some untranslated message"), "some untranslated message");
}
}
+34
View File
@@ -0,0 +1,34 @@
// NOTE: This is in a separate file because our build depends on it and
// we want to minimize the transitive dependencies of the build binary
// itself.
/// Supported locales for the application. This must be kept up to date
/// with the translations available in the `po/` directory; this is used
/// by our build process as well runtime libghostty APIs.
///
/// The order also matters. For incomplete locale information (i.e. only
/// a language code available), the first match is used. For example, if
/// we know the user requested `zh` but has no script code, then we'd pick
/// the first locale that matches `zh`.
///
/// For ordering, we prefer:
///
/// 1. The most common locales first, since there are places in the code
/// where we do linear searches for a locale and we want to minimize
/// the number of iterations for the common case.
///
/// 2. Alphabetical for otherwise equally common locales.
///
/// 3. Most preferred locale for a language without a country code.
///
/// Note for "most common" locales, this is subjective and based on
/// the perceived userbase of Ghostty, which may not be representative
/// of general populations or global language distribution. Also note
/// that ordering may be weird when we first merge a new locale since
/// we don't have a good way to determine this. We can always reorder
/// with some data.
pub const LOCALES: [&str; 29] = [
"zh_CN", "de", "fr", "ja", "nl", "nb", "ru", "uk", "pl", "ko_KR", "mk", "tr", "id", "es_BO",
"es_AR", "es_ES", "pt_BR", "ca", "it", "bg", "ga", "hu", "he", "zh_TW", "hr", "lt", "lv", "vi",
"kk",
];
+29
View File
@@ -0,0 +1,29 @@
use std::io::Read;
/// Returns the kernel release string (`/proc/sys/kernel/osrelease`).
pub fn get_kernel_info() -> Option<String> {
let file = std::fs::File::open("/proc/sys/kernel/osrelease").ok()?;
// 128 bytes should be enough to hold the kernel information. Zig's
// readToEndAlloc fails if the file is larger than the limit.
let mut buf = Vec::new();
file.take(129).read_to_end(&mut buf).ok()?;
if buf.len() > 128 {
return None;
}
let s = String::from_utf8_lossy(&buf);
Some(s.trim_matches(|c: char| c.is_ascii_whitespace()).to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_proc_sys_kernel_osrelease() {
let kernel_info = get_kernel_info().unwrap();
// Since we can't hardcode the info in tests, just check
// if something was read from the file
assert!(!kernel_info.is_empty());
}
}
+101 -1
View File
@@ -1 +1,101 @@
//! ghostty-os: see docs/rewrite/ARCHITECTURE.md for the module map. //! The "os" package contains utilities for interfacing with the operating
//! system. These aren't restricted to syscalls or low-level operations, but
//! also OS-specific features and conventions.
//!
//! Port of `src/os/*.zig` (Linux only) plus `src/file_type.zig`. See
//! `docs/rewrite/notes/ghostty-os.md` for deviations and skipped files.
#![cfg_attr(
test,
allow(
clippy::unwrap_used,
clippy::assert_is_empty,
clippy::items_after_statements
)
)]
pub mod args;
pub mod cgroup;
pub mod dbus;
pub mod desktop;
pub mod env;
pub mod file;
pub mod file_type;
pub mod flatpak;
pub mod homedir;
pub mod hostname;
pub mod i18n;
pub mod i18n_locales;
pub mod kernel_info;
pub mod locale;
pub mod mouse;
pub mod open;
pub mod passwd;
pub mod path;
pub mod pipe;
pub mod resourcesdir;
pub mod shell;
pub mod string_encoding;
pub mod systemd;
pub mod temp_dir;
pub mod uri;
pub mod xdg;
#[cfg(test)]
pub(crate) mod testutil;
// Functions and types (mirrors the flat exports of `os/main.zig`).
pub use dbus::launched_by_dbus_activation;
pub use desktop::{DesktopEnvironment, desktop_environment, launched_from_desktop};
pub use env::{append_env, append_env_always, get_env_map, getenv, getenv_not_empty, prepend_env};
pub use file::{Rlimit, alloc_tmp_dir, fix_max_files, restore_max_files};
pub use flatpak::{FlatpakHostCommand, is_flatpak};
pub use homedir::{expand_home, home};
pub use kernel_info::get_kernel_info;
pub use locale::ensure_locale;
pub use mouse::click_interval;
pub use open::open;
pub use pipe::pipe;
pub use resourcesdir::{ResourcesDir, resources_dir};
pub use shell::ShellEscapeWriter;
pub use systemd::launched_by_systemd;
pub use temp_dir::TempDir;
/// Join path components like `std.fs.path.join` (POSIX): empty components
/// are skipped and exactly one separator is placed between components.
pub(crate) fn path_join(parts: &[&str]) -> String {
let mut out = String::new();
for p in parts {
if p.is_empty() {
continue;
}
if out.is_empty() {
out.push_str(p);
continue;
}
let prev_sep = out.ends_with('/');
let this_sep = p.starts_with('/');
match (prev_sep, this_sep) {
(true, true) => out.push_str(&p[1..]),
(false, false) => {
out.push('/');
out.push_str(p);
}
_ => out.push_str(p),
}
}
out
}
#[cfg(test)]
mod tests {
use super::path_join;
#[test]
fn path_join_basics() {
assert_eq!(path_join(&["/a", ".cache", ""]), "/a/.cache");
assert_eq!(path_join(&["/a/", "/b"]), "/a/b");
assert_eq!(path_join(&["", "b"]), "b");
assert_eq!(path_join(&["/a", "b", "c"]), "/a/b/c");
}
}
+44
View File
@@ -0,0 +1,44 @@
//! Locale setup (`os/locale.zig`).
//!
//! DEFERRED: the Zig `ensureLocale` is built from `setlocale(LC_ALL, "")`
//! plus `setenv`/`unsetenv` of `LANG`. All of these are `unsafe` in Rust
//! (`libc::setlocale`, `gettextrs::setlocale` and Rust 2024's
//! `std::env::set_var`) and this crate forbids `unsafe`. There is no safe
//! crate that wraps `setlocale`.
//!
//! Resolution options for integration:
//! * GTK (`gtk_init`, called by `gtk4::init()`) already calls
//! `setlocale(LC_ALL, "")` on Linux, so for the GTK apprt nothing else is
//! needed for gettext/Pango to pick up the locale.
//! * If an explicit call is needed (e.g. CLI actions without GTK), an
//! `unsafe`-permitted crate (apprt-gtk / the root binary) must perform the
//! libc call; [`FALLBACK_LOCALE`] documents the Zig fallback behaviour
//! (try `setlocale(LC_ALL, "")`, then with `LANG` unset, then
//! `en_US.UTF-8` and export `LANG=en_US.UTF-8`).
//!
//! The macOS-specific `LANG` population from Cocoa is not applicable.
/// The locale Ghostty falls back to (and exports as `LANG`) when the
/// environment's locale can't be set.
pub const FALLBACK_LOCALE: &str = "en_US.UTF-8";
/// Ensure that the locale is set.
///
/// DEFERRED (see module docs): this is currently a no-op that only logs.
/// Returns `Some(value)` when `LANG` should be set to `value` for child
/// processes; always `None` for now.
pub fn ensure_locale() -> Option<String> {
log::debug!("ensure_locale: setlocale is not available from safe Rust; relying on the toolkit");
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ensure_locale_is_noop() {
assert_eq!(ensure_locale(), None);
assert_eq!(FALLBACK_LOCALE, "en_US.UTF-8");
}
}
+6
View File
@@ -0,0 +1,6 @@
/// The system-configured double-click interval if its available.
///
/// Only macOS can report this; on Linux this is always `None`.
pub fn click_interval() -> Option<u32> {
None
}
+92
View File
@@ -0,0 +1,92 @@
use std::io;
use std::process::{Command, Stdio};
/// Kind of URL being opened (`apprt.action.OpenUrl.Kind`). On Linux the
/// kind is ignored by [`open`] (it only matters for macOS `open -t`).
// TODO(integration): replace with the apprt action's OpenUrl kind once
// ghostty-core defines it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OpenUrlKind {
Text,
Html,
#[default]
Unknown,
}
/// Open a URL in the default handling application (via `xdg-open`).
///
/// Any output on stderr is logged as a warning in the application logs.
/// Output on stdout is ignored.
///
/// This function is purposely simple for the sake of providing
/// some portable way to open URLs. If you are implementing an
/// apprt for Ghostty, you should consider doing something special-cased
/// for your platform.
pub fn open(kind: OpenUrlKind, url: &str) -> io::Result<()> {
let _ = kind;
let mut cmd = Command::new("xdg-open");
cmd.arg(url);
// Pipe stdout/stderr so we can collect output from the command.
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// In the snap on Linux the launcher exports LD_LIBRARY_PATH pointing at
// the snap's bundled libraries. Leaking this into child process can
// can be problematic, so let's drop it from the env.
// TODO(integration): Zig does this only for `build_config.snap` builds;
// we do it when running inside a snap (`SNAP` is set).
if std::env::var_os("SNAP").is_some() {
cmd.env_remove("LD_LIBRARY_PATH");
}
// Spawn the process on our same thread so we can detect failure
// quickly.
let child = cmd.spawn()?;
// Create a thread that handles collecting output and reaping
// the process. This is done in a separate thread because SOME
// open implementations block and some do not. It's easier to just
// spawn a thread to handle this so that we never block.
std::thread::Builder::new()
.name("open-url".into())
.spawn(move || open_thread(child))?;
Ok(())
}
fn open_thread(child: std::process::Child) {
// 50 KiB is the default value used by std.process.Child.run and should
// be enough to get the output we care about.
const OUTPUT_MAX_SIZE: usize = 50 * 1024;
let output = match child.wait_with_output() {
Ok(o) => o,
Err(e) => {
log::warn!("error waiting for open command err={e}");
return;
}
};
// If we have any stderr output we log it. This makes it easier for
// users to debug why some open commands may not work as expected.
let stderr = &output.stderr[..output.stderr.len().min(OUTPUT_MAX_SIZE)];
if !stderr.is_empty() {
log::warn!("wait stderr={}", String::from_utf8_lossy(stderr));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_reports_spawn_failure_quickly() {
// With an empty PATH, xdg-open can't be found; Zig detects this
// synchronously and so do we.
temp_env::with_var("PATH", Some("/nonexistent-dir"), || {
let err = open(OpenUrlKind::Unknown, "https://example.com").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
});
}
}
+58
View File
@@ -0,0 +1,58 @@
use crate::flatpak::is_flatpak;
/// Entry that is retrieved from the passwd API. This only contains the fields
/// we care about.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Entry {
pub shell: Option<String>,
pub home: Option<String>,
pub name: Option<String>,
}
/// Get the passwd entry for the currently executing user.
pub fn get() -> std::io::Result<Entry> {
let uid = nix::unistd::getuid();
let user = match nix::unistd::User::from_uid(uid) {
Ok(Some(user)) => user,
Ok(None) => {
// Future: let's check if a better shell is available like zsh
log::warn!("no pw entry to detect default shell, will default to 'sh'");
return Ok(Entry::default());
}
Err(e) => {
log::warn!("error retrieving pw entry code={e}");
return Ok(Entry::default());
}
};
// TODO(integration): Zig shells out to the host via
// FlatpakHostCommand + a pty (`getent passwd <name>`) when running in
// Flatpak, because the sandbox passwd entry only has a correct username.
// That needs the `ghostty-termio` Pty; until then we use the sandbox
// entry and warn.
if is_flatpak() {
log::warn!(
"flatpak detected, but host passwd lookup is not implemented; \
using sandbox passwd entry"
);
}
Ok(Entry {
shell: Some(user.shell.to_string_lossy().into_owned()),
home: Some(user.dir.to_string_lossy().into_owned()),
name: Some(user.name),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn get_entry() {
// We should be able to get an entry
let entry = get().unwrap();
assert!(entry.shell.is_some());
assert!(entry.home.is_some());
}
}
+107
View File
@@ -0,0 +1,107 @@
use std::io;
use std::os::unix::fs::PermissionsExt;
/// Equivalent of `std.fs.max_path_bytes` on Linux.
const MAX_PATH_BYTES: usize = 4096;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("path too long")]
PathTooLong,
#[error("access denied")]
AccessDenied,
#[error(transparent)]
Io(#[from] io::Error),
}
/// Search for "cmd" in the PATH and return the absolute path.
pub fn expand(cmd: &str) -> Result<Option<String>, Error> {
// If the command already contains a slash, then we return it as-is
// because it is assumed to be absolute or relative.
if cmd.contains('/') {
return Ok(Some(cmd.to_owned()));
}
let Some(path_var) = std::env::var_os("PATH") else {
return Ok(None);
};
let path_var = path_var.to_string_lossy();
let mut seen_eacces = false;
for search_path in path_var.split(':').filter(|s| !s.is_empty()) {
// We need enough space in our path buffer to store this
let path_len = search_path.len() + cmd.len() + 1;
if MAX_PATH_BYTES < path_len {
return Err(Error::PathTooLong);
}
let full_path = format!("{search_path}/{cmd}");
// Stat it
let f = match std::fs::File::open(&full_path) {
Ok(f) => f,
Err(e) => match e.kind() {
io::ErrorKind::NotFound => continue,
io::ErrorKind::PermissionDenied => {
// Accumulate this and return it later so we can try other
// paths that we have access to.
seen_eacces = true;
continue;
}
_ => return Err(e.into()),
},
};
let meta = f.metadata()?;
if !meta.is_dir() && is_executable(meta.permissions().mode()) {
return Ok(Some(full_path));
}
}
if seen_eacces {
return Err(Error::AccessDenied);
}
Ok(None)
}
fn is_executable(mode: u32) -> bool {
mode & 0o0111 != 0
}
#[cfg(test)]
mod tests {
use super::*;
// `uname -n` is the *nix equivalent of `hostname.exe` on Windows
#[test]
fn expand_hostname() {
let path = crate::testutil::with_env_lock(|| expand("uname").unwrap().unwrap());
assert!(path.len() > "uname".len());
}
#[test]
fn expand_does_not_exist() {
let path =
crate::testutil::with_env_lock(|| expand("thisreallyprobablydoesntexist123").unwrap());
assert!(path.is_none());
}
#[test]
fn expand_slash() {
let path = expand("foo/env").unwrap().unwrap();
assert_eq!(path.len(), 7);
}
#[test]
fn expand_skips_non_executable_and_dirs() {
let dir = crate::TempDir::init().unwrap();
std::fs::write(dir.path().join("plain"), b"x").unwrap();
std::fs::create_dir(dir.path().join("adir")).unwrap();
let p = dir.path();
let p = p.to_str().unwrap();
temp_env::with_var("PATH", Some(p), || {
assert!(expand("plain").unwrap().is_none());
assert!(expand("adir").unwrap().is_none());
});
}
}
+28
View File
@@ -0,0 +1,28 @@
use std::io;
use std::os::fd::OwnedFd;
/// pipe() for POSIX. Sets CLOEXEC on the file descriptors.
/// Returns `(read, write)`.
pub fn pipe() -> io::Result<(OwnedFd, OwnedFd)> {
rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC).map_err(io::Error::from)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
#[test]
fn pipe_roundtrip_cloexec() {
let (r, w) = pipe().unwrap();
let flags = rustix::io::fcntl_getfd(&r).unwrap();
assert!(flags.contains(rustix::io::FdFlags::CLOEXEC));
let mut w = std::fs::File::from(w);
let mut r = std::fs::File::from(r);
w.write_all(b"hi").unwrap();
drop(w);
let mut s = String::new();
r.read_to_string(&mut s).unwrap();
assert_eq!(s, "hi");
}
}
+181
View File
@@ -0,0 +1,181 @@
use std::io;
use std::path::Path;
use crate::path_join;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResourcesDir {
/// Avoid accessing these directly, use the app() and host() methods instead.
pub app_path: Option<String>,
pub host_path: Option<String>,
}
impl ResourcesDir {
/// Get the directory to the bundled resources directory accessible
/// by the application.
pub fn app(&self) -> Option<&str> {
self.app_path.as_deref()
}
/// Get the directory to the bundled resources directory accessible
/// by the host environment (i.e. for sandboxed applications). The
/// returned directory might not be accessible from the application
/// itself.
///
/// In non-sandboxed environment, this should be the same as app().
pub fn host(&self) -> Option<&str> {
self.host_path.as_deref().or(self.app_path.as_deref())
}
}
/// Sentinels we look for in the path to know we've found the resources
/// directory (Linux).
const SENTINELS: [&str; 2] = ["terminfo/g/ghostty", "terminfo/x/xterm-ghostty"];
/// Gets the directory to the bundled resources directory, if it
/// exists (not all platforms or packages have it).
///
/// Zig's `builtin.mode == .Debug` is mapped to `cfg!(debug_assertions)`.
pub fn resources_dir() -> io::Result<ResourcesDir> {
let exe = std::env::current_exe().ok();
let env = std::env::var("GHOSTTY_RESOURCES_DIR").ok();
Ok(resources_dir_impl(
cfg!(debug_assertions),
exe.as_deref(),
env,
))
}
fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> ResourcesDir {
// Use the GHOSTTY_RESOURCES_DIR environment variable in release builds.
//
// In debug builds we try using terminfo detection first instead, since
// if debug Ghostty is launched by an older version of Ghostty, it
// would inherit the old, stale resources of older Ghostty instead of the
// freshly built ones under zig-out/share/ghostty.
if !debug && let Some(dir) = env.as_ref().filter(|d| !d.is_empty()) {
return ResourcesDir {
app_path: Some(dir.clone()),
host_path: None,
};
}
// Get the path to our running binary
let Some(mut exe) = exe else {
return ResourcesDir::default();
};
// We have an exe path! Climb the tree looking for the terminfo
// bundle as we expect it.
while let Some(dir) = exe.parent() {
exe = dir;
let dir_str = dir.to_string_lossy();
// We look for a /usr/share style path.
for sentinel in SENTINELS {
if let Some(v) = maybe_dir(&dir_str, "share", sentinel) {
return ResourcesDir {
app_path: Some(path_join(&[&v, "ghostty"])),
host_path: None,
};
}
}
}
// If terminfo detection failed in debug builds (somehow),
// fallback and use the provided resources dir.
if debug && let Some(dir) = env.filter(|d| !d.is_empty()) {
return ResourcesDir {
app_path: Some(dir),
host_path: None,
};
}
ResourcesDir::default()
}
/// Little helper to check if the "base/sub/suffix" directory exists and
/// if so return "base/sub". The "suffix" is just used as a way to verify a
/// directory seems roughly right.
pub fn maybe_dir(base: &str, sub: &str, suffix: &str) -> Option<String> {
let path = format!("{base}/{sub}/{suffix}");
// Folder doesn't exist. If a different error happens its okay
// we just ignore it and move on.
if Path::new(&path).exists() {
let len = path.len() - suffix.len() - 1;
return Some(path[..len].to_owned());
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resources_dir_accessors() {
let mut r = ResourcesDir::default();
assert_eq!(r.app(), None);
assert_eq!(r.host(), None);
r.app_path = Some("/a".into());
assert_eq!(r.host(), Some("/a"));
r.host_path = Some("/h".into());
assert_eq!(r.app(), Some("/a"));
assert_eq!(r.host(), Some("/h"));
}
fn make_tree() -> crate::TempDir {
let td = crate::TempDir::init().unwrap();
std::fs::create_dir_all(td.path().join("share/terminfo/x")).unwrap();
std::fs::write(td.path().join("share/terminfo/x/xterm-ghostty"), b"").unwrap();
std::fs::create_dir_all(td.path().join("bin")).unwrap();
td
}
#[test]
fn detects_share_dir_from_exe() {
let td = make_tree();
let exe = td.path().join("bin/ghostty");
let r = resources_dir_impl(true, Some(&exe), None);
let expected = format!("{}/share/ghostty", td.path().display());
assert_eq!(r.app(), Some(expected.as_str()));
}
#[test]
fn env_in_release_wins_and_in_debug_is_fallback() {
let td = make_tree();
let exe = td.path().join("bin/ghostty");
let env = Some("/env/res".to_owned());
assert_eq!(
resources_dir_impl(false, Some(&exe), env.clone()).app(),
Some("/env/res")
);
assert_ne!(
resources_dir_impl(true, Some(&exe), env.clone()).app(),
Some("/env/res")
);
// Debug fallback when detection fails.
assert_eq!(
resources_dir_impl(true, Some(Path::new("/nonexistent/bin/x")), env).app(),
Some("/env/res")
);
// Empty env is ignored.
assert_eq!(
resources_dir_impl(false, None, Some(String::new())).app(),
None
);
}
#[test]
fn maybe_dir_checks_suffix() {
let td = make_tree();
let base = td.path().to_string_lossy().into_owned();
assert_eq!(
maybe_dir(&base, "share", "terminfo/x/xterm-ghostty"),
Some(format!("{base}/share"))
);
assert_eq!(maybe_dir(&base, "share", "terminfo/g/ghostty"), None);
}
}
+155
View File
@@ -0,0 +1,155 @@
use std::ffi::{CString, NulError};
use std::io::{self, Write};
/// Builder for constructing space-separated shell command strings.
#[derive(Debug, Default, Clone)]
pub struct ShellCommandBuilder {
buffer: String,
}
impl ShellCommandBuilder {
pub fn new() -> Self {
Self::default()
}
/// Append an argument to the command with automatic space separation.
/// Empty arguments are ignored.
pub fn append_arg(&mut self, arg: &str) {
if arg.is_empty() {
return;
}
if !self.buffer.is_empty() {
self.buffer.push(' ');
}
self.buffer.push_str(arg);
}
/// The command written so far.
pub fn written(&self) -> &str {
&self.buffer
}
/// Get the final null-terminated command string.
pub fn to_owned_slice(self) -> Result<CString, NulError> {
CString::new(self.buffer)
}
}
/// Writer that escapes characters that shells treat specially to reduce the
/// risk of injection attacks or other such weirdness. Specifically excludes
/// linefeeds so that they can be used to delineate lists of file paths.
pub struct ShellEscapeWriter<W: Write> {
child: W,
}
impl<W: Write> ShellEscapeWriter<W> {
pub fn new(child: W) -> Self {
Self { child }
}
pub fn into_inner(self) -> W {
self.child
}
}
impl<W: Write> Write for ShellEscapeWriter<W> {
fn write(&mut self, data: &[u8]) -> io::Result<usize> {
for &byte in data {
match byte {
b'\\' | b'"' | b'\'' | b'$' | b'`' | b'*' | b'?' | b' ' | b'|' | b'(' | b')' => {
self.child.write_all(&[b'\\', byte])?;
}
_ => self.child.write_all(&[byte])?,
}
}
Ok(data.len())
}
fn flush(&mut self) -> io::Result<()> {
self.child.flush()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_command_builder() {
// Empty command
let cmd = ShellCommandBuilder::new();
assert_eq!("", cmd.written());
// Single arg
let mut cmd = ShellCommandBuilder::new();
cmd.append_arg("bash");
assert_eq!("bash", cmd.written());
// Multiple args
let mut cmd = ShellCommandBuilder::new();
cmd.append_arg("bash");
cmd.append_arg("--posix");
cmd.append_arg("-l");
assert_eq!("bash --posix -l", cmd.written());
// Empty arg
let mut cmd = ShellCommandBuilder::new();
cmd.append_arg("bash");
cmd.append_arg("");
assert_eq!("bash", cmd.written());
// toOwnedSlice
let mut cmd = ShellCommandBuilder::new();
cmd.append_arg("bash");
cmd.append_arg("--posix");
let result = cmd.to_owned_slice().unwrap();
assert_eq!(result.to_str().unwrap(), "bash --posix");
assert_eq!(result.as_bytes_with_nul().last(), Some(&0));
}
fn escape(input: &str) -> String {
let mut shell = ShellEscapeWriter::new(Vec::new());
shell.write_all(input.as_bytes()).unwrap();
String::from_utf8(shell.into_inner()).unwrap()
}
#[test]
fn shell_escape_1() {
assert_eq!(escape("abc"), "abc");
}
#[test]
fn shell_escape_2() {
assert_eq!(escape("a c"), "a\\ c");
}
#[test]
fn shell_escape_3() {
assert_eq!(escape("a?c"), "a\\?c");
}
#[test]
fn shell_escape_4() {
assert_eq!(escape("a\\c"), "a\\\\c");
}
#[test]
fn shell_escape_5() {
assert_eq!(escape("a|c"), "a\\|c");
}
#[test]
fn shell_escape_6() {
assert_eq!(escape("a\"c"), "a\\\"c");
}
#[test]
fn shell_escape_7() {
assert_eq!(escape("a(1)"), "a\\(1\\)");
}
#[test]
fn shell_escape_keeps_newlines() {
assert_eq!(escape("a\nb"), "a\nb");
}
}
+245
View File
@@ -0,0 +1,245 @@
use std::io::{self, Write};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("decode error")]
DecodeError,
#[error(transparent)]
Io(#[from] io::Error),
}
impl PartialEq for Error {
fn eq(&self, other: &Self) -> bool {
matches!((self, other), (Error::DecodeError, Error::DecodeError))
}
}
/// Decode date from the buffer that has been encoded in the same way that
/// `bash`'s `printf %q` encodes a string and write it to the writer. If an
/// error is returned garbage may have been written to the buffer.
pub fn printf_q_decode<W: Write>(writer: &mut W, buf: &[u8]) -> Result<(), Error> {
let data: &[u8] = if buf.starts_with(b"$'") {
// Strip off `$''` quoting.
if buf.len() < 3 || !buf.ends_with(b"'") {
return Err(Error::DecodeError);
}
&buf[2..buf.len() - 1]
} else if buf.starts_with(b"'") {
// Strip off `''` quoting.
if buf.len() < 2 || !buf.ends_with(b"'") {
return Err(Error::DecodeError);
}
&buf[1..buf.len() - 1]
} else {
buf
};
let mut src = 0;
while src < data.len() {
match data[src] {
b'\\' => {
if src + 1 >= data.len() {
return Err(Error::DecodeError);
}
let out = match data[src + 1] {
c @ (b' ' | b'\\' | b'"' | b'\'' | b'$') => c,
b'e' => 0x1b,
b'n' => b'\n',
b'r' => b'\r',
b't' => b'\t',
b'v' => 0x0b,
_ => return Err(Error::DecodeError),
};
writer.write_all(&[out])?;
src += 2;
}
c => {
writer.write_all(&[c])?;
src += 1;
}
}
}
Ok(())
}
/// Decode data from the buffer that has been URL percent encoded and write
/// it to the given buffer. If an error is returned the garbage may have been
/// written to the writer.
pub fn url_percent_decode<W: Write>(writer: &mut W, buf: &[u8]) -> Result<(), Error> {
let mut src = 0;
while src < buf.len() {
match buf[src] {
b'%' => {
if src + 2 >= buf.len() {
return Err(Error::DecodeError);
}
let (Some(hi), Some(lo)) = (hex(buf[src + 1]), hex(buf[src + 2])) else {
return Err(Error::DecodeError);
};
writer.write_all(&[(hi << 4) | lo])?;
src += 3;
}
c => {
writer.write_all(&[c])?;
src += 1;
}
}
}
Ok(())
}
fn hex(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
}
/// Is the given character valid in URI percent encoding?
fn is_valid_char(c: u8) -> bool {
match c {
b' ' | b';' | b'=' => false,
_ => (0x20..=0x7e).contains(&c),
}
}
/// Write data to the writer after URI percent encoding.
pub fn url_percent_encode<W: Write>(writer: &mut W, data: &[u8]) -> io::Result<()> {
for &c in data {
if is_valid_char(c) {
writer.write_all(&[c])?;
} else {
write!(writer, "%{c:02X}")?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn pq(s: &[u8]) -> Result<Vec<u8>, Error> {
let mut w = Vec::new();
printf_q_decode(&mut w, s).map(|()| w)
}
fn pd(s: &[u8]) -> Result<Vec<u8>, Error> {
let mut w = Vec::new();
url_percent_decode(&mut w, s).map(|()| w)
}
#[test]
fn printf_q_1() {
assert_eq!(pq(b"bobr\\ kurwa").unwrap(), b"bobr kurwa");
}
#[test]
fn printf_q_2() {
assert_eq!(pq(b"bobr\\nkurwa").unwrap(), b"bobr\nkurwa");
}
#[test]
fn printf_q_3() {
assert_eq!(pq(b"bobr\\dkurwa"), Err(Error::DecodeError));
}
#[test]
fn printf_q_4() {
assert_eq!(pq(b"bobr kurwa\\"), Err(Error::DecodeError));
}
#[test]
fn printf_q_5() {
assert_eq!(pq(b"$'bobr kurwa'").unwrap(), b"bobr kurwa");
}
#[test]
fn printf_q_6() {
assert_eq!(pq(b"'bobr kurwa'").unwrap(), b"bobr kurwa");
}
#[test]
fn printf_q_7() {
assert_eq!(pq(b"$'bobr kurwa"), Err(Error::DecodeError));
}
#[test]
fn printf_q_8() {
assert_eq!(pq(b"$'"), Err(Error::DecodeError));
}
#[test]
fn printf_q_9() {
assert_eq!(pq(b"'bobr kurwa"), Err(Error::DecodeError));
}
#[test]
fn printf_q_10() {
assert_eq!(pq(b"'"), Err(Error::DecodeError));
}
#[test]
fn printf_q_escapes() {
assert_eq!(
pq(b"\\e\\r\\t\\v\\\\\\\"\\'\\$").unwrap(),
b"\x1b\r\t\x0b\\\"'$"
);
}
#[test]
fn singles_percent() {
for c in 0..255u32 {
let lower = format!("%{c:02x}");
let decoded = pd(lower.as_bytes()).unwrap();
assert_eq!(decoded, [c as u8]);
let upper = format!("%{c:02X}");
let decoded = pd(upper.as_bytes()).unwrap();
assert_eq!(decoded, [c as u8]);
}
}
#[test]
fn percent_1() {
assert_eq!(pd(b"bobr%20kurwa").unwrap(), b"bobr kurwa");
}
#[test]
fn percent_2() {
assert_eq!(pd(b"bobr%2kurwa"), Err(Error::DecodeError));
}
#[test]
fn percent_3() {
assert_eq!(pd(b"bobr%kurwa"), Err(Error::DecodeError));
}
#[test]
fn percent_4() {
assert_eq!(pd(b"bobr%%kurwa"), Err(Error::DecodeError));
}
#[test]
fn percent_5() {
assert_eq!(pd(b"bobr%20kurwa%20").unwrap(), b"bobr kurwa ");
}
#[test]
fn percent_6() {
assert_eq!(pd(b"bobr%20kurwa%2"), Err(Error::DecodeError));
}
#[test]
fn percent_7() {
assert_eq!(pd(b"bobr%20kurwa%"), Err(Error::DecodeError));
}
#[test]
fn percent_encode() {
let mut w = Vec::new();
url_percent_encode(&mut w, b"a b;c=d\n\xc3\xa9~").unwrap();
assert_eq!(String::from_utf8(w).unwrap(), "a%20b%3Bc%3Dd%0A%C3%A9~");
}
}
+216
View File
@@ -0,0 +1,216 @@
use std::io::Read;
/// Kernel `TASK_COMM_LEN`.
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.
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"
}
/// 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.
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());
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::net::UnixDatagram;
#[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()),
);
}
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()
}
#[test]
fn notify_ready_sends_message() {
assert_eq!(recv_with_socket(notify::ready), "READY=1");
}
#[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);
}
}
+131
View File
@@ -0,0 +1,131 @@
//! Creates a temporary directory at runtime that can be safely used to
//! store temporary data and is destroyed on drop.
use std::io;
use std::os::unix::fs::DirBuilderExt;
use std::path::{Path, PathBuf};
use crate::file::alloc_tmp_dir;
// The amount of random bytes to get to determine our filename.
const RANDOM_BYTES: usize = 16;
// Base64 alphabet, replacing the standard `+/` with `-_` so that it can
// be used in a file name on any filesystem.
const B64_ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
/// Unpadded base64 using [`B64_ALPHABET`].
fn b64_encode(data: &[u8]) -> String {
let mut out = String::with_capacity((data.len() * 4).div_ceil(3));
for chunk in data.chunks(3) {
let n = match chunk.len() {
3 => u32::from(chunk[0]) << 16 | u32::from(chunk[1]) << 8 | u32::from(chunk[2]),
2 => u32::from(chunk[0]) << 16 | u32::from(chunk[1]) << 8,
_ => u32::from(chunk[0]) << 16,
};
let chars = chunk.len() + 1;
for i in 0..chars {
let idx = (n >> (18 - 6 * i)) & 0x3f;
out.push(B64_ALPHABET[idx as usize] as char);
}
}
out
}
#[derive(Debug)]
pub struct TempDir {
/// Parent directory
parent: PathBuf,
/// Name of the directory (basename only).
name: String,
}
impl TempDir {
/// Create the temporary directory.
pub fn init() -> io::Result<TempDir> {
let parent = match alloc_tmp_dir() {
Some(p) => PathBuf::from(p),
None => std::env::current_dir()?,
};
// Verify the parent can be opened (Zig opens it as a Dir).
std::fs::read_dir(&parent)?;
// We now loop forever until we can find a directory that we can create.
loop {
let mut rand_buf = [0u8; RANDOM_BYTES];
rustix::rand::getrandom(&mut rand_buf, rustix::rand::GetRandomFlags::empty())?;
let name = b64_encode(&rand_buf);
match std::fs::DirBuilder::new()
.mode(0o755)
.create(parent.join(&name))
{
Ok(()) => return Ok(TempDir { parent, name }),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(e),
}
}
}
/// Name returns the name of the directory. This is just the basename
/// and is not the full absolute path.
pub fn name(&self) -> &str {
&self.name
}
/// The parent directory containing the temporary directory.
pub fn parent(&self) -> &Path {
&self.parent
}
/// Full path to the temporary directory.
pub fn path(&self) -> PathBuf {
self.parent.join(&self.name)
}
/// Finish with the temporary directory. This deletes all contents in the
/// directory.
pub fn deinit(self) {}
}
impl Drop for TempDir {
fn drop(&mut self) {
if let Err(err) = std::fs::remove_dir_all(self.path()) {
log::error!("error deleting temp dir err={err}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn temp_dir_lifecycle() {
let td = TempDir::init().unwrap();
let name = td.name().to_owned();
assert!(!name.is_empty());
assert_eq!(name.len(), 22);
// Can open a new handle to it proves it exists.
let parent = td.parent().to_owned();
assert!(std::fs::read_dir(parent.join(&name)).is_ok());
std::fs::write(td.path().join("file"), b"x").unwrap();
// Should be deleted after we deinit
td.deinit();
let err = std::fs::read_dir(parent.join(&name)).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
}
#[test]
fn b64_encoder() {
assert_eq!(b64_encode(b""), "");
assert_eq!(b64_encode(b"f"), "Zg");
assert_eq!(b64_encode(b"fo"), "Zm8");
assert_eq!(b64_encode(b"foo"), "Zm9v");
assert_eq!(b64_encode(&[0xfb, 0xff]), "-_8");
assert_eq!(b64_encode(&[0u8; 16]).len(), 22);
}
}
+9
View File
@@ -0,0 +1,9 @@
//! Test helpers. Process environment mutation is `unsafe` in Rust 2024, so
//! tests go through `temp_env`, which serializes env access with a global
//! lock. Tests that merely *read* env vars that other tests mutate should
//! take the same lock via [`with_env_lock`].
/// Run `f` while holding the global env lock (no variables changed).
pub fn with_env_lock<R>(f: impl FnOnce() -> R) -> R {
temp_env::with_vars(Vec::<(&str, Option<&str>)>::new(), f)
}
+506
View File
@@ -0,0 +1,506 @@
//! URI parsing. Extends a faithful port of Zig's `std.Uri.parse` with the
//! additional [`ParseOptions`] from `os/uri.zig`.
use std::borrow::Cow;
#[derive(Debug, Clone, Copy, Default)]
pub struct ParseOptions {
/// Parse MAC addresses in the host component.
///
/// This is useful when the "Private Wi-Fi address" is enabled on macOS,
/// which sets the hostname to a rotating MAC address (12:34:56:ab:cd:ef).
pub mac_address: bool,
/// Return the full, raw, unencoded path string. Any query and fragment
/// values will be return as part of the path instead of as distinct
/// fields.
pub raw_path: bool,
}
/// Errors of `std.Uri.ParseError` plus `InvalidMacAddress`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum ParseError {
#[error("unexpected character")]
UnexpectedCharacter,
#[error("invalid format")]
InvalidFormat,
#[error("invalid port")]
InvalidPort,
#[error("invalid mac address")]
InvalidMacAddress,
}
/// `std.Uri.Component`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Component<'a> {
/// Unencoded text.
Raw(Cow<'a, str>),
/// Percent-encoded text.
PercentEncoded(&'a str),
}
impl Component<'_> {
pub fn is_empty(&self) -> bool {
match self {
Component::Raw(s) => s.is_empty(),
Component::PercentEncoded(s) => s.is_empty(),
}
}
/// The raw (percent-decoded) value (`toRaw`). Invalid escapes are left
/// as-is, matching `std.Uri.percentDecodeInPlace`.
pub fn to_raw(&self) -> Cow<'_, str> {
match self {
Component::Raw(s) => Cow::Borrowed(s.as_ref()),
Component::PercentEncoded(s) => percent_decode(s),
}
}
/// The percent-encoded value as it appears in the source, or the raw
/// text for `Raw` components.
pub fn as_str(&self) -> &str {
match self {
Component::Raw(s) => s,
Component::PercentEncoded(s) => s,
}
}
}
/// Lenient percent decoding like `std.Uri.percentDecodeBackwards`/InPlace:
/// valid `%XX` sequences are decoded, everything else passes through.
fn percent_decode(s: &str) -> Cow<'_, str> {
if !s.contains('%') {
return Cow::Borrowed(s);
}
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%'
&& i + 2 < b.len()
&& let (Some(h), Some(l)) = (hexval(b[i + 1]), hexval(b[i + 2]))
{
out.push((h << 4) | l);
i += 3;
continue;
}
out.push(b[i]);
i += 1;
}
Cow::Owned(String::from_utf8_lossy(&out).into_owned())
}
fn hexval(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
}
/// `std.Uri`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Uri<'a> {
pub scheme: &'a str,
pub user: Option<Component<'a>>,
pub password: Option<Component<'a>>,
pub host: Option<Component<'a>>,
pub port: Option<u16>,
pub path: Component<'a>,
pub query: Option<Component<'a>>,
pub fragment: Option<Component<'a>>,
}
/// Port of `std.Uri.parse`.
fn std_parse(text: &str) -> Result<Uri<'_>, ParseError> {
let Some(colon) = text.find(':') else {
return Err(ParseError::UnexpectedCharacter);
};
let scheme = &text[..colon];
parse_after_scheme(scheme, &text[colon + 1..])
}
/// Port of `std.Uri.parseAfterScheme`.
fn parse_after_scheme<'a>(scheme: &'a str, text: &'a str) -> Result<Uri<'a>, ParseError> {
let mut uri = Uri {
scheme,
user: None,
password: None,
host: None,
port: None,
path: Component::PercentEncoded(""),
query: None,
fragment: None,
};
let mut rest = text;
if let Some(after) = rest.strip_prefix("//") {
// authority part
let end = after.find(['/', '?', '#']).unwrap_or(after.len());
let authority = &after[..end];
rest = &after[end..];
if authority.is_empty() {
if !rest.starts_with('/') {
return Err(ParseError::InvalidFormat);
}
} else {
parse_authority(authority, &mut uri)?;
}
}
let path_end = rest.find(['?', '#']).unwrap_or(rest.len());
uri.path = Component::PercentEncoded(&rest[..path_end]);
rest = &rest[path_end..];
if let Some(after) = rest.strip_prefix('?') {
// query part
let end = after.find('#').unwrap_or(after.len());
uri.query = Some(Component::PercentEncoded(&after[..end]));
rest = &after[end..];
}
if let Some(after) = rest.strip_prefix('#') {
uri.fragment = Some(Component::PercentEncoded(after));
}
Ok(uri)
}
fn parse_authority<'a>(authority: &'a str, uri: &mut Uri<'a>) -> Result<(), ParseError> {
let mut start_of_host = 0;
if let Some(index) = authority.find('@') {
start_of_host = index + 1;
let user_info = &authority[..index];
if let Some(idx) = user_info.find(':') {
uri.user = Some(Component::PercentEncoded(&user_info[..idx]));
if idx + 1 < user_info.len() {
// empty password is also "no password"
uri.password = Some(Component::PercentEncoded(&user_info[idx + 1..]));
}
} else {
uri.user = Some(Component::PercentEncoded(user_info));
uri.password = None;
}
}
// only possible if uri consists of only `userinfo@`
if start_of_host >= authority.len() {
return Ok(());
}
let mut end_of_host = authority.len();
let ab = authority.as_bytes();
// if we see `]` first without `@`
if ab[start_of_host] == b']' {
return Err(ParseError::InvalidFormat);
}
if ab[start_of_host] == b'[' {
// IPv6
end_of_host = authority.rfind(']').ok_or(ParseError::InvalidFormat)? + 1;
if let Some(index) = authority.rfind(':')
&& index >= end_of_host
{
// if not part of the V6 address field
end_of_host = end_of_host.min(index);
uri.port = Some(
authority[index + 1..]
.parse::<u16>()
.map_err(|_| ParseError::InvalidPort)?,
);
}
} else if let Some(index) = authority.rfind(':')
&& index >= start_of_host
{
// if not part of the userinfo field
end_of_host = end_of_host.min(index);
uri.port = Some(
authority[index + 1..]
.parse::<u16>()
.map_err(|_| ParseError::InvalidPort)?,
);
}
uri.host = Some(Component::PercentEncoded(
&authority[start_of_host..end_of_host],
));
Ok(())
}
/// Byte offset of `inner` within `outer` (both must be sub-slices of the
/// same allocation; here `inner` always comes from parsing `outer`).
fn offset_in(outer: &str, inner: &str) -> usize {
inner.as_ptr() as usize - outer.as_ptr() as usize
}
/// Parses a URI from the given string.
///
/// This extends the standard URI parse with some additional ParseOptions.
pub fn parse(text: &str, options: ParseOptions) -> Result<Uri<'_>, ParseError> {
let mut uri = match std_parse(text) {
Ok(uri) => uri,
Err(err) => {
// We can attempt to re-parse the text as a URI that has a MAC address
// in its host field (which tripped up the standard port parsing):
//
// file://12:34:56:78:90:aa/path/to/file
// ^^ InvalidPort
//
if err != ParseError::InvalidPort || !options.mac_address {
return Err(err);
}
// We can assume that the initial parse already validated the
// scheme, so we only need to find its bounds within the string.
let scheme_end = text.find("://").ok_or(ParseError::InvalidFormat)?;
let scheme = &text[..scheme_end];
// We similarly find the bounds of the host component by looking
// for the first slash (/) after the scheme. This is all we need
// for this case because the resulting slice can be unambiguously
// determined to be a MAC address (or not).
let host_start = scheme_end + "://".len();
let host_end = text[host_start..]
.find('/')
.map_or(text.len(), |i| host_start + i);
let mac_address = &text[host_start..host_end];
if !is_valid_mac_address(mac_address) {
return Err(ParseError::InvalidMacAddress);
}
// Parse the rest of the text (starting with the path component) as a
// partial URI and then add our MAC address as its host component.
let mut uri = parse_after_scheme(scheme, &text[host_end..])?;
uri.host = Some(Component::PercentEncoded(mac_address));
uri
}
};
// When MAC address parsing is enabled, we need to handle the case where
// the standard parse parsed the address's last octet as a numeric port number.
// We use a few heuristics to identify this case (14 characters, 4 colons)
// and then "repair" the result by reassign the .host component to the full
// MAC address and clearing the .port component.
//
// 12:34:56:78:90:99 -> [12:34:56:78:90, 99] -> 12:34:56:78:90:99
// (original host) (parsed host + port) (restored host)
//
if options.mac_address
&& let Some(Component::PercentEncoded(host)) = uri.host.clone()
&& host.len() == 14
&& host.matches(':').count() == 4
&& let Some(port) = uri.port
&& port <= 99
{
// The parse returns slices pointing into the original text string.
let host_start = offset_in(text, host);
let path_start = offset_in(text, uri.path.as_str());
let mac_address = &text[host_start..path_start];
if !is_valid_mac_address(mac_address) {
return Err(ParseError::InvalidMacAddress);
}
uri.host = Some(Component::PercentEncoded(mac_address));
uri.port = None;
}
// When the raw_path option is active, return everything after the authority
// (host) in the .path component, including any query and fragment values.
if options.raw_path {
let path_start = offset_in(text, uri.path.as_str());
uri.path = Component::Raw(Cow::Borrowed(&text[path_start..]));
uri.query = None;
uri.fragment = None;
}
Ok(uri)
}
/// Checks if a string represents a valid MAC address, e.g. 12:34:56:ab:cd:ef.
fn is_valid_mac_address(s: &str) -> bool {
let b = s.as_bytes();
if b.len() != 17 {
return false;
}
for (i, &c) in b.iter().enumerate() {
if i % 3 == 2 {
if c != b':' {
return false;
}
} else if !c.is_ascii_hexdigit() {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
const MAC: ParseOptions = ParseOptions {
mac_address: true,
raw_path: false,
};
fn host<'a>(u: &'a Uri<'_>) -> &'a str {
match u.host.as_ref().unwrap() {
Component::PercentEncoded(s) => s,
Component::Raw(_) => panic!("raw host"),
}
}
#[test]
fn parse_mac_address() {
// Numeric MAC address without a port
let uri1 = parse("file://00:12:34:56:78:90/path", MAC).unwrap();
assert_eq!("file", uri1.scheme);
assert_eq!("00:12:34:56:78:90", host(&uri1));
assert_eq!("/path", uri1.path.as_str());
assert_eq!(None, uri1.port);
// Numeric MAC address with a port
let uri2 = parse("file://00:12:34:56:78:90:999/path", MAC).unwrap();
assert_eq!("file", uri2.scheme);
assert_eq!("00:12:34:56:78:90", host(&uri2));
assert_eq!("/path", uri2.path.as_str());
assert_eq!(Some(999), uri2.port);
// Alphabetic MAC address without a port
let uri3 = parse("file://ab:cd:ef:ab:cd:ef/path", MAC).unwrap();
assert_eq!("file", uri3.scheme);
assert_eq!("ab:cd:ef:ab:cd:ef", host(&uri3));
assert_eq!("/path", uri3.path.as_str());
assert_eq!(None, uri3.port);
// Alphabetic MAC address with a port
let uri4 = parse("file://ab:cd:ef:ab:cd:ef:999/path", MAC).unwrap();
assert_eq!("file", uri4.scheme);
assert_eq!("ab:cd:ef:ab:cd:ef", host(&uri4));
assert_eq!("/path", uri4.path.as_str());
assert_eq!(Some(999), uri4.port);
// Numeric MAC address without a path component
let uri5 = parse("file://00:12:34:56:78:90", MAC).unwrap();
assert_eq!("file", uri5.scheme);
assert_eq!("00:12:34:56:78:90", host(&uri5));
assert!(uri5.path.is_empty());
// Alphabetic MAC address without a path component
let uri6 = parse("file://ab:cd:ef:ab:cd:ef", MAC).unwrap();
assert_eq!("file", uri6.scheme);
assert_eq!("ab:cd:ef:ab:cd:ef", host(&uri6));
assert!(uri6.path.is_empty());
// Invalid MAC addresses
assert_eq!(
Err(ParseError::InvalidMacAddress),
parse("file://zz:zz:zz:zz:zz:00/path", MAC)
);
assert_eq!(
Err(ParseError::InvalidMacAddress),
parse("file://zz:zz:zz:zz:zz:zz/path", MAC)
);
}
#[test]
fn parse_raw_path() {
let text = "file://localhost/path??#fragment";
let uri1 = parse(
text,
ParseOptions {
raw_path: false,
..Default::default()
},
)
.unwrap();
assert_eq!("file", uri1.scheme);
assert_eq!("localhost", host(&uri1));
assert_eq!("/path", uri1.path.to_raw());
assert_eq!("?", uri1.query.as_ref().unwrap().as_str());
assert_eq!("fragment", uri1.fragment.as_ref().unwrap().as_str());
let uri2 = parse(
text,
ParseOptions {
raw_path: true,
..Default::default()
},
)
.unwrap();
assert_eq!("file", uri2.scheme);
assert_eq!("localhost", host(&uri2));
assert_eq!("/path??#fragment", uri2.path.to_raw());
assert_eq!(None, uri2.query);
assert_eq!(None, uri2.fragment);
let uri3 = parse(
"file://localhost",
ParseOptions {
raw_path: true,
..Default::default()
},
)
.unwrap();
assert_eq!("file", uri3.scheme);
assert_eq!("localhost", host(&uri3));
assert!(uri3.path.is_empty());
assert_eq!(None, uri3.query);
assert_eq!(None, uri3.fragment);
}
#[test]
fn is_valid_mac_address_cases() {
assert!(is_valid_mac_address("01:23:45:67:89:Aa"));
assert!(is_valid_mac_address("Aa:Bb:Cc:Dd:Ee:Ff"));
assert!(!is_valid_mac_address(""));
assert!(!is_valid_mac_address("00:23:45"));
assert!(!is_valid_mac_address("00:23:45:Xx:Yy:Zz"));
assert!(!is_valid_mac_address("01-23-45-67-89-Aa"));
assert!(!is_valid_mac_address("01:23:45:67:89:Aa:Bb"));
}
#[test]
fn std_parse_basics() {
let u = parse(
"https://user:pw@example.com:8080/a%20b?q=1#frag",
ParseOptions::default(),
)
.unwrap();
assert_eq!(u.scheme, "https");
assert_eq!(u.user.as_ref().unwrap().as_str(), "user");
assert_eq!(u.password.as_ref().unwrap().as_str(), "pw");
assert_eq!(host(&u), "example.com");
assert_eq!(u.port, Some(8080));
assert_eq!(u.path.to_raw(), "/a b");
assert_eq!(u.query.as_ref().unwrap().as_str(), "q=1");
assert_eq!(u.fragment.as_ref().unwrap().as_str(), "frag");
let u = parse("file:///tmp/x", ParseOptions::default()).unwrap();
assert_eq!(u.host, None);
assert_eq!(u.path.as_str(), "/tmp/x");
let u = parse("http://[::1]:80/", ParseOptions::default()).unwrap();
assert_eq!(host(&u), "[::1]");
assert_eq!(u.port, Some(80));
assert_eq!(
parse("nocolon", ParseOptions::default()),
Err(ParseError::UnexpectedCharacter)
);
assert_eq!(
parse("file://a:b/", ParseOptions::default()),
Err(ParseError::InvalidPort)
);
assert_eq!(
parse("file://?x", ParseOptions::default()),
Err(ParseError::InvalidFormat)
);
}
}
+201
View File
@@ -0,0 +1,201 @@
//! Implementation of the XDG Base Directory specification
//! (https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html)
use std::ffi::CStr;
use crate::env::getenv_not_empty;
use crate::{homedir, path_join};
#[derive(Debug, Clone, Default)]
pub struct Options<'a> {
/// Subdirectories to join to the base. This avoids extra allocations
/// when building up the directory. This is commonly the application.
pub subdir: Option<&'a str>,
/// The home directory for the user. If this is not set, we will attempt
/// to look it up which is an expensive process. By setting this, you can
/// avoid lookups.
pub home: Option<&'a str>,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("no home directory could be determined")]
NoHomeDir,
}
/// Get the XDG user config directory.
pub fn config(opts: &Options<'_>) -> Result<String, Error> {
dir(opts, "XDG_CONFIG_HOME", ".config")
}
/// Get the XDG cache directory.
pub fn cache(opts: &Options<'_>) -> Result<String, Error> {
dir(opts, "XDG_CACHE_HOME", ".cache")
}
/// Get the XDG state directory.
pub fn state(opts: &Options<'_>) -> Result<String, Error> {
dir(opts, "XDG_STATE_HOME", ".local/state")
}
/// Unified helper to get XDG directories that follow a common pattern.
fn dir(opts: &Options<'_>, env: &str, default_subdir: &str) -> Result<String, Error> {
// If we have a cached home dir, use that.
if let Some(home) = opts.home {
return Ok(path_join(&[
home,
default_subdir,
opts.subdir.unwrap_or(""),
]));
}
// First check the env var.
if let Some(env) = getenv_not_empty(env) {
// If we have a subdir, then we join it.
if let Some(subdir) = opts.subdir {
return Ok(path_join(&[&env, subdir]));
}
return Ok(env);
}
// Get our home dir
if let Ok(Some(home)) = homedir::home() {
return Ok(path_join(&[
&home,
default_subdir,
opts.subdir.unwrap_or(""),
]));
}
Err(Error::NoHomeDir)
}
/// Parses the xdg-terminal-exec specification. This expects argv[0] to
/// be "xdg-terminal-exec".
pub fn parse_terminal_exec<'a>(argv: &'a [&'a CStr]) -> Option<&'a [&'a CStr]> {
let first = argv.first()?.to_bytes();
let base = first.rsplit(|&b| b == b'/').next().unwrap_or(first);
if base != b"xdg-terminal-exec" {
return None;
}
// We expect at least one argument
if argv.len() < 2 {
return Some(&[]);
}
// If the first argument is "-e" we skip it.
let start = if argv[1].to_bytes() == b"-e" { 2 } else { 1 };
Some(&argv[start..])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_default() {
let value = crate::testutil::with_env_lock(|| config(&Options::default()).unwrap());
assert!(!value.is_empty());
}
#[test]
fn cache_directory_paths() {
let mock_home = "/Users/test";
// Test base path
let p = cache(&Options {
home: Some(mock_home),
..Default::default()
})
.unwrap();
assert_eq!(p, "/Users/test/.cache");
// Test with subdir
let p = cache(&Options {
home: Some(mock_home),
subdir: Some("ghostty"),
})
.unwrap();
assert_eq!(p, "/Users/test/.cache/ghostty");
}
type DirFn = fn(&Options<'_>) -> Result<String, Error>;
const CASES: [(&str, DirFn, &str); 3] = [
("XDG_CONFIG_HOME", config, ".config"),
("XDG_CACHE_HOME", cache, ".cache"),
("XDG_STATE_HOME", state, ".local/state"),
];
#[test]
fn fallback_when_xdg_env_empty() {
let temp_home = "/tmp/ghostty-test-home";
for (name, func, default_subdir) in CASES {
temp_env::with_vars([("HOME", Some(temp_home)), (name, Some(""))], || {
let expected = path_join(&[temp_home, default_subdir]);
let actual = func(&Options::default()).unwrap();
assert_eq!(expected, actual);
});
}
}
#[test]
fn fallback_when_xdg_env_empty_and_subdir() {
let temp_home = "/tmp/ghostty-test-home";
for (name, func, default_subdir) in CASES {
temp_env::with_vars([("HOME", Some(temp_home)), (name, Some(""))], || {
let expected = path_join(&[temp_home, default_subdir, "ghostty"]);
let actual = func(&Options {
subdir: Some("ghostty"),
..Default::default()
})
.unwrap();
assert_eq!(expected, actual);
});
}
}
#[test]
fn env_var_used_when_set() {
temp_env::with_vars([("XDG_CONFIG_HOME", Some("/x/cfg"))], || {
assert_eq!(config(&Options::default()).unwrap(), "/x/cfg");
let o = Options {
subdir: Some("ghostty"),
..Default::default()
};
assert_eq!(config(&o).unwrap(), "/x/cfg/ghostty");
});
}
fn c(s: &str) -> std::ffi::CString {
std::ffi::CString::new(s).unwrap()
}
#[test]
fn parse_terminal_exec_cases() {
fn run(args: &[&str]) -> Option<Vec<String>> {
let owned: Vec<std::ffi::CString> = args.iter().map(|s| c(s)).collect();
let refs: Vec<&CStr> = owned.iter().map(std::ffi::CString::as_c_str).collect();
parse_terminal_exec(&refs)
.map(|s| s.iter().map(|x| x.to_str().unwrap().to_owned()).collect())
}
assert_eq!(run(&["a", "b", "c"]), None);
assert_eq!(run(&["xdg-terminal-exec"]), Some(vec![]));
assert_eq!(
run(&["xdg-terminal-exec", "a", "b", "c"]),
Some(vec!["a".into(), "b".into(), "c".into()])
);
assert_eq!(
run(&["xdg-terminal-exec", "-e", "a", "b", "c"]),
Some(vec!["a".into(), "b".into(), "c".into()])
);
assert_eq!(
run(&["xdg-terminal-exec", "a", "-e", "b", "c"]),
Some(vec!["a".into(), "-e".into(), "b".into(), "c".into()])
);
assert_eq!(run(&["/usr/bin/xdg-terminal-exec"]), Some(vec![]));
}
}
+70
View File
@@ -0,0 +1,70 @@
# ghostty-os notes
Port of `ZIG/src/os/*` (Linux only) and `ZIG/src/file_type.zig`. `unsafe` is
forbidden; deps: rustix, nix (user), zbus, gettext-rs (`gettextrs`), thiserror,
log; dev: temp-env.
## File -> module
xdg, homedir, hostname, env, file, flatpak, i18n, i18n_locales, locale, open,
passwd, path, pipe, resourcesdir, shell, string_encoding, systemd,
TempDir (`temp_dir`), uri, desktop, kernel_info, cgroup, dbus, mouse, args,
main (re-exports in `lib.rs`), `file_type` (from `src/file_type.zig`).
## Skipped files
macos.zig, mach.zig, cf_release_thread.zig, windows.zig, wasm.zig, wasm/*
(macOS/Windows/WASM are out of scope). Windows/macOS branches inside ported
files are dropped. `main.zig` exports `OpenType` (stale, nonexistent in
open.zig) - not ported.
## API shape deviations (buffers/allocators -> owned values)
- Zig allocator/fixed-buffer APIs return `String`/`Option<String>`. Fixed
buffer overflow semantics kept only where tested: `homedir::home_with_capacity`
/ `expand_home_with_capacity` (BufferTooSmall).
- `env::getenv` returns `Option<String>` (no `GetEnvResult`); `setenv`/`unsetenv`
are NOT provided (unsafe in Rust 2024). Tests use `temp_env` (dev-dep).
- `ShellEscapeWriter<W: io::Write>`, `ShellCommandBuilder::to_owned_slice` -> `CString`.
- `string_encoding` take `impl io::Write`; error enum `{DecodeError, Io}`.
- `uri`: own `Uri`/`Component` types replicating `std.Uri.parse` (Zig std not
available in Rust); `parse(text, ParseOptions)` as in Zig.
- `cgroup::current(pid)` reads at most 4096 bytes (caller buffer in Zig).
- `resourcesdir`: Zig `builtin.mode == .Debug` mapped to `cfg!(debug_assertions)`
(note: workspace `release` profile has debug-assertions on; `production` off).
- `TempDir`: path-based (parent + name) rather than Dir handles; deletes on Drop;
`deinit(self)` provided. Dir created mode 0o755.
- `file::fix_max_files` uses rustix rlimit; `None` == RLIM_INFINITY.
- `systemd::notify`: a leading `@` in NOTIFY_SOCKET is now treated as an abstract
socket (Zig copied it literally, which can't work). Port otherwise 1:1.
- `i18n::canonicalize_locale`: gnulib's `_libintl_locale_name_canonicalize` is
not available; pure-Rust approximation (zh fixes + `-` -> `_`). `tr()` ==
`_()` (dgettext on bundle id domain). `static_locale` is a `const fn` that
panics at compile time for unsupported locales. i18n always enabled.
- `open::open(kind, url)`: local `OpenUrlKind` enum
(TODO(integration): apprt action kind). Snap handling keyed on `SNAP` env
instead of build flag.
## Deferred / incomplete
- **locale::ensure_locale is a no-op.** `setlocale` and `setenv` are unsafe;
no safe crate wraps them (`gettextrs::setlocale` is `unsafe fn`). gtk_init
calls `setlocale(LC_ALL, "")` on Linux. TODO(integration): if needed, perform
the libc call in an unsafe-allowed crate (steps documented in locale.rs).
- **passwd Flatpak branch**: Zig shells out to host `getent passwd` via
FlatpakHostCommand + Pty. Needs ghostty-termio Pty -> TODO(integration);
currently warns and uses sandbox entry.
- **FlatpakHostCommand**: implemented with `zbus` blocking API (HostCommand,
HostCommandSignal, HostCommandExited signal, fd passing) in a helper thread;
`waitXev` replaced by `wait_callback`. Compiles and the state machine/error
paths are tested, but the D-Bus path is UNTESTED (no Flatpak portal here).
Constructor `FlatpakHostCommand::new(argv, stdin, stdout, stderr)`; fds are
`OwnedFd` and dup'd on spawn.
- `mouse::click_interval` is `None` on Linux (macOS only).
## Tests
Ported Linux-applicable Zig tests (all passing, 88 total incl. extras):
xdg(5), homedir(2: expandHomeUnix, home), hostname(4), env(4), path(3),
shell(1 builder + 7 escape), string_encoding(10 printf_q + singles_percent +
7 percent), uri(3), desktop(1), kernel_info(1), args(1), passwd(1), TempDir(1),
i18n canonicalize (darwin-only in Zig, run here against the approximation).
Skipped: none of the Linux tests. Extras: path_join, file_type, systemd notify
(real unix datagram socket), resourcesdir, pipe, uri/std parse, etc.
Zig tests that mutate env were rewritten with `temp_env::with_vars`; tests that
read PATH/HOME take the same lock via `testutil::with_env_lock`.