fix: preserve non-UTF-8 bytes in OS, config and CLI layers

Zig treats paths, arguments and environment values as byte slices; the
port converted them lossily in many places. Now (final-Z1.md):
- argv, env (incl. present non-UTF-8 values), HOME, PATH lookup,
  passwd, resources dir, TMPDIR, cgroup, config path canonicalization,
  +validate-config, +list-themes and Zig-string escapes (\xFF) keep raw
  bytes. String-based config values carry bytes via ghostty_os::osbytes,
  a bijective escape (noncharacter U+FDD0 + byte) that leaves all valid
  UTF-8 - including Nerd Font private-use icons - unchanged.
- os/file rlimit fallbacks exist on all targets.
- i18n locale canonicalization is an exact port of libintl's tables
  (e.g. pa-Guru -> pa_IN).
- New ghostty-build-info crate is the single source of version, channel,
  commit and build mode for all consumers.

Test Plan:
- cargo test -p ghostty-os -p ghostty-config -p ghostty-cli
  (incl. randomized osbytes round-trip property tests)
This commit is contained in:
ddidderr committed 2026-10-12 08:14:02 +02:00
1 parent e4cdd8e007
commit 395cff8981
32 files changed
+1464 -333

No files matched your search

+45 -16
View File
@@ -5,9 +5,28 @@
//! don't panic).
/// Returns an iterator over the command line arguments.
///
/// If [`set_argv`] was called (the embedded `ghostty_init`, Zig:
/// `std.os.argv = argv[0..argc]`) those arguments are returned instead of
/// the process arguments.
#[cfg(not(target_os = "macos"))]
pub fn iterator() -> std::env::ArgsOs {
std::env::args_os()
pub fn iterator() -> std::vec::IntoIter<std::ffi::OsString> {
match ARGV_OVERRIDE.lock().ok().and_then(|g| g.clone()) {
Some(v) => v.into_iter(),
None => std::env::args_os().collect::<Vec<_>>().into_iter(),
}
}
static ARGV_OVERRIDE: std::sync::Mutex<Option<Vec<std::ffi::OsString>>> =
std::sync::Mutex::new(None);
/// Replace the process arguments seen by [`iterator`] (Zig's assignment to
/// `std.os.argv` in `ghostty_init`). Has no effect on macOS, where the
/// arguments always come from `NSProcessInfo` (as in Zig).
pub fn set_argv(args: Vec<std::ffi::OsString>) {
if let Ok(mut g) = ARGV_OVERRIDE.lock() {
*g = Some(args);
}
}
/// Returns an iterator over the command line arguments.
@@ -27,15 +46,25 @@ pub fn iterator() -> std::vec::IntoIter<std::ffi::OsString> {
.into_iter()
}
/// Collect the process arguments as `String`s without ever panicking on
/// non-UTF-8 bytes (invalid sequences become U+FFFD). The CLI/config layers
/// are string based; `+action` dispatch only depends on the ASCII prefix.
pub fn collect_lossy() -> Vec<String> {
lossy(iterator())
/// Collect the process arguments as raw OS strings (no conversion, never
/// panics on non-UTF-8 bytes).
pub fn collect_os() -> Vec<std::ffi::OsString> {
iterator().collect()
}
fn lossy(it: impl Iterator<Item = std::ffi::OsString>) -> Vec<String> {
it.map(|a| a.to_string_lossy().into_owned()).collect()
/// Collect the process arguments as raw byte vectors (Zig `[]const u8`).
pub fn collect_bytes() -> Vec<Vec<u8>> {
iterator().map(|a| crate::osbytes::os_bytes(&a)).collect()
}
/// Collect the process arguments as `String`s using the reversible
/// byte-escape encoding of [`crate::osbytes`]: no bytes are lost, invalid
/// UTF-8 bytes are carried as private-use escapes that the config layer
/// decodes back to the original bytes.
pub fn collect_config_strings() -> Vec<String> {
iterator()
.map(|a| crate::osbytes::os_to_config_string(&a))
.collect()
}
#[cfg(test)]
@@ -50,13 +79,13 @@ mod tests {
#[cfg(unix)]
#[test]
fn lossy_does_not_panic_on_non_utf8() {
fn config_strings_keep_non_utf8_bytes() {
use std::os::unix::ffi::OsStringExt;
let args = vec![
std::ffi::OsString::from("ghostty"),
std::ffi::OsString::from_vec(vec![b'+', b'x', 0xff]),
];
let out = lossy(args.into_iter());
assert_eq!(out, vec!["ghostty".to_owned(), "+x\u{fffd}".to_owned()]);
let a = std::ffi::OsString::from_vec(vec![b'+', b'x', 0xff]);
let s = crate::osbytes::os_to_config_string(&a);
assert_eq!(
crate::osbytes::config_str_to_bytes(&s),
vec![b'+', b'x', 0xff]
);
}
}
+23 -6
View File
@@ -4,8 +4,14 @@ use std::io::Read;
/// 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.
/// Returns the path to the cgroup for the given pid. Non-UTF-8 bytes are
/// carried via [`crate::osbytes`]; see [`current_os`] for the raw bytes.
pub fn current(pid: u32) -> Option<String> {
current_os(pid).map(|p| crate::osbytes::os_to_config_string(&p))
}
/// Byte-preserving [`current`].
pub fn current_os(pid: u32) -> Option<std::ffi::OsString> {
// 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
@@ -21,15 +27,15 @@ pub fn current(pid: u32) -> Option<String> {
.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())
Some(crate::osbytes::os_from_bytes(rest[..end].to_vec()))
}
/// True if the final path component of `pid`'s cgroup equals `expected`.
pub fn scope_matches(pid: u32, expected: &str) -> bool {
current(pid)
.as_deref()
.and_then(|raw| raw.rsplit_once('/'))
.is_some_and(|(_, last)| last == expected)
current_os(pid).is_some_and(|raw| {
let raw = crate::osbytes::os_bytes(&raw);
raw.rsplit(|&b| b == b'/').next() == Some(expected.as_bytes()) && raw.contains(&b'/')
})
}
/// Poll (every 25ms, at most 250ms) until `pid`'s cgroup ends in `expected`.
@@ -61,4 +67,15 @@ mod tests {
}
assert!(current(u32::MAX).is_none());
}
#[test]
fn scope_matches_requires_slash_and_last_component() {
// Our own cgroup path always matches its own last component.
if let Some(p) = current(std::process::id())
&& let Some((_, last)) = p.rsplit_once('/')
{
assert!(scope_matches(std::process::id(), last));
}
assert!(!scope_matches(u32::MAX, "x"));
}
}
+30 -3
View File
@@ -235,10 +235,24 @@ pub fn prepend_env(current: &str, value: &str) -> String {
format!("{value}{DELIMITER}{current}")
}
/// Gets the value of an environment variable, or None if not found
/// (or not valid unicode).
/// Gets the raw value of an environment variable, or None if not found.
/// A present value that is not valid UTF-8 is returned as-is (never treated
/// as absent).
pub fn getenv_os(key: &str) -> Option<OsString> {
std::env::var_os(key)
}
/// Gets the raw value of an environment variable. Returns None if not found
/// or the value is empty.
pub fn getenv_not_empty_os(key: &str) -> Option<OsString> {
getenv_os(key).filter(|v| !v.is_empty())
}
/// Gets the value of an environment variable, or None if not found. A
/// non-UTF-8 value is still present: its invalid bytes are carried through
/// the reversible [`crate::osbytes`] encoding.
pub fn getenv(key: &str) -> Option<String> {
std::env::var(key).ok()
getenv_os(key).map(|v| crate::osbytes::os_to_config_string(&v))
}
/// Gets the value of an environment variable. Returns None if not found or
@@ -294,6 +308,19 @@ mod tests {
assert_eq!(append_env("a:b", "foo"), format!("a:b{DELIMITER}foo"));
}
#[cfg(unix)]
#[test]
fn getenv_non_utf8_is_present() {
let v = OsString::from_vec(vec![b'a', 0xff]);
temp_env::with_var("GHOSTTY_RS_T3", Some(&v), || {
assert_eq!(getenv_os("GHOSTTY_RS_T3").unwrap(), v);
assert_eq!(getenv_not_empty_os("GHOSTTY_RS_T3").unwrap(), v);
let s = getenv("GHOSTTY_RS_T3").unwrap();
assert_eq!(crate::osbytes::config_str_to_bytes(&s), vec![b'a', 0xff]);
assert!(getenv_not_empty("GHOSTTY_RS_T3").is_some());
});
}
#[cfg(unix)]
#[test]
fn os_variants_preserve_bytes() {
+40 -6
View File
@@ -70,19 +70,41 @@ 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> {
/// Resource limit placeholder on platforms without `rlimit` (Zig:
/// `rlimit = if (@hasDecl(posix.system, "rlimit")) posix.rlimit else struct {}`).
#[cfg(not(unix))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Rlimit;
/// Unsupported-platform fallback: returns `None` like Zig.
#[cfg(not(unix))]
pub fn fix_max_files() -> Option<Rlimit> {
None
}
/// Unsupported-platform fallback: no-op like Zig.
#[cfg(not(unix))]
pub fn restore_max_files(_lim: Rlimit) {}
/// Return the recommended path for temporary files (byte-preserving).
pub fn alloc_tmp_dir_os() -> Option<std::path::PathBuf> {
if cfg!(windows) {
// TODO: what is a good fallback path on windows? (same as Zig)
return std::env::var_os("TMP").map(|v| v.to_string_lossy().into_owned());
return std::env::var_os("TMP").map(Into::into);
}
if let Some(v) = std::env::var_os("TMPDIR") {
return Some(v.to_string_lossy().into_owned());
return Some(v.into());
}
if let Some(v) = std::env::var_os("TMP") {
return Some(v.to_string_lossy().into_owned());
return Some(v.into());
}
Some("/tmp".to_owned())
Some("/tmp".into())
}
/// Return the recommended path for temporary files. Non-UTF-8 bytes are
/// carried via [`crate::osbytes`]; see [`alloc_tmp_dir_os`].
pub fn alloc_tmp_dir() -> Option<String> {
alloc_tmp_dir_os().map(|p| crate::osbytes::os_to_config_string(p.as_os_str()))
}
#[cfg(all(test, unix))]
@@ -103,6 +125,18 @@ mod tests {
});
}
#[test]
fn tmp_dir_non_utf8() {
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let v = std::ffi::OsString::from_vec(b"/tmp/\xff".to_vec());
temp_env::with_vars([("TMPDIR", Some(v.clone())), ("TMP", None)], || {
assert_eq!(
alloc_tmp_dir_os().unwrap().as_os_str().as_bytes(),
b"/tmp/\xff"
);
});
}
#[cfg(unix)]
#[test]
fn fix_and_restore_max_files() {
+69 -27
View File
@@ -1,6 +1,8 @@
use std::ffi::{OsStr, OsString};
#[cfg(unix)]
use std::process::{Command, Stdio};
use crate::osbytes;
#[cfg(unix)]
use crate::passwd;
@@ -24,16 +26,29 @@ 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.
///
/// Non-UTF-8 bytes are preserved through [`crate::osbytes`]; use
/// [`home_os`] for the raw OS string.
pub fn home() -> Result<Option<String>, Error> {
home_with_capacity(MAX_PATH_BYTES)
}
/// Byte-preserving [`home`].
pub fn home_os() -> Result<Option<OsString>, Error> {
home_os_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.
#[cfg(unix)]
pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
let check = |s: String| {
if cap < s.len() {
Ok(home_os_with_capacity(cap)?.map(|h| osbytes::os_to_config_string(&h)))
}
/// Byte-preserving [`home_with_capacity`].
#[cfg(unix)]
pub fn home_os_with_capacity(cap: usize) -> Result<Option<OsString>, Error> {
let check = |s: OsString| {
if cap < osbytes::os_bytes(&s).len() {
Err(Error::BufferTooSmall)
} else {
Ok(Some(s))
@@ -42,7 +57,7 @@ pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
// 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());
return check(result);
}
// On macOS: [NSFileManager defaultManager].homeDirectoryForCurrentUser.path
@@ -53,12 +68,12 @@ pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
let manager = NSFileManager::defaultManager();
let home_url = manager.homeDirectoryForCurrentUser();
if let Some(path) = home_url.path() {
return check(path.to_string());
return check(OsString::from(path.to_string()));
}
}
// We try passwd. This doesn't work on multi-user mac but we try it anyways.
if let Ok(pw) = passwd::get()
if let Ok(pw) = passwd::get_os()
&& let Some(result) = pw.home
{
return check(result);
@@ -73,8 +88,7 @@ pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
if let Ok(run) = run
&& run.status.success()
{
let out = String::from_utf8_lossy(&run.stdout);
return check(trim_space(&out).to_owned());
return check(osbytes::os_from_bytes(trim_space(&run.stdout).to_vec()));
}
Ok(None)
@@ -83,12 +97,10 @@ pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
/// Windows: `%HOMEDRIVE%%HOMEPATH%` (`homeWindows`). Returns `None` if
/// either variable is missing.
#[cfg(windows)]
pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
home_windows(
std::env::var("HOMEDRIVE").ok().as_deref(),
std::env::var("HOMEPATH").ok().as_deref(),
cap,
)
pub fn home_os_with_capacity(cap: usize) -> Result<Option<OsString>, Error> {
let drive = std::env::var_os("HOMEDRIVE").map(|v| v.to_string_lossy().into_owned());
let path = std::env::var_os("HOMEPATH").map(|v| v.to_string_lossy().into_owned());
Ok(home_windows(drive.as_deref(), path.as_deref(), cap)?.map(OsString::from))
}
/// Pure part of [`home_with_capacity`] on Windows.
@@ -108,8 +120,14 @@ fn home_windows(
}
#[cfg(unix)]
fn trim_space(input: &str) -> &str {
input.trim_matches([' ', '\n', '\t'])
fn trim_space(input: &[u8]) -> &[u8] {
let is_ws = |b: &u8| matches!(b, b' ' | b'\n' | b'\t');
let start = input.iter().position(|b| !is_ws(b)).unwrap_or(input.len());
let end = input
.iter()
.rposition(|b| !is_ws(b))
.map_or(start, |i| i + 1);
&input[start..end]
}
/// Expands a path that starts with a tilde (~) to the home directory of
@@ -121,31 +139,41 @@ pub fn expand_home(path: &str) -> Result<String, ExpandError> {
expand_home_with_capacity(path, MAX_PATH_BYTES)
}
/// Byte-preserving [`expand_home`].
pub fn expand_home_os(path: &OsStr) -> Result<OsString, ExpandError> {
expand_home_os_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> {
expand_home_os_with_capacity(&osbytes::config_str_to_os(path), cap)
.map(|p| osbytes::os_to_config_string(&p))
}
/// Windows: `~/` is not an idiom generally used on Windows.
#[cfg(windows)]
pub fn expand_home_with_capacity(path: &str, _cap: usize) -> Result<String, ExpandError> {
// `~/` is not an idiom generally used on Windows
pub fn expand_home_os_with_capacity(path: &OsStr, _cap: usize) -> Result<OsString, ExpandError> {
Ok(path.to_owned())
}
/// See the Windows variant above.
#[cfg(unix)]
pub fn expand_home_with_capacity(path: &str, cap: usize) -> Result<String, ExpandError> {
if !path.starts_with("~/") {
pub fn expand_home_os_with_capacity(path: &OsStr, cap: usize) -> Result<OsString, ExpandError> {
let bytes = osbytes::os_bytes(path);
if !bytes.starts_with(b"~/") {
return Ok(path.to_owned());
}
let Ok(Some(home_dir)) = home_with_capacity(cap) else {
let Ok(Some(home_dir)) = home_os_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 {
let mut out = osbytes::os_bytes(&home_dir);
let rest = &bytes[1..]; // Skip the ~
if out.len() + rest.len() > cap {
return Err(ExpandError::BufferTooSmall);
}
Ok(format!("{home_dir}{rest}"))
out.extend_from_slice(rest);
Ok(osbytes::os_from_bytes(out))
}
#[cfg(test)]
@@ -211,6 +239,20 @@ mod tests_unix {
});
}
#[test]
fn home_preserves_non_utf8_env() {
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let h = OsString::from_vec(b"/home/\xffu".to_vec());
temp_env::with_var("HOME", Some(&h), || {
assert_eq!(home_os().unwrap().unwrap(), h);
let e = expand_home_os(OsStr::from_bytes(b"~/x\xfe")).unwrap();
assert_eq!(e.as_bytes(), b"/home/\xffu/x\xfe");
// The String API round-trips through the escape encoding.
let s = expand_home("~/y").unwrap();
assert_eq!(osbytes::config_str_to_bytes(&s), b"/home/\xffu/y");
});
}
#[test]
fn home_prefers_env() {
temp_env::with_var("HOME", Some("/some/home"), || {
+66 -12
View File
@@ -79,26 +79,49 @@ pub fn tr(msgid: &str) -> String {
/// 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`).
/// `_libintl_locale_name_canonicalize` (the Mac OS X flavour of
/// `gl_locale_name_canonicalize`), after fixing up some `zh` locales. This
/// ports that function exactly, including its legacy-name, language-tag and
/// script tables ([`crate::i18n_canon_table`]). 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(),
_ => {}
libintl_locale_name_canonicalize(locale)
}
/// `gl_locale_name_canonicalize` (gnulib `localename-unsafe.c`).
fn libintl_locale_name_canonicalize(name: &str) -> String {
use crate::i18n_canon_table::{LANGTAG_TABLE, LEGACY_TABLE, SCRIPT_TABLE};
// Step 1: Convert using legacy_table.
if name.as_bytes().first().is_some_and(u8::is_ascii_uppercase)
&& let Some((_, unixy)) = LEGACY_TABLE.iter().find(|(l, _)| *l == name)
{
return (*unixy).to_owned();
}
locale.replace('-', "_")
// Step 2: Convert using langtag_table and script_table.
if name.len() == 7 && name.as_bytes()[2] == b'-' {
if let Some((_, unixy)) = LANGTAG_TABLE.iter().find(|(l, _)| *l == name) {
return (*unixy).to_owned();
}
// `name.get(3..)` is None only when byte 3 is not a char boundary,
// in which case no script entry can match.
if let Some(script) = name.get(3..)
&& let Some((_, unixy)) = SCRIPT_TABLE.iter().find(|(s, _)| *s == script)
{
// name[2] = '@'; strcpy (name + 3, unixy)
return format!("{}@{unixy}", &name[..2]);
}
}
// Step 3: Convert new-style dash to Unix underscore.
name.replace('-', "_")
}
/// Handles some zh locales canonicalization because internal libintl
@@ -183,6 +206,37 @@ mod tests {
assert_eq!("en_US.UTF_8", canonicalize_locale("en_US.UTF-8"));
}
#[test]
fn canonicalize_locale_libintl_tables() {
// langtag_table
assert_eq!("pa_IN", canonicalize_locale("pa-Guru"));
assert_eq!("pa_PK", canonicalize_locale("pa-Arab"));
assert_eq!("az", canonicalize_locale("az-Latn"));
assert_eq!("sr", canonicalize_locale("sr-Cyrl"));
assert_eq!("kk", canonicalize_locale("kk-Cyrl"));
assert_eq!("ga", canonicalize_locale("ga-dots"));
// 8 bytes long: the `strlen == 7` guard skips the tables (upstream quirk).
assert_eq!("yue_Hans", canonicalize_locale("yue-Hans"));
// script_table (name[2] = '@')
assert_eq!("sr@latin", canonicalize_locale("sr-Latn"));
assert_eq!("uz@arabic", canonicalize_locale("uz-Arab"));
assert_eq!("mn@mongolian", canonicalize_locale("mn-Mong"));
// legacy_table (only for names starting uppercase)
assert_eq!("pt_BR", canonicalize_locale("Brazilian Portuguese"));
assert_eq!("zh_TW", canonicalize_locale("Traditional Chinese"));
assert_eq!("de", canonicalize_locale("German"));
assert_eq!("yi", canonicalize_locale("Yiddish"));
assert_eq!("english", canonicalize_locale("english"));
// dash fallback
assert_eq!("pt_BR", canonicalize_locale("pt-BR"));
assert_eq!("en_US", canonicalize_locale("en-US"));
// non-ASCII input of length 7 must not panic
assert_eq!(
"\u{e9}\u{e9}\u{e9}x",
canonicalize_locale("\u{e9}\u{e9}\u{e9}x")
);
}
#[test]
fn locales_map_and_static_locale() {
assert!(is_supported_locale("zh_CN"));
+167
View File
@@ -0,0 +1,167 @@
//! Tables for [`crate::i18n::canonicalize_locale`], transcribed from gnulib's
//! `localename-unsafe.c` (`gl_locale_name_canonicalize`, Mac OS X flavour) as
//! bundled in gettext (`_libintl_locale_name_canonicalize`).
/// Legacy (NeXTstep inherited) Mac OS X English names to Unix names
/// (gnulib `localename-unsafe.c`, `legacy_table`).
pub(crate) const LEGACY_TABLE: [(&str, &str); 131] = [
("Afrikaans", "af"),
("Albanian", "sq"),
("Amharic", "am"),
("Arabic", "ar"),
("Armenian", "hy"),
("Assamese", "as"),
("Aymara", "ay"),
("Azerbaijani", "az"),
("Basque", "eu"),
("Belarusian", "be"),
("Belorussian", "be"),
("Bengali", "bn"),
("Brazilian Portugese", "pt_BR"),
("Brazilian Portuguese", "pt_BR"),
("Breton", "br"),
("Bulgarian", "bg"),
("Burmese", "my"),
("Byelorussian", "be"),
("Catalan", "ca"),
("Chewa", "ny"),
("Chichewa", "ny"),
("Chinese", "zh"),
("Chinese, Simplified", "zh_CN"),
("Chinese, Traditional", "zh_TW"),
("Chinese, Tradtional", "zh_TW"),
("Croatian", "hr"),
("Czech", "cs"),
("Danish", "da"),
("Dutch", "nl"),
("Dzongkha", "dz"),
("English", "en"),
("Esperanto", "eo"),
("Estonian", "et"),
("Faroese", "fo"),
("Farsi", "fa"),
("Finnish", "fi"),
("Flemish", "nl_BE"),
("French", "fr"),
("Galician", "gl"),
("Gallegan", "gl"),
("Georgian", "ka"),
("German", "de"),
("Greek", "el"),
("Greenlandic", "kl"),
("Guarani", "gn"),
("Gujarati", "gu"),
("Hawaiian", "haw"),
("Hebrew", "he"),
("Hindi", "hi"),
("Hungarian", "hu"),
("Icelandic", "is"),
("Indonesian", "id"),
("Inuktitut", "iu"),
("Irish", "ga"),
("Italian", "it"),
("Japanese", "ja"),
("Javanese", "jv"),
("Kalaallisut", "kl"),
("Kannada", "kn"),
("Kashmiri", "ks"),
("Kazakh", "kk"),
("Khmer", "km"),
("Kinyarwanda", "rw"),
("Kirghiz", "ky"),
("Korean", "ko"),
("Kurdish", "ku"),
("Latin", "la"),
("Latvian", "lv"),
("Lithuanian", "lt"),
("Macedonian", "mk"),
("Malagasy", "mg"),
("Malay", "ms"),
("Malayalam", "ml"),
("Maltese", "mt"),
("Manx", "gv"),
("Marathi", "mr"),
("Moldavian", "mo"),
("Mongolian", "mn"),
("Nepali", "ne"),
("Norwegian", "nb"),
("Nyanja", "ny"),
("Nynorsk", "nn"),
("Oriya", "or"),
("Oromo", "om"),
("Panjabi", "pa"),
("Pashto", "ps"),
("Persian", "fa"),
("Polish", "pl"),
("Portuguese", "pt"),
("Portuguese, Brazilian", "pt_BR"),
("Punjabi", "pa"),
("Pushto", "ps"),
("Quechua", "qu"),
("Romanian", "ro"),
("Ruanda", "rw"),
("Rundi", "rn"),
("Russian", "ru"),
("Sami", "se_NO"),
("Sanskrit", "sa"),
("Scottish", "gd"),
("Serbian", "sr"),
("Simplified Chinese", "zh_CN"),
("Sindhi", "sd"),
("Sinhalese", "si"),
("Slovak", "sk"),
("Slovenian", "sl"),
("Somali", "so"),
("Spanish", "es"),
("Sundanese", "su"),
("Swahili", "sw"),
("Swedish", "sv"),
("Tagalog", "tl"),
("Tajik", "tg"),
("Tajiki", "tg"),
("Tamil", "ta"),
("Tatar", "tt"),
("Telugu", "te"),
("Thai", "th"),
("Tibetan", "bo"),
("Tigrinya", "ti"),
("Tongan", "to"),
("Traditional Chinese", "zh_TW"),
("Turkish", "tr"),
("Turkmen", "tk"),
("Uighur", "ug"),
("Ukrainian", "uk"),
("Urdu", "ur"),
("Uzbek", "uz"),
("Vietnamese", "vi"),
("Welsh", "cy"),
("Yiddish", "yi"),
];
/// New-style language tags (ISO 639 + ISO 15924) to Unix names (`langtag_table`).
pub(crate) const LANGTAG_TABLE: [(&str, &str); 16] = [
("az-Latn", "az"),
("bs-Latn", "bs"),
("ga-dots", "ga"),
("kk-Cyrl", "kk"),
("mn-Cyrl", "mn"),
("ms-Latn", "ms"),
("pa-Arab", "pa_PK"),
("pa-Guru", "pa_IN"),
("sr-Cyrl", "sr"),
("tg-Cyrl", "tg"),
("tk-Cyrl", "tk"),
("tt-Cyrl", "tt"),
("uz-Latn", "uz"),
("yue-Hans", "yue"),
("zh-Hans", "zh_CN"),
("zh-Hant", "zh_TW"),
];
/// Script names (ISO 15924) to Unix conventions (`script_table`).
pub(crate) const SCRIPT_TABLE: [(&str, &str); 4] = [
("Arab", "arabic"),
("Cyrl", "cyrillic"),
("Latn", "latin"),
("Mong", "mongolian"),
];
+19 -3
View File
@@ -30,6 +30,7 @@ pub mod file;
pub mod file_type;
#[cfg(unix)]
pub mod flatpak;
pub mod osbytes;
#[cfg(windows)]
pub mod flatpak {
//! Flatpak does not exist on Windows.
@@ -45,6 +46,8 @@ pub mod homedir;
pub mod hostname;
#[cfg(not(target_arch = "wasm32"))]
pub mod i18n;
#[cfg(not(target_arch = "wasm32"))]
mod i18n_canon_table;
pub mod i18n_locales;
#[cfg(not(target_arch = "wasm32"))]
pub mod kernel_info;
@@ -73,10 +76,23 @@ pub mod passwd {
pub name: Option<String>,
}
/// Byte-preserving entry (same shape as the Unix one).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EntryOs {
pub shell: Option<std::ffi::OsString>,
pub home: Option<std::ffi::OsString>,
pub name: Option<std::ffi::OsString>,
}
/// No passwd database on Windows: always an empty entry.
pub fn get() -> std::io::Result<Entry> {
Ok(Entry::default())
}
/// See [`get`].
pub fn get_os() -> std::io::Result<EntryOs> {
Ok(EntryOs::default())
}
}
#[cfg(not(target_arch = "wasm32"))]
pub mod path;
@@ -123,13 +139,13 @@ pub use env::{
get_env_map,
getenv,
getenv_not_empty,
getenv_not_empty_os,
getenv_os,
prepend_env,
prepend_env_os,
};
#[cfg(not(target_arch = "wasm32"))]
pub use file::alloc_tmp_dir;
#[cfg(unix)]
pub use file::{Rlimit, fix_max_files, restore_max_files};
pub use file::{Rlimit, alloc_tmp_dir, alloc_tmp_dir_os, fix_max_files, restore_max_files};
#[cfg(unix)]
pub use flatpak::FlatpakHostCommand;
#[cfg(not(target_arch = "wasm32"))]
+224
View File
@@ -0,0 +1,224 @@
//! Byte-preserving conversions between OS strings and Rust `String`s.
//!
//! Zig treats paths, arguments and environment values as byte slices. Rust
//! `String` cannot hold invalid UTF-8, so the String based layers (the config
//! system) use a reversible encoding that is a true bijection on byte
//! strings:
//!
//! * the escape character is the Unicode noncharacter `U+FDD0` (never used
//! in real text);
//! * every byte that is not part of a valid UTF-8 sequence (always
//! `0x80..=0xFF`) is encoded as `U+FDD0` followed by the char `U+00bb`
//! (`bb` = the byte), e.g. `0xFF` becomes `U+FDD0 U+00FF`;
//! * a literal `U+FDD0` in valid input is escaped as `U+FDD0 U+FDD0`.
//!
//! Hence every valid UTF-8 string without `U+FDD0` is encoded unchanged, and
//! `config_str_to_bytes(bytes_to_config_string(x)) == x` for all byte strings.
use std::ffi::{OsStr, OsString};
const ESC: char = '\u{FDD0}';
/// Encode arbitrary bytes into a `String`, escaping invalid UTF-8 bytes.
pub fn bytes_to_config_string(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len());
for chunk in bytes.utf8_chunks() {
for c in chunk.valid().chars() {
out.push(c);
if c == ESC {
out.push(ESC);
}
}
for &b in chunk.invalid() {
out.push(ESC);
out.push(char::from(b));
}
}
out
}
/// Inverse of [`bytes_to_config_string`].
pub fn config_str_to_bytes(s: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == ESC {
match chars.peek().copied() {
Some(ESC) => {
chars.next();
}
Some(n) if (0x80..=0xFF).contains(&(n as u32)) => {
chars.next();
out.push(n as u32 as u8);
continue;
}
// Not produced by the encoder: keep the char as-is.
_ => {}
}
}
let mut buf = [0u8; 4];
out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
}
out
}
/// The raw bytes of an OS string (Unix; lossless). On other platforms the
/// string is converted via its UTF-8 (WTF-8) form.
#[cfg(unix)]
pub fn os_bytes(s: &OsStr) -> Vec<u8> {
use std::os::unix::ffi::OsStrExt;
s.as_bytes().to_vec()
}
#[cfg(not(unix))]
pub fn os_bytes(s: &OsStr) -> Vec<u8> {
s.to_string_lossy().into_owned().into_bytes()
}
/// Build an OS string from raw bytes.
#[cfg(unix)]
pub fn os_from_bytes(b: Vec<u8>) -> OsString {
use std::os::unix::ffi::OsStringExt;
OsString::from_vec(b)
}
#[cfg(not(unix))]
pub fn os_from_bytes(b: Vec<u8>) -> OsString {
OsString::from(String::from_utf8_lossy(&b).into_owned())
}
/// OS string to reversible `String` (see the module docs).
pub fn os_to_config_string(s: &OsStr) -> String {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
bytes_to_config_string(s.as_bytes())
}
#[cfg(not(unix))]
{
s.to_string_lossy().into_owned()
}
}
/// Inverse of [`os_to_config_string`].
pub fn config_str_to_os(s: &str) -> OsString {
os_from_bytes(config_str_to_bytes(s))
}
/// Path to reversible `String` (see the module docs).
pub fn path_to_config_string(p: &std::path::Path) -> String {
os_to_config_string(p.as_os_str())
}
/// Inverse of [`path_to_config_string`].
pub fn config_str_to_path(s: &str) -> std::path::PathBuf {
std::path::PathBuf::from(config_str_to_os(s))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_invalid_bytes() {
let raw = vec![b'a', 0xff, b'b', 0xc3, 0xa9, 0xc3];
let s = bytes_to_config_string(&raw);
assert_eq!(s, "a\u{fdd0}\u{ff}b\u{e9}\u{fdd0}\u{c3}");
assert_eq!(config_str_to_bytes(&s), raw);
}
#[test]
fn valid_utf8_unchanged() {
let s = "h\u{e9}llo \u{1f600}";
assert_eq!(bytes_to_config_string(s.as_bytes()), s);
assert_eq!(config_str_to_bytes(s), s.as_bytes());
// Nerd Font icons, including the old escape range, are untouched.
let icons = "\u{f500}\u{f780}\u{f7ff}\u{fd46}\u{e0b0}";
assert_eq!(bytes_to_config_string(icons.as_bytes()), icons);
assert_eq!(config_str_to_bytes(icons), icons.as_bytes());
}
#[test]
fn literal_escape_char_is_doubled() {
let s = "a\u{fdd0}\u{ff}";
let e = bytes_to_config_string(s.as_bytes());
assert_eq!(e, "a\u{fdd0}\u{fdd0}\u{ff}");
assert_eq!(config_str_to_bytes(&e), s.as_bytes());
}
/// Tiny xorshift generator (no external rand dependency).
fn rng(seed: &mut u64) -> u64 {
*seed ^= *seed << 13;
*seed ^= *seed >> 7;
*seed ^= *seed << 17;
*seed
}
#[test]
fn property_bytes_round_trip() {
// Fragments that stress the boundaries: icon ranges, the escape
// char, its doubled form, and truncated/invalid sequences.
let frags: [&[u8]; 12] = [
"\u{f780}".as_bytes(),
"\u{f7ff}".as_bytes(),
"\u{f500}".as_bytes(),
"\u{fd46}".as_bytes(),
"\u{fdd0}".as_bytes(),
"\u{fdd0}\u{fdd0}".as_bytes(),
"\u{fdd0}\u{ff}".as_bytes(),
"\u{1f600}".as_bytes(),
b"\xef\xb7",
b"\xff",
b"\xc3",
b"abc",
];
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
for _ in 0..5000 {
let mut x = Vec::new();
for _ in 0..(rng(&mut seed) % 12) {
match rng(&mut seed) % 3 {
0 => x.push(rng(&mut seed) as u8),
_ => x.extend_from_slice(frags[(rng(&mut seed) % 12) as usize]),
}
}
let enc = bytes_to_config_string(&x);
assert_eq!(config_str_to_bytes(&enc), x, "bytes {x:?}");
if let Ok(valid) = std::str::from_utf8(&x)
&& !valid.contains('\u{fdd0}')
{
assert_eq!(enc, valid);
}
}
}
#[test]
fn property_valid_strings_round_trip() {
// Every string (valid UTF-8) decodes back to itself after encoding.
let mut seed = 0x1234_5678_9ABC_DEF1u64;
for _ in 0..5000 {
let mut s = String::new();
for _ in 0..(rng(&mut seed) % 10) {
let cp = match rng(&mut seed) % 4 {
0 => 0xF500 + (rng(&mut seed) % 0x847) as u32,
1 => 0xFDD0,
2 => 0x80 + (rng(&mut seed) % 0x80) as u32,
_ => 0x20 + (rng(&mut seed) % 0x5f) as u32,
};
s.push(char::from_u32(cp).unwrap());
}
let enc = bytes_to_config_string(s.as_bytes());
assert_eq!(config_str_to_bytes(&enc), s.as_bytes(), "{s:?}");
if !s.contains('\u{fdd0}') {
assert_eq!(enc, s);
}
}
}
#[cfg(unix)]
#[test]
fn os_roundtrip() {
let o = os_from_bytes(vec![0xff, b'/', 0x80]);
let s = os_to_config_string(&o);
assert_eq!(config_str_to_os(&s), o);
}
}
+62 -18
View File
@@ -1,5 +1,8 @@
use std::ffi::OsString;
#[cfg(any(target_os = "linux", target_os = "android"))]
use crate::flatpak::is_flatpak;
use crate::osbytes;
/// Entry that is retrieved from the passwd API. This only contains the fields
/// we care about.
@@ -10,19 +13,45 @@ pub struct Entry {
pub name: Option<String>,
}
/// Get the passwd entry for the currently executing user.
/// Byte-preserving [`Entry`] (Zig slices are arbitrary bytes).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EntryOs {
pub shell: Option<OsString>,
pub home: Option<OsString>,
pub name: Option<OsString>,
}
impl From<EntryOs> for Entry {
fn from(e: EntryOs) -> Self {
let f = |v: Option<OsString>| v.map(|v| osbytes::os_to_config_string(&v));
Entry {
shell: f(e.shell),
home: f(e.home),
name: f(e.name),
}
}
}
/// Get the passwd entry for the currently executing user. Non-UTF-8 bytes
/// are carried via the reversible [`crate::osbytes`] encoding; see
/// [`get_os`] for the raw bytes.
pub fn get() -> std::io::Result<Entry> {
get_os().map(Entry::from)
}
/// Byte-preserving [`get`].
pub fn get_os() -> std::io::Result<EntryOs> {
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());
return Ok(EntryOs::default());
}
Err(e) => {
log::warn!("error retrieving pw entry code={e}");
return Ok(Entry::default());
return Ok(EntryOs::default());
}
};
@@ -35,10 +64,10 @@ pub fn get() -> std::io::Result<Entry> {
return host_entry(&user.name);
}
Ok(Entry {
shell: Some(user.shell.to_string_lossy().into_owned()),
home: Some(user.dir.to_string_lossy().into_owned()),
name: Some(user.name),
Ok(EntryOs {
shell: Some(user.shell.into_os_string()),
home: Some(user.dir.into_os_string()),
name: Some(OsString::from(user.name)),
})
}
@@ -49,7 +78,7 @@ pub fn get() -> std::io::Result<Entry> {
/// `Pty`) depends on this crate, so a minimal openpt/ptsname pair is opened
/// here with rustix instead. Like Zig, only `shell` and `home` are filled in.
#[cfg(any(target_os = "linux", target_os = "android"))]
fn host_entry(name: &str) -> std::io::Result<Entry> {
fn host_entry(name: &str) -> std::io::Result<EntryOs> {
use std::io::Read;
use rustix::{
@@ -112,17 +141,20 @@ fn host_entry(name: &str) -> std::io::Result<Entry> {
}
}
Ok(parse_getent(&String::from_utf8_lossy(&output)))
Ok(parse_getent(&output))
}
#[cfg(any(target_os = "linux", target_os = "android"))]
/// Shell and home are the last two `:`-separated fields of the output.
#[cfg(any(target_os = "linux", target_os = "android"))]
fn parse_getent(output: &str) -> Entry {
let mut it = output.trim_end_matches([' ', '\r', '\n']).rsplit(':');
let shell = it.next().map(str::to_owned);
let home = it.next().map(str::to_owned);
Entry {
fn parse_getent(output: &[u8]) -> EntryOs {
let end = output
.iter()
.rposition(|b| !matches!(b, b' ' | b'\r' | b'\n'))
.map_or(0, |i| i + 1);
let mut it = output[..end].rsplit(|&b| b == b':');
let shell = it.next().map(|b| osbytes::os_from_bytes(b.to_vec()));
let home = it.next().map(|b| osbytes::os_from_bytes(b.to_vec()));
EntryOs {
shell,
home,
name: None,
@@ -136,12 +168,24 @@ mod tests {
#[cfg(any(target_os = "linux", target_os = "android"))]
#[test]
fn parse_getent_output() {
let e = parse_getent("mitchell:x:1000:1000:Mitchell:/home/mitchell:/bin/zsh\r\n");
assert_eq!(e.shell.as_deref(), Some("/bin/zsh"));
assert_eq!(e.home.as_deref(), Some("/home/mitchell"));
let e = parse_getent(b"mitchell:x:1000:1000:Mitchell:/home/mitchell:/bin/zsh\r\n");
assert_eq!(e.shell.as_deref(), Some(std::ffi::OsStr::new("/bin/zsh")));
assert_eq!(
e.home.as_deref(),
Some(std::ffi::OsStr::new("/home/mitchell"))
);
assert_eq!(e.name, None);
}
#[cfg(any(target_os = "linux", target_os = "android"))]
#[test]
fn parse_getent_keeps_non_utf8() {
use std::os::unix::ffi::OsStrExt;
let e = parse_getent(b"u:x:1:1::/home/\xff:/bin/\xfesh\n");
assert_eq!(e.shell.unwrap().as_bytes(), b"/bin/\xfesh");
assert_eq!(e.home.unwrap().as_bytes(), b"/home/\xff");
}
#[test]
fn get_entry() {
// We should be able to get an entry
+43 -8
View File
@@ -1,6 +1,6 @@
use std::io;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::{ffi::OsStr, io, path::PathBuf};
/// `std.fs.path.delimiter` / `sep`.
#[cfg(windows)]
@@ -25,28 +25,44 @@ pub enum Error {
Io(#[from] io::Error),
}
/// Search for "cmd" in the PATH and return the absolute path.
/// Search for "cmd" in the PATH and return the absolute path. Non-UTF-8
/// bytes are carried via [`crate::osbytes`]; see [`expand_os`].
pub fn expand(cmd: &str) -> Result<Option<String>, Error> {
let cmd = crate::osbytes::config_str_to_os(cmd);
Ok(expand_os(&cmd)?.map(|p| crate::osbytes::os_to_config_string(p.as_os_str())))
}
/// Byte-preserving [`expand`]: `$PATH` and the command are handled as raw
/// bytes, so executables in directories with non-UTF-8 names are found.
pub fn expand_os(cmd: &OsStr) -> Result<Option<PathBuf>, Error> {
let cmd_bytes = crate::osbytes::os_bytes(cmd);
// 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()));
if cmd_bytes.contains(&b'/') {
return Ok(Some(PathBuf::from(cmd)));
}
let Some(path_var) = std::env::var_os("PATH") else {
return Ok(None);
};
let path_var = path_var.to_string_lossy();
let path_var = crate::osbytes::os_bytes(&path_var);
let mut seen_eacces = false;
for search_path in path_var.split(DELIMITER).filter(|s| !s.is_empty()) {
for search_path in path_var
.split(|&b| b == DELIMITER as u8)
.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;
let path_len = search_path.len() + cmd_bytes.len() + 1;
if MAX_PATH_BYTES < path_len {
return Err(Error::PathTooLong);
}
let full_path = format!("{search_path}{SEP}{cmd}");
let mut full = search_path.to_vec();
full.push(SEP as u8);
full.extend_from_slice(&cmd_bytes);
let full_path = PathBuf::from(crate::osbytes::os_from_bytes(full));
// Stat it
let f = match std::fs::File::open(&full_path) {
@@ -127,4 +143,23 @@ mod tests {
assert!(expand("adir").unwrap().is_none());
});
}
#[cfg(unix)]
#[test]
fn expand_finds_executable_in_non_utf8_dir() {
use std::os::unix::{
ffi::{OsStrExt, OsStringExt},
fs::PermissionsExt,
};
let td = crate::TempDir::init().unwrap();
let dir = td.path().join(OsStr::from_bytes(b"d\xffir"));
std::fs::create_dir(&dir).unwrap();
let exe = dir.join("mytool");
std::fs::write(&exe, b"#!/bin/sh\n").unwrap();
std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
let pv = std::ffi::OsString::from_vec(dir.as_os_str().as_bytes().to_vec());
temp_env::with_var("PATH", Some(&pv), || {
assert_eq!(expand_os(OsStr::new("mytool")).unwrap(), Some(exe.clone()));
});
}
}
+59 -17
View File
@@ -1,6 +1,14 @@
use std::{io, path::Path};
use std::{
ffi::OsString,
io,
path::{Path, PathBuf},
};
use crate::path_join;
use crate::osbytes::{config_str_to_os, os_to_config_string};
fn enc(p: &Path) -> String {
os_to_config_string(p.as_os_str())
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResourcesDir {
@@ -25,6 +33,17 @@ impl ResourcesDir {
pub fn host(&self) -> Option<&str> {
self.host_path.as_deref().or(self.app_path.as_deref())
}
/// Byte-preserving [`ResourcesDir::app`]: the string fields carry
/// non-UTF-8 bytes via [`crate::osbytes`], this decodes them.
pub fn app_os(&self) -> Option<PathBuf> {
self.app().map(|p| PathBuf::from(config_str_to_os(p)))
}
/// Byte-preserving [`ResourcesDir::host`].
pub fn host_os(&self) -> Option<PathBuf> {
self.host().map(|p| PathBuf::from(config_str_to_os(p)))
}
}
/// Sentinels we look for in the path to know we've found the resources
@@ -50,7 +69,7 @@ const DEV_SENTINELS: [&str; 1] = ["terminfo/x/xterm-ghostty"];
/// 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();
let env = std::env::var_os("GHOSTTY_RESOURCES_DIR");
Ok(resources_dir_impl(
cfg!(debug_assertions),
exe.as_deref(),
@@ -58,7 +77,7 @@ pub fn resources_dir() -> io::Result<ResourcesDir> {
))
}
fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> ResourcesDir {
fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<OsString>) -> ResourcesDir {
// Use the GHOSTTY_RESOURCES_DIR environment variable in release builds.
//
// In debug builds we try using terminfo detection first instead, since
@@ -67,7 +86,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
// 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()),
app_path: Some(os_to_config_string(dir)),
host_path: None,
};
}
@@ -82,14 +101,12 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
// bundle as we expect it.
while let Some(dir) = exe.parent() {
exe = dir;
let dir_str = dir.to_string_lossy();
// On MacOS, we look for the app bundle path.
#[cfg(target_vendor = "apple")]
for sentinel in SENTINELS {
if let Some(v) = maybe_dir(&dir_str, "Contents/Resources", sentinel) {
if let Some(v) = maybe_dir_os(dir, "Contents/Resources", sentinel) {
return ResourcesDir {
app_path: Some(path_join(&[&v, "ghostty"])),
app_path: Some(enc(&v.join("ghostty"))),
host_path: None,
};
}
@@ -97,9 +114,9 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
// We look for a /usr/share style path.
for sentinel in SENTINELS {
if let Some(v) = maybe_dir(&dir_str, "share", sentinel) {
if let Some(v) = maybe_dir_os(dir, "share", sentinel) {
return ResourcesDir {
app_path: Some(path_join(&[&v, "ghostty"])),
app_path: Some(enc(&v.join("ghostty"))),
host_path: None,
};
}
@@ -110,7 +127,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
// fallback and use the provided resources dir.
if debug && let Some(dir) = env.filter(|d| !d.is_empty()) {
return ResourcesDir {
app_path: Some(dir),
app_path: Some(os_to_config_string(&dir)),
host_path: None,
};
}
@@ -122,7 +139,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
// lives inside this workspace.
if debug && let Some(dir) = dev_resources_dir(exe_start) {
return ResourcesDir {
app_path: Some(dir),
app_path: Some(enc(&dir)),
host_path: None,
};
}
@@ -137,7 +154,7 @@ fn workspace_root() -> std::path::PathBuf {
/// Returns `<workspace>/resources` if `exe` is inside the workspace this
/// crate was built from and the directory contains our sentinel.
fn dev_resources_dir(exe: &Path) -> Option<String> {
fn dev_resources_dir(exe: &Path) -> Option<PathBuf> {
let root = workspace_root().canonicalize().ok()?;
let exe = exe.canonicalize().unwrap_or_else(|_| exe.to_path_buf());
if !exe.starts_with(&root) {
@@ -148,7 +165,15 @@ fn dev_resources_dir(exe: &Path) -> Option<String> {
.iter()
.chain(DEV_SENTINELS.iter())
.any(|s| res.join(s).exists())
.then(|| res.to_string_lossy().into_owned())
.then_some(res)
}
/// Byte-preserving [`maybe_dir`].
pub fn maybe_dir_os(base: &Path, sub: &str, suffix: &str) -> Option<PathBuf> {
let dir = base.join(sub);
// Folder doesn't exist. If a different error happens its okay
// we just ignore it and move on.
dir.join(suffix).exists().then_some(dir)
}
/// Little helper to check if the "base/sub/suffix" directory exists and
@@ -204,7 +229,7 @@ mod tests {
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());
let env = Some(OsString::from("/env/res"));
assert_eq!(
resources_dir_impl(false, Some(&exe), env.clone()).app(),
Some("/env/res")
@@ -220,7 +245,7 @@ mod tests {
);
// Empty env is ignored.
assert_eq!(
resources_dir_impl(false, None, Some(String::new())).app(),
resources_dir_impl(false, None, Some(OsString::new())).app(),
None
);
}
@@ -238,6 +263,23 @@ mod tests {
assert_eq!(r.app(), None);
}
#[test]
fn non_utf8_env_dir_is_honored_and_exe_dir_detected() {
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let env = OsString::from_vec(b"/env/\xffres".to_vec());
let r = resources_dir_impl(false, None, Some(env.clone()));
assert_eq!(r.app_os().unwrap().as_os_str(), env.as_os_str());
// Executable lives under a directory with a non-UTF-8 name.
let td = crate::TempDir::init().unwrap();
let root = td.path().join(std::ffi::OsStr::from_bytes(b"r\xfeoot"));
std::fs::create_dir_all(root.join("share/terminfo/x")).unwrap();
std::fs::write(root.join("share/terminfo/x/xterm-ghostty"), b"").unwrap();
std::fs::create_dir_all(root.join("bin")).unwrap();
let r = resources_dir_impl(true, Some(&root.join("bin/ghostty")), None);
assert_eq!(r.app_os().unwrap(), root.join("share/ghostty"));
}
#[test]
fn maybe_dir_checks_suffix() {
let td = make_tree();
+3 -3
View File
@@ -8,7 +8,7 @@ use std::{
path::{Path, PathBuf},
};
use crate::file::alloc_tmp_dir;
use crate::file::alloc_tmp_dir_os;
// The amount of random bytes to get to determine our filename.
const RANDOM_BYTES: usize = 16;
@@ -89,8 +89,8 @@ fn fill_random(buf: &mut [u8]) -> io::Result<()> {
impl TempDir {
/// Create the temporary directory.
pub fn init() -> io::Result<TempDir> {
let parent = match alloc_tmp_dir() {
Some(p) => PathBuf::from(p),
let parent = match alloc_tmp_dir_os() {
Some(p) => p,
None => std::env::current_dir()?,
};
// Verify the parent can be opened (Zig opens it as a Dir).