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:
1 parent
1388f117d3
commit
f8c5ce06d6
30 files changed
+3736
-1
No files matched your search
@@ -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"
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(|| {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
];
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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~");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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![]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`.
|
||||||
Reference in new issue
Block a user