refactor: type escaped OS strings and fix the final audit findings

Escaped config strings (ghostty_os::osbytes) kept reaching OS
boundaries undecoded, and decoding was occasionally applied to plain
text. The root cause was that both were plain `String`s. Now:
- `EncodedOs` is a newtype (no Display/AsRef<Path>) that decodes only
  via to_os/to_path/to_bytes/to_wide; plain text enters via
  from_plain_text. OS helpers (env, home, xdg, passwd, resources dir,
  path expansion), config::Path, WorkingDirectory, theme/file_load/edit
  paths, terminal pwd, OpenUrl/Pwd actions and termio cwd use it, so the
  compiler forces decoding at every boundary. os::open takes &OsStr.
  Config output is unchanged.

Fixes all rows of docs/rewrite/notes/review-rv5-codex.md (final-Z7.md):
screen-file copy; regex/OSC 8 URLs no longer decoded (regression);
lossless Windows WTF-8/WTF-16 for args, env and CreateProcessW; Windows
resources dir/TERMINFO; color OSC request lists and X11 color lookup no
longer allocate globally (C zero-allocation test extended); macOS Mach
handler resumes all threads before KERN_FAILURE (hang regression) and
SIGABRT minidumps go through the exception thread; plus open-config,
background images, crash dir, ssh cache, exe dir/PATH, fontconfig
filenames, file links, kitty TMPDIR, .hushlogin and benchmark paths.
The typing also surfaced and fixed gtk-custom-css, bell audio, bash
HISTFILE, OSC 52 writes and the C config getters.

Test Plan:
- cargo test --workspace (3376 passed)
- just check-macos (both), just check-windows, just check-wasm
- diff <(ghostty-rs +show-config --default --docs) \
  <(ghostty +show-config --default --docs)
This commit is contained in:
ddidderr committed 2026-10-12 10:06:07 +02:00
1 parent 7a3f0b0f2c
commit f7fec6f44d
92 files changed
+2967 -992

No files matched your search

