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

+1
View File
@@ -28,3 +28,4 @@ workspace = true
[dev-dependencies]
tempfile = "3.27.0"
temp-env = "0.3.6"
+2 -1
View File
@@ -85,7 +85,8 @@ fn run_inner(
};
format!("{} ago", d.round(1_000_000_000))
};
writeln!(stdout, "{} ({since})", report.name)?;
// Display only: the file name is shown as text.
writeln!(stdout, "{} ({since})", report.name.to_string_lossy())?;
}
Ok(0)
+14 -9
View File
@@ -5,6 +5,7 @@ use std::io::Write;
use std::os::unix::process::CommandExt;
use ghostty_config::config::Config;
use ghostty_os::osbytes::EncodedOs;
use crate::error::Error;
pub use crate::options::NoOptions as Options;
@@ -43,7 +44,7 @@ pub fn run(args: &[String]) -> Result<u8, Error> {
/// Pick the editor from `$VISUAL` then `$EDITOR` (empty values are skipped).
#[must_use]
pub fn find_editor() -> Option<String> {
pub fn find_editor() -> Option<EncodedOs> {
// VISUAL vs. EDITOR: https://unix.stackexchange.com/questions/4859/visual-vs-editor-what-s-the-difference
for var in ["VISUAL", "EDITOR"] {
if let Some(v) = ghostty_os::getenv(var) {
@@ -76,19 +77,20 @@ fn run_inner(stderr: &mut dyn Write) -> Result<u8, Error> {
/// Windows branch of `run`: print the path and fail.
#[cfg(windows)]
fn write_windows_unsupported(stderr: &mut dyn Write, path: &str) -> Result<u8, Error> {
fn write_windows_unsupported(stderr: &mut dyn Write, path: &EncodedOs) -> Result<u8, Error> {
write!(
stderr,
"The `ghostty +edit-config` command is not supported on Windows.\n\
Please edit the configuration file manually at the following path:\n\
\n\
{path}\n"
{}\n",
path.as_encoded()
)?;
Ok(1)
}
#[cfg(unix)]
fn exec_editor(stderr: &mut dyn Write, path: &str) -> Result<u8, Error> {
fn exec_editor(stderr: &mut dyn Write, path: &EncodedOs) -> Result<u8, Error> {
// Get our editor
let Some(editor) = find_editor() else {
// If we don't have `$EDITOR` set then we can't do anything
@@ -107,15 +109,16 @@ fn exec_editor(stderr: &mut dyn Write, path: &str) -> Result<u8, Error> {
);
// Output the path using the OSC8 sequence so that it is linked.
// (Paths are raw bytes: decode the reversible escape when writing.)
let msg = format!("{msg}\x1b]8;;file://{path}\x1b\\{path}\x1b]8;;\x1b\\\n");
let p = path.as_encoded();
let msg = format!("{msg}\x1b]8;;file://{p}\x1b\\{p}\x1b]8;;\x1b\\\n");
stderr.write_all(&ghostty_os::osbytes::config_str_to_bytes(&msg))?;
return Ok(1);
};
// Flush before exec so no output is lost.
let _ = stderr.flush();
let err = std::process::Command::new(ghostty_os::osbytes::config_str_to_os(&editor))
.arg(ghostty_os::osbytes::config_str_to_os(path))
let err = std::process::Command::new(editor.to_os())
.arg(path.to_os())
.exec();
// If we reached this point then exec failed.
@@ -131,8 +134,10 @@ fn exec_editor(stderr: &mut dyn Write, path: &str) -> Result<u8, Error> {
permissions, or the shell environment not being set up\n\
correctly.\n\
\n\
Editor: {editor}\n\
Path: {path}\n"
Editor: {}\n\
Path: {}\n",
editor.as_encoded(),
path.as_encoded()
);
stderr.write_all(&ghostty_os::osbytes::config_str_to_bytes(&msg))?;
Ok(1)
+19 -25
View File
@@ -15,12 +15,7 @@ use ghostty_config::{
config_struct,
theme::{Location, LocationIterator},
};
use ghostty_os::osbytes::{
config_str_to_bytes,
config_str_to_path,
os_to_config_string,
path_to_config_string,
};
use ghostty_os::osbytes::{EncodedOs, config_str_to_bytes, os_to_config_string};
use crate::{error::Error, tui};
@@ -58,7 +53,7 @@ cli_options!(Options, help);
#[derive(Debug, Clone, PartialEq)]
pub struct ThemeListElement {
pub location: Location,
pub path: String,
pub path: EncodedOs,
pub theme: String,
pub rank: Option<f64>,
}
@@ -76,7 +71,7 @@ impl ThemeListElement {
#[must_use]
pub fn to_uri(&self) -> String {
let mut out = String::from("file://");
for b in ghostty_os::osbytes::config_str_to_bytes(&self.path) {
for b in self.path.to_bytes() {
// std.Uri path component escaping: unreserved + sub-delims + ":@/".
if b.is_ascii_alphanumeric()
|| matches!(
@@ -123,15 +118,15 @@ pub fn location_name(loc: Location) -> &'static str {
/// Returns the unsorted list (in directory order). Errors opening a
/// directory other than "not found" are printed to stderr and skipped.
#[must_use]
pub fn collect_themes(resources_dir: Option<&str>) -> Vec<ThemeListElement> {
pub fn collect_themes(resources_dir: Option<&EncodedOs>) -> Vec<ThemeListElement> {
let mut themes = vec![];
let mut it = LocationIterator::new(resources_dir);
while let Some((location, dir)) = it.next_location() {
let rd = match std::fs::read_dir(config_str_to_path(&dir)) {
let rd = match std::fs::read_dir(dir.to_path()) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => {
eprintln!("error trying to open {dir}: {e}");
eprintln!("error trying to open {}: {e}", dir.as_encoded());
continue;
}
};
@@ -150,7 +145,7 @@ pub fn collect_themes(resources_dir: Option<&str>) -> Vec<ThemeListElement> {
continue;
}
themes.push(ThemeListElement {
path: path_to_config_string(&config_str_to_path(&dir).join(&raw_name)),
path: EncodedOs::from_path(&dir.to_path().join(&raw_name)),
location,
theme: name,
rank: None,
@@ -174,15 +169,14 @@ pub fn should_include_theme(theme_filter: ColorScheme, theme_config: &Config) ->
}
/// Make `path` absolute (`Config::load_file` requires absolute paths).
pub(crate) fn absolute_path(path: &str) -> String {
std::path::absolute(config_str_to_path(path))
.map_or_else(|_| path.to_string(), |p| path_to_config_string(&p))
pub(crate) fn absolute_path(path: &EncodedOs) -> EncodedOs {
std::path::absolute(path.to_path()).map_or_else(|_| path.clone(), |p| EncodedOs::from_path(&p))
}
fn resources_dir() -> Option<String> {
fn resources_dir() -> Option<EncodedOs> {
ghostty_os::resources_dir()
.ok()
.and_then(|r| r.app().map(str::to_string))
.and_then(|r| r.app().cloned())
}
/// The `list-themes` command is used to preview or list all the available
@@ -238,7 +232,7 @@ pub fn run(args: &[String]) -> Result<u8, Error> {
)?;
}
let mut themes = collect_themes(resources_dir.as_deref());
let mut themes = collect_themes(resources_dir.as_ref());
if themes.is_empty() {
write!(
@@ -281,7 +275,7 @@ pub fn write_plain(
// Names/paths are raw bytes in Zig: decode the reversible escape so
// non-UTF-8 bytes are written back unchanged.
let line = if opts.path {
format!("{} ({loc}) {}\n", theme.theme, theme.path)
format!("{} ({loc}) {}\n", theme.theme, theme.path.as_encoded())
} else {
format!("{} ({loc})\n", theme.theme)
};
@@ -314,7 +308,7 @@ mod tests {
std::fs::create_dir_all(&dir).unwrap();
let raw = std::ffi::OsStr::from_bytes(b"th\xffeme");
std::fs::write(dir.join(raw), "background = #000000\n").unwrap();
let res = tmp.path().to_string_lossy().into_owned();
let res = EncodedOs::from_path(tmp.path());
let themes: Vec<_> = collect_themes(Some(&res))
.into_iter()
.filter(|t| t.location == Location::Resources)
@@ -322,7 +316,7 @@ mod tests {
assert_eq!(1, themes.len());
assert_eq!(config_str_to_bytes(&themes[0].theme), b"th\xffeme".to_vec());
// The path points at the real file and the listing loads it.
assert_eq!(config_str_to_path(&themes[0].path), dir.join(raw));
assert_eq!(themes[0].path.to_path(), dir.join(raw));
let mut out = Vec::new();
write_plain(&mut out, &themes, &Options::default()).unwrap();
assert_eq!(out, b"th\xffeme (resources)\n");
@@ -336,7 +330,7 @@ mod tests {
("Beta", "background = 202020\n"),
(".DS_Store", "junk"),
]);
let res = tmp.path().to_string_lossy().into_owned();
let res = EncodedOs::from_path(tmp.path());
let mut themes: Vec<_> = collect_themes(Some(&res))
.into_iter()
.filter(|t| t.location == Location::Resources)
@@ -365,7 +359,7 @@ mod tests {
);
assert_eq!("Zeta (resources)\n", list(ColorScheme::Light, false));
assert_eq!(
format!("Zeta (resources) {res}/themes/Zeta\n"),
format!("Zeta (resources) {}/themes/Zeta\n", res.as_encoded()),
list(ColorScheme::Light, true)
);
}
@@ -392,7 +386,7 @@ mod tests {
fn uri_escaping() {
let t = ThemeListElement {
location: Location::User,
path: "/a b/c%d".into(),
path: EncodedOs::from_encoded("/a b/c%d"),
theme: "x".into(),
rank: None,
};
@@ -403,7 +397,7 @@ mod tests {
fn uri_escaping_percent_encodes_original_bytes() {
let t = ThemeListElement {
location: Location::User,
path: ghostty_os::osbytes::bytes_to_config_string(b"/a/\xff\xc3x"),
path: EncodedOs::from_bytes(b"/a/\xff\xc3x"),
theme: "x".into(),
rank: None,
};
+29 -27
View File
@@ -353,8 +353,8 @@ enum Loaded {
/// A pending side effect requested by the state (needs the terminal).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Effect {
/// Copy the text to the clipboard (OSC 52).
Copy(String),
/// Copy the (decoded) bytes to the clipboard (OSC 52).
Copy(ghostty_os::osbytes::EncodedOs),
}
/// The preview state (Zig: `Preview`).
@@ -373,9 +373,9 @@ pub struct Preview {
mouse: Option<MouseEvent>,
/// The list height as of the last draw (for clamping the window).
list_height: u16,
loaded: Option<(String, Loaded)>,
loaded: Option<(ghostty_os::osbytes::EncodedOs, Loaded)>,
/// Where `w` writes the auto theme file (overridable for tests).
pub auto_theme_path: Option<String>,
pub auto_theme_path: Option<std::path::PathBuf>,
effects: Vec<Effect>,
overlays: Vec<Overlay>,
}
@@ -581,7 +581,8 @@ impl Preview {
}
if is_char('c') {
if let Some(t) = self.selected() {
let name = t.theme.clone();
// Theme names are in the reversible OS-string encoding.
let name = ghostty_os::osbytes::EncodedOs::from_encoded(t.theme.clone());
self.effects.push(Effect::Copy(name));
}
} else if code == KeyCode::Char('C')
@@ -965,10 +966,10 @@ impl Preview {
(child.area.width / 2).saturating_sub(gw(&text) / 2),
);
child.print_ex(
&theme.path,
theme.path.as_encoded(),
ui_err,
middle,
(child.area.width / 2).saturating_sub(gw(&theme.path) / 2),
(child.area.width / 2).saturating_sub(gw(theme.path.as_encoded()) / 2),
Some(&theme.to_uri()),
None,
);
@@ -1024,10 +1025,10 @@ impl Preview {
None,
);
child.print_ex(
&theme.path,
theme.path.as_encoded(),
standard,
2,
(child.area.width / 2).saturating_sub(gw(&theme.path) / 2),
(child.area.width / 2).saturating_sub(gw(theme.path.as_encoded()) / 2),
Some(&uri),
None,
);
@@ -1405,25 +1406,25 @@ fn error_name(e: &ghostty_config::config::Error) -> String {
}
/// `resolveAutoThemePath`: `<config dir>/auto/theme.ghostty`.
pub fn resolve_auto_theme_path() -> Result<String, Error> {
pub fn resolve_auto_theme_path() -> Result<std::path::PathBuf, Error> {
let main_cfg_path = ghostty_config::file_load::preferred_default_file_path()
.map_err(|e| Error::Other(e.to_string()))?;
let main_cfg_path = ghostty_os::osbytes::config_str_to_path(&main_cfg_path);
.map_err(|e| Error::Other(e.to_string()))?
.to_path();
let base_dir = main_cfg_path
.parent()
.ok_or_else(|| Error::Other("BadPathName".to_string()))?;
Ok(ghostty_os::osbytes::path_to_config_string(
&base_dir.join("auto").join("theme.ghostty"),
))
Ok(base_dir.join("auto").join("theme.ghostty"))
}
/// `writeAutoThemeFile`.
pub fn write_auto_theme_file(path: Option<&str>, theme_name: &str) -> Result<(), Error> {
pub fn write_auto_theme_file(
path: Option<&std::path::Path>,
theme_name: &str,
) -> Result<(), Error> {
let auto_path = match path {
Some(p) => p.to_string(),
Some(p) => p.to_path_buf(),
None => resolve_auto_theme_path()?,
};
let auto_path = ghostty_os::osbytes::config_str_to_path(&auto_path);
if let Some(dir) = auto_path.parent() {
std::fs::create_dir_all(dir)?;
}
@@ -1737,11 +1738,7 @@ fn run_loop(app: &mut Preview) -> Result<(), Error> {
match effect {
Effect::Copy(text) => {
let out = terminal.backend_mut();
write!(
out,
"\x1b]52;c;{}\x1b\\",
base64(&ghostty_os::osbytes::config_str_to_bytes(&text))
)?;
write!(out, "\x1b]52;c;{}\x1b\\", base64(&text.to_bytes()))?;
out.flush()?;
}
}
@@ -1788,7 +1785,7 @@ mod tests {
.unwrap();
ThemeListElement {
location: Location::Resources,
path: path.to_string_lossy().into_owned(),
path: ghostty_os::osbytes::EncodedOs::from_path(&path),
theme: (*name).to_string(),
rank: None,
}
@@ -1939,14 +1936,19 @@ mod tests {
KeyModifiers::SHIFT,
)));
let effects = p.take_effects();
assert_eq!(Effect::Copy("alpha".into()), effects[0]);
assert_eq!(
Effect::Copy(tmp.path().join("alpha").to_string_lossy().into_owned()),
Effect::Copy(ghostty_os::osbytes::EncodedOs::from_encoded("alpha")),
effects[0]
);
assert_eq!(
Effect::Copy(ghostty_os::osbytes::EncodedOs::from_path(
&tmp.path().join("alpha")
)),
effects[1]
);
let auto = tmp.path().join("cfg/auto/theme.ghostty");
p.auto_theme_path = Some(auto.to_string_lossy().into_owned());
p.auto_theme_path = Some(auto.clone());
p.update(&key(KeyCode::Enter));
assert_eq!(Mode::Save, p.mode);
p.update(&ch('w'));
+7 -6
View File
@@ -8,6 +8,7 @@ use std::sync::OnceLock;
use ghostty_apprt::ipc;
use ghostty_config::cli_args::{ArgError, ArgIter, CliTarget};
use ghostty_os::osbytes::EncodedOs;
use crate::error::Error;
@@ -56,15 +57,15 @@ impl Options {
if stripped == "home" || stripped == "inherit" {
return Ok(Some(arg.to_string()));
}
let expanded =
ghostty_os::expand_home(stripped).map_err(|e| ArgError::Other(e.to_string()))?;
let realpath =
std::fs::canonicalize(ghostty_os::osbytes::config_str_to_path(&expanded))
.map_err(|e| ArgError::Other(e.to_string()))?;
// The CLI argument is an encoded OS string.
let expanded = ghostty_os::expand_home(&EncodedOs::from_encoded(stripped))
.map_err(|e| ArgError::Other(e.to_string()))?;
let realpath = std::fs::canonicalize(expanded.to_path())
.map_err(|e| ArgError::Other(e.to_string()))?;
self.working_directory_seen = true;
return Ok(Some(format!(
"--working-directory={}",
ghostty_os::osbytes::path_to_config_string(&realpath)
EncodedOs::from_path(&realpath).as_encoded()
)));
}
+15 -1
View File
@@ -59,7 +59,7 @@ impl DiskCache {
home: None,
})
.map_err(|_| Error::XdgLookupFailed)?;
Ok(PathBuf::from(state_dir).join("ssh_cache"))
Ok(state_dir.to_path().join("ssh_cache"))
}
/// Clear all cache data stored in the disk cache.
@@ -346,6 +346,20 @@ fn is_valid_user(user: &str) -> bool {
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
#[cfg(unix)]
#[test]
fn default_path_decodes_non_utf8_xdg_state() {
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let v = std::ffi::OsString::from_vec(b"/tmp/st\xffate".to_vec());
let p = temp_env::with_var("XDG_STATE_HOME", Some(&v), || {
super::DiskCache::default_path("ghostty").unwrap()
});
assert_eq!(
p.as_os_str().as_bytes(),
b"/tmp/st\xffate/ghostty/ssh_cache"
);
}
#[cfg(not(windows))]
#[test]
fn lock_is_exclusive_until_dropped() {
+3 -2
View File
@@ -3,6 +3,7 @@
use std::io::Write;
use ghostty_config::{config::Config, config_struct};
use ghostty_os::osbytes::EncodedOs;
use crate::error::Error;
@@ -41,8 +42,8 @@ fn run_inner(opts: &Options, stdout: &mut dyn Write) -> Result<u8, Error> {
if let Some(config_path) = &opts.config_file {
// Byte-preserving: the option string carries non-UTF-8 bytes via the
// reversible `osbytes` escape and the canonical path may too.
let abs_path = std::fs::canonicalize(ghostty_os::osbytes::config_str_to_path(config_path))?;
cfg.load_file(&ghostty_os::osbytes::path_to_config_string(&abs_path))?;
let abs_path = std::fs::canonicalize(EncodedOs::from_encoded(config_path).to_path())?;
cfg.load_file(&EncodedOs::from_path(&abs_path))?;
cfg.load_recursive_files()?;
} else {
cfg = Config::load()?;