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 0f898d3e41
commit 8f3e206d9f
92 files changed
+2967 -992

No files matched your search

+1 -1
View File
@@ -234,7 +234,7 @@ fn init_with(opts: InitOptions, default_logging: Logging) -> &'static GlobalStat
// Setup i18n
if let Some(v) = resources_dir.app()
&& let Err(err) = ghostty_os::i18n::init(v)
&& let Err(err) = ghostty_os::i18n::init(&v.to_path())
{
log::warn!("failed to init i18n, translations will not be available err={err}");
}
+29 -25
View File
@@ -419,11 +419,16 @@ pub fn screen_file_contents(
/// Write `contents` to `<tempdir>/<loc>.<ext>` (mode 0600) and return the
/// canonical path. The temporary directory is intentionally left behind so
/// the file stays around for the user (Zig does not delete it either).
/// The raw bytes of a path (what Zig copies/pastes for a screen file).
fn path_bytes(path: &std::path::Path) -> Vec<u8> {
ghostty_os::osbytes::os_bytes(path.as_os_str())
}
pub fn write_screen_temp_file(
contents: &str,
loc: WriteScreenLoc,
emit: WriteScreenFormat,
) -> Result<String, CoreError> {
) -> Result<std::path::PathBuf, CoreError> {
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt as _;
@@ -432,7 +437,7 @@ pub fn write_screen_temp_file(
let filename = screen_file_name(loc, emit);
let file_path = tmp_dir.path().join(&filename);
let result = (|| -> Result<String, CoreError> {
let result = (|| -> Result<std::path::PathBuf, CoreError> {
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
@@ -440,11 +445,7 @@ pub fn write_screen_temp_file(
let mut file = opts.open(&file_path)?;
file.write_all(contents.as_bytes())?;
file.flush()?;
// Reversible config-string encoding: a non-UTF-8 temp dir keeps its
// bytes (decode with `config_str_to_path` at OS boundaries).
Ok(ghostty_os::osbytes::path_to_config_string(
&std::fs::canonicalize(&file_path)?,
))
Ok(std::fs::canonicalize(&file_path)?)
})();
match result {
@@ -628,13 +629,10 @@ impl Surface {
match write_screen.action {
WriteScreenAction::Copy => {
// The file name is copied as the raw bytes of the path.
self.rt_surface.set_clipboard(
Clipboard::Standard,
&[(
"text/plain".to_owned(),
String::from_utf8_lossy(&ghostty_os::osbytes::config_str_to_bytes(&path))
.into_owned(),
)],
&[("text/plain".to_owned(), path_bytes(&path))],
false,
);
}
@@ -643,11 +641,11 @@ impl Surface {
WriteScreenFormat::Plain | WriteScreenFormat::Vt => OpenUrlKind::Text,
WriteScreenFormat::Html => OpenUrlKind::Html,
},
url: path,
url: ghostty_os::osbytes::EncodedOs::from_path(&path),
})?,
WriteScreenAction::Paste => self.queue_io(IoMessage::write_req(
&ghostty_os::osbytes::config_str_to_bytes(&path),
)),
WriteScreenAction::Paste => {
self.queue_io(IoMessage::write_req(&path_bytes(&path)));
}
}
Ok(())
}
@@ -1278,6 +1276,7 @@ mod tests {
let path =
write_screen_temp_file(&s, WriteScreenLoc::Screen, WriteScreenFormat::Plain).unwrap();
assert!(path.ends_with("screen.txt"));
let path = path.to_string_lossy().into_owned(); // test-only: ASCII temp path
let read = std::fs::read_to_string(&path).unwrap();
assert_eq!(read, s);
#[cfg(unix)]
@@ -1292,18 +1291,23 @@ mod tests {
#[cfg(unix)]
#[test]
fn screen_temp_file_returns_encoded_non_utf8_path() {
use std::os::unix::ffi::OsStrExt;
fn screen_temp_file_keeps_non_utf8_path_bytes() {
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let td = ghostty_os::temp_dir::TempDir::init().unwrap();
let bad = td.path().join(std::ffi::OsStr::from_bytes(b"d\xff"));
std::fs::create_dir_all(&bad).unwrap();
// Use TMPDIR to force the non-UTF-8 directory.
let file = bad.join("x.txt");
std::fs::write(&file, b"x").unwrap();
let canon = std::fs::canonicalize(&file).unwrap();
let enc = ghostty_os::osbytes::path_to_config_string(&canon);
assert!(enc.contains('\u{fdd0}'));
assert_eq!(ghostty_os::osbytes::config_str_to_path(&enc), canon);
let tmpdir = std::ffi::OsString::from_vec(bad.as_os_str().as_bytes().to_vec());
// Force the non-UTF-8 directory through TMPDIR.
let path = temp_env::with_var("TMPDIR", Some(&tmpdir), || {
write_screen_temp_file("x", WriteScreenLoc::Screen, WriteScreenFormat::Plain).unwrap()
});
assert!(path.as_os_str().as_bytes().contains(&0xff));
assert_eq!(std::fs::read(&path).unwrap(), b"x");
// Copy/paste carry the exact path bytes (no U+FFFD), and the open
// action's EncodedOs decodes back to the same path.
assert_eq!(path_bytes(&path), path.as_os_str().as_bytes());
let url = ghostty_os::osbytes::EncodedOs::from_path(&path);
assert_eq!(url.to_path(), path);
}
#[test]
+9 -8
View File
@@ -64,7 +64,8 @@ impl Surface {
log::info!("application sent invalid base64 data for OSC 52");
return;
};
let text = String::from_utf8_lossy(&decoded).into_owned();
// OSC 52 data is written as the application sent it (bytes).
let text = decoded;
// When clipboard-write is "ask" a prompt is displayed to the user
// asking them to confirm the clipboard access. Each app runtime
@@ -111,11 +112,11 @@ impl Surface {
}
};
let mut contents: Vec<(String, String)> = Vec::new();
let mut contents: Vec<(String, Vec<u8>)> = Vec::new();
match format {
CopyToClipboard::Plain => {
let Some(s) = run(opts) else { return };
contents.push(("text/plain".to_owned(), s));
contents.push(("text/plain".to_owned(), s.into_bytes()));
}
CopyToClipboard::Vt => {
@@ -125,7 +126,7 @@ impl Surface {
// it contains escape sequences that should be preserved
// as-is
let Some(s) = run(o) else { return };
contents.push(("text/plain".to_owned(), s));
contents.push(("text/plain".to_owned(), s.into_bytes()));
}
CopyToClipboard::Html => {
@@ -135,7 +136,7 @@ impl Surface {
// since HTML has its own character encoding and entity
// system
let Some(s) = run(o) else { return };
contents.push(("text/html".to_owned(), s));
contents.push(("text/html".to_owned(), s.into_bytes()));
}
CopyToClipboard::Mixed => {
@@ -143,7 +144,7 @@ impl Surface {
let Some(plain) = run(opts.clone()) else {
return;
};
contents.push(("text/plain".to_owned(), plain));
contents.push(("text/plain".to_owned(), plain.into_bytes()));
// Second, generate HTML without codepoint mappings
let mut o = opts;
@@ -154,7 +155,7 @@ impl Surface {
o.background = None;
o.foreground = None;
let Some(html) = run(o) else { return };
contents.push(("text/html".to_owned(), html));
contents.push(("text/html".to_owned(), html.into_bytes()));
}
}
@@ -207,7 +208,7 @@ impl Surface {
ClipboardRequest::Osc52Write(clipboard) => {
self.rt_surface.set_clipboard(
clipboard,
&[("text/plain".to_owned(), data.to_owned())],
&[("text/plain".to_owned(), data.as_bytes().to_vec())],
!confirmed,
);
Ok(())
+6 -7
View File
@@ -469,8 +469,8 @@ impl Surface {
.working_directory
.as_ref()
.and_then(|wd| wd.value())
.map(str::to_owned);
ecfg.resources_dir = global::state().resources_dir.host().map(str::to_owned);
.cloned();
ecfg.resources_dir = global::state().resources_dir.host().cloned();
// The Windows exec backend has no pre-exec/post-fork hooks,
// cgroup scopes or rlimits (Zig: Linux/POSIX only).
#[cfg(unix)]
@@ -940,9 +940,8 @@ impl Surface {
}
Message::PwdChange(w) => {
// Keep the OSC 7 bytes (see `Pwd`): non-UTF-8 bytes are
// carried with the reversible byte escape.
let pwd = ghostty_os::osbytes::bytes_to_config_string(w.slice());
// Keep the OSC 7 bytes (see `Pwd`).
let pwd = ghostty_os::osbytes::EncodedOs::from_bytes(w.slice());
self.perform(RtAction::Pwd(Pwd { pwd }));
}
@@ -1368,10 +1367,10 @@ impl Surface {
/// Returns the pwd of the terminal, if any. This is always copied
/// because the pwd can change at any point from termio.
#[must_use]
pub fn pwd(&self) -> Option<String> {
pub fn pwd(&self) -> Option<ghostty_os::osbytes::EncodedOs> {
let t = self.lock_terminal();
let pwd = t.get_pwd()?;
Some(ghostty_os::osbytes::bytes_to_config_string(pwd))
Some(ghostty_os::osbytes::EncodedOs::from_bytes(pwd))
}
/// The raw bytes of [`Surface::pwd`] (Zig returns the OSC 7 bytes as is).
+76 -20
View File
@@ -394,15 +394,20 @@ fn drag_left_click_triple_selection(
/// Resolve a relative file path to an absolute path using `pwd` (Zig
/// `resolvePathForOpening`). Returns `None` for absolute paths (use as-is),
/// when there's no pwd, or when the resolved path doesn't exist.
fn resolve_path_for_opening(pwd: Option<&str>, path: &str) -> Option<String> {
///
/// `pwd` is the raw OSC 7 byte string; `path` is plain terminal text.
fn resolve_path_for_opening(
pwd: Option<&[u8]>,
path: &str,
) -> Option<ghostty_os::osbytes::EncodedOs> {
use std::path::{Component, Path, PathBuf};
if Path::new(path).is_absolute() {
return None;
}
let pwd = pwd?;
let joined = Path::new(pwd).join(path);
let pwd = ghostty_os::osbytes::os_from_bytes(pwd?.to_vec());
let joined = Path::new(&pwd).join(path);
// Lexically normalize (std.fs.path.resolve).
let mut out = PathBuf::new();
@@ -419,7 +424,16 @@ fn resolve_path_for_opening(pwd: Option<&str>, path: &str) -> Option<String> {
if !out.exists() {
return None;
}
Some(out.to_string_lossy().into_owned())
Some(ghostty_os::osbytes::EncodedOs::from_path(&out))
}
/// What to hand to the opener for a matched `open` link: a relative path
/// that resolves against the terminal's pwd becomes that (OS) path, anything
/// else (a URL, an absolute path) is the matched *plain text*, wrapped so it
/// is never mistaken for an escaped OS string.
fn open_link_target(pwd: Option<&[u8]>, text: &str) -> ghostty_os::osbytes::EncodedOs {
resolve_path_for_opening(pwd, text)
.unwrap_or_else(|| ghostty_os::osbytes::EncodedOs::from_plain_text(text))
}
impl Surface {
@@ -1603,7 +1617,7 @@ impl Surface {
/// Return the URI for an OSC8 hyperlink at the given position or `None`
/// if there is no hyperlink.
fn osc8_uri(screen: &Screen, pin: Pin) -> Option<String> {
fn osc8_uri(screen: &Screen, pin: Pin) -> Option<Vec<u8>> {
let node = screen.pages.node(pin.node)?;
let page = &node.data;
let idx = page
@@ -1611,13 +1625,13 @@ impl Surface {
.ok()?;
let id = page.lookup_hyperlink(idx)?;
let entry = page.hyperlink_set.get(id)?;
Some(String::from_utf8_lossy(&entry.uri).into_owned())
Some(entry.uri.clone())
}
/// The URL of the link (`open` regex link or OSC8) under the current
/// cursor position, if the mouse is over a link (Zig
/// `copy_url_to_clipboard`). Takes the renderer lock.
pub(crate) fn link_url_at_cursor(&self) -> Option<String> {
pub(crate) fn link_url_at_cursor(&self) -> Option<Vec<u8>> {
// If the mouse isn't over a link, nothing we can do.
if !self.mouse.borrow().over_link {
return None;
@@ -1632,10 +1646,14 @@ impl Surface {
let link = self.link_at_pos(&t, vp, mods)?;
let screen = t.screen();
match link.action {
LinkAction::Open => Some(screen.selection_string(SelectionString {
sel: link.selection,
trim,
})),
LinkAction::Open => Some(
screen
.selection_string(SelectionString {
sel: link.selection,
trim,
})
.into_bytes(),
),
LinkAction::_OpenOsc8 => {
let uri = Self::osc8_uri(screen, link.selection.start(&screen.pages));
if uri.is_none() {
@@ -1664,8 +1682,9 @@ impl Surface {
sel: link.selection,
trim: false,
});
let pwd = t.get_pwd().map(ghostty_os::osbytes::bytes_to_config_string);
resolve_path_for_opening(pwd.as_deref(), &str).unwrap_or(str)
// The matched text is plain terminal text (a URL or a
// path), never an escaped OS string.
open_link_target(t.get_pwd(), &str)
}
LinkAction::_OpenOsc8 => {
@@ -1674,7 +1693,7 @@ impl Surface {
log::warn!("failed to get URI for OSC8 hyperlink");
return Ok(false);
};
uri
ghostty_os::osbytes::EncodedOs::from_bytes(&uri)
}
}
};
@@ -1703,7 +1722,7 @@ impl Surface {
OpenUrlKind::Text => ghostty_os::open::OpenUrlKind::Text,
OpenUrlKind::Html => ghostty_os::open::OpenUrlKind::Html,
};
ghostty_os::open::open(kind, &url)?;
ghostty_os::open::open(kind, &url.to_os())?;
Ok(())
}
@@ -1795,7 +1814,10 @@ impl Surface {
break 'link (None, false);
};
(
Some(MouseOverLink { url: uri }),
// Display-only text for the status bar.
Some(MouseOverLink {
url: String::from_utf8_lossy(&uri).into_owned(),
}),
cfg.link_previews != LinkPreviews::False,
)
}
@@ -2484,21 +2506,55 @@ mod tests {
#[test]
fn resolve_path_for_opening_semantics() {
// Absolute paths are used as-is.
assert_eq!(resolve_path_for_opening(Some("/tmp"), "/etc/passwd"), None);
assert_eq!(resolve_path_for_opening(Some(b"/tmp"), "/etc/passwd"), None);
// No pwd, no resolution.
assert_eq!(resolve_path_for_opening(None, "foo"), None);
// Non-existent relative paths are not resolved.
assert_eq!(
resolve_path_for_opening(Some("/tmp"), "does-not-exist-xyz"),
resolve_path_for_opening(Some(b"/tmp"), "does-not-exist-xyz"),
None
);
// Existing relative path resolves, including `..`.
assert_eq!(
resolve_path_for_opening(Some("/tmp"), "../etc"),
Some("/etc".to_owned())
resolve_path_for_opening(Some(b"/tmp"), "../etc"),
Some(ghostty_os::osbytes::EncodedOs::from_plain_text("/etc"))
);
}
#[test]
fn open_link_plain_text_is_never_decoded() {
// Regex-matched text is ordinary text: the escape character followed
// by U+00FF must not turn into the byte 0xFF.
let text = "https://example.com/\u{fdd0}\u{ff}";
let t = open_link_target(Some(b"/tmp"), text);
assert_eq!(t.to_bytes(), text.as_bytes());
let abs = open_link_target(None, "/etc/\u{fdd0}\u{ff}");
assert_eq!(abs.to_bytes(), "/etc/\u{fdd0}\u{ff}".as_bytes());
}
#[cfg(unix)]
#[test]
fn open_link_resolves_against_non_utf8_pwd() {
use std::os::unix::ffi::OsStrExt;
let td = ghostty_os::TempDir::init().unwrap();
let dir = td.path().join(std::ffi::OsStr::from_bytes(b"d\xffir"));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("file.txt"), "x").unwrap();
// The OSC 7 pwd is raw bytes.
let pwd = dir.as_os_str().as_bytes().to_vec();
let t = open_link_target(Some(&pwd), "file.txt");
assert_eq!(t.to_path(), dir.join("file.txt"));
assert!(t.to_path().exists());
}
#[test]
fn osc8_uri_bytes_are_carried_unchanged() {
let uri = b"https://example.com/a\xffb".to_vec();
let t = ghostty_os::osbytes::EncodedOs::from_bytes(&uri);
assert_eq!(t.to_bytes(), uri);
assert_ne!(t.as_encoded().as_bytes(), uri.as_slice());
}
#[test]
fn scroll_amount_direction() {
let a = ScrollAmount { delta: -3 };