+9 -6
View File
@@ -259,13 +259,13 @@ pub fn getenv_not_empty_os(key: &str) -> Option<OsString> {
/// 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> {
getenv_os(key).map(|v| crate::osbytes::os_to_config_string(&v))
pub fn getenv(key: &str) -> Option<crate::osbytes::EncodedOs> {
getenv_os(key).map(|v| crate::osbytes::EncodedOs::from_os(&v))
}
/// 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> {
pub fn getenv_not_empty(key: &str) -> Option<crate::osbytes::EncodedOs> {
getenv(key).filter(|v| !v.is_empty())
}
@@ -324,7 +324,10 @@ mod tests {
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_eq!(
crate::osbytes::config_str_to_bytes(s.as_encoded()),
vec![b'a', 0xff]
);
assert!(getenv_not_empty("GHOSTTY_RS_T3").is_some());
});
}
@@ -361,9 +364,9 @@ mod tests {
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("GHOSTTY_RS_T1").unwrap(), "");
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_T2").unwrap(), "v");
assert_eq!(getenv_not_empty("GHOSTTY_RS_NOPE"), None);
},
);
+3 -9
View File
@@ -101,12 +101,6 @@ pub fn alloc_tmp_dir_os() -> Option<std::path::PathBuf> {
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))]
mod tests {
use super::*;
@@ -115,13 +109,13 @@ mod tests {
#[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"));
assert_eq!(alloc_tmp_dir_os(), Some("/a".into()));
});
temp_env::with_vars([("TMPDIR", None), ("TMP", Some("/b"))], || {
assert_eq!(alloc_tmp_dir().as_deref(), Some("/b"));
assert_eq!(alloc_tmp_dir_os(), Some("/b".into()));
});
temp_env::with_vars([("TMPDIR", None::<&str>), ("TMP", None)], || {
assert_eq!(alloc_tmp_dir().as_deref(), Some("/tmp"));
assert_eq!(alloc_tmp_dir_os(), Some("/tmp".into()));
});
}
+6 -6
View File
@@ -101,7 +101,7 @@ pub struct FlatpakHostCommand {
/// 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>,
pub cwd: Option<crate::osbytes::EncodedOs>,
/// Environment variables for the child process. If this is None, this
/// does not send any environment variables.
@@ -298,15 +298,15 @@ impl FlatpakSignalHandle {
/// Decode a reversible config string to the original bytes plus NUL, the
/// D-Bus `ay` form of a path/argument.
fn nul_terminated_bytes(s: &str) -> Vec<u8> {
let mut v = crate::osbytes::config_str_to_bytes(s);
fn nul_terminated_bytes(s: &crate::osbytes::EncodedOs) -> Vec<u8> {
let mut v = s.to_bytes();
v.push(0);
v
}
struct Request {
argv: Vec<String>,
cwd: Option<String>,
cwd: Option<crate::osbytes::EncodedOs>,
env: HashMap<String, String>,
fds: [OwnedFd; 3],
}
@@ -443,8 +443,8 @@ fn wait_for_exit(shared: &Shared, iter: MessageIterator, pid: u32) {
mod tests {
#[test]
fn dbus_bytes_are_decoded() {
let enc = crate::osbytes::bytes_to_config_string(b"/tmp/\xff\xfe");
assert_ne!(enc.as_bytes(), b"/tmp/\xff\xfe");
let enc = crate::osbytes::EncodedOs::from_bytes(b"/tmp/\xff\xfe");
assert_ne!(enc.as_encoded().as_bytes(), b"/tmp/\xff\xfe");
assert_eq!(super::nul_terminated_bytes(&enc), b"/tmp/\xff\xfe\0");
}
+52 -37
View File
@@ -29,7 +29,7 @@ const MAX_PATH_BYTES: usize = 4096;
///
/// Non-UTF-8 bytes are preserved through [`crate::osbytes`]; use
/// [`home_os`] for the raw OS string.
pub fn home() -> Result<Option<String>, Error> {
pub fn home() -> Result<Option<osbytes::EncodedOs>, Error> {
home_with_capacity(MAX_PATH_BYTES)
}
@@ -40,8 +40,8 @@ pub fn home_os() -> Result<Option<OsString>, Error> {
/// 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> {
Ok(home_os_with_capacity(cap)?.map(|h| osbytes::os_to_config_string(&h)))
pub fn home_with_capacity(cap: usize) -> Result<Option<osbytes::EncodedOs>, Error> {
Ok(home_os_with_capacity(cap)?.map(|h| osbytes::EncodedOs::from_os(&h)))
}
/// Byte-preserving [`home_with_capacity`].
@@ -98,25 +98,29 @@ pub fn home_os_with_capacity(cap: usize) -> Result<Option<OsString>, Error> {
/// either variable is missing.
#[cfg(windows)]
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))
let drive = std::env::var_os("HOMEDRIVE");
let path = std::env::var_os("HOMEPATH");
home_windows(drive.as_deref(), path.as_deref(), cap)
}
/// Pure part of [`home_with_capacity`] on Windows.
#[cfg(any(windows, test))]
fn home_windows(
drive: Option<&str>,
path: Option<&str>,
drive: Option<&OsStr>,
path: Option<&OsStr>,
cap: usize,
) -> Result<Option<String>, Error> {
) -> Result<Option<OsString>, Error> {
let (Some(drive), Some(path)) = (drive, path) else {
return Ok(None);
};
if cap < drive.len() + path.len() {
// Lossless (WTF-16 safe) concatenation; the size is measured in WTF-8
// bytes like the Zig buffer.
if cap < osbytes::os_bytes(drive).len() + osbytes::os_bytes(path).len() {
return Err(Error::BufferTooSmall);
}
Ok(Some(format!("{drive}{path}")))
let mut home = drive.to_owned();
home.push(path);
Ok(Some(home))
}
#[cfg(unix)]
@@ -135,7 +139,7 @@ fn trim_space(input: &[u8]) -> &[u8] {
///
/// 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> {
pub fn expand_home(path: &osbytes::EncodedOs) -> Result<osbytes::EncodedOs, ExpandError> {
expand_home_with_capacity(path, MAX_PATH_BYTES)
}
@@ -146,9 +150,11 @@ pub fn expand_home_os(path: &OsStr) -> Result<OsString, ExpandError> {
/// 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))
pub fn expand_home_with_capacity(
path: &osbytes::EncodedOs,
cap: usize,
) -> Result<osbytes::EncodedOs, ExpandError> {
expand_home_os_with_capacity(&path.to_os(), cap).map(osbytes::EncodedOs::from)
}
/// Windows: `~/` is not an idiom generally used on Windows.
@@ -183,13 +189,17 @@ mod tests {
#[test]
fn home_windows_joins_drive_and_path() {
assert_eq!(
home_windows(Some("C:"), Some("\\Users\\me"), 4096),
Ok(Some("C:\\Users\\me".to_owned()))
home_windows(
Some(OsStr::new("C:")),
Some(OsStr::new("\\Users\\me")),
4096
),
Ok(Some(OsString::from("C:\\Users\\me")))
);
assert_eq!(home_windows(None, Some("\\x"), 4096), Ok(None));
assert_eq!(home_windows(Some("C:"), None, 4096), Ok(None));
assert_eq!(home_windows(None, Some(OsStr::new("\\x")), 4096), Ok(None));
assert_eq!(home_windows(Some(OsStr::new("C:")), None, 4096), Ok(None));
assert_eq!(
home_windows(Some("C:"), Some("\\Users"), 3),
home_windows(Some(OsStr::new("C:")), Some(OsStr::new("\\Users")), 3),
Err(Error::BufferTooSmall)
);
}
@@ -204,28 +214,30 @@ mod tests_unix {
fn expand_home_unix() {
with_env_lock(|| {
let cap = MAX_PATH_BYTES;
let home_dir = expand_home_with_capacity("~/", cap).unwrap();
let ex =
|s: &str| expand_home_with_capacity(&osbytes::EncodedOs::from_plain_text(s), cap);
let home_dir = ex("~/").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('/'));
assert!(home_dir.ends_with_plain("/"));
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);
let downloads = ex("~/Downloads/shader.glsl").unwrap();
let expected_downloads = format!("{}Downloads/shader.glsl", home_dir.as_encoded());
assert_eq!(downloads, expected_downloads.as_str());
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());
assert_eq!(ex("~").unwrap(), "~");
assert_eq!(ex("~abc/").unwrap(), "~abc/");
assert_eq!(ex("/home/user").unwrap(), "/home/user");
assert_eq!(ex("").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())
expand_home_with_capacity(
&osbytes::EncodedOs::from_plain_text("~/Downloads"),
home_dir.as_encoded().len()
)
);
});
}
@@ -248,16 +260,19 @@ mod tests_unix {
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");
let s = expand_home(&osbytes::EncodedOs::from_plain_text("~/y")).unwrap();
assert_eq!(s.to_bytes(), b"/home/\xffu/y");
});
}
#[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().unwrap().unwrap(), "/some/home");
assert_eq!(
expand_home(&osbytes::EncodedOs::from_plain_text("~/x")).unwrap(),
"/some/home/x"
);
assert_eq!(home_with_capacity(3), Err(Error::BufferTooSmall));
});
}
+7 -5
View File
@@ -27,19 +27,18 @@ pub enum InitError {
/// used by libghostty.
#[cfg(windows)]
#[allow(clippy::unnecessary_wraps)]
pub fn init(_resources_dir: &str) -> Result<(), InitError> {
pub fn init(_resources_dir: &std::path::Path) -> Result<(), InitError> {
// i18n is unsupported on Windows
Ok(())
}
/// See the Windows variant above.
#[cfg(not(windows))]
pub fn init(resources_dir: &str) -> Result<(), InitError> {
pub fn init(resources_dir: &std::path::Path) -> 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 resources_dir = crate::osbytes::config_str_to_path(resources_dir);
let share_dir = resources_dir
.parent()
.filter(|p| !p.as_os_str().is_empty())
@@ -248,10 +247,13 @@ mod tests {
#[test]
fn init_rejects_dirless_resources_dir() {
assert!(matches!(
init("ghostty"),
init(std::path::Path::new("ghostty")),
Err(InitError::InvalidResourcesDir)
));
assert!(matches!(
init(std::path::Path::new("/")),
Err(InitError::InvalidResourcesDir)
));
assert!(matches!(init("/"), Err(InitError::InvalidResourcesDir)));
}
#[test]
+1 -1
View File
@@ -145,7 +145,7 @@ pub use env::{
prepend_env_os,
};
#[cfg(not(target_arch = "wasm32"))]
pub use file::{Rlimit, alloc_tmp_dir, alloc_tmp_dir_os, fix_max_files, restore_max_files};
pub use file::{Rlimit, alloc_tmp_dir_os, fix_max_files, restore_max_files};
#[cfg(unix)]
pub use flatpak::FlatpakHostCommand;
#[cfg(not(target_arch = "wasm32"))]
+20 -12
View File
@@ -1,5 +1,5 @@
use std::{
ffi::{OsStr, OsString},
ffi::OsStr,
io,
process::{Command, Stdio},
};
@@ -18,9 +18,10 @@ pub enum OpenUrlKind {
/// Open a URL in the default handling application (via `xdg-open`).
///
/// `url` is the core's "config string" form (see [`crate::osbytes`]): a
/// non-UTF-8 path is carried in an escaped form and is decoded to the real
/// path bytes here, right before it is handed to the opener.
/// `url` is the exact OS string to hand to the opener. Callers holding an
/// [`EncodedOs`](crate::osbytes::EncodedOs) decode it explicitly
/// (`to_os`); plain text (a regex link, an OSC 8 URI) is wrapped with
/// `EncodedOs::from_plain_text` first, so it is never decoded by mistake.
///
/// Any output on stderr is logged as a warning in the application logs.
/// Output on stdout is ignored.
@@ -29,9 +30,8 @@ pub enum OpenUrlKind {
/// 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 url: OsString = crate::osbytes::config_str_to_os(url);
let mut cmd = open_command(kind, &url)?;
pub fn open(kind: OpenUrlKind, url: &OsStr) -> io::Result<()> {
let mut cmd = open_command(kind, url)?;
// Pipe stdout/stderr so we can collect output from the command.
cmd.stdin(Stdio::null())
@@ -141,12 +141,20 @@ mod tests {
assert_eq!(macos_open_args(OpenUrlKind::Unknown, u), ["u"]);
}
/// The opener receives decoded bytes for encoded paths and the text
/// unchanged for plain URLs (even when they contain the escape char).
#[test]
fn encoded_non_utf8_path_is_decoded_for_the_opener() {
fn encoded_paths_decode_but_plain_urls_do_not() {
use std::os::unix::ffi::OsStrExt as _;
let enc = crate::osbytes::bytes_to_config_string(b"/tmp/a\xffb");
let os = crate::osbytes::config_str_to_os(&enc);
assert_eq!(os.as_bytes(), b"/tmp/a\xffb");
use crate::osbytes::EncodedOs;
let path = EncodedOs::from_bytes(b"/tmp/a\xffb");
assert_eq!(path.to_os().as_bytes(), b"/tmp/a\xffb");
// U+FDD0 U+00FF in an ordinary URL must stay two chars, not byte FF.
let url = "https://x/\u{fdd0}\u{ff}";
let plain = EncodedOs::from_plain_text(url);
assert_eq!(plain.to_os().as_bytes(), url.as_bytes());
}
#[cfg(not(target_vendor = "apple"))]
@@ -155,7 +163,7 @@ mod tests {
// 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();
let err = open(OpenUrlKind::Unknown, OsStr::new("https://example.com")).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
});
}
+336 -25
View File
@@ -62,52 +62,127 @@ pub fn config_str_to_bytes(s: &str) -> Vec<u8> {
out
}
/// The raw bytes of an OS string (Unix; lossless). On other platforms the
/// string is converted via its UTF-8 (WTF-8) form.
/// The raw bytes of an OS string. Lossless on every platform: Unix returns
/// the bytes, Windows returns WTF-8 (unpaired UTF-16 surrogates are encoded
/// as 3-byte sequences, like Zig's Windows argument/environment handling).
#[cfg(unix)]
pub fn os_bytes(s: &OsStr) -> Vec<u8> {
use std::os::unix::ffi::OsStrExt;
s.as_bytes().to_vec()
}
#[cfg(not(unix))]
#[cfg(windows)]
pub fn os_bytes(s: &OsStr) -> Vec<u8> {
s.to_string_lossy().into_owned().into_bytes()
use std::os::windows::ffi::OsStrExt;
wide_to_wtf8(&s.encode_wide().collect::<Vec<u16>>())
}
/// Build an OS string from raw bytes.
#[cfg(not(any(unix, windows)))]
pub fn os_bytes(s: &OsStr) -> Vec<u8> {
s.to_str().map_or_else(Vec::new, |s| s.as_bytes().to_vec())
}
/// Build an OS string from raw bytes (WTF-8 on Windows; bytes that are not
/// well-formed WTF-8 become U+FFFD, like Zig's `wtf8ToWtf16Le`/`utf8ToUtf16`).
#[cfg(unix)]
pub fn os_from_bytes(b: Vec<u8>) -> OsString {
use std::os::unix::ffi::OsStringExt;
OsString::from_vec(b)
}
#[cfg(not(unix))]
#[cfg(windows)]
#[allow(clippy::needless_pass_by_value)] // same signature as the Unix variant
pub fn os_from_bytes(b: Vec<u8>) -> OsString {
// Windows OS strings are WTF-16; valid UTF-8 converts losslessly and is
// reused without copying, anything else is replaced like Zig's utf8ToUtf16.
OsString::from(
String::from_utf8(b).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()),
)
use std::os::windows::ffi::OsStringExt;
OsString::from_wide(&wtf8_to_wide(&b))
}
/// OS string to reversible `String` (see the module docs).
#[cfg(not(any(unix, windows)))]
#[allow(clippy::needless_pass_by_value)]
pub fn os_from_bytes(b: Vec<u8>) -> OsString {
OsString::from(String::from_utf8_lossy(&b).into_owned())
}
/// Encode UTF-16 code units (possibly with unpaired surrogates) as WTF-8.
/// Platform independent so the Windows logic is testable everywhere.
pub fn wide_to_wtf8(wide: &[u16]) -> Vec<u8> {
let mut out = Vec::with_capacity(wide.len());
for r in char::decode_utf16(wide.iter().copied()) {
match r {
Ok(c) => {
let mut buf = [0u8; 4];
out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
}
Err(e) => {
let u = u32::from(e.unpaired_surrogate());
out.push(0xE0 | (u >> 12) as u8);
out.push(0x80 | ((u >> 6) & 0x3F) as u8);
out.push(0x80 | (u & 0x3F) as u8);
}
}
}
out
}
/// Decode WTF-8 into UTF-16 code units. Surrogate code points encoded as
/// 3-byte sequences are kept as lone surrogates; anything not well-formed
/// WTF-8 yields U+FFFD for each offending byte.
pub fn wtf8_to_wide(b: &[u8]) -> Vec<u16> {
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
let cont = |j: usize| {
b.get(j)
.filter(|c| (**c & 0xC0) == 0x80)
.map(|c| u32::from(*c & 0x3F))
};
while i < b.len() {
let lead = b[i];
let (cp, len) = match lead {
0x00..=0x7F => (u32::from(lead), 1),
0xC2..=0xDF => match cont(i + 1) {
Some(c1) => ((u32::from(lead & 0x1F) << 6) | c1, 2),
None => (0xFFFD, 1),
},
0xE0..=0xEF => match (cont(i + 1), cont(i + 2)) {
(Some(c1), Some(c2)) if !(lead == 0xE0 && b[i + 1] < 0xA0) => {
((u32::from(lead & 0x0F) << 12) | (c1 << 6) | c2, 3)
}
_ => (0xFFFD, 1),
},
0xF0..=0xF4 => match (cont(i + 1), cont(i + 2), cont(i + 3)) {
(Some(c1), Some(c2), Some(c3)) => {
let cp = (u32::from(lead & 0x07) << 18) | (c1 << 12) | (c2 << 6) | c3;
if (0x1_0000..=0x10_FFFF).contains(&cp) {
(cp, 4)
} else {
(0xFFFD, 1)
}
}
_ => (0xFFFD, 1),
},
_ => (0xFFFD, 1),
};
i += len;
if cp >= 0x1_0000 {
let c = cp - 0x1_0000;
out.push(0xD800 | (c >> 10) as u16);
out.push(0xDC00 | (c & 0x3FF) as u16);
} else {
out.push(cp as u16);
}
}
out
}
/// OS string to reversible `String` (see the module docs). Lossless on every
/// platform (Windows goes through WTF-8).
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))]
{
valid_str_to_config_string(&s.to_string_lossy())
}
bytes_to_config_string(&os_bytes(s))
}
/// Encode a string that is already valid UTF-8 (e.g. the WTF-8 to UTF-8
/// conversion of a non-Unix OS string). The only thing that needs escaping is
/// a literal escape character, so decoding stays a bijection on every
/// platform. Shared (not cfg gated) so it is testable everywhere.
/// Encode a string that is already valid UTF-8 (plain text, e.g. a URL). The
/// only thing that needs escaping is a literal escape character, so decoding
/// stays a bijection on every platform.
pub fn valid_str_to_config_string(s: &str) -> String {
bytes_to_config_string(s.as_bytes())
}
@@ -117,6 +192,189 @@ pub fn config_str_to_os(s: &str) -> OsString {
os_from_bytes(config_str_to_bytes(s))
}
/// An OS string (path, environment value, home directory, ...) carried as a
/// `String` through the reversible escape encoding of this module.
///
/// This is deliberately a distinct type: it cannot be used as text or passed
/// to filesystem/exec APIs without an explicit [`EncodedOs::to_os`] /
/// [`EncodedOs::to_path`] decode, and plain text (URLs, regex matches, ...)
/// never becomes an `EncodedOs` except through [`EncodedOs::from_plain_text`],
/// which escapes instead of decoding.
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct EncodedOs(String);
impl EncodedOs {
/// Empty value.
#[must_use]
pub const fn new() -> Self {
EncodedOs(String::new())
}
/// Encode an OS string.
#[must_use]
pub fn from_os(s: &OsStr) -> Self {
EncodedOs(os_to_config_string(s))
}
/// Encode a path.
#[must_use]
pub fn from_path(p: &std::path::Path) -> Self {
Self::from_os(p.as_os_str())
}
/// Encode raw bytes.
#[must_use]
pub fn from_bytes(b: &[u8]) -> Self {
EncodedOs(bytes_to_config_string(b))
}
/// Adopt a string that is *already* in the escape encoding (config
/// values, CLI arguments collected with `args::collect_config_strings`).
#[must_use]
pub fn from_encoded(s: impl Into<String>) -> Self {
EncodedOs(s.into())
}
/// Wrap plain text (not previously encoded): a literal escape character
/// is escaped so decoding returns exactly the same text.
#[must_use]
pub fn from_plain_text(s: &str) -> Self {
EncodedOs(valid_str_to_config_string(s))
}
/// The encoded text (config files, `+show-config`, display of config
/// values). Never pass this to the OS; decode first.
#[must_use]
pub fn as_encoded(&self) -> &str {
&self.0
}
/// Consume into the encoded string.
#[must_use]
pub fn into_encoded(self) -> String {
self.0
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// Whether the decoded value ends with the given plain suffix.
#[must_use]
pub fn ends_with_plain(&self, suffix: &str) -> bool {
self.0.ends_with(suffix)
}
/// Decode to the raw bytes.
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
config_str_to_bytes(&self.0)
}
/// Decode to an OS string.
#[must_use]
pub fn to_os(&self) -> OsString {
config_str_to_os(&self.0)
}
/// Decode to UTF-16 code units (WTF-16: lone surrogates round-trip).
/// This is the Windows API form; it is platform independent so the
/// Windows logic is testable everywhere.
#[must_use]
pub fn to_wide(&self) -> Vec<u16> {
wtf8_to_wide(&self.to_bytes())
}
/// [`EncodedOs::to_wide`] plus a trailing NUL.
#[must_use]
pub fn to_wide_nul(&self) -> Vec<u16> {
let mut w = self.to_wide();
w.push(0);
w
}
/// Encode UTF-16 code units (possibly with lone surrogates).
#[must_use]
pub fn from_wide(w: &[u16]) -> Self {
Self::from_bytes(&wide_to_wtf8(w))
}
/// Decode to a path.
#[must_use]
pub fn to_path(&self) -> std::path::PathBuf {
config_str_to_path(&self.0)
}
/// Whether the decoded value starts with the given plain prefix.
#[must_use]
pub fn starts_with_plain(&self, prefix: &str) -> bool {
// Valid UTF-8 prefixes (without the escape char) encode to themselves.
self.0.starts_with(prefix)
}
/// Join plain-text components like `std.fs.path.join` (empty components
/// skipped, exactly one separator in between). Separators are ASCII and
/// never part of an escape, so joining the encoded text is exact.
#[cfg(not(target_arch = "wasm32"))]
#[must_use]
pub fn join_plain(&self, parts: &[&str]) -> Self {
let esc: Vec<String> = parts
.iter()
.map(|p| valid_str_to_config_string(p))
.collect();
let mut all: Vec<&str> = Vec::with_capacity(parts.len() + 1);
all.push(&self.0);
all.extend(esc.iter().map(String::as_str));
EncodedOs(crate::path_join(&all))
}
/// Join another encoded path (same rules as [`EncodedOs::join_plain`]).
#[cfg(not(target_arch = "wasm32"))]
#[must_use]
pub fn join(&self, other: &EncodedOs) -> Self {
EncodedOs(crate::path_join(&[&self.0, &other.0]))
}
}
/// Compares the *encoded* form with a literal (for plain ASCII/UTF-8 text
/// without the escape character the two are identical).
impl PartialEq<str> for EncodedOs {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<&str> for EncodedOs {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl From<&OsStr> for EncodedOs {
fn from(s: &OsStr) -> Self {
Self::from_os(s)
}
}
impl From<OsString> for EncodedOs {
fn from(s: OsString) -> Self {
Self::from_os(&s)
}
}
impl From<&std::path::Path> for EncodedOs {
fn from(p: &std::path::Path) -> Self {
Self::from_path(p)
}
}
impl From<std::path::PathBuf> for EncodedOs {
fn from(p: std::path::PathBuf) -> Self {
Self::from_path(&p)
}
}
/// 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())
@@ -237,6 +495,59 @@ mod tests {
}
}
#[test]
fn wtf8_round_trips_unpaired_surrogates() {
// "a", lone high, "b", valid pair (U+1F600), lone low.
let wide = [0x61u16, 0xD800, 0x62, 0xD83D, 0xDE00, 0xDC00];
let b = wide_to_wtf8(&wide);
assert_eq!(&b[..1], b"a");
assert_eq!(&b[1..4], &[0xED, 0xA0, 0x80]);
assert_eq!(wtf8_to_wide(&b), wide);
// Through the reversible string encoding too.
let s = bytes_to_config_string(&b);
assert_eq!(wtf8_to_wide(&config_str_to_bytes(&s)), wide);
}
#[test]
fn wtf8_invalid_bytes_become_replacement() {
assert_eq!(wtf8_to_wide(b"a\xffb"), vec![0x61, 0xFFFD, 0x62]);
assert_eq!(wtf8_to_wide(b"\xe0\x80\x80"), vec![0xFFFD; 3]);
assert_eq!(
wtf8_to_wide("\u{e9}\u{1F600}".as_bytes()),
vec![0xE9, 0xD83D, 0xDE00]
);
}
#[test]
fn encoded_os_plain_text_is_not_decoded() {
let plain = "http://x/\u{fdd0}\u{ff}";
let e = EncodedOs::from_plain_text(plain);
assert_eq!(e.to_bytes(), plain.as_bytes());
// Adopting the same text as "already encoded" would decode to 0xFF.
assert_ne!(EncodedOs::from_encoded(plain).to_bytes(), plain.as_bytes());
}
#[test]
fn encoded_os_wide_round_trips_lone_surrogates() {
let wide = [0x43u16, 0x3A, 0x5C, 0xD800, 0x61, 0xD83D, 0xDE00, 0xDFFF];
let e = EncodedOs::from_wide(&wide);
assert_eq!(e.to_wide(), wide);
assert_eq!(*e.to_wide_nul().last().unwrap(), 0);
// A literal escape char in a Windows name is still preserved.
let w2: Vec<u16> = "a\u{fdd0}\u{ff}".encode_utf16().collect();
assert_eq!(EncodedOs::from_wide(&w2).to_wide(), w2);
}
#[cfg(unix)]
#[test]
fn encoded_os_unix_round_trip() {
let o = os_from_bytes(vec![b'/', 0xff, b'x']);
let e = EncodedOs::from_os(&o);
assert_eq!(e.to_os(), o);
assert_eq!(e.to_path(), std::path::PathBuf::from(&o));
assert_eq!(e.join_plain(&["a", "", "b"]).to_bytes(), b"/\xffx/a/b");
}
#[cfg(unix)]
#[test]
fn os_roundtrip() {
+7 -7
View File
@@ -2,15 +2,15 @@ use std::ffi::OsString;
#[cfg(any(target_os = "linux", target_os = "android"))]
use crate::flatpak::is_flatpak;
use crate::osbytes;
use crate::osbytes::EncodedOs;
/// 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>,
pub shell: Option<EncodedOs>,
pub home: Option<EncodedOs>,
pub name: Option<EncodedOs>,
}
/// Byte-preserving [`Entry`] (Zig slices are arbitrary bytes).
@@ -23,7 +23,7 @@ pub struct EntryOs {
impl From<EntryOs> for Entry {
fn from(e: EntryOs) -> Self {
let f = |v: Option<OsString>| v.map(|v| osbytes::os_to_config_string(&v));
let f = |v: Option<OsString>| v.map(|v| EncodedOs::from_os(&v));
Entry {
shell: f(e.shell),
home: f(e.home),
@@ -152,8 +152,8 @@ fn parse_getent(output: &[u8]) -> EntryOs {
.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()));
let shell = it.next().map(|b| crate::osbytes::os_from_bytes(b.to_vec()));
let home = it.next().map(|b| crate::osbytes::os_from_bytes(b.to_vec()));
EntryOs {
shell,
home,
+8 -3
View File
@@ -27,9 +27,8 @@ pub enum Error {
/// 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())))
pub fn expand(cmd: &crate::osbytes::EncodedOs) -> Result<Option<crate::osbytes::EncodedOs>, Error> {
Ok(expand_os(&cmd.to_os())?.map(crate::osbytes::EncodedOs::from))
}
/// Byte-preserving [`expand`]: `$PATH` and the command are handled as raw
@@ -105,6 +104,12 @@ fn is_executable(_meta: &std::fs::Metadata) -> bool {
mod tests {
use super::*;
/// Plain-text wrapper over [`expand`] for the literals used below.
fn expand(cmd: &str) -> Result<Option<String>, Error> {
super::expand(&crate::osbytes::EncodedOs::from_plain_text(cmd))
.map(|o| o.map(crate::osbytes::EncodedOs::into_encoded))
}
// `uname -n` is the *nix equivalent of `hostname.exe` on Windows
#[test]
fn expand_hostname() {
+36 -55
View File
@@ -4,24 +4,24 @@ use std::{
path::{Path, PathBuf},
};
use crate::osbytes::{config_str_to_os, os_to_config_string};
use crate::osbytes::EncodedOs;
fn enc(p: &Path) -> String {
os_to_config_string(p.as_os_str())
fn enc(p: &Path) -> EncodedOs {
EncodedOs::from_path(p)
}
#[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>,
pub app_path: Option<EncodedOs>,
pub host_path: Option<EncodedOs>,
}
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()
pub fn app(&self) -> Option<&EncodedOs> {
self.app_path.as_ref()
}
/// Get the directory to the bundled resources directory accessible
@@ -30,19 +30,19 @@ impl ResourcesDir {
/// 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())
pub fn host(&self) -> Option<&EncodedOs> {
self.host_path.as_ref().or(self.app_path.as_ref())
}
/// 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)))
self.app().map(EncodedOs::to_path)
}
/// Byte-preserving [`ResourcesDir::host`].
pub fn host_os(&self) -> Option<PathBuf> {
self.host().map(|p| PathBuf::from(config_str_to_os(p)))
self.host().map(EncodedOs::to_path)
}
}
@@ -86,7 +86,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<OsString>) ->
// 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(os_to_config_string(dir)),
app_path: Some(EncodedOs::from_os(dir)),
host_path: None,
};
}
@@ -127,7 +127,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<OsString>) ->
// fallback and use the provided resources dir.
if debug && let Some(dir) = env.filter(|d| !d.is_empty()) {
return ResourcesDir {
app_path: Some(os_to_config_string(&dir)),
app_path: Some(EncodedOs::from_os(&dir)),
host_path: None,
};
}
@@ -176,22 +176,6 @@ pub fn maybe_dir_os(base: &Path, sub: &str, suffix: &str) -> Option<PathBuf> {
dir.join(suffix).exists().then_some(dir)
}
/// 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(all(test, unix))]
mod tests {
use super::*;
@@ -199,13 +183,13 @@ mod tests {
#[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"));
assert_eq!(r.app().map(EncodedOs::as_encoded), None);
assert_eq!(r.host().map(EncodedOs::as_encoded), None);
r.app_path = Some(EncodedOs::from_encoded("/a"));
assert_eq!(r.host().map(EncodedOs::as_encoded), Some("/a"));
r.host_path = Some(EncodedOs::from_encoded("/h"));
assert_eq!(r.app().map(EncodedOs::as_encoded), Some("/a"));
assert_eq!(r.host().map(EncodedOs::as_encoded), Some("/h"));
}
fn make_tree() -> crate::TempDir {
@@ -222,7 +206,7 @@ mod tests {
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()));
assert_eq!(r.app().map(EncodedOs::as_encoded), Some(expected.as_str()));
}
#[test]
@@ -231,21 +215,29 @@ mod tests {
let exe = td.path().join("bin/ghostty");
let env = Some(OsString::from("/env/res"));
assert_eq!(
resources_dir_impl(false, Some(&exe), env.clone()).app(),
resources_dir_impl(false, Some(&exe), env.clone())
.app()
.map(EncodedOs::as_encoded),
Some("/env/res")
);
assert_ne!(
resources_dir_impl(true, Some(&exe), env.clone()).app(),
resources_dir_impl(true, Some(&exe), env.clone())
.app()
.map(EncodedOs::as_encoded),
Some("/env/res")
);
// Debug fallback when detection fails.
assert_eq!(
resources_dir_impl(true, Some(Path::new("/nonexistent/bin/x")), env).app(),
resources_dir_impl(true, Some(Path::new("/nonexistent/bin/x")), env)
.app()
.map(EncodedOs::as_encoded),
Some("/env/res")
);
// Empty env is ignored.
assert_eq!(
resources_dir_impl(false, None, Some(OsString::new())).app(),
resources_dir_impl(false, None, Some(OsString::new()))
.app()
.map(EncodedOs::as_encoded),
None
);
}
@@ -254,13 +246,13 @@ mod tests {
fn dev_fallback_finds_workspace_resources() {
let exe = workspace_root().join("target/debug/ghostty");
let r = resources_dir_impl(true, Some(&exe), None);
assert!(r.app().is_some_and(|d| d.ends_with("/resources")));
assert!(r.app().is_some_and(|d| d.ends_with_plain("/resources")));
// Outside the workspace there is no fallback.
let r = resources_dir_impl(true, Some(Path::new("/nonexistent/bin/x")), None);
assert_eq!(r.app(), None);
assert_eq!(r.app().map(EncodedOs::as_encoded), None);
// Release builds never use it.
let r = resources_dir_impl(false, Some(&exe), None);
assert_eq!(r.app(), None);
assert_eq!(r.app().map(EncodedOs::as_encoded), None);
}
#[test]
@@ -279,15 +271,4 @@ mod tests {
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();
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);
}
}
+20 -16
View File
@@ -6,8 +6,12 @@
use std::io;
use crate::osbytes::EncodedOs;
/// `homedir.zig` is unavailable: nothing can be expanded.
pub mod homedir {
use crate::osbytes::EncodedOs;
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ExpandError {
#[error("home detection failed")]
@@ -17,11 +21,11 @@ pub mod homedir {
}
/// Paths without a leading `~/` pass through; others fail.
pub fn expand_home(path: &str) -> Result<String, ExpandError> {
if path.starts_with("~/") {
pub fn expand_home(path: &EncodedOs) -> Result<EncodedOs, ExpandError> {
if path.starts_with_plain("~/") {
Err(ExpandError::HomeDetectionFailed)
} else {
Ok(path.to_owned())
Ok(path.clone())
}
}
}
@@ -29,9 +33,9 @@ pub mod homedir {
pub mod passwd {
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Entry {
pub shell: Option<String>,
pub home: Option<String>,
pub name: Option<String>,
pub shell: Option<crate::osbytes::EncodedOs>,
pub home: Option<crate::osbytes::EncodedOs>,
pub name: Option<crate::osbytes::EncodedOs>,
}
/// Zig: `@compileError("passwd is not available for wasm")`.
@@ -49,7 +53,7 @@ pub mod xdg {
#[derive(Debug, Clone, Copy, Default)]
pub struct Options<'a> {
pub subdir: Option<&'a str>,
pub home: Option<&'a str>,
pub home: Option<&'a crate::osbytes::EncodedOs>,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
@@ -58,15 +62,15 @@ pub mod xdg {
Unavailable,
}
pub fn config(_: &Options<'_>) -> Result<String, Error> {
pub fn config(_: &Options<'_>) -> Result<crate::osbytes::EncodedOs, Error> {
Err(Error::Unavailable)
}
pub fn cache(_: &Options<'_>) -> Result<String, Error> {
pub fn cache(_: &Options<'_>) -> Result<crate::osbytes::EncodedOs, Error> {
Err(Error::Unavailable)
}
pub fn state(_: &Options<'_>) -> Result<String, Error> {
pub fn state(_: &Options<'_>) -> Result<crate::osbytes::EncodedOs, Error> {
Err(Error::Unavailable)
}
@@ -78,17 +82,17 @@ pub mod xdg {
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResourcesDir {
pub app_path: Option<String>,
pub host_path: Option<String>,
pub app_path: Option<EncodedOs>,
pub host_path: Option<EncodedOs>,
}
impl ResourcesDir {
pub fn app(&self) -> Option<&str> {
self.app_path.as_deref()
pub fn app(&self) -> Option<&EncodedOs> {
self.app_path.as_ref()
}
pub fn host(&self) -> Option<&str> {
self.host_path.as_deref()
pub fn host(&self) -> Option<&EncodedOs> {
self.host_path.as_ref()
}
}
+16 -24
View File
@@ -3,7 +3,7 @@
use std::ffi::CStr;
use crate::{env::getenv_not_empty, homedir, path_join};
use crate::{env::getenv_not_empty, homedir, osbytes::EncodedOs};
#[derive(Debug, Clone, Default)]
pub struct Options<'a> {
@@ -11,10 +11,10 @@ pub struct Options<'a> {
/// 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
/// The home directory for the user (encoded OS string). 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>,
pub home: Option<&'a EncodedOs>,
}
#[derive(Debug, thiserror::Error)]
@@ -28,29 +28,25 @@ pub enum Error {
}
/// Get the XDG user config directory.
pub fn config(opts: &Options<'_>) -> Result<String, Error> {
pub fn config(opts: &Options<'_>) -> Result<EncodedOs, Error> {
dir(opts, "XDG_CONFIG_HOME", ".config")
}
/// Get the XDG cache directory.
pub fn cache(opts: &Options<'_>) -> Result<String, Error> {
pub fn cache(opts: &Options<'_>) -> Result<EncodedOs, Error> {
dir(opts, "XDG_CACHE_HOME", ".cache")
}
/// Get the XDG state directory.
pub fn state(opts: &Options<'_>) -> Result<String, Error> {
pub fn state(opts: &Options<'_>) -> Result<EncodedOs, 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> {
fn dir(opts: &Options<'_>, env: &str, default_subdir: &str) -> Result<EncodedOs, 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(""),
]));
return Ok(home.join_plain(&[default_subdir, opts.subdir.unwrap_or("")]));
}
// First check the env var. On Windows we treat `LOCALAPPDATA` as a
@@ -61,7 +57,7 @@ fn dir(opts: &Options<'_>, env: &str, default_subdir: &str) -> Result<String, Er
if let Some(env) = env_value {
// If we have a subdir, then we join it.
if let Some(subdir) = opts.subdir {
return Ok(path_join(&[&env, subdir]));
return Ok(env.join_plain(&[subdir]));
}
return Ok(env);
@@ -69,11 +65,7 @@ fn dir(opts: &Options<'_>, env: &str, default_subdir: &str) -> Result<String, Er
// Get our home dir
if let Ok(Some(home)) = homedir::home() {
return Ok(path_join(&[
&home,
default_subdir,
opts.subdir.unwrap_or(""),
]));
return Ok(home.join_plain(&[default_subdir, opts.subdir.unwrap_or("")]));
}
Err(Error::NoHomeDir)
@@ -110,7 +102,7 @@ mod tests {
#[test]
fn cache_directory_paths() {
let mock_home = "/Users/test";
let mock_home = &EncodedOs::from_plain_text("/Users/test");
// Test base path
let p = cache(&Options {
@@ -129,7 +121,7 @@ mod tests {
assert_eq!(p, "/Users/test/.cache/ghostty");
}
type DirFn = fn(&Options<'_>) -> Result<String, Error>;
type DirFn = fn(&Options<'_>) -> Result<EncodedOs, Error>;
const CASES: [(&str, DirFn, &str); 3] = [
("XDG_CONFIG_HOME", config, ".config"),
("XDG_CACHE_HOME", cache, ".cache"),
@@ -141,9 +133,9 @@ mod tests {
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 expected = crate::path_join(&[temp_home, default_subdir]);
let actual = func(&Options::default()).unwrap();
assert_eq!(expected, actual);
assert_eq!(actual, expected.as_str());
});
}
}
@@ -153,13 +145,13 @@ mod tests {
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 expected = crate::path_join(&[temp_home, default_subdir, "ghostty"]);
let actual = func(&Options {
subdir: Some("ghostty"),
..Default::default()
})
.unwrap();
assert_eq!(expected, actual);
assert_eq!(actual, expected.as_str());
});
}
}