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

+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);
}
}