From f7fec6f44d88b0ccb88623750e321d9baf309072 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Mon, 12 Oct 2026 10:06:07 +0200 Subject: [PATCH] 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) 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) --- Cargo.lock | 6 + .../src/class/application.rs | 10 +- .../src/class/command_palette.rs | 5 +- crates/ghostty-apprt-gtk/src/class/surface.rs | 46 ++- crates/ghostty-apprt-gtk/src/class/window.rs | 2 +- crates/ghostty-apprt-gtk/src/css.rs | 17 +- crates/ghostty-apprt-gtk/src/flatpak.rs | 14 +- crates/ghostty-apprt/Cargo.toml | 1 + crates/ghostty-apprt/src/action.rs | 24 +- crates/ghostty-apprt/src/runtime_api.rs | 4 +- crates/ghostty-apprt/src/surface.rs | 19 +- crates/ghostty-bench/Cargo.toml | 1 + crates/ghostty-bench/src/args.rs | 112 ++++-- crates/ghostty-bench/src/benches.rs | 28 +- .../src/bin/ghostty-synthetic.rs | 6 +- crates/ghostty-bench/src/main.rs | 6 +- crates/ghostty-cli/Cargo.toml | 1 + crates/ghostty-cli/src/crash_report.rs | 3 +- crates/ghostty-cli/src/edit_config.rs | 23 +- crates/ghostty-cli/src/list_themes.rs | 44 +-- crates/ghostty-cli/src/list_themes/preview.rs | 56 +-- crates/ghostty-cli/src/new_window.rs | 13 +- .../ghostty-cli/src/ssh_cache/disk_cache.rs | 16 +- crates/ghostty-cli/src/validate_config.rs | 5 +- crates/ghostty-config/Cargo.toml | 1 + crates/ghostty-config/src/config.rs | 113 +++--- crates/ghostty-config/src/edit.rs | 57 ++- crates/ghostty-config/src/file_load.rs | 85 +++-- crates/ghostty-config/src/path.rs | 132 ++++--- crates/ghostty-config/src/theme.rs | 85 +++-- crates/ghostty-config/src/types_a.rs | 37 +- crates/ghostty-core/Cargo.toml | 3 + crates/ghostty-core/src/global.rs | 2 +- crates/ghostty-core/src/surface/binding.rs | 54 +-- crates/ghostty-core/src/surface/clipboard.rs | 17 +- crates/ghostty-core/src/surface/mod.rs | 13 +- crates/ghostty-core/src/surface/mouse.rs | 96 ++++- crates/ghostty-core/tests/common/mod.rs | 8 +- crates/ghostty-core/tests/e2e_term.rs | 9 +- crates/ghostty-crash/Cargo.toml | 1 + crates/ghostty-crash/src/dir.rs | 31 +- crates/ghostty-crash/src/exception.rs | 66 ++++ crates/ghostty-crash/src/lib.rs | 1 + crates/ghostty-crash/src/mach_handler.rs | 221 +++++++++-- crates/ghostty-crash/src/sentry.rs | 16 +- crates/ghostty-crash/src/signal.rs | 9 +- crates/ghostty-crash/src/signal_windows.rs | 2 +- crates/ghostty-crash/src/suspend.rs | 112 ++++++ crates/ghostty-font/src/deferred_face.rs | 5 +- crates/ghostty-font/src/face/freetype.rs | 6 +- crates/ghostty-font/src/fontconfig.rs | 48 ++- crates/ghostty-lib/src/action_c.rs | 4 +- crates/ghostty-lib/src/benchmark.rs | 39 +- crates/ghostty-lib/src/config_capi.rs | 50 ++- crates/ghostty-lib/src/embedded.rs | 9 +- crates/ghostty-lib/src/main_c.rs | 8 +- crates/ghostty-os/src/env.rs | 15 +- crates/ghostty-os/src/file.rs | 12 +- crates/ghostty-os/src/flatpak.rs | 12 +- crates/ghostty-os/src/homedir.rs | 89 +++-- crates/ghostty-os/src/i18n.rs | 12 +- crates/ghostty-os/src/lib.rs | 2 +- crates/ghostty-os/src/open.rs | 32 +- crates/ghostty-os/src/osbytes.rs | 361 ++++++++++++++++-- crates/ghostty-os/src/passwd.rs | 14 +- crates/ghostty-os/src/path.rs | 11 +- crates/ghostty-os/src/resourcesdir.rs | 91 ++--- crates/ghostty-os/src/unsupported.rs | 36 +- crates/ghostty-os/src/xdg.rs | 40 +- crates/ghostty-os/tests/flatpak_mock.rs | 8 +- crates/ghostty-renderer/src/generic.rs | 48 ++- crates/ghostty-renderer/src/shadertoy.rs | 14 +- .../src/kitty/graphics_image.rs | 40 +- .../ghostty-terminal/src/stream_readonly.rs | 12 +- crates/ghostty-termio/src/command.rs | 16 +- crates/ghostty-termio/src/command_windows.rs | 31 +- crates/ghostty-termio/src/exec.rs | 122 ++++-- crates/ghostty-termio/src/exec_windows.rs | 81 ++-- .../ghostty-termio/src/shell_integration.rs | 86 +++-- crates/ghostty-termio/src/stream_handler.rs | 12 +- crates/ghostty-termio/src/windows_args.rs | 61 +++ crates/ghostty-vt-capi/src/osc.rs | 59 ++- crates/ghostty-vt-capi/tests/c/alloc_test.c | 43 ++- crates/ghostty-vt/build.rs | 37 ++ crates/ghostty-vt/src/kitty/color.rs | 162 +++++++- crates/ghostty-vt/src/osc/mod.rs | 47 ++- crates/ghostty-vt/src/osc/parsers/color.rs | 339 +++++++++++++--- .../ghostty-vt/src/osc/parsers/kitty_color.rs | 50 ++- crates/ghostty-vt/src/parser.rs | 8 +- crates/ghostty-vt/src/stream.rs | 4 +- crates/ghostty-vt/src/x11_color.rs | 32 +- docs/rewrite/notes/final-Z7.md | 118 ++++++ 92 files changed, 2967 insertions(+), 992 deletions(-) create mode 100644 crates/ghostty-crash/src/suspend.rs create mode 100644 crates/ghostty-vt/build.rs create mode 100644 docs/rewrite/notes/final-Z7.md diff --git a/Cargo.lock b/Cargo.lock index 9b0b94e..de5eec1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1359,6 +1359,7 @@ dependencies = [ "ghostty-datastruct", "ghostty-input", "ghostty-inspector", + "ghostty-os", "ghostty-renderer", "ghostty-terminal", "ghostty-vt", @@ -1404,6 +1405,7 @@ dependencies = [ name = "ghostty-bench" version = "0.1.0" dependencies = [ + "ghostty-os", "ghostty-terminal", "ghostty-unicode", "ghostty-vt", @@ -1441,6 +1443,7 @@ dependencies = [ "ghostty-vt", "log", "ratatui", + "temp-env", "tempfile", "termina", "thiserror 2.0.21", @@ -1458,6 +1461,7 @@ dependencies = [ "ghostty-vt", "log", "onig", + "temp-env", "thiserror 2.0.21", ] @@ -1486,6 +1490,7 @@ dependencies = [ "onig", "oslog", "png", + "temp-env", "thiserror 2.0.21", ] @@ -1500,6 +1505,7 @@ dependencies = [ "minidump", "minidump-writer", "serde_json", + "temp-env", "tempfile", "thiserror 2.0.21", "windows-sys", diff --git a/crates/ghostty-apprt-gtk/src/class/application.rs b/crates/ghostty-apprt-gtk/src/class/application.rs index bcd5550..a760357 100644 --- a/crates/ghostty-apprt-gtk/src/class/application.rs +++ b/crates/ghostty-apprt-gtk/src/class/application.rs @@ -78,7 +78,7 @@ pub enum QuitTimer { #[derive(Debug, Default, Clone)] pub struct WindowOverrides { pub command: Option, - pub working_directory: Option, + pub working_directory: Option, pub title: Option, } @@ -1141,7 +1141,7 @@ impl Application { fn action_new_window(&self, parameter: Option<&glib::Variant>) { log::debug!("received new window action"); - let mut working_directory: Option = None; + let mut working_directory: Option = None; let mut title: Option = None; let mut command: Option = None; let mut args: Vec = Vec::new(); @@ -1180,7 +1180,9 @@ impl Application { continue; } if let Some(v) = s.strip_prefix("--working-directory=") { - working_directory = Some(v.trim().to_owned()); + // IPC carries the encoded form of the OS string. + working_directory = + Some(ghostty_os::osbytes::EncodedOs::from_encoded(v.trim())); continue; } if let Some(v) = s.strip_prefix("--title=") { @@ -1791,7 +1793,7 @@ impl Application { action::OpenUrlKind::Text => ghostty_os::open::OpenUrlKind::Text, action::OpenUrlKind::Html => ghostty_os::open::OpenUrlKind::Html, }; - if let Err(err) = ghostty_os::open::open(kind, &value.url) { + if let Err(err) = ghostty_os::open::open(kind, &value.url.to_os()) { log::warn!("unable to open url: {err}"); } } diff --git a/crates/ghostty-apprt-gtk/src/class/command_palette.rs b/crates/ghostty-apprt-gtk/src/class/command_palette.rs index 0c27bb9..c6b7ec6 100644 --- a/crates/ghostty-apprt-gtk/src/class/command_palette.rs +++ b/crates/ghostty-apprt-gtk/src/class/command_palette.rs @@ -138,7 +138,10 @@ mod command_imp { let title = surface .get_effective_title() .unwrap_or_else(|| "Untitled".to_string()); - if let Some(pwd) = surface.get_pwd() + // Display text only. + if let Some(pwd) = surface + .get_pwd() + .map(|p| String::from_utf8_lossy(&p.to_bytes()).into_owned()) && !title.contains(&pwd) { *description = Some(pwd); diff --git a/crates/ghostty-apprt-gtk/src/class/surface.rs b/crates/ghostty-apprt-gtk/src/class/surface.rs index 947532f..3aa6382 100644 --- a/crates/ghostty-apprt-gtk/src/class/surface.rs +++ b/crates/ghostty-apprt-gtk/src/class/surface.rs @@ -132,7 +132,7 @@ pub enum DebugImKey { #[derive(Debug, Clone, Default)] pub struct SurfaceOverrides { pub command: Option, - pub working_directory: Option, + pub working_directory: Option, pub title: Option, } @@ -268,7 +268,7 @@ impl Surface { if ghostty_apprt::surface::should_inherit_working_directory(context, &config_obj.get()) && let Some(pwd) = parent.get_pwd() { - imp.pwd.replace(Some(pwd)); + imp.pwd.replace(Some(pwd.into_encoded())); self.notify("pwd"); } } @@ -1500,7 +1500,7 @@ impl Surface { pub fn set_clipboard( &self, clipboard_type: Clipboard, - contents: &[(String, String)], + contents: &[(String, Vec)], confirm: bool, ) { clipboard::set(self, clipboard_type, contents, confirm); @@ -1582,7 +1582,7 @@ impl Surface { } self.set_clipboard( Clipboard::Standard, - &[("text/plain".to_owned(), title)], + &[("text/plain".to_owned(), title.into_bytes())], false, ); true @@ -1608,13 +1608,22 @@ impl Surface { } /// Returns the pwd property. - pub fn get_pwd(&self) -> Option { - self.imp().pwd.borrow().clone() + pub fn get_pwd(&self) -> Option { + // The GObject `pwd` property is a (UTF-8) string, so it stores the + // encoded form of the OS string; this accessor is the only place it + // is converted back to the typed value. + self.imp() + .pwd + .borrow() + .clone() + .map(ghostty_os::osbytes::EncodedOs::from_encoded) } /// Set the pwd for this surface, copies the value. - pub fn set_pwd(&self, pwd: Option<&str>) { - self.imp().pwd.replace(pwd.map(str::to_owned)); + pub fn set_pwd(&self, pwd: Option<&ghostty_os::osbytes::EncodedOs>) { + self.imp() + .pwd + .replace(pwd.map(|p| p.as_encoded().to_owned())); self.notify("pwd"); } @@ -1806,8 +1815,10 @@ impl Surface { let volume = config.bell_audio_volume.clamp(0.0, 1.0); - debug_assert!(std::path::Path::new(path).is_absolute()); - let media_file = gtk::MediaFile::for_filename(path); + // The config path is an encoded OS string: decode it first. + let path = path.to_path(); + debug_assert!(path.is_absolute()); + let media_file = gtk::MediaFile::for_filename(&path); // If the audio file is marked as required, we'll emit an error if // there was a problem playing it. Otherwise there will be silence. @@ -2125,7 +2136,7 @@ impl Surface { /// The first `focused` surface other than ourselves, used to inherit the /// working directory like `App.focusedSurface` does in the core. - fn focused_surface_pwd(&self) -> Option { + fn focused_surface_pwd(&self) -> Option { Surface::all() .into_iter() .find(|s| s != self && s.core().is_some() && s.get_focused()) @@ -2178,7 +2189,7 @@ impl Surface { if let Some(size) = *imp.font_size_request.borrow() { config.font_size = size.0.points; } - if let Some(pwd) = imp.pwd.borrow().clone() { + if let Some(pwd) = self.get_pwd() { let mut wd_val = WorkingDirectory::Path(pwd); wd_val.finalize(); config.working_directory = Some(wd_val); @@ -2421,7 +2432,7 @@ impl RtSurface for RtSurfaceHandle { .is_some_and(|s| s.clipboard_request(clipboard, state)) } - fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, String)], confirm: bool) { + fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, Vec)], confirm: bool) { if let Some(s) = self.gobj() { s.set_clipboard(clipboard, contents, confirm); } @@ -2485,15 +2496,16 @@ mod clipboard { pub(super) fn set( surface: &Surface, clipboard_type: Clipboard, - contents: &[(String, String)], + contents: &[(String, Vec)], confirm: bool, ) { // Grab our plaintext content for use in confirmation dialogs - // and signals. We always expect one to exist. + // and signals (display text only; the clipboard gets the raw bytes). + // We always expect one to exist. let Some(text) = contents .iter() .find(|(mime, _)| mime == "text/plain") - .map(|(_, data)| data.clone()) + .map(|(_, data)| String::from_utf8_lossy(data).into_owned()) else { return; }; @@ -2505,7 +2517,7 @@ mod clipboard { let providers: Vec = contents .iter() .map(|(mime, data)| { - let bytes = glib::Bytes::from(data.as_bytes()); + let bytes = glib::Bytes::from(data.as_slice()); if mime == "text/plain" { // Add an explicit UTF-8 encoding parameter to the // text/plain type. The default charset when there is diff --git a/crates/ghostty-apprt-gtk/src/class/window.rs b/crates/ghostty-apprt-gtk/src/class/window.rs index c180ebc..d6876fe 100644 --- a/crates/ghostty-apprt-gtk/src/class/window.rs +++ b/crates/ghostty-apprt-gtk/src/class/window.rs @@ -73,7 +73,7 @@ pub enum SelectTab { #[derive(Debug, Clone, Default)] pub struct TabOverrides { pub command: Option, - pub working_directory: Option, + pub working_directory: Option, pub title: Option, } diff --git a/crates/ghostty-apprt-gtk/src/css.rs b/crates/ghostty-apprt-gtk/src/css.rs index 30fb8da..30055ad 100644 --- a/crates/ghostty-apprt-gtk/src/css.rs +++ b/crates/ghostty-apprt-gtk/src/css.rs @@ -110,29 +110,30 @@ impl CssProviders { for p in &config.gtk_custom_css.value { let (path, optional) = match p { - Path::Optional(path) => (path, true), - Path::Required(path) => (path, false), + Path::Optional(path) => (path.to_path(), true), + Path::Required(path) => (path.to_path(), false), }; - let meta = match std::fs::metadata(path) { + let shown = path.display(); + let meta = match std::fs::metadata(&path) { Ok(m) => m, Err(e) => { if e.kind() != std::io::ErrorKind::NotFound || !optional { - log::warn!("error opening gtk-custom-css file {path}: {e}"); + log::warn!("error opening gtk-custom-css file {shown}: {e}"); } continue; } }; - log::info!("loading gtk-custom-css path={path}"); + log::info!("loading gtk-custom-css path={shown}"); if meta.len() > CSS_FILE_SIZE_LIMIT { log::warn!( - "gtk-custom-css file {path} was larger than {CSS_FILE_SIZE_LIMIT} bytes" + "gtk-custom-css file {shown} was larger than {CSS_FILE_SIZE_LIMIT} bytes" ); continue; } - let contents = match std::fs::read(path) { + let contents = match std::fs::read(&path) { Ok(c) => c, Err(e) => { - log::warn!("error reading gtk-custom-css file {path}: {e}"); + log::warn!("error reading gtk-custom-css file {shown}: {e}"); continue; } }; diff --git a/crates/ghostty-apprt-gtk/src/flatpak.rs b/crates/ghostty-apprt-gtk/src/flatpak.rs index 035394c..50e97f1 100644 --- a/crates/ghostty-apprt-gtk/src/flatpak.rs +++ b/crates/ghostty-apprt-gtk/src/flatpak.rs @@ -1,6 +1,6 @@ //! Flatpak helpers (port of `apprt/gtk/flatpak.zig`). -use ghostty_os::ResourcesDir; +use ghostty_os::{ResourcesDir, osbytes::EncodedOs}; use gtk::glib; /// Returns the resources directory, consulting the Flatpak runtime data @@ -9,7 +9,7 @@ pub fn resources_dir() -> std::io::Result { // Only consult Flatpak runtime data for the host case. if ghostty_os::is_flatpak() { let mut result = ResourcesDir { - app_path: Some("/app/share/ghostty".to_owned()), + app_path: Some(EncodedOs::from_plain_text("/app/share/ghostty")), host_path: None, }; @@ -24,13 +24,11 @@ pub fn resources_dir() -> std::io::Result { return Ok(result); }; - result.host_path = Some( - std::path::Path::new(app_dir.as_str()) + result.host_path = Some(EncodedOs::from_path( + &std::path::Path::new(app_dir.as_str()) .join("share") - .join("ghostty") - .to_string_lossy() - .into_owned(), - ); + .join("ghostty"), + )); return Ok(result); } diff --git a/crates/ghostty-apprt/Cargo.toml b/crates/ghostty-apprt/Cargo.toml index ccef9e7..87cf188 100644 --- a/crates/ghostty-apprt/Cargo.toml +++ b/crates/ghostty-apprt/Cargo.toml @@ -11,6 +11,7 @@ ghostty-config.workspace = true ghostty-datastruct.workspace = true ghostty-inspector.workspace = true ghostty-terminal.workspace = true +ghostty-os.workspace = true ghostty-renderer.workspace = true thiserror = "2.0.21" diff --git a/crates/ghostty-apprt/src/action.rs b/crates/ghostty-apprt/src/action.rs index 6b90949..d3bb5b2 100644 --- a/crates/ghostty-apprt/src/action.rs +++ b/crates/ghostty-apprt/src/action.rs @@ -650,16 +650,14 @@ impl std::fmt::Display for SetTitle { #[derive(Debug, Clone, PartialEq, Eq)] pub struct Pwd { /// The working directory as reported by OSC 7. Zig keeps raw bytes; here - /// non-UTF-8 bytes are carried with the reversible escape of - /// `ghostty_os::osbytes` (valid UTF-8 is unchanged). Decode with - /// `osbytes::config_str_to_bytes` / `config_str_to_os` before using it - /// as a path or handing it to C. - pub pwd: String, + /// they are carried as an [`EncodedOs`](ghostty_os::osbytes::EncodedOs) + /// that must be decoded (`to_path`/`to_bytes`) at the OS/C boundary. + pub pwd: ghostty_os::osbytes::EncodedOs, } impl std::fmt::Display for Pwd { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "Pwd{{ {} }}", self.pwd) + write!(f, "Pwd{{ {} }}", self.pwd.as_encoded()) } } @@ -752,8 +750,10 @@ pub struct OpenUrl { /// The type of data that the URL refers to. pub kind: OpenUrlKind, - /// The URL. - pub url: String, + /// The URL or path to open, as raw bytes (Zig `[]const u8`). Plain text + /// URLs are wrapped with `EncodedOs::from_plain_text`, file names with + /// `EncodedOs::from_path`/`from_bytes`; the opener decodes it. + pub url: ghostty_os::osbytes::EncodedOs, } /// The type of the data at the URL to open. This is used as a hint to @@ -870,7 +870,13 @@ mod tests { #[test] fn display_formats() { assert_eq!(SetTitle { title: "x".into() }.to_string(), "SetTitle{ x }"); - assert_eq!(Pwd { pwd: "/a".into() }.to_string(), "Pwd{ /a }"); + assert_eq!( + Pwd { + pwd: ghostty_os::osbytes::EncodedOs::from_encoded("/a") + } + .to_string(), + "Pwd{ /a }" + ); assert_eq!( DesktopNotification { title: "t".into(), diff --git a/crates/ghostty-apprt/src/runtime_api.rs b/crates/ghostty-apprt/src/runtime_api.rs index e8b9fa4..abbf9e7 100644 --- a/crates/ghostty-apprt/src/runtime_api.rs +++ b/crates/ghostty-apprt/src/runtime_api.rs @@ -307,7 +307,9 @@ pub trait RtSurface { /// `CoreSurface::complete_clipboard_request`. Returns true if started. fn clipboard_request(&self, clipboard: Clipboard, state: ClipboardRequest) -> bool; /// `setClipboard(type, contents, confirm)`; contents are (mime, data). - fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, String)], confirm: bool); + /// The data is raw bytes like Zig's `[:0]const u8` (a copied file name + /// need not be valid UTF-8). + fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, Vec)], confirm: bool); /// `defaultTermioEnv`: env vars to set for the child (GDK/GTK related /// cleanups). fn default_termio_env(&self) -> Vec<(std::ffi::OsString, std::ffi::OsString)>; diff --git a/crates/ghostty-apprt/src/surface.rs b/crates/ghostty-apprt/src/surface.rs index 197fc3d..aefe8b4 100644 --- a/crates/ghostty-apprt/src/surface.rs +++ b/crates/ghostty-apprt/src/surface.rs @@ -222,7 +222,11 @@ pub fn should_inherit_working_directory(context: NewSurfaceContext, config: &Con /// `app.focusedSurface()?.pwd()`), which the caller (core) must look up. // TODO(integration): core could wrap this to resolve the focused surface. #[must_use] -pub fn new_config(config: &Config, context: NewSurfaceContext, prev_pwd: Option) -> Config { +pub fn new_config( + config: &Config, + context: NewSurfaceContext, + prev_pwd: Option, +) -> Config { let mut copy = config.clone(); if let Some(pwd) = prev_pwd && should_inherit_working_directory(context, config) @@ -238,6 +242,10 @@ mod tests { use super::*; + fn tmp() -> ghostty_os::osbytes::EncodedOs { + ghostty_os::osbytes::EncodedOs::from_encoded("/tmp") + } + struct Rec(RefCell>); impl AppMailbox for Rec { fn push_surface_message(&self, s: u32, m: Message, _t: Timeout) -> usize { @@ -277,12 +285,9 @@ mod tests { NewSurfaceContext::Tab, &c )); - let n = new_config(&c, NewSurfaceContext::Window, Some("/tmp".into())); - assert_eq!( - n.working_directory, - Some(WorkingDirectory::Path("/tmp".into())) - ); - let n = new_config(&c, NewSurfaceContext::Tab, Some("/tmp".into())); + let n = new_config(&c, NewSurfaceContext::Window, Some(tmp())); + assert_eq!(n.working_directory, Some(WorkingDirectory::Path(tmp()))); + let n = new_config(&c, NewSurfaceContext::Tab, Some(tmp())); assert_eq!(n.working_directory, None); } } diff --git a/crates/ghostty-bench/Cargo.toml b/crates/ghostty-bench/Cargo.toml index c20cf04..5b7ee5c 100644 --- a/crates/ghostty-bench/Cargo.toml +++ b/crates/ghostty-bench/Cargo.toml @@ -14,6 +14,7 @@ name = "ghostty-synthetic" path = "src/bin/ghostty-synthetic.rs" [dependencies] +ghostty-os.workspace = true ghostty-terminal.workspace = true ghostty-unicode.workspace = true ghostty-vt.workspace = true diff --git a/crates/ghostty-bench/src/args.rs b/crates/ghostty-bench/src/args.rs index 8cfc815..5c88ed8 100644 --- a/crates/ghostty-bench/src/args.rs +++ b/crates/ghostty-bench/src/args.rs @@ -2,48 +2,61 @@ //! the benchmark and synthetic CLIs). use std::{ collections::HashMap, + ffi::{OsStr, OsString}, fs::File, io::{self, Read}, + path::{Path, PathBuf}, str::FromStr, }; +use ghostty_os::osbytes::{os_bytes, os_from_bytes}; + /// Parsed `--key[=value]` arguments. #[derive(Debug, Default)] pub struct Args { - map: HashMap>, + // Values are OS strings: file names (`--data=...`) need not be UTF-8. + map: HashMap>, +} + +/// The action name of a `+action` argument, if `arg` is one. +#[must_use] +pub fn action_name(arg: &OsStr) -> Option { + let bytes = os_bytes(arg); + let name = bytes.strip_prefix(b"+")?; + Some(String::from_utf8_lossy(name).into_owned()) } impl Args { /// Parse arguments (skipping `+action` words and the program name). - pub fn parse>(iter: I) -> Result { + pub fn parse(iter: I) -> Result + where + I: IntoIterator, + S: Into, + { let mut map = HashMap::new(); for arg in iter { - if arg.starts_with('+') { + let bytes = os_bytes(&arg.into()); + if bytes.starts_with(b"+") { continue; } - let Some(rest) = arg.strip_prefix("--") else { - return Err(format!("unexpected argument: {arg}")); + let Some(rest) = bytes.strip_prefix(b"--") else { + return Err(format!( + "unexpected argument: {}", + String::from_utf8_lossy(&bytes) + )); }; - match rest.split_once('=') { - Some((k, v)) => map.insert(k.to_owned(), Some(v.to_owned())), - None => map.insert(rest.to_owned(), None), + let (key, value) = match rest.iter().position(|b| *b == b'=') { + Some(i) => (&rest[..i], Some(os_from_bytes(rest[i + 1..].to_vec()))), + None => (rest, None), }; + let key = String::from_utf8(key.to_vec()) + .map_err(|_| "option names must be valid UTF-8".to_owned())?; + map.insert(key, value); } Ok(Args { map }) } - /// Take a typed value, falling back to `default`. - pub fn take(&mut self, key: &str, default: T) -> Result { - match self.map.remove(key) { - None => Ok(default), - Some(None) => Err(format!("option --{key} requires a value")), - Some(Some(v)) => v - .parse() - .map_err(|_| format!("invalid value for --{key}: {v}")), - } - } - - pub fn take_opt_string(&mut self, key: &str) -> Result, String> { + fn take_os(&mut self, key: &str) -> Result, String> { match self.map.remove(key) { None => Ok(None), Some(None) => Err(format!("option --{key} requires a value")), @@ -51,15 +64,44 @@ impl Args { } } + /// Take a typed value, falling back to `default`. + pub fn take(&mut self, key: &str, default: T) -> Result { + match self.take_os(key)? { + None => Ok(default), + Some(v) => v + .to_str() + .and_then(|s| s.parse().ok()) + .ok_or_else(|| format!("invalid value for --{key}: {}", v.to_string_lossy())), + } + } + + pub fn take_opt_string(&mut self, key: &str) -> Result, String> { + match self.take_os(key)? { + None => Ok(None), + Some(v) => v + .into_string() + .map(Some) + .map_err(|v| format!("invalid value for --{key}: {}", v.to_string_lossy())), + } + } + + /// Take a file path value (any bytes are allowed). + pub fn take_opt_path(&mut self, key: &str) -> Result, String> { + Ok(self.take_os(key)?.map(PathBuf::from)) + } + /// Take a boolean flag (`--key`, `--key=true`, `--key=false`). pub fn take_flag(&mut self, key: &str) -> Result { match self.map.remove(key) { None => Ok(false), Some(None) => Ok(true), - Some(Some(v)) => match v.as_str() { + Some(Some(v)) => match v.to_str().unwrap_or_default() { "true" | "t" | "1" | "yes" => Ok(true), "false" | "f" | "0" | "no" => Ok(false), - _ => Err(format!("invalid value for --{key}: {v}")), + _ => Err(format!( + "invalid value for --{key}: {}", + v.to_string_lossy() + )), }, } } @@ -75,10 +117,10 @@ impl Args { /// Returns the data source for the path: `None` if unset, stdin for `-`, /// otherwise the opened file (mirrors `benchmark/options.zig`). -pub fn data_file(path: Option<&str>) -> io::Result>> { +pub fn data_file(path: Option<&Path>) -> io::Result>> { match path { None => Ok(None), - Some("-") => Ok(Some(Box::new(io::stdin()))), + Some(p) if p == Path::new("-") => Ok(Some(Box::new(io::stdin()))), Some(p) => Ok(Some(Box::new(File::open(p)?))), } } @@ -96,3 +138,25 @@ pub fn read_short(r: &mut dyn Read, buf: &mut [u8]) -> io::Result { } Ok(n) } + +#[cfg(all(test, unix))] +mod tests { + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + + use super::*; + + #[test] + fn data_path_keeps_non_utf8_bytes() { + let arg = OsString::from_vec(b"--data=/tmp/a\xffb".to_vec()); + let mut args = Args::parse([arg]).unwrap(); + let p = args.take_opt_path("data").unwrap().unwrap(); + assert_eq!(p.as_os_str().as_bytes(), b"/tmp/a\xffb"); + args.finish().unwrap(); + } + + #[test] + fn action_name_extracts_plus_words() { + assert_eq!(action_name(OsStr::new("+ascii")).as_deref(), Some("ascii")); + assert_eq!(action_name(OsStr::new("--x")), None); + } +} diff --git a/crates/ghostty-bench/src/benches.rs b/crates/ghostty-bench/src/benches.rs index 3de8496..549db35 100644 --- a/crates/ghostty-bench/src/benches.rs +++ b/crates/ghostty-bench/src/benches.rs @@ -40,12 +40,12 @@ fn warn(what: &str, e: &dyn std::fmt::Display) -> BenchmarkFailed { /// Shared data-file handling: opened in setup, closed in teardown. struct Data { - path: Option, + path: Option, f: Option>, } impl Data { - fn new(path: Option) -> Data { + fn new(path: Option) -> Data { Data { path, f: None } } @@ -103,7 +103,7 @@ impl TerminalStream { pub fn create(args: &mut Args) -> std::result::Result { let rows = args.take("terminal-rows", 80u16)?; let cols = args.take("terminal-cols", 120u16)?; - let data = args.take_opt_string("data")?; + let data = args.take_opt_path("data")?; let full = match args.take_opt_string("handler")?.as_deref() { None | Some("print") => false, Some("full") => true, @@ -160,7 +160,7 @@ pub struct TerminalParser { impl TerminalParser { pub fn create(args: &mut Args) -> std::result::Result { Ok(TerminalParser { - data: Data::new(args.take_opt_string("data")?), + data: Data::new(args.take_opt_path("data")?), }) } } @@ -196,7 +196,7 @@ pub struct OscParser { impl OscParser { pub fn create(args: &mut Args) -> std::result::Result { Ok(OscParser { - data: Data::new(args.take_opt_string("data")?), + data: Data::new(args.take_opt_path("data")?), parser: osc::Parser::init(true), }) } @@ -267,7 +267,7 @@ impl CodepointWidth { Some(m) => return Err(format!("invalid value for --mode: {m}")), }; Ok(CodepointWidth { - data: Data::new(args.take_opt_string("data")?), + data: Data::new(args.take_opt_path("data")?), mode, }) } @@ -390,7 +390,7 @@ impl GraphemeBreak { Some(m) => return Err(format!("invalid value for --mode: {m}")), }; Ok(GraphemeBreak { - data: Data::new(args.take_opt_string("data")?), + data: Data::new(args.take_opt_path("data")?), noop, }) } @@ -479,7 +479,7 @@ impl IsSymbol { Some(m) => return Err(format!("invalid value for --mode: {m}")), }; Ok(IsSymbol { - data: Data::new(args.take_opt_string("data")?), + data: Data::new(args.take_opt_path("data")?), mode, }) } @@ -544,7 +544,7 @@ impl ScreenClone { }; let rows = args.take("terminal-rows", 80u16)?; let cols = args.take("terminal-cols", 120u16)?; - let data = args.take_opt_string("data")?; + let data = args.take_opt_path("data")?; let terminal = Terminal::new(TermOptions::new(cols, rows)).map_err(|e| format!("{e:?}"))?; Ok(ScreenClone { data: Data::new(data), @@ -674,9 +674,13 @@ mod tests { fn run(action: &str, mode: &str, data: &std::path::Path) -> Result { let mut args = Args::parse([ - format!("+{action}"), - format!("--mode={mode}"), - format!("--data={}", data.display()), + std::ffi::OsString::from(format!("+{action}")), + std::ffi::OsString::from(format!("--mode={mode}")), + { + let mut a = std::ffi::OsString::from("--data="); + a.push(data); + a + }, ]) .unwrap(); match action { diff --git a/crates/ghostty-bench/src/bin/ghostty-synthetic.rs b/crates/ghostty-bench/src/bin/ghostty-synthetic.rs index 78ea769..0d44e99 100644 --- a/crates/ghostty-bench/src/bin/ghostty-synthetic.rs +++ b/crates/ghostty-bench/src/bin/ghostty-synthetic.rs @@ -11,14 +11,14 @@ use ghostty_bench::{ }; fn run() -> Result<(), String> { - let argv: Vec = std::env::args().skip(1).collect(); + let argv: Vec = std::env::args_os().skip(1).collect(); let mut action = None; for a in &argv { - if let Some(name) = a.strip_prefix('+') { + if let Some(name) = ghostty_bench::args::action_name(a) { if action.is_some() { return Err("multiple actions specified".into()); } - action = Some(name.to_owned()); + action = Some(name); } } let action = action.ok_or("no action specified; available: ascii, osc, sgr, utf8")?; diff --git a/crates/ghostty-bench/src/main.rs b/crates/ghostty-bench/src/main.rs index 351ed77..481f85e 100644 --- a/crates/ghostty-bench/src/main.rs +++ b/crates/ghostty-bench/src/main.rs @@ -4,15 +4,15 @@ use std::process::ExitCode; use ghostty_bench::{args::Args, benches}; fn main() -> ExitCode { - let argv: Vec = std::env::args().skip(1).collect(); + let argv: Vec = std::env::args_os().skip(1).collect(); let mut action = None; for a in &argv { - if let Some(name) = a.strip_prefix('+') { + if let Some(name) = ghostty_bench::args::action_name(a) { if action.is_some() { eprintln!("error: multiple actions specified"); return ExitCode::FAILURE; } - action = Some(name.to_owned()); + action = Some(name); } } let Some(action) = action else { diff --git a/crates/ghostty-cli/Cargo.toml b/crates/ghostty-cli/Cargo.toml index f6a32b9..92c6be6 100644 --- a/crates/ghostty-cli/Cargo.toml +++ b/crates/ghostty-cli/Cargo.toml @@ -28,3 +28,4 @@ workspace = true [dev-dependencies] tempfile = "3.27.0" +temp-env = "0.3.6" diff --git a/crates/ghostty-cli/src/crash_report.rs b/crates/ghostty-cli/src/crash_report.rs index f3a1c12..627361c 100644 --- a/crates/ghostty-cli/src/crash_report.rs +++ b/crates/ghostty-cli/src/crash_report.rs @@ -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) diff --git a/crates/ghostty-cli/src/edit_config.rs b/crates/ghostty-cli/src/edit_config.rs index 3efc05a..c01c0c1 100644 --- a/crates/ghostty-cli/src/edit_config.rs +++ b/crates/ghostty-cli/src/edit_config.rs @@ -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 { /// Pick the editor from `$VISUAL` then `$EDITOR` (empty values are skipped). #[must_use] -pub fn find_editor() -> Option { +pub fn find_editor() -> Option { // 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 { /// Windows branch of `run`: print the path and fail. #[cfg(windows)] -fn write_windows_unsupported(stderr: &mut dyn Write, path: &str) -> Result { +fn write_windows_unsupported(stderr: &mut dyn Write, path: &EncodedOs) -> Result { 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 { +fn exec_editor(stderr: &mut dyn Write, path: &EncodedOs) -> Result { // 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 { ); // 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 { 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) diff --git a/crates/ghostty-cli/src/list_themes.rs b/crates/ghostty-cli/src/list_themes.rs index 9f0bd39..7acd867 100644 --- a/crates/ghostty-cli/src/list_themes.rs +++ b/crates/ghostty-cli/src/list_themes.rs @@ -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, } @@ -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 { +pub fn collect_themes(resources_dir: Option<&EncodedOs>) -> Vec { 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 { 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 { +fn resources_dir() -> Option { 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 { )?; } - 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, }; diff --git a/crates/ghostty-cli/src/list_themes/preview.rs b/crates/ghostty-cli/src/list_themes/preview.rs index 7c99bd7..0728a76 100644 --- a/crates/ghostty-cli/src/list_themes/preview.rs +++ b/crates/ghostty-cli/src/list_themes/preview.rs @@ -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, /// 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, + pub auto_theme_path: Option, effects: Vec, overlays: Vec, } @@ -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`: `/auto/theme.ghostty`. -pub fn resolve_auto_theme_path() -> Result { +pub fn resolve_auto_theme_path() -> Result { 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')); diff --git a/crates/ghostty-cli/src/new_window.rs b/crates/ghostty-cli/src/new_window.rs index b3b589f..e11c527 100644 --- a/crates/ghostty-cli/src/new_window.rs +++ b/crates/ghostty-cli/src/new_window.rs @@ -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() ))); } diff --git a/crates/ghostty-cli/src/ssh_cache/disk_cache.rs b/crates/ghostty-cli/src/ssh_cache/disk_cache.rs index d97041e..8ad8e2c 100644 --- a/crates/ghostty-cli/src/ssh_cache/disk_cache.rs +++ b/crates/ghostty-cli/src/ssh_cache/disk_cache.rs @@ -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() { diff --git a/crates/ghostty-cli/src/validate_config.rs b/crates/ghostty-cli/src/validate_config.rs index 1237843..b10e18f 100644 --- a/crates/ghostty-cli/src/validate_config.rs +++ b/crates/ghostty-cli/src/validate_config.rs @@ -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 { 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()?; diff --git a/crates/ghostty-config/Cargo.toml b/crates/ghostty-config/Cargo.toml index 19b0cb1..b2050dc 100644 --- a/crates/ghostty-config/Cargo.toml +++ b/crates/ghostty-config/Cargo.toml @@ -18,3 +18,4 @@ workspace = true [dev-dependencies] onig = "6.5.3" +temp-env = "0.3.6" diff --git a/crates/ghostty-config/src/config.rs b/crates/ghostty-config/src/config.rs index c44af9a..f9db304 100644 --- a/crates/ghostty-config/src/config.rs +++ b/crates/ghostty-config/src/config.rs @@ -6,6 +6,8 @@ use std::collections::HashSet; +use ghostty_os::osbytes::EncodedOs; + use crate::{ cli_args::{self, ArgError, ArgIter, CliTarget}, command::Command, @@ -3765,7 +3767,7 @@ pub enum ReplayStep { Arg(String), /// A base path to expand relative paths against. - Expand(String), + Expand(EncodedOs), /// A conditional argument. This arg is parsed only if all /// conditions match (an "AND"). An "OR" can be achieved by @@ -3876,12 +3878,15 @@ impl Config { /// Load configuration from the target config file at `path`. /// /// `path` must be resolved and absolute. - pub fn load_file(&mut self, path: &str) -> Result<(), Error> { - assert!(ghostty_os::osbytes::config_str_to_path(path).is_absolute()); + pub fn load_file(&mut self, path: &EncodedOs) -> Result<(), Error> { + assert!(path.to_path().is_absolute()); let file = match crate::file_load::open(path) { Ok(f) => f, Err(crate::file_load::OpenFileError::NotAFile) => { - log::warn!("config-file {path}: not reading because it is not a file"); + log::warn!( + "config-file {}: not reading because it is not a file", + path.as_encoded() + ); return Ok(()); } Err(crate::file_load::OpenFileError::FileNotFound) => { @@ -3894,8 +3899,8 @@ impl Config { } /// Load config from the given File. - fn load_fs_file(&mut self, file: std::fs::File, path: &str) -> Result<(), Error> { - log::info!("reading configuration file path={path}"); + fn load_fs_file(&mut self, file: std::fs::File, path: &EncodedOs) -> Result<(), Error> { + log::info!("reading configuration file path={}", path.as_encoded()); let mut reader = std::io::BufReader::with_capacity(2048, file); self.load_reader(&mut reader, path) } @@ -3904,7 +3909,7 @@ impl Config { fn load_reader( &mut self, reader: &mut R, - path: &str, + path: &EncodedOs, ) -> Result<(), Error> { // If the file starts with a UTF-8 byte order mark, skip it. // https://en.wikipedia.org/wiki/Byte_order_mark#UTF-8 @@ -3914,11 +3919,12 @@ impl Config { reader.consume(3); } } - let mut iter = cli_args::LineIterator::with_path(reader, path); + let mut iter = cli_args::LineIterator::with_path(reader, path.as_encoded()); self.load_iter(&mut iter)?; - let dir = ghostty_os::osbytes::config_str_to_path(path) + let dir = path + .to_path() .parent() - .map(ghostty_os::osbytes::path_to_config_string) + .map(EncodedOs::from_path) .unwrap_or_default(); self.expand_paths(&dir)?; Ok(()) @@ -3927,23 +3933,26 @@ impl Config { /// Load optional configuration file from `path`. All errors are ignored. /// /// Returns the action that was taken. - pub fn load_optional_file(&mut self, path: &str) -> OptionalFileAction { + pub fn load_optional_file(&mut self, path: &EncodedOs) -> OptionalFileAction { match self.load_file(path) { Ok(()) => OptionalFileAction::Loaded, Err(Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => { OptionalFileAction::NotFound } Err(err) => { - log::warn!("error reading optional config file, not loading err={err} path={path}"); + log::warn!( + "error reading optional config file, not loading err={err} path={}", + path.as_encoded() + ); OptionalFileAction::Error } } } /// The default template with the (decoded, byte-exact) path substituted. - fn render_config_template(path: &str) -> Vec { + fn render_config_template(path: &EncodedOs) -> Vec { let tmpl = include_str!("config-template"); - let path_bytes = ghostty_os::osbytes::config_str_to_bytes(path); + let path_bytes = path.to_bytes(); let mut out = Vec::with_capacity(tmpl.len() + path_bytes.len()); for (i, part) in tmpl.split("{[path]s}").enumerate() { if i > 0 { @@ -3954,10 +3963,9 @@ impl Config { out } - fn write_config_template(path: &str) -> std::io::Result<()> { - log::info!("creating template config file: path={path}"); - // `path` carries the reversible byte encoding; decode it for the OS. - let os_path = ghostty_os::osbytes::config_str_to_path(path); + fn write_config_template(path: &EncodedOs) -> std::io::Result<()> { + log::info!("creating template config file: path={}", path.as_encoded()); + let os_path = path.to_path(); if let Some(dir) = os_path.parent() { std::fs::create_dir_all(dir)?; } @@ -3982,7 +3990,11 @@ impl Config { if xdg_action != OptionalFileAction::NotFound && legacy_xdg_action != OptionalFileAction::NotFound { - log::warn!("both config files `{legacy_xdg_path}` and `{xdg_path}` exist."); + log::warn!( + "both config files `{}` and `{}` exist.", + legacy_xdg_path.as_encoded(), + xdg_path.as_encoded() + ); log::warn!("loading them both in that order"); true } else { @@ -4014,7 +4026,9 @@ impl Config { && legacy_app_support_action != OptionalFileAction::NotFound { log::warn!( - "both config files `{legacy_app_support_path}` and `{app_support_path}` exist." + "both config files `{}` and `{}` exist.", + legacy_app_support_path.as_encoded(), + app_support_path.as_encoded() ); log::warn!("loading them both in that order"); true @@ -4142,7 +4156,7 @@ impl Config { // Any paths referenced from the CLI are relative to the current working // directory. let cwd = std::fs::canonicalize(".")?; - self.expand_paths(&ghostty_os::osbytes::path_to_config_string(&cwd))?; + self.expand_paths(&EncodedOs::from_path(&cwd))?; Ok(()) } @@ -4153,7 +4167,7 @@ impl Config { } // Keeps track of loaded files to prevent cycles. - let mut loaded: HashSet = HashSet::new(); + let mut loaded: HashSet = HashSet::new(); // We need to insert all of our loaded config-file values // PRIOR to the "-e" in our replay steps, since everything @@ -4186,12 +4200,12 @@ impl Config { // All paths should already be absolute at this point because // they're fixed up after each load. - assert!(ghostty_os::osbytes::config_str_to_path(&path).is_absolute()); + assert!(path.to_path().is_absolute()); // We must only load a unique file once if !loaded.insert(path.clone()) { let diag = Diagnostic { - message: format!("config-file {path}: cycle detected"), + message: format!("config-file {}: cycle detected", path.as_encoded()), ..Diagnostic::default() }; self.diagnostics.append(diag.clone()); @@ -4199,13 +4213,14 @@ impl Config { continue; } - let file = match std::fs::File::open(ghostty_os::osbytes::config_str_to_path(&path)) { + let file = match std::fs::File::open(path.to_path()) { Ok(f) => f, Err(err) => { if err.kind() != std::io::ErrorKind::NotFound || !optional { let diag = Diagnostic { message: format!( - "error opening config-file {path}: {}", + "error opening config-file {}: {}", + path.as_encoded(), zig_io_error_name(&err) ), ..Diagnostic::default() @@ -4221,7 +4236,8 @@ impl Config { if !meta.is_file() { let diag = Diagnostic { message: format!( - "config-file {path}: not reading because file type is {}", + "config-file {}: not reading because file type is {}", + path.as_encoded(), file_kind_name(&meta) ), ..Diagnostic::default() @@ -4347,9 +4363,9 @@ impl Config { /// Expand the relative paths in config-files to be absolute paths /// relative to the base directory. - fn expand_paths(&mut self, base: &str) -> Result<(), Error> { + fn expand_paths(&mut self, base: &EncodedOs) -> Result<(), Error> { // Keep track of this step for replays - self.replay_steps.push(ReplayStep::Expand(base.to_string())); + self.replay_steps.push(ReplayStep::Expand(base.clone())); // Expand all of our paths if let Some(path) = &mut self.background_image { @@ -4376,7 +4392,7 @@ impl Config { // Find our theme file and open it. See the open function for details. let resources_dir = ghostty_os::resources_dir().ok(); let Some(themefile) = crate::theme::open( - name, + &EncodedOs::from_encoded(name), resources_dir.as_ref().and_then(|r| r.app()), &mut self.diagnostics, ) else { @@ -4400,7 +4416,7 @@ impl Config { // Load our theme let mut reader = std::io::BufReader::with_capacity(2048, file); - let mut iter = cli_args::LineIterator::with_path(&mut reader, path); + let mut iter = cli_args::LineIterator::with_path(&mut reader, path.as_encoded()); new_config.load_iter(&mut iter)?; // Setup our replay to be conditional. @@ -4641,14 +4657,17 @@ impl Config { let pw = ghostty_os::passwd::get()?; if self.command.is_none() { if let Some(sh) = pw.shell { - log::info!("default shell src=passwd value={sh}"); - self.command = Some(Command::Shell(sh)); + log::info!("default shell src=passwd value={}", sh.as_encoded()); + self.command = Some(Command::Shell(sh.into_encoded())); } } if *wd == WorkingDirectory::Home { if let Some(home) = pw.home { - log::info!("default working directory src=passwd value={home}"); + log::info!( + "default working directory src=passwd value={}", + home.as_encoded() + ); *wd = WorkingDirectory::Path(home); } else { *wd = WorkingDirectory::Inherit; @@ -4951,7 +4970,7 @@ mod tests { let td = ghostty_os::TempDir::init().unwrap(); let dir = td.path().join(std::ffi::OsStr::from_bytes(b"cfg\xff")); let file = dir.join("config.ghostty"); - let enc = ghostty_os::osbytes::path_to_config_string(&file); + let enc = EncodedOs::from_path(&file); Config::write_config_template(&enc).unwrap(); let data = std::fs::read(&file).unwrap(); let raw = file.as_os_str().as_bytes(); @@ -4985,8 +5004,11 @@ mod tests { let data = "\u{feff}abnormal-command-exit-runtime = 2500\n"; let mut reader = Cursor::new(data.as_bytes().to_vec()); let mut cfg = Config::default(); - cfg.load_reader(&mut reader, "/home/ghostty/.config/ghostty/config.ghostty") - .unwrap(); + cfg.load_reader( + &mut reader, + &EncodedOs::from_encoded("/home/ghostty/.config/ghostty/config.ghostty"), + ) + .unwrap(); cfg.finalize().unwrap(); assert!(cfg.diagnostics.empty()); @@ -4997,8 +5019,11 @@ mod tests { let data = "abnormal-command-exit-runtime = 2500\n"; let mut reader = Cursor::new(data.as_bytes().to_vec()); let mut cfg = Config::default(); - cfg.load_reader(&mut reader, "/home/ghostty/.config/ghostty/config.ghostty") - .unwrap(); + cfg.load_reader( + &mut reader, + &EncodedOs::from_encoded("/home/ghostty/.config/ghostty/config.ghostty"), + ) + .unwrap(); cfg.finalize().unwrap(); assert!(cfg.diagnostics.empty()); @@ -5141,11 +5166,11 @@ mod tests { cfg.load_iter(&mut i).unwrap(); cfg.finalize().unwrap(); - let expected = ghostty_os::expand_home("~/projects/ghostty") - .unwrap_or_else(|_| "~/projects/ghostty".to_string()); + let expected = ghostty_os::expand_home(&EncodedOs::from_encoded("~/projects/ghostty")) + .unwrap_or_else(|_| EncodedOs::from_encoded("~/projects/ghostty")); assert_eq!( cfg.working_directory.as_ref().unwrap().value().unwrap(), - expected + &expected ); } @@ -5420,7 +5445,7 @@ mod tests { let mut cfg = Config::default(); cfg.load_iter(&mut it(&[&format!("--config-file={}", a.display())])) .unwrap(); - cfg.expand_paths("/").unwrap(); + cfg.expand_paths(&EncodedOs::from_encoded("/")).unwrap(); cfg.load_recursive_files().unwrap(); assert!( cfg.diagnostics @@ -5435,7 +5460,7 @@ mod tests { let mut cfg = Config::default(); cfg.load_iter(&mut it(&["--config-file=?/nonexistent/ghostty/file"])) .unwrap(); - cfg.expand_paths("/").unwrap(); + cfg.expand_paths(&EncodedOs::from_encoded("/")).unwrap(); cfg.load_recursive_files().unwrap(); assert!(cfg.diagnostics.empty()); } diff --git a/crates/ghostty-config/src/edit.rs b/crates/ghostty-config/src/edit.rs index 2b7167c..090d679 100644 --- a/crates/ghostty-config/src/edit.rs +++ b/crates/ghostty-config/src/edit.rs @@ -1,6 +1,8 @@ //! Opening the config for editing (port of `config/edit.zig`). -use std::{fs::OpenOptions, io, path::Path}; +use std::{fs::OpenOptions, io}; + +use ghostty_os::osbytes::EncodedOs; use crate::file_load; @@ -13,12 +15,16 @@ use crate::file_load; /// come first. /// /// The config file (and its directory) is created if it doesn't exist. -pub fn open_path() -> io::Result { +pub fn open_path() -> io::Result { // Get the path we should open let config_path = config_path()?; + // The config path is an encoded OS string: decode before touching the + // filesystem. + let real_path = config_path.to_path(); + // Create config directory recursively. - if let Some(config_dir) = Path::new(&config_path).parent() { + if let Some(config_dir) = real_path.parent() { std::fs::create_dir_all(config_dir)?; } @@ -26,7 +32,7 @@ pub fn open_path() -> io::Result { match OpenOptions::new() .write(true) .create_new(true) - .open(&config_path) + .open(&real_path) { Ok(_) => {} Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {} @@ -41,15 +47,15 @@ fn xdg_err(e: ghostty_os::xdg::Error) -> io::Error { } /// Returns the config path to use for open for the current OS. -fn config_path() -> io::Result { +fn config_path() -> io::Result { let paths = config_path_candidates()?; // Find the first path that exists and is non-empty. If no paths are // non-empty but at least one exists, we will return the first path that // exists. - let mut exists: Option<&String> = None; + let mut exists: Option<&EncodedOs> = None; for path in &paths { - let meta = match std::fs::File::open(path).and_then(|f| f.metadata()) { + let meta = match std::fs::File::open(path.to_path()).and_then(|f| f.metadata()) { Ok(m) => m, // File doesn't exist, continue. Err(e) if e.kind() == io::ErrorKind::NotFound => continue, @@ -79,7 +85,7 @@ fn config_path() -> io::Result { /// Returns a list of possible paths the main config file could be /// in for the current OS. -fn config_path_candidates() -> io::Result> { +fn config_path_candidates() -> io::Result> { let mut paths = Vec::with_capacity(4); #[cfg(target_os = "macos")] @@ -92,3 +98,38 @@ fn config_path_candidates() -> io::Result> { paths.push(file_load::legacy_default_xdg_path().map_err(xdg_err)?); Ok(paths) } + +#[cfg(all(test, unix, not(target_os = "macos")))] +mod tests { + #![allow(clippy::unwrap_used)] + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + + use super::*; + + /// "Open config" must create/return the file under the *decoded* XDG + /// directory, not a second file at the escaped pathname. + #[test] + fn open_path_uses_decoded_non_utf8_xdg_dir() { + let td = ghostty_os::TempDir::init().unwrap(); + let xdg = td.path().join(std::ffi::OsStr::from_bytes(b"cfg\xff")); + let value = std::ffi::OsString::from_vec(xdg.as_os_str().as_bytes().to_vec()); + temp_env::with_var("XDG_CONFIG_HOME", Some(&value), || { + let p = open_path().unwrap(); + let real = p.to_path(); + assert_eq!(real, xdg.join("ghostty/config.ghostty")); + assert!(real.is_file()); + // Nothing was created at the escaped (encoded) pathname. + assert_ne!(p.as_encoded().as_bytes(), real.as_os_str().as_bytes()); + assert!(!std::path::Path::new(p.as_encoded()).exists()); + }); + } + + #[test] + fn candidates_are_xdg_paths_in_priority_order() { + temp_env::with_var("XDG_CONFIG_HOME", Some("/x/cfg"), || { + let c = config_path_candidates().unwrap(); + assert_eq!(c[0], "/x/cfg/ghostty/config.ghostty"); + assert_eq!(c[1], "/x/cfg/ghostty/config"); + }); + } +} diff --git a/crates/ghostty-config/src/file_load.rs b/crates/ghostty-config/src/file_load.rs index e983744..88f7ec0 100644 --- a/crates/ghostty-config/src/file_load.rs +++ b/crates/ghostty-config/src/file_load.rs @@ -7,10 +7,10 @@ use std::fs::File; -use ghostty_os::xdg; +use ghostty_os::{osbytes::EncodedOs, xdg}; /// Default path for the XDG home configuration file. -pub fn default_xdg_path() -> Result { +pub fn default_xdg_path() -> Result { xdg::config(&xdg::Options { subdir: Some("ghostty/config.ghostty"), home: None, @@ -18,7 +18,7 @@ pub fn default_xdg_path() -> Result { } /// Ghostty <1.3.0 default path for the XDG home configuration file. -pub fn legacy_default_xdg_path() -> Result { +pub fn legacy_default_xdg_path() -> Result { xdg::config(&xdg::Options { subdir: Some("ghostty/config"), home: None, @@ -26,7 +26,7 @@ pub fn legacy_default_xdg_path() -> Result { } /// Preferred default path for the XDG home configuration file. -pub fn preferred_xdg_path() -> Result { +pub fn preferred_xdg_path() -> Result { // If the XDG path exists, use that. let xdg_path = default_xdg_path()?; if open(&xdg_path).is_ok() { @@ -45,28 +45,28 @@ pub fn preferred_xdg_path() -> Result { /// Default path for the macOS Application Support configuration file. #[cfg(target_os = "macos")] -pub fn default_app_support_path() -> Result { +pub fn default_app_support_path() -> Result { app_support_dir("config.ghostty") } /// Ghostty <1.3.0 default path for the macOS Application Support /// configuration file. #[cfg(target_os = "macos")] -pub fn legacy_default_app_support_path() -> Result { +pub fn legacy_default_app_support_path() -> Result { app_support_dir("config") } #[cfg(target_os = "macos")] -fn app_support_dir(sub_path: &str) -> Result { +fn app_support_dir(sub_path: &str) -> Result { ghostty_os::macos::app_support_dir(sub_path) - .map(|p| ghostty_os::osbytes::path_to_config_string(&p)) + .map(|p| EncodedOs::from_path(&p)) .map_err(|_| xdg::Error::AppleApiFailed) } /// Preferred default path for the macOS Application Support configuration /// file. #[cfg(target_os = "macos")] -pub fn preferred_app_support_path() -> Result { +pub fn preferred_app_support_path() -> Result { Ok(preferred_of( default_app_support_path()?, legacy_default_app_support_path()?, @@ -76,7 +76,7 @@ pub fn preferred_app_support_path() -> Result { /// Pick between a new path and its legacy path: the new one if it exists, /// else the legacy one if it exists, else the new one. #[cfg(any(target_os = "macos", test))] -fn preferred_of(new: String, legacy: String) -> String { +fn preferred_of(new: EncodedOs, legacy: EncodedOs) -> EncodedOs { // If the new path exists, use that. if open(&new).is_ok() { return new; @@ -95,9 +95,9 @@ fn preferred_of(new: String, legacy: String) -> String { /// one if it exists, else Application Support. #[cfg(any(target_os = "macos", test))] fn preferred_macos( - app_support_path: String, - xdg_path: &dyn Fn() -> Result, -) -> Result { + app_support_path: EncodedOs, + xdg_path: &dyn Fn() -> Result, +) -> Result { // macOS prefers the Application Support directory if it exists. if open(&app_support_path).is_ok() { return Ok(app_support_path); @@ -119,7 +119,7 @@ fn preferred_macos( /// This doesn't create or populate the file with any default /// contents; downstream callers must handle this. #[cfg(target_os = "macos")] -pub fn preferred_default_file_path() -> Result { +pub fn preferred_default_file_path() -> Result { preferred_macos(preferred_app_support_path()?, &preferred_xdg_path) } @@ -129,7 +129,7 @@ pub fn preferred_default_file_path() -> Result { /// This doesn't create or populate the file with any default /// contents; downstream callers must handle this. #[cfg(not(target_os = "macos"))] -pub fn preferred_default_file_path() -> Result { +pub fn preferred_default_file_path() -> Result { // All other platforms use XDG only preferred_xdg_path() } @@ -158,21 +158,24 @@ impl std::error::Error for OpenFileError {} /// Opens the file at the given path and returns the file handle /// if it exists and is non-empty. This also constrains the possible /// errors to a smaller set that we can explicitly handle. -pub fn open(path: &str) -> Result { - assert!(ghostty_os::osbytes::config_str_to_path(path).is_absolute()); +pub fn open(path: &EncodedOs) -> Result { + let real = path.to_path(); + assert!(real.is_absolute()); - // `path` may carry the reversible byte escape of non-UTF-8 names. - let file = File::open(ghostty_os::osbytes::config_str_to_os(path)).map_err(|err| { + let file = File::open(&real).map_err(|err| { if err.kind() == std::io::ErrorKind::NotFound { OpenFileError::FileNotFound } else { - log::warn!("unexpected file open error path={path} err={err}"); + log::warn!( + "unexpected file open error path={} err={err}", + real.display() + ); OpenFileError::FileOpenFailed } })?; let meta = file.metadata().map_err(|err| { - log::warn!("error getting file stat path={path} err={err}"); + log::warn!("error getting file stat path={} err={err}", real.display()); OpenFileError::FileOpenFailed })?; if !meta.is_file() { @@ -190,21 +193,25 @@ pub fn open(path: &str) -> Result { mod tests { use super::*; + fn e(s: &str) -> EncodedOs { + EncodedOs::from_encoded(s) + } + #[test] fn open_missing_dir_and_empty() { assert_eq!( - open("/nonexistent/ghostty/file").unwrap_err(), + open(&e("/nonexistent/ghostty/file")).unwrap_err(), OpenFileError::FileNotFound ); - assert_eq!(open("/tmp").unwrap_err(), OpenFileError::NotAFile); + assert_eq!(open(&e("/tmp")).unwrap_err(), OpenFileError::NotAFile); let p = std::env::temp_dir().join(format!("ghostty-file-load-{}", std::process::id())); std::fs::write(&p, "").unwrap(); assert_eq!( - open(p.to_str().unwrap()).unwrap_err(), + open(&EncodedOs::from_path(&p)).unwrap_err(), OpenFileError::FileIsEmpty ); std::fs::write(&p, "a=b").unwrap(); - assert!(open(p.to_str().unwrap()).is_ok()); + assert!(open(&EncodedOs::from_path(&p)).is_ok()); std::fs::remove_file(&p).unwrap(); } @@ -212,18 +219,18 @@ mod tests { fn preferred_of_prefers_existing_new_then_legacy() { let dir = std::env::temp_dir().join(format!("ghostty-pref-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); - let new = dir.join("config.ghostty").to_str().unwrap().to_owned(); - let legacy = dir.join("config").to_str().unwrap().to_owned(); - let _ = std::fs::remove_file(&new); - let _ = std::fs::remove_file(&legacy); + let new = EncodedOs::from_path(&dir.join("config.ghostty")); + let legacy = EncodedOs::from_path(&dir.join("config")); + let _ = std::fs::remove_file(new.to_path()); + let _ = std::fs::remove_file(legacy.to_path()); // Neither exists: the new one. assert_eq!(preferred_of(new.clone(), legacy.clone()), new); // Only legacy exists: the legacy one. - std::fs::write(&legacy, "a=b").unwrap(); + std::fs::write(legacy.to_path(), "a=b").unwrap(); assert_eq!(preferred_of(new.clone(), legacy.clone()), legacy); // Both exist: the new one. - std::fs::write(&new, "a=b").unwrap(); + std::fs::write(new.to_path(), "a=b").unwrap(); assert_eq!(preferred_of(new.clone(), legacy.clone()), new); std::fs::remove_dir_all(&dir).unwrap(); } @@ -232,19 +239,19 @@ mod tests { fn preferred_macos_order() { let dir = std::env::temp_dir().join(format!("ghostty-pref-mac-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); - let app = dir.join("app").to_str().unwrap().to_owned(); - let xdg_p = dir.join("xdg").to_str().unwrap().to_owned(); - let _ = std::fs::remove_file(&app); - let _ = std::fs::remove_file(&xdg_p); + let app = EncodedOs::from_path(&dir.join("app")); + let xdg_p = EncodedOs::from_path(&dir.join("xdg")); + let _ = std::fs::remove_file(app.to_path()); + let _ = std::fs::remove_file(xdg_p.to_path()); let xdg_fn = || Ok(xdg_p.clone()); // Neither exists: app support. assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), app); // XDG exists only: XDG. - std::fs::write(&xdg_p, "a=b").unwrap(); + std::fs::write(xdg_p.to_path(), "a=b").unwrap(); assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), xdg_p); // App support exists: app support wins. - std::fs::write(&app, "a=b").unwrap(); + std::fs::write(app.to_path(), "a=b").unwrap(); assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), app); std::fs::remove_dir_all(&dir).unwrap(); } @@ -252,8 +259,8 @@ mod tests { #[test] fn default_paths_end_with_expected_names() { if let (Ok(a), Ok(b)) = (default_xdg_path(), legacy_default_xdg_path()) { - assert!(a.ends_with("ghostty/config.ghostty")); - assert!(b.ends_with("ghostty/config")); + assert!(a.ends_with_plain("ghostty/config.ghostty")); + assert!(b.ends_with_plain("ghostty/config")); } } } diff --git a/crates/ghostty-config/src/path.rs b/crates/ghostty-config/src/path.rs index c879e9d..34f5000 100644 --- a/crates/ghostty-config/src/path.rs +++ b/crates/ghostty-config/src/path.rs @@ -5,7 +5,7 @@ use std::{ path::{Component, PathBuf}, }; -use ghostty_os::osbytes::{config_str_to_path, path_to_config_string}; +use ghostty_os::osbytes::EncodedOs; use crate::{ c_value::CValue, @@ -20,21 +20,21 @@ use crate::{ #[derive(Debug, Clone, PartialEq, Eq)] pub enum Path { /// No error if the file does not exist. - Optional(String), + Optional(EncodedOs), /// The file is required to exist. - Required(String), + Required(EncodedOs), } impl Default for Path { fn default() -> Self { - Path::Required(String::new()) + Path::Required(EncodedOs::new()) } } /// Lexically resolve `base/path` like `std.fs.path.resolve`. -fn resolve_lexical(base: &str, path: &str) -> String { - let joined = config_str_to_path(base).join(config_str_to_path(path)); +fn resolve_lexical(base: &EncodedOs, path: &EncodedOs) -> EncodedOs { + let joined = base.to_path().join(path.to_path()); let mut out = PathBuf::new(); for c in joined.components() { match c { @@ -45,29 +45,24 @@ fn resolve_lexical(base: &str, path: &str) -> String { other => out.push(other.as_os_str()), } } - path_to_config_string(&out) + EncodedOs::from_path(&out) } impl Path { /// The inner path string. #[must_use] - pub fn path(&self) -> &str { + pub fn path(&self) -> &EncodedOs { match self { Path::Optional(p) | Path::Required(p) => p, } } - fn path_mut(&mut self) -> &mut String { + fn path_mut(&mut self) -> &mut EncodedOs { match self { Path::Optional(p) | Path::Required(p) => p, } } - #[must_use] - pub fn len(&self) -> usize { - self.path().len() - } - #[must_use] pub fn is_empty(&self) -> bool { self.path().is_empty() @@ -98,9 +93,9 @@ impl Path { } Ok(Some(if optional { - Path::Optional(value.to_string()) + Path::Optional(EncodedOs::from_encoded(value)) } else { - Path::Required(value.to_string()) + Path::Required(EncodedOs::from_encoded(value)) })) } @@ -110,16 +105,15 @@ impl Path { /// `base` is the base directory to expand relative paths; it must be an /// absolute path. Errors are added to `diags` if they occur; an `Err` is /// only returned if the base directory cannot be opened. - pub fn expand(&mut self, base: &str, diags: &mut DiagnosticList) -> std::io::Result<()> { - // Config strings carry non-UTF-8 bytes via a reversible escape - // encoding; filesystem calls use the decoded OS paths. - let base_path = config_str_to_path(base); + pub fn expand(&mut self, base: &EncodedOs, diags: &mut DiagnosticList) -> std::io::Result<()> { + // Filesystem calls use the decoded OS paths. + let base_path = base.to_path(); assert!(base_path.is_absolute()); - let path = self.path().to_string(); + let path = self.path().clone(); // If it is already absolute we can ignore it. - if path.is_empty() || config_str_to_path(&path).is_absolute() { + if path.is_empty() || path.to_path().is_absolute() { return Ok(()); } @@ -127,21 +121,27 @@ impl Path { // home directory. We explicitly look for "~/" because we don't // support alternate users such as "~alice/" // (Windows isn't supported yet: Zig skips this there.) - if !cfg!(windows) && path.starts_with("~/") { + if !cfg!(windows) && path.starts_with_plain("~/") { match ghostty_os::expand_home(&path) { Ok(expanded) => { - log::debug!("expanding file path from home directory: path={expanded}"); + log::debug!( + "expanding file path from home directory: path={}", + expanded.as_encoded() + ); *self.path_mut() = expanded; } Err(err) => { diags.append(Diagnostic { - message: format!("error expanding home directory for path {path}: {err}"), + message: format!( + "error expanding home directory for path {}: {err}", + path.as_encoded() + ), ..Diagnostic::default() }); // Blank this path so that we don't attempt to resolve it // again - *self = Path::Required(String::new()); + *self = Path::Required(EncodedOs::new()); } } return Ok(()); @@ -150,9 +150,9 @@ impl Path { // Make sure the base directory can be opened. std::fs::read_dir(&base_path)?; - let full = base_path.join(config_str_to_path(&path)); + let full = base_path.join(path.to_path()); let abs = match std::fs::canonicalize(&full) { - Ok(p) => path_to_config_string(&p), + Ok(p) => EncodedOs::from_path(&p), Err(err) if err.kind() == std::io::ErrorKind::NotFound => { // The file doesn't exist. Try to resolve the relative path // another way. @@ -160,17 +160,21 @@ impl Path { } Err(err) => { diags.append(Diagnostic { - message: format!("error resolving file path {path}: {err}"), + message: format!("error resolving file path {}: {err}", path.as_encoded()), ..Diagnostic::default() }); // Blank this path so that we don't attempt to resolve it again - *self = Path::Required(String::new()); + *self = Path::Required(EncodedOs::new()); return Ok(()); } }; - log::debug!("expanding file path relative={path} abs={abs}"); + log::debug!( + "expanding file path relative={} abs={}", + path.as_encoded(), + abs.as_encoded() + ); *self.path_mut() = abs; Ok(()) } @@ -180,11 +184,11 @@ impl ConfigField for Path { fn c_value(&self) -> CValue { match self { Path::Optional(p) => CValue::Path { - path: p.clone(), + path: p.as_encoded().to_owned(), optional: true, }, Path::Required(p) => CValue::Path { - path: p.clone(), + path: p.as_encoded().to_owned(), optional: false, }, } @@ -205,8 +209,8 @@ impl ConfigField for Path { /// Used by formatter. fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { match self { - Path::Optional(p) => writeln!(f.writer, "{} = ?{p}", f.name), - Path::Required(p) => writeln!(f.writer, "{} = {p}", f.name), + Path::Optional(p) => writeln!(f.writer, "{} = ?{}", f.name, p.as_encoded()), + Path::Required(p) => writeln!(f.writer, "{} = {}", f.name, p.as_encoded()), } } } @@ -222,7 +226,7 @@ pub struct RepeatablePath { impl RepeatablePath { /// Expand all the paths relative to the base directory. - pub fn expand(&mut self, base: &str, diags: &mut DiagnosticList) -> std::io::Result<()> { + pub fn expand(&mut self, base: &EncodedOs, diags: &mut DiagnosticList) -> std::io::Result<()> { for path in &mut self.value { path.expand(base, diags)?; } @@ -267,6 +271,10 @@ mod tests { use super::*; use crate::formatter::format_entry; + fn e(s: &str) -> EncodedOs { + EncodedOs::from_encoded(s) + } + fn fmt(v: &T) -> String { let mut s = String::new(); format_entry("a", v, &mut s).unwrap(); @@ -276,13 +284,13 @@ mod tests { #[test] fn path_parse() { let p = |s| Path::parse(Some(s)).unwrap().unwrap(); - assert_eq!(p("config.1"), Path::Required("config.1".into())); - assert_eq!(p("?config.2"), Path::Optional("config.2".into())); - assert_eq!(p("\"?config.3\""), Path::Required("?config.3".into())); - assert_eq!(p("?\"config.4\""), Path::Optional("config.4".into())); - assert_eq!(p("?"), Path::Optional(String::new())); - assert_eq!(p("\"\""), Path::Required(String::new())); - assert_eq!(p("?\"\""), Path::Optional(String::new())); + assert_eq!(p("config.1"), Path::Required(e("config.1"))); + assert_eq!(p("?config.2"), Path::Optional(e("config.2"))); + assert_eq!(p("\"?config.3\""), Path::Required(e("?config.3"))); + assert_eq!(p("?\"config.4\""), Path::Optional(e("config.4"))); + assert_eq!(p("?"), Path::Optional(EncodedOs::new())); + assert_eq!(p("\"\""), Path::Required(EncodedOs::new())); + assert_eq!(p("?\"\""), Path::Optional(EncodedOs::new())); assert_eq!(Path::parse(None), Err(ArgError::ValueRequired)); assert_eq!(Path::parse(Some("")), Ok(None)); } @@ -292,21 +300,21 @@ mod tests { let mut item = Path::default(); item.parse_cli(Some("config.1")).unwrap(); - assert_eq!(item, Path::Required("config.1".into())); + assert_eq!(item, Path::Required(e("config.1"))); item.parse_cli(Some("?config.2")).unwrap(); - assert_eq!(item, Path::Optional("config.2".into())); + assert_eq!(item, Path::Optional(e("config.2"))); item.parse_cli(Some("\"?config.3\"")).unwrap(); - assert_eq!(item, Path::Required("?config.3".into())); + assert_eq!(item, Path::Required(e("?config.3"))); // Zero-length values, ignored item.parse_cli(Some("?")).unwrap(); - assert_eq!(item, Path::Required("?config.3".into())); + assert_eq!(item, Path::Required(e("?config.3"))); item.parse_cli(Some("\"\"")).unwrap(); - assert_eq!(item, Path::Required("?config.3".into())); + assert_eq!(item, Path::Required(e("?config.3"))); item.parse_cli(Some("?\"\"")).unwrap(); - assert_eq!(item, Path::Required("?config.3".into())); + assert_eq!(item, Path::Required(e("?config.3"))); } #[test] @@ -339,9 +347,9 @@ mod tests { assert_eq!(list.value.len(), 3); - assert_eq!(list.value[0], Path::Required("config.1".into())); - assert_eq!(list.value[1], Path::Optional("config.2".into())); - assert_eq!(list.value[2], Path::Required("?config.3".into())); + assert_eq!(list.value[0], Path::Required(e("config.1"))); + assert_eq!(list.value[1], Path::Optional(e("config.2"))); + assert_eq!(list.value[2], Path::Required(e("?config.3"))); list.parse_cli(Some("")).unwrap(); assert_eq!(list.value.len(), 0); @@ -370,13 +378,13 @@ mod tests { #[test] fn path_expand_relative_nonexistent_and_absolute() { let mut diags = DiagnosticList::default(); - let mut p = Path::Required("../nope/x.conf".into()); - p.expand("/tmp", &mut diags).unwrap(); - assert_eq!(p, Path::Required("/nope/x.conf".into())); + let mut p = Path::Required(e("../nope/x.conf")); + p.expand(&e("/tmp"), &mut diags).unwrap(); + assert_eq!(p, Path::Required(e("/nope/x.conf"))); - let mut p = Path::Optional("/abs/path".into()); - p.expand("/tmp", &mut diags).unwrap(); - assert_eq!(p, Path::Optional("/abs/path".into())); + let mut p = Path::Optional(e("/abs/path")); + p.expand(&e("/tmp"), &mut diags).unwrap(); + assert_eq!(p, Path::Optional(e("/abs/path"))); assert!(diags.is_empty()); } @@ -390,12 +398,12 @@ mod tests { std::fs::write(&target, b"x").unwrap(); std::os::unix::fs::symlink(&target, base.join("link")).unwrap(); - let mut p = Path::Required("link".into()); + let mut p = Path::Required(e("link")); let mut diags = DiagnosticList::default(); - p.expand(&path_to_config_string(&base), &mut diags).unwrap(); + p.expand(&EncodedOs::from_path(&base), &mut diags).unwrap(); assert_eq!(diags.items().len(), 0); // The canonical (non-UTF-8) target is preserved byte for byte. - assert_eq!(config_str_to_path(p.path()), target); - assert!(std::fs::metadata(config_str_to_path(p.path())).is_ok()); + assert_eq!(p.path().to_path(), target); + assert!(std::fs::metadata(p.path().to_path()).is_ok()); } } diff --git a/crates/ghostty-config/src/theme.rs b/crates/ghostty-config/src/theme.rs index 31f1d00..4d02ed1 100644 --- a/crates/ghostty-config/src/theme.rs +++ b/crates/ghostty-config/src/theme.rs @@ -5,12 +5,7 @@ use std::fs::File; -use ghostty_os::osbytes::{ - config_str_to_os, - config_str_to_path, - os_to_config_string, - path_to_config_string, -}; +use ghostty_os::osbytes::EncodedOs; use crate::diagnostics::{Diagnostic, DiagnosticList}; @@ -33,7 +28,7 @@ impl Location { /// invalid for any reason. For example, it is perfectly valid to /// install and run Ghostty without the resources directory. #[must_use] - pub fn dir(self, resources_dir: Option<&str>) -> Option { + pub fn dir(self, resources_dir: Option<&EncodedOs>) -> Option { match self { Location::User => ghostty_os::xdg::config(&ghostty_os::xdg::Options { subdir: Some("ghostty/themes"), @@ -42,9 +37,7 @@ impl Location { .ok(), Location::Resources => { let base = resources_dir?; - Some(path_to_config_string( - &config_str_to_path(base).join("themes"), - )) + Some(EncodedOs::from_path(&base.to_path().join("themes"))) } } } @@ -54,24 +47,24 @@ impl Location { /// order of priority. #[derive(Debug, Clone)] pub struct LocationIterator { - pub resources_dir: Option, + pub resources_dir: Option, pub i: usize, } impl LocationIterator { #[must_use] - pub fn new(resources_dir: Option<&str>) -> Self { + pub fn new(resources_dir: Option<&EncodedOs>) -> Self { Self { - resources_dir: resources_dir.map(str::to_string), + resources_dir: resources_dir.cloned(), i: 0, } } - pub fn next_location(&mut self) -> Option<(Location, String)> { + pub fn next_location(&mut self) -> Option<(Location, EncodedOs)> { while self.i < Location::ALL.len() { let location = Location::ALL[self.i]; self.i += 1; - if let Some(dir) = location.dir(self.resources_dir.as_deref()) { + if let Some(dir) = location.dir(self.resources_dir.as_ref()) { return Some((location, dir)); } } @@ -86,7 +79,7 @@ impl LocationIterator { /// An opened theme. #[derive(Debug)] pub struct OpenedTheme { - pub path: String, + pub path: EncodedOs, pub file: File, } @@ -160,31 +153,34 @@ fn check_regular(file: &File, theme: &str, diags: &mut DiagnosticList) -> bool { /// the theme resolves to something other than a regular file a diagnostic entry /// will be added to the list and None will be returned. pub fn open( - theme: &str, - resources_dir: Option<&str>, + theme: &EncodedOs, + resources_dir: Option<&EncodedOs>, diags: &mut DiagnosticList, ) -> Option { + let theme_path = theme.to_path(); + let theme_text = theme.as_encoded(); + // Absolute themes are loaded a different path. - if config_str_to_path(theme).is_absolute() { + if theme_path.is_absolute() { let file = open_absolute(theme, diags)?; - if !check_regular(&file, theme, diags) { + if !check_regular(&file, theme_text, diags) { return None; } return Some(OpenedTheme { - path: theme.to_string(), + path: theme.clone(), file, }); } - let basename = config_str_to_path(theme) + let basename = theme_path .file_name() - .map(os_to_config_string) + .map(EncodedOs::from_os) .unwrap_or_default(); - if theme != basename { + if *theme != basename { diag( diags, format!( - "theme \"{theme}\" cannot include path separators unless it is an absolute path" + "theme \"{theme_text}\" cannot include path separators unless it is an absolute path" ), ); return None; @@ -194,10 +190,10 @@ pub fn open( // one that exists. let mut it = LocationIterator::new(resources_dir); while let Some((_, dir)) = it.next_location() { - let path = path_to_config_string(&config_str_to_path(&dir).join(config_str_to_os(theme))); - match File::open(config_str_to_path(&path)) { + let path = EncodedOs::from_path(&dir.to_path().join(theme.to_os())); + match File::open(path.to_path()) { Ok(file) => { - if !check_regular(&file, theme, diags) { + if !check_regular(&file, theme_text, diags) { return None; } return Some(OpenedTheme { path, file }); @@ -208,7 +204,10 @@ pub fn open( Err(err) => { diag( diags, - format!("failed to load theme \"{theme}\" from the file \"{path}\": {err}"), + format!( + "failed to load theme \"{theme_text}\" from the file \"{}\": {err}", + path.as_encoded() + ), ); return None; } @@ -219,10 +218,13 @@ pub fn open( // our iterator, reiterate over in order to build a better error message. it.reset(); while let Some((_, dir)) = it.next_location() { - let path = path_to_config_string(&config_str_to_path(&dir).join(config_str_to_os(theme))); + let path = EncodedOs::from_path(&dir.to_path().join(theme.to_os())); diag( diags, - format!("theme \"{theme}\" not found, tried path \"{path}\""), + format!( + "theme \"{theme_text}\" not found, tried path \"{}\"", + path.as_encoded() + ), ); } @@ -232,8 +234,9 @@ pub fn open( /// Open the given theme from an absolute path. If there are any errors /// then messages will be appended to the given error list and None is /// returned. -pub fn open_absolute(theme: &str, diags: &mut DiagnosticList) -> Option { - match File::open(config_str_to_path(theme)) { +pub fn open_absolute(theme: &EncodedOs, diags: &mut DiagnosticList) -> Option { + let theme = theme.as_encoded(); + match File::open(EncodedOs::from_encoded(theme).to_path()) { Ok(f) => Some(f), Err(err) => { if err.kind() == std::io::ErrorKind::NotFound { @@ -257,10 +260,14 @@ pub fn open_absolute(theme: &str, diags: &mut DiagnosticList) -> Option { mod tests { use super::*; + fn e(s: &str) -> EncodedOs { + EncodedOs::from_encoded(s) + } + #[test] fn theme_path_separators_rejected() { let mut d = DiagnosticList::default(); - assert!(open("a/b", None, &mut d).is_none()); + assert!(open(&e("a/b"), None, &mut d).is_none()); assert!( d.items()[0] .message @@ -271,13 +278,13 @@ mod tests { #[test] fn theme_absolute_missing_and_dir() { let mut d = DiagnosticList::default(); - assert!(open("/nonexistent/ghostty/theme", None, &mut d).is_none()); + assert!(open(&e("/nonexistent/ghostty/theme"), None, &mut d).is_none()); assert_eq!( d.items()[0].message, "failed to load theme from the path \"/nonexistent/ghostty/theme\"" ); let mut d = DiagnosticList::default(); - assert!(open("/tmp", None, &mut d).is_none()); + assert!(open(&e("/tmp"), None, &mut d).is_none()); assert_eq!( d.items()[0].message, "not reading theme from \"/tmp\": it is a directory" @@ -290,8 +297,8 @@ mod tests { std::fs::create_dir_all(dir.join("themes")).unwrap(); std::fs::write(dir.join("themes/mytheme-zzz"), "background = #000000\n").unwrap(); let mut d = DiagnosticList::default(); - let t = open("mytheme-zzz", Some(dir.to_str().unwrap()), &mut d).unwrap(); - assert!(t.path.ends_with("themes/mytheme-zzz")); + let t = open(&e("mytheme-zzz"), Some(&EncodedOs::from_path(&dir)), &mut d).unwrap(); + assert!(t.path.ends_with_plain("themes/mytheme-zzz")); assert!(d.is_empty()); std::fs::remove_dir_all(&dir).unwrap(); } @@ -299,7 +306,7 @@ mod tests { #[test] fn theme_not_found_lists_tried_paths() { let mut d = DiagnosticList::default(); - assert!(open("nope-zzz", Some("/nonexistent-res"), &mut d).is_none()); + assert!(open(&e("nope-zzz"), Some(&e("/nonexistent-res")), &mut d).is_none()); assert!(d.items().iter().any(|x| x.message == "theme \"nope-zzz\" not found, tried path \"/nonexistent-res/themes/nope-zzz\"")); } diff --git a/crates/ghostty-config/src/types_a.rs b/crates/ghostty-config/src/types_a.rs index d29ce83..28057fc 100644 --- a/crates/ghostty-config/src/types_a.rs +++ b/crates/ghostty-config/src/types_a.rs @@ -147,7 +147,7 @@ pub enum WorkingDirectory { /// Use an explicit working directory path. This may be not be /// expanded until finalize is called. - Path(String), + Path(ghostty_os::osbytes::EncodedOs), } impl ConfigField for WorkingDirectory { @@ -169,7 +169,7 @@ impl ConfigField for WorkingDirectory { *self = match input { "home" => WorkingDirectory::Home, "inherit" => WorkingDirectory::Inherit, - _ => WorkingDirectory::Path(input.to_string()), + _ => WorkingDirectory::Path(ghostty_os::osbytes::EncodedOs::from_encoded(input)), }; Ok(()) } @@ -178,7 +178,7 @@ impl ConfigField for WorkingDirectory { match self { WorkingDirectory::Home => write_str(f, "home"), WorkingDirectory::Inherit => write_str(f, "inherit"), - WorkingDirectory::Path(p) => write_str(f, p), + WorkingDirectory::Path(p) => write_str(f, p.as_encoded()), } } } @@ -190,7 +190,7 @@ impl WorkingDirectory { return; }; - if !path.starts_with("~/") { + if !path.starts_with_plain("~/") { return; } @@ -198,7 +198,8 @@ impl WorkingDirectory { Ok(e) => e, Err(err) => { log::warn!( - "error expanding home directory for working-directory path={path}: {err}" + "error expanding home directory for working-directory path={}: {err}", + path.as_encoded() ); return; } @@ -211,7 +212,7 @@ impl WorkingDirectory { } #[must_use] - pub fn value(&self) -> Option<&str> { + pub fn value(&self) -> Option<&ghostty_os::osbytes::EncodedOs> { match self { WorkingDirectory::Path(p) => Some(p), WorkingDirectory::Home | WorkingDirectory::Inherit => None, @@ -1044,6 +1045,8 @@ impl ConfigField for RepeatableLink { #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { + use ghostty_os::osbytes::EncodedOs; + use super::*; use crate::cli_args::ConfigField; @@ -1069,18 +1072,28 @@ mod tests { parse(&mut wd, "home").unwrap(); assert_eq!(wd, WorkingDirectory::Home); parse(&mut wd, "~/projects/ghostty").unwrap(); - assert_eq!(wd, WorkingDirectory::Path("~/projects/ghostty".into())); + assert_eq!( + wd, + WorkingDirectory::Path(ghostty_os::osbytes::EncodedOs::from_encoded( + "~/projects/ghostty" + )) + ); parse(&mut wd, "\"/tmp path\"").unwrap(); - assert_eq!(wd, WorkingDirectory::Path("/tmp path".into())); + assert_eq!( + wd, + WorkingDirectory::Path(ghostty_os::osbytes::EncodedOs::from_encoded("/tmp path")) + ); } #[test] fn working_directory_finalize() { - let mut wd = WorkingDirectory::Path("~/projects/ghostty".into()); + let mut wd = WorkingDirectory::Path(ghostty_os::osbytes::EncodedOs::from_encoded( + "~/projects/ghostty", + )); wd.finalize(); - let expected = ghostty_os::expand_home("~/projects/ghostty") - .unwrap_or_else(|_| "~/projects/ghostty".to_string()); - assert_eq!(wd.value().unwrap(), expected); + let expected = ghostty_os::expand_home(&EncodedOs::from_encoded("~/projects/ghostty")) + .unwrap_or_else(|_| EncodedOs::from_encoded("~/projects/ghostty")); + assert_eq!(wd.value().unwrap(), &expected); } } diff --git a/crates/ghostty-core/Cargo.toml b/crates/ghostty-core/Cargo.toml index 5c2cb93..7392847 100644 --- a/crates/ghostty-core/Cargo.toml +++ b/crates/ghostty-core/Cargo.toml @@ -26,6 +26,9 @@ onig = "6.5.3" png = "0.18.1" thiserror = "2" +[dev-dependencies] +temp-env = "0.3.6" + [lints] workspace = true diff --git a/crates/ghostty-core/src/global.rs b/crates/ghostty-core/src/global.rs index 76ef226..59f3f4f 100644 --- a/crates/ghostty-core/src/global.rs +++ b/crates/ghostty-core/src/global.rs @@ -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}"); } diff --git a/crates/ghostty-core/src/surface/binding.rs b/crates/ghostty-core/src/surface/binding.rs index 1c70687..0493442 100644 --- a/crates/ghostty-core/src/surface/binding.rs +++ b/crates/ghostty-core/src/surface/binding.rs @@ -419,11 +419,16 @@ pub fn screen_file_contents( /// Write `contents` to `/.` (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 { + ghostty_os::osbytes::os_bytes(path.as_os_str()) +} + pub fn write_screen_temp_file( contents: &str, loc: WriteScreenLoc, emit: WriteScreenFormat, -) -> Result { +) -> Result { #[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 { + let result = (|| -> Result { 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] diff --git a/crates/ghostty-core/src/surface/clipboard.rs b/crates/ghostty-core/src/surface/clipboard.rs index a91b278..82abc7a 100644 --- a/crates/ghostty-core/src/surface/clipboard.rs +++ b/crates/ghostty-core/src/surface/clipboard.rs @@ -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)> = 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(()) diff --git a/crates/ghostty-core/src/surface/mod.rs b/crates/ghostty-core/src/surface/mod.rs index 15ed2b5..0dc7712 100644 --- a/crates/ghostty-core/src/surface/mod.rs +++ b/crates/ghostty-core/src/surface/mod.rs @@ -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 { + pub fn pwd(&self) -> Option { 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). diff --git a/crates/ghostty-core/src/surface/mouse.rs b/crates/ghostty-core/src/surface/mouse.rs index dc580d9..d3d142d 100644 --- a/crates/ghostty-core/src/surface/mouse.rs +++ b/crates/ghostty-core/src/surface/mouse.rs @@ -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 { +/// +/// `pwd` is the raw OSC 7 byte string; `path` is plain terminal text. +fn resolve_path_for_opening( + pwd: Option<&[u8]>, + path: &str, +) -> Option { 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 { 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 { + fn osc8_uri(screen: &Screen, pin: Pin) -> Option> { 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 { + pub(crate) fn link_url_at_cursor(&self) -> Option> { // 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 }; diff --git a/crates/ghostty-core/tests/common/mod.rs b/crates/ghostty-core/tests/common/mod.rs index 0a0fd8f..b737df9 100644 --- a/crates/ghostty-core/tests/common/mod.rs +++ b/crates/ghostty-core/tests/common/mod.rs @@ -50,7 +50,7 @@ impl Waker for NopWaker { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ClipboardWrite { pub clipboard: Clipboard, - pub contents: Vec<(String, String)>, + pub contents: Vec<(String, Vec)>, pub confirm: bool, } @@ -131,13 +131,15 @@ impl RtSurface for FakeRt { self.pending.borrow_mut().push_back((clipboard, state)); true } - fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, String)], confirm: bool) { + fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, Vec)], confirm: bool) { self.writes.borrow_mut().push(ClipboardWrite { clipboard, contents: contents.to_vec(), confirm, }); - if let Some((_, text)) = contents.iter().find(|(m, _)| m == "text/plain") { + if let Some((_, bytes)) = contents.iter().find(|(m, _)| m == "text/plain") { + // Test harness: the fake clipboard stores text. + let text = &String::from_utf8_lossy(bytes).into_owned(); // Like GTK, a confirm=true write shows a dialog first; the // harness only stores unconfirmed writes. if confirm { diff --git a/crates/ghostty-core/tests/e2e_term.rs b/crates/ghostty-core/tests/e2e_term.rs index 23d3010..b6a0c5f 100644 --- a/crates/ghostty-core/tests/e2e_term.rs +++ b/crates/ghostty-core/tests/e2e_term.rs @@ -111,7 +111,7 @@ fn pwd_osc7() { _ => None, }); assert_eq!(pwd, "/tmp/a b"); - assert_eq!(h.surface.pwd().as_deref(), Some("/tmp/a b")); + assert_eq!(h.surface.pwd().unwrap(), "/tmp/a b"); } #[test] @@ -124,12 +124,9 @@ fn pwd_osc7_keeps_non_utf8_bytes() { Action::Pwd(p) if !p.pwd.is_empty() => Some(p.pwd.clone()), _ => None, }); - assert_eq!( - ghostty_os::osbytes::config_str_to_bytes(&pwd), - b"/tmp/a\xffb" - ); + assert_eq!(pwd.to_bytes(), b"/tmp/a\xffb"); assert_eq!(h.surface.pwd_bytes().as_deref(), Some(&b"/tmp/a\xffb"[..])); - assert_eq!(h.surface.pwd().as_deref(), Some(pwd.as_str())); + assert_eq!(h.surface.pwd(), Some(pwd)); } #[test] diff --git a/crates/ghostty-crash/Cargo.toml b/crates/ghostty-crash/Cargo.toml index cad0086..b1f5858 100644 --- a/crates/ghostty-crash/Cargo.toml +++ b/crates/ghostty-crash/Cargo.toml @@ -37,6 +37,7 @@ windows-sys = { version = "0.61", features = [ [dev-dependencies] minidump = "0.27.0" tempfile = "3.27.0" +temp-env = "0.3.6" # The fatal-signal handler needs sigaction/sigaltstack/raw syscalls. Unsafe is # denied crate-wide and only allowed in `signal.rs` with SAFETY comments. diff --git a/crates/ghostty-crash/src/dir.rs b/crates/ghostty-crash/src/dir.rs index d599ba8..de5983e 100644 --- a/crates/ghostty-crash/src/dir.rs +++ b/crates/ghostty-crash/src/dir.rs @@ -19,7 +19,8 @@ pub fn default_dir() -> io::Result { }) .map_err(|e| io::Error::other(e.to_string()))?; Ok(Dir { - path: PathBuf::from(path), + // The XDG helpers return the encoded form of the OS string. + path: path.to_path(), }) } @@ -62,7 +63,7 @@ impl ReportIterator { if !entry.file_type()?.is_file() { continue; } - let name = entry.file_name().to_string_lossy().into_owned(); + let name = entry.file_name(); // statFile follows symlinks; entries here are regular files. let mtime = fs::metadata(self.dir.join(entry.file_name()))?.modified()?; let mtime = match mtime.duration_since(UNIX_EPOCH) { @@ -84,7 +85,8 @@ impl Iterator for ReportIterator { /// A crash report file. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Report { - pub name: String, + /// The file name as the raw OS string (Zig `[]const u8`). + pub name: std::ffi::OsString, /// Modification time in nanoseconds since the unix epoch. pub mtime: i128, } @@ -121,6 +123,29 @@ mod tests { assert_eq!("a.ghosttycrash", r[0].name); } + #[cfg(unix)] + #[test] + fn default_dir_decodes_non_utf8_xdg_state() { + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + let v = std::ffi::OsString::from_vec(b"/tmp/state\xff".to_vec()); + let d = temp_env::with_var("XDG_STATE_HOME", Some(&v), || default_dir().unwrap()); + assert_eq!( + d.path.as_os_str().as_bytes(), + b"/tmp/state\xff/ghostty/crash" + ); + } + + #[cfg(unix)] + #[test] + fn non_utf8_report_name_is_kept() { + use std::os::unix::ffi::OsStrExt; + let t = tempfile::tempdir().unwrap(); + let name = std::ffi::OsStr::from_bytes(b"a\xff.ghosttycrash"); + fs::write(t.path().join(name), "x").unwrap(); + let r = list_reports(t.path()).unwrap(); + assert_eq!(r[0].name, name); + } + #[test] fn default_dir_uses_xdg_state() { let d = default_dir().unwrap(); diff --git a/crates/ghostty-crash/src/exception.rs b/crates/ghostty-crash/src/exception.rs index f39ec4d..8c7c741 100644 --- a/crates/ghostty-crash/src/exception.rs +++ b/crates/ghostty-crash/src/exception.rs @@ -15,8 +15,51 @@ pub(crate) const MACH_EXCEPTION_RAISE: i32 = 2405; pub(crate) const EXC_BAD_ACCESS: u32 = 1; pub(crate) const EXC_BAD_INSTRUCTION: u32 = 2; pub(crate) const EXC_ARITHMETIC: u32 = 3; +pub(crate) const EXC_SOFTWARE: u32 = 5; pub(crate) const EXC_BREAKPOINT: u32 = 6; +/// `EXC_SOFT_SIGNAL`: the code of an `EXC_SOFTWARE` exception that stands for +/// a BSD signal (the subcode is the signal number). This is how an +/// `abort()` (SIGABRT) is described, as Apple's crash reporter does. +pub(crate) const EXC_SOFT_SIGNAL: i64 = 0x10003; + +/// Message id of the "write a dump for this signal" request the signal +/// handler sends to the exception handler thread (not a Mach exception id). +pub(crate) const ABORT_MSG_ID: i32 = 0x6768_6f73; + +impl MachException { + /// The exception describing a fatal BSD signal that never went through + /// the Mach exception path (`abort()` raises SIGABRT directly). + pub(crate) fn from_signal(signo: i32) -> MachException { + MachException { + kind: EXC_SOFTWARE, + code: EXC_SOFT_SIGNAL, + subcode: i64::from(signo), + } + } +} + +/// Wire layout of the abort request after the 24 byte Mach header: +/// `thread: u32`, `signo: i32` (native endian). +pub(crate) const ABORT_BODY_LEN: usize = 8; + +/// Encode the body of an abort request. +pub(crate) fn abort_body(thread: u32, signo: i32) -> [u8; ABORT_BODY_LEN] { + let mut b = [0u8; ABORT_BODY_LEN]; + b[..4].copy_from_slice(&thread.to_ne_bytes()); + b[4..].copy_from_slice(&signo.to_ne_bytes()); + b +} + +/// Decode an abort request message (header included): the faulting thread +/// and the signal. `None` for any other message. +pub(crate) fn parse_abort(msg: &[u8]) -> Option<(u32, i32)> { + if u32_at(msg, 20)? as i32 != ABORT_MSG_ID { + return None; + } + Some((u32_at(msg, HEADER)?, u32_at(msg, HEADER + 4)? as i32)) +} + /// A decoded Mach exception. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct MachException { @@ -61,6 +104,7 @@ impl MachException { EXC_BAD_INSTRUCTION => (4, 1), // SIGILL, ILL_ILLOPC EXC_ARITHMETIC => (8, 0), // SIGFPE EXC_BREAKPOINT => (5, 1), // SIGTRAP, TRAP_BRKPT + EXC_SOFTWARE if self.code == EXC_SOFT_SIGNAL => (self.subcode as i32, 0), _ => (0, 0), } } @@ -248,6 +292,28 @@ mod tests { assert_eq!(e(EXC_BREAKPOINT, 1).signal().0, 5); } + #[test] + fn abort_request_round_trips() { + let mut msg = vec![0u8; 20]; + msg.extend_from_slice(&ABORT_MSG_ID.to_ne_bytes()); + msg.extend_from_slice(&abort_body(77, 6)); + assert_eq!(parse_abort(&msg), Some((77, 6))); + // A Mach exception message or a truncated one is not an abort request. + assert_eq!(parse_abort(&raise(EXC_BAD_ACCESS, &[1, 2])), None); + assert_eq!(parse_abort(&msg[..26]), None); + } + + #[test] + fn sigabrt_is_described_as_a_soft_signal() { + let e = MachException::from_signal(6); + assert_eq!(e.name(), "EXC_SOFTWARE"); + assert_eq!(e.signal(), (6, 0)); + assert_eq!(e.fault_addr(), 0); + let mut s = String::new(); + e.describe(&mut s); + assert_eq!(s, "EXC_SOFTWARE (code=0x10003, subcode=0x6)"); + } + #[test] fn windows_names() { assert_eq!( diff --git a/crates/ghostty-crash/src/lib.rs b/crates/ghostty-crash/src/lib.rs index 3d9c098..34850ca 100644 --- a/crates/ghostty-crash/src/lib.rs +++ b/crates/ghostty-crash/src/lib.rs @@ -28,6 +28,7 @@ mod signal; #[cfg(windows)] #[path = "signal_windows.rs"] mod signal; +mod suspend; pub use context::{DimsSnapshot, SurfaceDims, ThreadGuard, ThreadType, current_dims}; pub use dir::{Dir, Report, ReportIterator, default_dir}; diff --git a/crates/ghostty-crash/src/mach_handler.rs b/crates/ghostty-crash/src/mach_handler.rs index 58b3206..153ace6 100644 --- a/crates/ghostty-crash/src/mach_handler.rs +++ b/crates/ghostty-crash/src/mach_handler.rs @@ -10,7 +10,9 @@ //! sends a `mach_exception_raise` message; the handler thread (which runs on //! its own healthy stack, so stack overflows work) then: //! -//! 1. suspends all other threads, +//! 1. suspends all other threads (and resumes *all* of them, the faulting one +//! included, before replying; otherwise the process hangs instead of +//! dying), //! 2. reads the crashed thread's registers with `thread_get_state` for the //! event's stack trace, //! 3. writes a minidump with `minidump-writer` for the whole task and attaches @@ -20,9 +22,11 @@ //! the process dies as usual (Apple's crash reporter included). The signal //! path (`signal.rs`) sees `BUSY` set and writes nothing more. //! -//! Signals that are not Mach exceptions (`SIGABRT` from `abort()`) keep going -//! through the signal handler. A watchdog `alarm(2)` kills the process if the -//! dump hangs (e.g. a suspended thread held the allocator lock). +//! `SIGABRT` from `abort()` is not a Mach exception. Like Breakpad, the signal +//! handler asks this handler thread for the dump (`request_abort_dump`: a +//! message to the exception port, then it waits on a reply port), so +//! `abort()` gets a minidump too. A watchdog `alarm(2)` kills the process if +//! the dump hangs (e.g. a suspended thread held the allocator lock). //! //! Unlike Breakpad this does not forward the exception to previously //! registered handlers; it restores them, which only matters for debuggers @@ -53,12 +57,14 @@ use mach2::{ mach_types::{thread_act_t, thread_t}, message::{ MACH_MSG_TIMEOUT_NONE, + MACH_MSG_TYPE_COPY_SEND, MACH_MSG_TYPE_MAKE_SEND, MACH_MSGH_BITS, MACH_MSGH_BITS_REMOTE_MASK, MACH_RCV_LARGE, MACH_RCV_MSG, MACH_RCV_PORT_DIED, + MACH_RCV_TIMEOUT, MACH_SEND_MSG, mach_msg, mach_msg_header_t, @@ -77,6 +83,7 @@ use crate::{ native::Native, sentry, signal::BUSY, + suspend::{SuspendGuard, Threads}, }; /// Maximum number of registered exception handlers (`EXC_TYPES_COUNT`). @@ -99,6 +106,11 @@ struct Previous { static PREVIOUS: OnceLock = OnceLock::new(); static PORT: AtomicU32 = AtomicU32::new(MACH_PORT_NULL); +/// Receive port the signal handler waits on while the handler thread dumps. +static REPLY_PORT: AtomicU32 = AtomicU32::new(MACH_PORT_NULL); + +/// How long the signal handler waits for the dump (a bit above the watchdog). +const ABORT_WAIT_MS: u32 = (WATCHDOG_SECS + 10) * 1000; #[cfg(target_arch = "x86_64")] type ThreadState = mach2::structs::x86_thread_state64_t; @@ -136,6 +148,21 @@ pub(crate) fn install() -> bool { return false; } + // Reply port for the SIGABRT path (best effort: without it SIGABRT + // keeps the event-only report). + let mut reply: mach_port_t = MACH_PORT_NULL; + if mach2::mach_port::mach_port_allocate(task, MACH_PORT_RIGHT_RECEIVE, &raw mut reply) + == KERN_SUCCESS + { + if mach2::mach_port::mach_port_insert_right(task, reply, reply, MACH_MSG_TYPE_MAKE_SEND) + == KERN_SUCCESS + { + REPLY_PORT.store(reply, Ordering::Release); + } else { + mach2::mach_port::mach_port_destroy(task, reply); + } + } + let mut prev = Previous { count: MAX_PORTS as u32, masks: [0; MAX_PORTS], @@ -237,6 +264,9 @@ fn handler_thread(port: mach_port_t) { continue; } let Some(req) = exception::parse_raise(&buf.0) else { + if let Some((thread, signo)) = exception::parse_abort(&buf.0) { + handle_abort(thread, signo, handler); + } continue; }; handle_exception(&req, handler); @@ -273,23 +303,40 @@ fn handler_thread(port: mach_port_t) { } } -/// Suspend every thread of the task except `keep` (the handler thread). -fn suspend_other_threads(keep: thread_act_t) { - // SAFETY: task_threads returns a vm-allocated array we only read. - unsafe { - let task = mach2::traps::mach_task_self(); - let mut list: mach2::mach_types::thread_act_array_t = std::ptr::null_mut(); - let mut n: u32 = 0; - if mach2::task::task_threads(task, &raw mut list, &raw mut n) != KERN_SUCCESS { - return; - } - for i in 0..n as usize { - let t = *list.add(i); - if t != keep { - mach2::thread_act::thread_suspend(t); +/// The Mach threads of this task (for [`SuspendGuard`]). +struct MachThreads; + +impl Threads for MachThreads { + type Id = thread_act_t; + + fn list(&self) -> Vec { + // SAFETY: task_threads returns a vm-allocated array we copy and free. + unsafe { + let task = mach2::traps::mach_task_self(); + let mut list: mach2::mach_types::thread_act_array_t = std::ptr::null_mut(); + let mut n: u32 = 0; + if mach2::task::task_threads(task, &raw mut list, &raw mut n) != KERN_SUCCESS { + return Vec::new(); } + let threads = std::slice::from_raw_parts(list, n as usize).to_vec(); + mach2::vm::mach_vm_deallocate( + task, + list as mach2::vm_types::mach_vm_address_t, + u64::from(n) * size_of::() as u64, + ); + threads } } + + fn suspend(&self, t: thread_act_t) { + // SAFETY: plain Mach call on a thread port of this task. + unsafe { mach2::thread_act::thread_suspend(t) }; + } + + fn resume(&self, t: thread_act_t) { + // SAFETY: as above. + unsafe { mach2::thread_act::thread_resume(t) }; + } } fn handle_exception(req: &exception::RaiseRequest, handler: thread_t) { @@ -299,17 +346,141 @@ fn handle_exception(req: &exception::RaiseRequest, handler: thread_t) { // SAFETY: alarm has no preconditions. SIGALRM's default action ends // the process if the dump below hangs. unsafe { libc::alarm(WATCHDOG_SECS) }; - suspend_other_threads(handler); - write_report(st, req, handler); + { + // Every thread (the faulting one included) is resumed when the + // guard drops, before the exception is handed back to the kernel. + let _threads = SuspendGuard::suspend_all_except(&MachThreads, handler); + write_report(st, req.exception, req.thread, req.task, handler); + } // SAFETY: as above. unsafe { libc::alarm(0) }; } restore_ports(); } -fn write_report(st: &sentry::State, req: &exception::RaiseRequest, handler: thread_t) { - let ex: MachException = req.exception; +/// A `SIGABRT` dump request from the signal handler: dump the whole task with +/// `thread` as the crashed thread, then wake the waiting signal handler. +fn handle_abort(thread: u32, signo: i32, handler: thread_t) { + if let Some(st) = sentry::state() + && !BUSY.swap(true, Ordering::Acquire) + { + // SAFETY: as in `handle_exception`. + unsafe { libc::alarm(WATCHDOG_SECS) }; + { + let _threads = SuspendGuard::suspend_all_except(&MachThreads, handler); + // SAFETY: plain Mach trap. + let task = unsafe { mach2::traps::mach_task_self() }; + write_report(st, MachException::from_signal(signo), thread, task, handler); + } + // SAFETY: as above. + unsafe { libc::alarm(0) }; + } + notify_signal_handler(); +} +/// Wake the signal handler waiting in [`request_abort_dump`]. +fn notify_signal_handler() { + let reply = REPLY_PORT.load(Ordering::Acquire); + if reply == MACH_PORT_NULL { + return; + } + let mut head = mach_msg_header_t { + msgh_bits: MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0), + msgh_size: size_of::() as u32, + msgh_remote_port: reply, + msgh_local_port: MACH_PORT_NULL, + msgh_voucher_port: 0, + msgh_id: exception::ABORT_MSG_ID + 1, + }; + // SAFETY: `head` is a complete message with no body. + unsafe { + mach_msg( + &raw mut head, + MACH_SEND_MSG, + size_of::() as u32, + 0, + MACH_PORT_NULL, + MACH_MSG_TIMEOUT_NONE, + MACH_PORT_NULL, + ); + } +} + +#[repr(C, packed(4))] +struct Request { + head: mach_msg_header_t, + body: [u8; exception::ABORT_BODY_LEN], +} + +/// Called from the `SIGABRT` signal handler: ask the exception handler thread +/// to write the report with a minidump and wait for it. Returns false if the +/// handler thread is unavailable (the caller then writes the event-only +/// report itself). +pub(crate) fn request_abort_dump(signo: libc::c_int) -> bool { + let port = PORT.load(Ordering::Acquire); + let reply = REPLY_PORT.load(Ordering::Acquire); + if port == MACH_PORT_NULL || reply == MACH_PORT_NULL || sentry::state().is_none() { + return false; + } + if BUSY.load(Ordering::Acquire) { + // Another crash is already being written. + return false; + } + + // SAFETY: mach_thread_self has no preconditions. + let thread = unsafe { mach2::mach_init::mach_thread_self() }; + let mut req = Request { + head: mach_msg_header_t { + msgh_bits: MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0), + msgh_size: size_of::() as u32, + msgh_remote_port: port, + msgh_local_port: MACH_PORT_NULL, + msgh_voucher_port: 0, + msgh_id: exception::ABORT_MSG_ID, + }, + body: exception::abort_body(thread, signo), + }; + // SAFETY: `req` is a fully initialized message of the given size. + let sent = unsafe { + mach_msg( + (&raw mut req).cast(), + MACH_SEND_MSG, + size_of::() as u32, + 0, + MACH_PORT_NULL, + MACH_MSG_TIMEOUT_NONE, + MACH_PORT_NULL, + ) + }; + if sent != 0 { + return false; + } + + // Block until the handler thread has finished (it resumes this thread's + // suspension before replying). Bounded, in case the dump hangs. + let mut buf = Buffer([0; 1024]); + // SAFETY: `buf` is a writable 1024 byte aligned buffer. + unsafe { + mach_msg( + buf.0.as_mut_ptr().cast(), + MACH_RCV_MSG | MACH_RCV_TIMEOUT, + 0, + buf.0.len() as u32, + reply, + ABORT_WAIT_MS, + MACH_PORT_NULL, + ); + } + true +} + +fn write_report( + st: &sentry::State, + ex: MachException, + thread: thread_t, + task: mach_port_t, + handler: thread_t, +) { // Registers and frame-pointer walk of the faulting thread. let mut native = Box::new(Native::new()); (native.signo, native.code) = ex.signal(); @@ -319,7 +490,7 @@ fn write_report(st: &sentry::State, req: &exception::RaiseRequest, handler: thre // SAFETY: `state` is a writable thread state of the requested flavor. let kr = unsafe { mach2::thread_act::thread_get_state( - req.thread, + thread, state_flavor(), (&raw mut state).cast(), &raw mut count, @@ -331,8 +502,8 @@ fn write_report(st: &sentry::State, req: &exception::RaiseRequest, handler: thre } let cc = CrashContext { - task: req.task, - thread: req.thread, + task, + thread, handler_thread: handler, exception: Some(ExceptionInfo { kind: ex.kind, diff --git a/crates/ghostty-crash/src/sentry.rs b/crates/ghostty-crash/src/sentry.rs index b7a79c6..7e5ccf6 100644 --- a/crates/ghostty-crash/src/sentry.rs +++ b/crates/ghostty-crash/src/sentry.rs @@ -138,7 +138,8 @@ pub fn install(opts: &Options) -> Result<(), InitError> { } let state = State { - dir: dir.as_os_str().as_encoded_bytes().to_vec(), + // Bytes on Unix, WTF-8 on Windows (lossless both ways). + dir: ghostty_os::osbytes::os_bytes(dir.as_os_str()), prefix, }; if STATE.set(state).is_err() { @@ -415,17 +416,8 @@ pub(crate) fn write_report_ext( data.extend_from_slice(&enc.payload); } - #[cfg(unix)] - let dir = { - use std::os::unix::ffi::OsStrExt as _; - std::path::Path::new(std::ffi::OsStr::from_bytes(&st.dir)) - }; - // Windows paths are not arbitrary bytes; the dir was UTF-8 in practice. - #[cfg(not(unix))] - let dir_buf = PathBuf::from(String::from_utf8_lossy(&st.dir).into_owned()); - #[cfg(not(unix))] - let dir = dir_buf.as_path(); - std::fs::create_dir_all(dir)?; + let dir = PathBuf::from(ghostty_os::osbytes::os_from_bytes(st.dir.clone())); + std::fs::create_dir_all(&dir)?; let path = dir.join(report_file_name(uuid)); std::fs::write(&path, data)?; Ok(path) diff --git a/crates/ghostty-crash/src/signal.rs b/crates/ghostty-crash/src/signal.rs index 9bbe0d0..09ec9ca 100644 --- a/crates/ghostty-crash/src/signal.rs +++ b/crates/ghostty-crash/src/signal.rs @@ -252,7 +252,14 @@ fn write_signal_report(name: &str, desc: &str, info: *mut libc::siginfo_t, uc: * } extern "C" fn handler(sig: libc::c_int, info: *mut libc::siginfo_t, ctx: *mut libc::c_void) { - if let Some((_, name, desc)) = SIGNALS.iter().find(|(s, ..)| *s == sig) { + // macOS: `abort()` raises SIGABRT directly (no Mach exception), so ask the + // exception handler thread for the report with a minidump, like + // Breakpad. The event-only report below is the fallback. + #[cfg(target_os = "macos")] + let dumped = sig == libc::SIGABRT && crate::mach_handler::request_abort_dump(sig); + #[cfg(not(target_os = "macos"))] + let dumped = false; + if !dumped && let Some((_, name, desc)) = SIGNALS.iter().find(|(s, ..)| *s == sig) { write_signal_report(name, desc, info, ctx); } // Restore the default action and re-raise so the process dies with the diff --git a/crates/ghostty-crash/src/signal_windows.rs b/crates/ghostty-crash/src/signal_windows.rs index b28f4bc..e4f266d 100644 --- a/crates/ghostty-crash/src/signal_windows.rs +++ b/crates/ghostty-crash/src/signal_windows.rs @@ -124,7 +124,7 @@ fn write_report(st: &sentry::State, info: *mut EXCEPTION_POINTERS, tid: u32) { .map_or(0, |r| r.ExceptionCode) as u32; let (kind, desc) = exception::windows_exception(code); - let dir = std::path::PathBuf::from(String::from_utf8_lossy(&st.dir).into_owned()); + let dir = std::path::PathBuf::from(ghostty_os::osbytes::os_from_bytes(st.dir.clone())); let dump = (|| -> Result, String> { std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; let tmp = dir.join(format!("minidump-{}.tmp", std::process::id())); diff --git a/crates/ghostty-crash/src/suspend.rs b/crates/ghostty-crash/src/suspend.rs new file mode 100644 index 0000000..c49c40b --- /dev/null +++ b/crates/ghostty-crash/src/suspend.rs @@ -0,0 +1,112 @@ +//! Suspending every other thread of the process while a crash report is +//! written, and *always* resuming them afterwards. +//! +//! The macOS exception handler thread suspends all other threads so the dump +//! is consistent. Breakpad resumes them before handing the exception back to +//! the kernel; without that, the faulting thread (which the kernel parked +//! until the handler replies) stays suspended forever and the process hangs +//! instead of dying through BSD signal delivery. The platform specific +//! thread operations are behind [`Threads`] so the bookkeeping is testable +//! everywhere. + +// Only the macOS handler uses this outside of tests. +#![cfg_attr(not(target_os = "macos"), allow(dead_code))] + +/// The thread operations needed by [`SuspendGuard`]. +pub(crate) trait Threads { + type Id: Copy + PartialEq; + + /// All threads of the process. + fn list(&self) -> Vec; + fn suspend(&self, id: Self::Id); + fn resume(&self, id: Self::Id); +} + +/// Resumes the threads it suspended when dropped (also on panic). +pub(crate) struct SuspendGuard<'a, T: Threads> { + ops: &'a T, + suspended: Vec, +} + +impl<'a, T: Threads> SuspendGuard<'a, T> { + /// Suspend every thread except `keep` (the calling handler thread). + pub(crate) fn suspend_all_except(ops: &'a T, keep: T::Id) -> Self { + let suspended: Vec = ops.list().into_iter().filter(|t| *t != keep).collect(); + for t in &suspended { + ops.suspend(*t); + } + SuspendGuard { ops, suspended } + } + + /// The number of threads this guard suspended. + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.suspended.len() + } +} + +impl Drop for SuspendGuard<'_, T> { + fn drop(&mut self) { + for t in self.suspended.drain(..) { + self.ops.resume(t); + } + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + + use super::*; + + #[derive(Default)] + struct Mock { + // (id, suspend count) + threads: RefCell>, + } + + impl Threads for Mock { + type Id = u32; + fn list(&self) -> Vec { + self.threads.borrow().iter().map(|t| t.0).collect() + } + fn suspend(&self, id: u32) { + for t in self.threads.borrow_mut().iter_mut().filter(|t| t.0 == id) { + t.1 += 1; + } + } + fn resume(&self, id: u32) { + for t in self.threads.borrow_mut().iter_mut().filter(|t| t.0 == id) { + t.1 -= 1; + } + } + } + + #[test] + fn suspends_all_but_handler_and_resumes_all_on_drop() { + // Thread 1 is the faulting thread, 9 the handler. + let m = Mock { + threads: RefCell::new(vec![(1, 0), (2, 0), (9, 0)]), + }; + { + let g = SuspendGuard::suspend_all_except(&m, 9); + assert_eq!(g.len(), 2); + assert_eq!(*m.threads.borrow(), [(1, 1), (2, 1), (9, 0)]); + } + // Every thread, including the faulting one, runs again. + assert_eq!(*m.threads.borrow(), [(1, 0), (2, 0), (9, 0)]); + } + + #[test] + fn resumes_even_if_the_dump_panics() { + let m = Mock { + threads: RefCell::new(vec![(1, 0), (9, 0)]), + }; + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _g = SuspendGuard::suspend_all_except(&m, 9); + panic!("dump failed"); + })); + assert!(r.is_err()); + assert_eq!(*m.threads.borrow(), [(1, 0), (9, 0)]); + } +} diff --git a/crates/ghostty-font/src/deferred_face.rs b/crates/ghostty-font/src/deferred_face.rs index 2f4b86f..04462dd 100644 --- a/crates/ghostty-font/src/deferred_face.rs +++ b/crates/ghostty-font/src/deferred_face.rs @@ -69,10 +69,11 @@ impl DeferredFace { .expect("deferred face has no fontconfig data"); // Filename and index for our face so we can load it - let filename = fc.pattern.get_string(fc::Property::File, 0)?; + // File names are raw OS bytes, not text. + let filename = fc.pattern.get_os_string(fc::Property::File, 0)?; let face_index = fc.pattern.get_integer(fc::Property::Index, 0)?; - let mut face = Face::init_file(lib, &filename, face_index, opts)?; + let mut face = Face::init_file(lib, std::path::Path::new(&filename), face_index, opts)?; face.set_variations(&fc.variations, opts)?; Ok(face) } diff --git a/crates/ghostty-font/src/face/freetype.rs b/crates/ghostty-font/src/face/freetype.rs index 158179e..57751d6 100644 --- a/crates/ghostty-font/src/face/freetype.rs +++ b/crates/ghostty-font/src/face/freetype.rs @@ -131,11 +131,13 @@ impl Face { /// Initialize a new font face from a file. pub fn init_file( lib: &Library, - path: &str, + path: impl AsRef, index: i32, opts: Options, ) -> Result { - let cpath = CString::new(path).map_err(|_| FaceError::InvalidPath)?; + // File names are raw OS bytes (they need not be UTF-8). + let cpath = CString::new(path.as_ref().as_os_str().as_encoded_bytes()) + .map_err(|_| FaceError::InvalidPath)?; let face = { let _g = lib.lock(); ft::FtFace::new_file(&lib.inner.lib, &cpath, index).map_err(FaceError::Freetype)? diff --git a/crates/ghostty-font/src/fontconfig.rs b/crates/ghostty-font/src/fontconfig.rs index 5817511..362ab40 100644 --- a/crates/ghostty-font/src/fontconfig.rs +++ b/crates/ghostty-font/src/fontconfig.rs @@ -186,6 +186,11 @@ impl Pattern { } pub fn add_string(&mut self, prop: Property, v: &str) -> bool { + self.add_string_bytes(prop, v.as_bytes()) + } + + /// [`Pattern::add_string`] for arbitrary bytes (file names). + pub fn add_string_bytes(&mut self, prop: Property, v: &[u8]) -> bool { let Ok(c) = CString::new(v) else { return false; }; @@ -205,8 +210,31 @@ impl Pattern { unsafe { sys::FcPatternAddCharSet(self.as_ptr(), prop.cstr().as_ptr(), cs.0.as_ptr()) != 0 } } - /// Get the string value at index `id`. + /// Get the string value at index `id` as the raw OS string. Use this for + /// file names: they are arbitrary bytes, not necessarily UTF-8. + pub fn get_os_string(&self, prop: Property, id: u32) -> Result { + let bytes = self.get_string_bytes(prop, id)?; + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + Ok(std::ffi::OsString::from_vec(bytes)) + } + #[cfg(not(unix))] + { + Ok(std::ffi::OsString::from( + String::from_utf8_lossy(&bytes).into_owned(), + )) + } + } + + /// Get the string value at index `id` as text. Only for properties that + /// fontconfig defines as UTF-8 (family/style/full names); invalid bytes + /// are replaced. File names use [`Pattern::get_os_string`]. pub fn get_string(&self, prop: Property, id: u32) -> Result { + Ok(String::from_utf8_lossy(&self.get_string_bytes(prop, id)?).into_owned()) + } + + fn get_string_bytes(&self, prop: Property, id: u32) -> Result, Error> { let mut out: *mut sys::FcChar8 = ptr::null_mut(); // SAFETY: valid pattern; out is a valid out pointer. let r = unsafe { @@ -223,7 +251,7 @@ impl Pattern { } // SAFETY: fontconfig returned a NUL-terminated string owned by the pattern. let s = unsafe { CStr::from_ptr(out.cast()) }; - Ok(s.to_string_lossy().into_owned()) + Ok(s.to_bytes().to_vec()) } pub fn get_integer(&self, prop: Property, id: u32) -> Result { @@ -420,6 +448,22 @@ mod tests { assert!(!emoji.has_lang("en-US")); } + #[cfg(unix)] + #[test] + fn file_name_keeps_non_utf8_bytes() { + use std::os::unix::ffi::OsStrExt; + let mut p = Pattern::create(); + assert!(p.add_string_bytes(Property::File, b"/fonts/a\xffb.ttf")); + let f = p.get_os_string(Property::File, 0).unwrap(); + assert_eq!(f.as_bytes(), b"/fonts/a\xffb.ttf"); + // The text accessor is for UTF-8 names only and is lossy. + assert!( + p.get_string(Property::File, 0) + .unwrap() + .contains('\u{fffd}') + ); + } + #[test] fn charset_add_has() { let mut cs = CharSet::create(); diff --git a/crates/ghostty-lib/src/action_c.rs b/crates/ghostty-lib/src/action_c.rs index 8dd39e0..7caab11 100644 --- a/crates/ghostty-lib/src/action_c.rs +++ b/crates/ghostty-lib/src/action_c.rs @@ -131,7 +131,7 @@ pub fn action_to_c(action: &Action, keep: &mut Keep) -> ghostty_action_s { Action::SetTabTitle(t) => v.set_tab_title.title = keep.cstr(&t.title), Action::PromptTitle(p) => v.prompt_title = *p as c_int, Action::Pwd(p) => { - v.pwd.pwd = keep.cstr_bytes(&ghostty_os::osbytes::config_str_to_bytes(&p.pwd)); + v.pwd.pwd = keep.cstr_bytes(&p.pwd.to_bytes()); } Action::MouseShape(s) => v.mouse_shape = *s as c_int, Action::MouseVisibility(m) => v.mouse_visibility = *m as c_int, @@ -185,7 +185,7 @@ pub fn action_to_c(action: &Action, keep: &mut Keep) -> ghostty_action_s { v.open_url.kind = u.kind as c_int; // The URL may be the escaped form of a non-UTF-8 path; hand the // embedder the real bytes. - let url = ghostty_os::osbytes::config_str_to_bytes(&u.url); + let url = u.url.to_bytes(); v.open_url.url = keep.cstr_bytes(&url); v.open_url.len = url.len(); } diff --git a/crates/ghostty-lib/src/benchmark.rs b/crates/ghostty-lib/src/benchmark.rs index 0b6a027..14795a5 100644 --- a/crates/ghostty-lib/src/benchmark.rs +++ b/crates/ghostty-lib/src/benchmark.rs @@ -7,27 +7,27 @@ use ghostty_bench::{args::Args, benches}; /// Split the argument string like `std.process.ArgIteratorGeneral(.{})`: /// whitespace separated, with double quotes grouping and backslash -/// escapes inside quotes. -fn split_args(s: &str) -> Vec { +/// escapes inside quotes. Works on raw bytes: file names need not be UTF-8. +fn split_args(s: &[u8]) -> Vec { let mut out = Vec::new(); - let mut cur = String::new(); + let mut cur: Vec = Vec::new(); let mut have = false; let mut in_quote = false; - let mut chars = s.chars().peekable(); - while let Some(c) = chars.next() { + let mut bytes = s.iter().copied(); + while let Some(c) = bytes.next() { match c { - '"' => { + b'"' => { in_quote = !in_quote; have = true; } - '\\' if in_quote => { - if let Some(n) = chars.next() { + b'\\' if in_quote => { + if let Some(n) = bytes.next() { cur.push(n); } } - c if c.is_whitespace() && !in_quote => { + c if c.is_ascii_whitespace() && !in_quote => { if have { - out.push(std::mem::take(&mut cur)); + out.push(ghostty_os::osbytes::os_from_bytes(std::mem::take(&mut cur))); have = false; } } @@ -38,7 +38,7 @@ fn split_args(s: &str) -> Vec { } } if have { - out.push(cur); + out.push(ghostty_os::osbytes::os_from_bytes(cur)); } out } @@ -62,7 +62,7 @@ pub unsafe extern "C" fn ghostty_benchmark_cli( log::warn!("unknown action={action}"); return false; } - let argv = split_args(&args.to_string_lossy()); + let argv = split_args(args.to_bytes()); let parsed = match Args::parse(argv) { Ok(a) => a, Err(err) => { @@ -85,9 +85,18 @@ mod tests { #[test] fn split_args_basic() { - assert_eq!(split_args("a b c"), ["a", "b", "c"]); - assert_eq!(split_args("--x=\"a b\" c"), ["--x=a b", "c"]); - assert_eq!(split_args(" ").len(), 0); + assert_eq!(split_args(b"a b c"), ["a", "b", "c"]); + assert_eq!(split_args(b"--x=\"a b\" c"), ["--x=a b", "c"]); + assert_eq!(split_args(b" ").len(), 0); + } + + #[cfg(unix)] + #[test] + fn split_args_keeps_non_utf8_bytes() { + use std::os::unix::ffi::OsStrExt; + let v = split_args(b"--data=/tmp/a\xffb x"); + assert_eq!(v[0].as_bytes(), b"--data=/tmp/a\xffb"); + assert_eq!(v[1], "x"); } #[test] diff --git a/crates/ghostty-lib/src/config_capi.rs b/crates/ghostty-lib/src/config_capi.rs index 0120e2a..b6437de 100644 --- a/crates/ghostty-lib/src/config_capi.rs +++ b/crates/ghostty-lib/src/config_capi.rs @@ -16,7 +16,7 @@ use ghostty_config::{ use ghostty_input::binding::{Action as BindingAction, TriggerC, TriggerCTag}; use crate::{ - main_c::{EMPTY_STRING, string_from_owned}, + main_c::{EMPTY_STRING, string_from_bytes}, types::{ ghostty_command_s, ghostty_config_color_list_s, @@ -103,6 +103,15 @@ fn cstring(s: &str) -> CString { CString::new(s.replace('\0', "")).unwrap_or_default() } +/// A C string for a config *value*: config strings carry non-UTF-8 bytes in +/// the reversible [`ghostty_os::osbytes`] encoding, so the embedder gets the +/// decoded bytes (Zig hands out the original byte slice). +fn cstring_value(s: &str) -> CString { + let mut bytes = ghostty_os::osbytes::config_str_to_bytes(s); + bytes.retain(|b| *b != 0); + CString::new(bytes).unwrap_or_default() +} + /// Intern a string for the lifetime of the process (enum tag names). fn intern(s: &str) -> *const c_char { static POOL: OnceLock>>> = OnceLock::new(); @@ -122,8 +131,8 @@ fn intern(s: &str) -> *const c_char { fn build_backing(value: &CValue) -> Backing { let mut b = Backing::default(); match value { - CValue::String(s) => b.strings.push(cstring(s)), - CValue::Path { path, .. } => b.strings.push(cstring(path)), + CValue::String(s) => b.strings.push(cstring_value(s)), + CValue::Path { path, .. } => b.strings.push(cstring_value(path)), CValue::ColorList(list) => { b.colors = list .iter() @@ -136,10 +145,10 @@ fn build_backing(value: &CValue) -> Backing { } CValue::Commands(cmds) => { for c in cmds { - b.strings.push(cstring(&c.action_key)); - b.strings.push(cstring(&c.action)); - b.strings.push(cstring(&c.title)); - b.strings.push(cstring(&c.description)); + b.strings.push(cstring_value(&c.action_key)); + b.strings.push(cstring_value(&c.action)); + b.strings.push(cstring_value(&c.title)); + b.strings.push(cstring_value(&c.description)); } // The CStrings' heap buffers don't move when the Vec grows. b.commands = b @@ -252,9 +261,12 @@ pub unsafe extern "C" fn ghostty_config_load_file(ptr: ghostty_config_t, path: * // Non-UTF-8 file names are valid on Unix; keep the bytes (reversible // escape understood by the config file loader). let path = - ghostty_os::osbytes::bytes_to_config_string(unsafe { CStr::from_ptr(path) }.to_bytes()); + ghostty_os::osbytes::EncodedOs::from_bytes(unsafe { CStr::from_ptr(path) }.to_bytes()); if let Err(err) = c.config.load_file(&path) { - log::error!("error loading config from file path={path} err={err}"); + log::error!( + "error loading config from file path={} err={err}", + path.as_encoded() + ); } } @@ -574,7 +586,8 @@ pub unsafe extern "C" fn ghostty_config_get_diagnostic( #[unsafe(no_mangle)] pub extern "C" fn ghostty_config_open_path() -> ghostty_string_s { match ghostty_config::edit::open_path() { - Ok(path) => string_from_owned(&path), + // The embedder gets the real bytes of the path. + Ok(path) => string_from_bytes(&path.to_bytes()), Err(err) => { log::error!("error opening config in editor err={err}"); EMPTY_STRING @@ -588,6 +601,23 @@ mod tests { use super::*; + #[test] + fn config_values_reach_c_as_decoded_bytes() { + use ghostty_os::osbytes::EncodedOs; + let enc = EncodedOs::from_bytes(b"/a\xffb"); + assert_ne!(enc.as_encoded().as_bytes(), b"/a\xffb"); + let b = build_backing(&CValue::Path { + path: enc.as_encoded().to_owned(), + optional: false, + }); + assert_eq!(b.strings[0].as_bytes(), b"/a\xffb"); + let b = build_backing(&CValue::String(enc.into_encoded())); + assert_eq!(b.strings[0].as_bytes(), b"/a\xffb"); + // Plain text (even with the escape char) is unchanged. + let b = build_backing(&CValue::String("h\u{e9}llo".to_owned())); + assert_eq!(b.strings[0].to_str().unwrap(), "h\u{e9}llo"); + } + fn get(cfg: ghostty_config_t, key: &str, init: T) -> (bool, T) { let mut out = init; // SAFETY: `out` is a valid destination of the key's C type. diff --git a/crates/ghostty-lib/src/embedded.rs b/crates/ghostty-lib/src/embedded.rs index 82ec7be..e81e3d3 100644 --- a/crates/ghostty-lib/src/embedded.rs +++ b/crates/ghostty-lib/src/embedded.rs @@ -619,7 +619,7 @@ impl RtSurface for SurfaceRt { true } - fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, String)], confirm: bool) { + fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, Vec)], confirm: bool) { let Some(cb) = self.app.opts.write_clipboard_cb else { return; }; @@ -628,7 +628,8 @@ impl RtSurface for SurfaceRt { .map(|(m, d)| { ( CString::new(m.replace('\0', "")).unwrap_or_default(), - CString::new(d.replace('\0', "")).unwrap_or_default(), + CString::new(d.iter().copied().filter(|b| *b != 0).collect::>()) + .unwrap_or_default(), ) }) .collect(); @@ -889,7 +890,7 @@ unsafe fn apply_surface_options(config: &mut Config, opts: &ghostty_surface_conf && !wd.is_empty() { // Raw bytes are kept (reversible escape in the String based config). - let wd_str = osbytes::bytes_to_config_string(wd); + let wd_str = osbytes::EncodedOs::from_bytes(wd); let wd_os = osbytes::os_from_bytes(wd.to_vec()); let wd_disp = String::from_utf8_lossy(wd); match std::fs::metadata(&wd_os) { @@ -2207,7 +2208,7 @@ mod tests { .as_ref() .and_then(|w| w.value()) .unwrap(); - assert_eq!(osbytes::config_str_to_bytes(got_wd), dir); + assert_eq!(got_wd.to_bytes(), dir); let Some(Command::Shell(c)) = &config.command else { panic!("no command"); }; diff --git a/crates/ghostty-lib/src/main_c.rs b/crates/ghostty-lib/src/main_c.rs index 53146af..a201c1a 100644 --- a/crates/ghostty-lib/src/main_c.rs +++ b/crates/ghostty-lib/src/main_c.rs @@ -21,9 +21,15 @@ static ARGS: OnceLock> = OnceLock::new(); /// Build the `ghostty_string_s` for an owned string (Zig: /// `String.fromSlice` of an allocated `[:0]const u8`). Free with /// `ghostty_string_free`. +#[cfg(test)] pub(crate) fn string_from_owned(s: &str) -> ghostty_string_s { + string_from_bytes(s.as_bytes()) +} + +/// [`string_from_owned`] for raw bytes (paths need not be UTF-8). +pub(crate) fn string_from_bytes(s: &[u8]) -> ghostty_string_s { let mut bytes = Vec::with_capacity(s.len() + 1); - bytes.extend_from_slice(s.as_bytes()); + bytes.extend_from_slice(s); bytes.push(0); let boxed = bytes.into_boxed_slice(); let len = boxed.len() - 1; diff --git a/crates/ghostty-os/src/env.rs b/crates/ghostty-os/src/env.rs index 6aa3867..73104ca 100644 --- a/crates/ghostty-os/src/env.rs +++ b/crates/ghostty-os/src/env.rs @@ -259,13 +259,13 @@ pub fn getenv_not_empty_os(key: &str) -> Option { /// 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 { - getenv_os(key).map(|v| crate::osbytes::os_to_config_string(&v)) +pub fn getenv(key: &str) -> Option { + 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 { +pub fn getenv_not_empty(key: &str) -> Option { 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); }, ); diff --git a/crates/ghostty-os/src/file.rs b/crates/ghostty-os/src/file.rs index d7ed730..89b51c1 100644 --- a/crates/ghostty-os/src/file.rs +++ b/crates/ghostty-os/src/file.rs @@ -101,12 +101,6 @@ pub fn alloc_tmp_dir_os() -> Option { 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 { - 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())); }); } diff --git a/crates/ghostty-os/src/flatpak.rs b/crates/ghostty-os/src/flatpak.rs index 240c06b..49c6a19 100644 --- a/crates/ghostty-os/src/flatpak.rs +++ b/crates/ghostty-os/src/flatpak.rs @@ -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, + pub cwd: Option, /// 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 { - let mut v = crate::osbytes::config_str_to_bytes(s); +fn nul_terminated_bytes(s: &crate::osbytes::EncodedOs) -> Vec { + let mut v = s.to_bytes(); v.push(0); v } struct Request { argv: Vec, - cwd: Option, + cwd: Option, env: HashMap, 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"); } diff --git a/crates/ghostty-os/src/homedir.rs b/crates/ghostty-os/src/homedir.rs index d91ac26..883b695 100644 --- a/crates/ghostty-os/src/homedir.rs +++ b/crates/ghostty-os/src/homedir.rs @@ -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, Error> { +pub fn home() -> Result, Error> { home_with_capacity(MAX_PATH_BYTES) } @@ -40,8 +40,8 @@ pub fn home_os() -> Result, 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, Error> { - Ok(home_os_with_capacity(cap)?.map(|h| osbytes::os_to_config_string(&h))) +pub fn home_with_capacity(cap: usize) -> Result, 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, Error> { /// either variable is missing. #[cfg(windows)] pub fn home_os_with_capacity(cap: usize) -> Result, 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, Error> { +) -> Result, 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 { +pub fn expand_home(path: &osbytes::EncodedOs) -> Result { expand_home_with_capacity(path, MAX_PATH_BYTES) } @@ -146,9 +150,11 @@ pub fn expand_home_os(path: &OsStr) -> Result { /// 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 { - 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 { + 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)); }); } diff --git a/crates/ghostty-os/src/i18n.rs b/crates/ghostty-os/src/i18n.rs index 36503ba..d9df52d 100644 --- a/crates/ghostty-os/src/i18n.rs +++ b/crates/ghostty-os/src/i18n.rs @@ -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] diff --git a/crates/ghostty-os/src/lib.rs b/crates/ghostty-os/src/lib.rs index da4a702..efef642 100644 --- a/crates/ghostty-os/src/lib.rs +++ b/crates/ghostty-os/src/lib.rs @@ -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"))] diff --git a/crates/ghostty-os/src/open.rs b/crates/ghostty-os/src/open.rs index 3400152..705fd6a 100644 --- a/crates/ghostty-os/src/open.rs +++ b/crates/ghostty-os/src/open.rs @@ -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); }); } diff --git a/crates/ghostty-os/src/osbytes.rs b/crates/ghostty-os/src/osbytes.rs index fec4db4..b7857a4 100644 --- a/crates/ghostty-os/src/osbytes.rs +++ b/crates/ghostty-os/src/osbytes.rs @@ -62,52 +62,127 @@ pub fn config_str_to_bytes(s: &str) -> Vec { 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 { use std::os::unix::ffi::OsStrExt; s.as_bytes().to_vec() } -#[cfg(not(unix))] +#[cfg(windows)] pub fn os_bytes(s: &OsStr) -> Vec { - s.to_string_lossy().into_owned().into_bytes() + use std::os::windows::ffi::OsStrExt; + wide_to_wtf8(&s.encode_wide().collect::>()) } -/// Build an OS string from raw bytes. +#[cfg(not(any(unix, windows)))] +pub fn os_bytes(s: &OsStr) -> Vec { + 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) -> 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) -> 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) -> 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 { + 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 { + 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) -> 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 { + 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 { + wtf8_to_wide(&self.to_bytes()) + } + + /// [`EncodedOs::to_wide`] plus a trailing NUL. + #[must_use] + pub fn to_wide_nul(&self) -> Vec { + 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 = 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 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 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 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 = "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() { diff --git a/crates/ghostty-os/src/passwd.rs b/crates/ghostty-os/src/passwd.rs index b903949..07e6245 100644 --- a/crates/ghostty-os/src/passwd.rs +++ b/crates/ghostty-os/src/passwd.rs @@ -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, - pub home: Option, - pub name: Option, + pub shell: Option, + pub home: Option, + pub name: Option, } /// Byte-preserving [`Entry`] (Zig slices are arbitrary bytes). @@ -23,7 +23,7 @@ pub struct EntryOs { impl From for Entry { fn from(e: EntryOs) -> Self { - let f = |v: Option| v.map(|v| osbytes::os_to_config_string(&v)); + let f = |v: Option| 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, diff --git a/crates/ghostty-os/src/path.rs b/crates/ghostty-os/src/path.rs index 01ca94c..dfc3f8a 100644 --- a/crates/ghostty-os/src/path.rs +++ b/crates/ghostty-os/src/path.rs @@ -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, 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, 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, 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() { diff --git a/crates/ghostty-os/src/resourcesdir.rs b/crates/ghostty-os/src/resourcesdir.rs index ec39798..479f2b7 100644 --- a/crates/ghostty-os/src/resourcesdir.rs +++ b/crates/ghostty-os/src/resourcesdir.rs @@ -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, - pub host_path: Option, + pub app_path: Option, + pub host_path: Option, } 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 { - 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 { - 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) -> // 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) -> // 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 { 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 { - 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); - } } diff --git a/crates/ghostty-os/src/unsupported.rs b/crates/ghostty-os/src/unsupported.rs index c22838a..3b68a09 100644 --- a/crates/ghostty-os/src/unsupported.rs +++ b/crates/ghostty-os/src/unsupported.rs @@ -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 { - if path.starts_with("~/") { + pub fn expand_home(path: &EncodedOs) -> Result { + 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, - pub home: Option, - pub name: Option, + pub shell: Option, + pub home: Option, + pub name: Option, } /// 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 { + pub fn config(_: &Options<'_>) -> Result { Err(Error::Unavailable) } - pub fn cache(_: &Options<'_>) -> Result { + pub fn cache(_: &Options<'_>) -> Result { Err(Error::Unavailable) } - pub fn state(_: &Options<'_>) -> Result { + pub fn state(_: &Options<'_>) -> Result { Err(Error::Unavailable) } @@ -78,17 +82,17 @@ pub mod xdg { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ResourcesDir { - pub app_path: Option, - pub host_path: Option, + pub app_path: Option, + pub host_path: Option, } 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() } } diff --git a/crates/ghostty-os/src/xdg.rs b/crates/ghostty-os/src/xdg.rs index 66c2b4a..aeb2937 100644 --- a/crates/ghostty-os/src/xdg.rs +++ b/crates/ghostty-os/src/xdg.rs @@ -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 { +pub fn config(opts: &Options<'_>) -> Result { dir(opts, "XDG_CONFIG_HOME", ".config") } /// Get the XDG cache directory. -pub fn cache(opts: &Options<'_>) -> Result { +pub fn cache(opts: &Options<'_>) -> Result { dir(opts, "XDG_CACHE_HOME", ".cache") } /// Get the XDG state directory. -pub fn state(opts: &Options<'_>) -> Result { +pub fn state(opts: &Options<'_>) -> Result { 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 { +fn dir(opts: &Options<'_>, env: &str, default_subdir: &str) -> Result { // 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, env: &str, default_subdir: &str) -> Result) -> Result; + type DirFn = fn(&Options<'_>) -> Result; 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()); }); } } diff --git a/crates/ghostty-os/tests/flatpak_mock.rs b/crates/ghostty-os/tests/flatpak_mock.rs index 32de516..682199c 100644 --- a/crates/ghostty-os/tests/flatpak_mock.rs +++ b/crates/ghostty-os/tests/flatpak_mock.rs @@ -53,7 +53,7 @@ fn host_command_spawn_exit_status_and_signal() { w, devnull(), ); - cmd.cwd = Some("/".into()); + cmd.cwd = Some(ghostty_os::osbytes::EncodedOs::from_encoded("/")); let mut env = ghostty_os::env::EnvMap::default(); env.insert("FOO", "bar"); cmd.env = Some(env); @@ -84,7 +84,7 @@ fn host_command_spawn_exit_status_and_signal() { devnull(), devnull(), ); - cmd.cwd = Some("/".into()); + cmd.cwd = Some(ghostty_os::osbytes::EncodedOs::from_encoded("/")); cmd.spawn().unwrap(); let (tx, rx) = std::sync::mpsc::channel(); cmd.wait_callback(move |r| tx.send(r).unwrap()).unwrap(); @@ -99,7 +99,7 @@ fn host_command_spawn_exit_status_and_signal() { devnull(), devnull(), ); - cmd.cwd = Some("/".into()); + cmd.cwd = Some(ghostty_os::osbytes::EncodedOs::from_encoded("/")); assert_eq!(cmd.spawn(), Err(Error::FlatpakSpawnFail)); } @@ -113,7 +113,7 @@ fn host_command_spawn_exit_status_and_signal() { devnull(), devnull(), ); - cmd.cwd = Some("/".into()); + cmd.cwd = Some(ghostty_os::osbytes::EncodedOs::from_encoded("/")); let pid = cmd.spawn().unwrap(); let handle = cmd.signal_handle(); let (tx, rx) = std::sync::mpsc::channel(); diff --git a/crates/ghostty-renderer/src/generic.rs b/crates/ghostty-renderer/src/generic.rs index 3735f4c..242d2ed 100644 --- a/crates/ghostty-renderer/src/generic.rs +++ b/crates/ghostty-renderer/src/generic.rs @@ -244,8 +244,9 @@ impl DerivedConfig { .value .iter() .map(|p| match p { - ConfigPath::Optional(s) => PathItem::Optional(s.clone()), - ConfigPath::Required(s) => PathItem::Required(s.clone()), + // Decode the config path (encoded OS string) here, once. + ConfigPath::Optional(s) => PathItem::Optional(s.to_path()), + ConfigPath::Required(s) => PathItem::Required(s.to_path()), }) .collect(); @@ -858,7 +859,8 @@ impl DrawState { fn prep_background_image(&mut self) { // Then we try to load the background image if we have a path. if let Some(p) = &self.config.bg_image - && let Some(image) = load_background_image(p.path()).map(image::Image::Pending) + && let Some(image) = + load_background_image(&p.path().to_path()).map(image::Image::Pending) { // If we have an existing background image, replace it. // Otherwise, set this as our background image directly. @@ -1094,16 +1096,17 @@ fn upload_background_image(bg_image: &mut BgImageState, gpu: /// Decode a background image file (`prepBackgroundImage`). Failures are /// logged and result in no image, as in Zig. -fn load_background_image(path: &str) -> Option { +fn load_background_image(path: &std::path::Path) -> Option { + let shown = path.display(); // Read it (Max size of 4 GiB, for now.) let contents = match std::fs::read(path) { Ok(c) if u32::try_from(c.len()).is_ok() => c, Ok(_) => { - log::warn!("background image file \"{path}\" is too large"); + log::warn!("background image file \"{shown}\" is too large"); return None; } Err(err) => { - log::warn!("error opening background image file \"{path}\": {err}"); + log::warn!("error opening background image file \"{shown}\": {err}"); return None; } }; @@ -1119,23 +1122,23 @@ fn load_background_image(path: &str) -> Option { FileType::Png => match decode_png(&contents) { Ok(v) => v, Err(err) => { - log::warn!("error decoding png background image \"{path}\": {err}"); + log::warn!("error decoding png background image \"{shown}\": {err}"); return None; } }, FileType::Jpeg => match decode_jpeg(&contents) { Ok(v) => v, Err(err) => { - log::warn!("error decoding jpeg background image \"{path}\": {err}"); + log::warn!("error decoding jpeg background image \"{shown}\": {err}"); return None; } }, FileType::Unknown => { - log::warn!("Cannot determine file type for background image file \"{path}\"!"); + log::warn!("Cannot determine file type for background image file \"{shown}\"!"); return None; } f => { - log::warn!("Unsupported file type {f:?} for background image file \"{path}\"!"); + log::warn!("Unsupported file type {f:?} for background image file \"{shown}\"!"); return None; } }; @@ -1179,8 +1182,8 @@ fn detect_file_type(contents: &[u8]) -> FileType { } } -fn guess_file_type_from_extension(path: &str) -> FileType { - let ext = std::path::Path::new(path) +fn guess_file_type_from_extension(path: &std::path::Path) -> FileType { + let ext = path .extension() .and_then(|e| e.to_str()) .map(str::to_ascii_lowercase); @@ -3634,9 +3637,18 @@ mod tests { assert_eq!(detect_file_type(&[0xFF, 0xD8, 0xFF, 0xE0]), FileType::Jpeg); assert_eq!(detect_file_type(b"GIF89a.."), FileType::Gif); assert_eq!(detect_file_type(b"hello"), FileType::Unknown); - assert_eq!(guess_file_type_from_extension("/a/b.JPG"), FileType::Jpeg); - assert_eq!(guess_file_type_from_extension("/a/b.png"), FileType::Png); - assert_eq!(guess_file_type_from_extension("/a/b"), FileType::Unknown); + assert_eq!( + guess_file_type_from_extension("/a/b.JPG".as_ref()), + FileType::Jpeg + ); + assert_eq!( + guess_file_type_from_extension("/a/b.png".as_ref()), + FileType::Png + ); + assert_eq!( + guess_file_type_from_extension("/a/b".as_ref()), + FileType::Unknown + ); } #[test] @@ -3655,15 +3667,15 @@ mod tests { let path = dir.join("bg.png"); std::fs::write(&path, &bytes).unwrap(); - let img = load_background_image(path.to_str().unwrap()).expect("decoded"); + let img = load_background_image(&path).expect("decoded"); assert_eq!((img.width, img.height), (2, 1)); assert_eq!(img.data, [1, 2, 3, 255, 4, 5, 6, 128]); // Missing / undecodable files are not an error, just no image. - assert!(load_background_image(dir.join("missing.png").to_str().unwrap()).is_none()); + assert!(load_background_image(&dir.join("missing.png")).is_none()); let bad = dir.join("bad.txt"); std::fs::write(&bad, b"not an image").unwrap(); - assert!(load_background_image(bad.to_str().unwrap()).is_none()); + assert!(load_background_image(&bad).is_none()); std::fs::remove_dir_all(&dir).unwrap(); } } diff --git a/crates/ghostty-renderer/src/shadertoy.rs b/crates/ghostty-renderer/src/shadertoy.rs index dc63bac..4373343 100644 --- a/crates/ghostty-renderer/src/shadertoy.rs +++ b/crates/ghostty-renderer/src/shadertoy.rs @@ -80,9 +80,9 @@ pub enum Target { #[derive(Debug, Clone, PartialEq, Eq)] pub enum PathItem { /// A path that may be missing (`?path`); missing files are skipped. - Optional(String), + Optional(std::path::PathBuf), /// A path that must exist. - Required(String), + Required(std::path::PathBuf), } #[derive(Debug, thiserror::Error)] @@ -124,14 +124,14 @@ pub fn load_from_files(paths: &[PathItem], target: Target) -> Result PathItem::Required(p) => (p, false), }; - let shader = match load_from_file(&ghostty_os::osbytes::config_str_to_path(path), target) { + let shader = match load_from_file(path, target) { Ok(s) => s, Err(Error::Io(e)) if optional && e.kind() == std::io::ErrorKind::NotFound => { continue; } Err(e) => return Err(e), }; - log::info!("loaded custom shader path={path}"); + log::info!("loaded custom shader path={}", path.display()); list.push(shader); } @@ -709,13 +709,13 @@ mod tests { let missing = dir.join("missing.glsl"); let items = [ - PathItem::Optional(missing.to_string_lossy().into_owned()), - PathItem::Required(good.to_string_lossy().into_owned()), + PathItem::Optional(missing.clone()), + PathItem::Required(good.clone()), ]; let shaders = load_from_files(&items, Target::Glsl).unwrap(); assert_eq!(shaders.len(), 1); - let items = [PathItem::Required(missing.to_string_lossy().into_owned())]; + let items = [PathItem::Required(missing.clone())]; assert!(matches!( load_from_files(&items, Target::Glsl), Err(Error::Io(_)) diff --git a/crates/ghostty-terminal/src/kitty/graphics_image.rs b/crates/ghostty-terminal/src/kitty/graphics_image.rs index abaa66b..28b0de6 100644 --- a/crates/ghostty-terminal/src/kitty/graphics_image.rs +++ b/crates/ghostty-terminal/src/kitty/graphics_image.rs @@ -560,14 +560,20 @@ fn contains_subslice(hay: &[u8], needle: &[u8]) -> bool { /// Returns true if path appears to be in a temporary directory. /// Copies logic from Kitty. fn is_path_in_temp_dir(path: &[u8]) -> bool { + is_path_in_temp_dir_with(path, alloc_tmp_dir()) +} + +/// [`is_path_in_temp_dir`] with an explicit temporary directory (an OS +/// string: it may be non-UTF-8, so it is compared byte for byte). +fn is_path_in_temp_dir_with(path: &[u8], tmp_dir: Option) -> bool { if path.starts_with(b"/tmp") { return true; } if path.starts_with(b"/dev/shm") { return true; } - if let Some(dir) = alloc_tmp_dir() { - if path.starts_with(dir.as_bytes()) { + if let Some(dir) = tmp_dir { + if path.starts_with(dir.as_os_str().as_bytes()) { return true; } @@ -582,22 +588,23 @@ fn is_path_in_temp_dir(path: &[u8]) -> bool { false } -/// `os/file.zig allocTmpDir` on POSIX (TMPDIR, TMP, then /tmp). +/// `os/file.zig allocTmpDir` on POSIX (TMPDIR, TMP, then /tmp), as the raw +/// OS string. #[cfg(not(windows))] #[allow(clippy::unnecessary_wraps)] -fn alloc_tmp_dir() -> Option { +fn alloc_tmp_dir() -> Option { for key in ["TMPDIR", "TMP"] { if let Some(v) = std::env::var_os(key) { - return Some(v.to_string_lossy().into_owned()); + return Some(v); } } - Some("/tmp".to_owned()) + Some("/tmp".into()) } /// `os/file.zig allocTmpDir` on Windows (`TMP` only). #[cfg(windows)] -fn alloc_tmp_dir() -> Option { - std::env::var_os("TMP").map(|v| v.to_string_lossy().into_owned()) +fn alloc_tmp_dir() -> Option { + std::env::var_os("TMP") } /// Image represents a single fully loaded image. @@ -655,6 +662,23 @@ pub struct Rect { #[cfg(test)] mod tests { + #[cfg(unix)] + #[test] + fn temp_dir_check_keeps_non_utf8_tmpdir_bytes() { + use std::os::unix::ffi::OsStringExt; + let dir = std::ffi::OsString::from_vec(b"/data/t\xffmp".to_vec()); + assert!(is_path_in_temp_dir_with( + b"/data/t\xffmp/tty-graphics-protocol-x", + Some(dir.clone()) + )); + // A lossy conversion would have turned the byte into U+FFFD. + assert!(!is_path_in_temp_dir_with( + b"/data/t\xef\xbf\xbdmp/tty-graphics-protocol-x", + Some(dir) + )); + assert!(!is_path_in_temp_dir_with(b"/data/x", None)); + } + use ghostty_vt::kitty::graphics_command::{Control, Transmission}; use super::*; diff --git a/crates/ghostty-terminal/src/stream_readonly.rs b/crates/ghostty-terminal/src/stream_readonly.rs index 5eadf55..30c0aba 100644 --- a/crates/ghostty-terminal/src/stream_readonly.rs +++ b/crates/ghostty-terminal/src/stream_readonly.rs @@ -185,9 +185,9 @@ impl<'a> Handler<'a> { Ok(()) } - fn color_operation(&mut self, requests: &osc_color::List) { - for req in requests { - match *req { + fn color_operation(&mut self, requests: &osc_color::List<'_>) { + for req in requests.iter() { + match req { Request::Set(set) => match set.target { Target::Palette(i) => { self.terminal.flags.dirty.palette = true; @@ -231,10 +231,10 @@ impl<'a> Handler<'a> { } } - fn kitty_color_operation(&mut self, request: &kitty::color::OSC) { + fn kitty_color_operation(&mut self, request: &kitty::color::OSC<'_>) { use kitty::color::{Kind, Request as KReq, Special}; - for item in &request.list { - match item { + for item in request.list.iter() { + match &item { KReq::Set { key, color } => match key { Kind::Palette(palette) => { self.terminal.flags.dirty.palette = true; diff --git a/crates/ghostty-termio/src/command.rs b/crates/ghostty-termio/src/command.rs index 9dfc920..49e9414 100644 --- a/crates/ghostty-termio/src/command.rs +++ b/crates/ghostty-termio/src/command.rs @@ -177,7 +177,7 @@ pub struct Command { /// Working directory to change to in the child process. If not set, the /// working directory of the calling process is preserved. Failure to /// change directory is ignored (as in Ghostty). - pub cwd: Option, + pub cwd: Option, /// The file handle to set for stdin/out/err. If this isn't set, we do /// nothing explicitly so it is up to the behavior of the operating system. @@ -317,8 +317,8 @@ impl Command { let prep = self.prepare()?; let cwd = self .cwd - .as_deref() - .and_then(|c| CString::new(config_str_to_bytes(c)).ok()); + .as_ref() + .and_then(|c| CString::new(c.to_bytes()).ok()); let mut argv_ptrs: Vec<*const c_char> = prep.argv.iter().map(|c| c.as_ptr()).collect(); argv_ptrs.push(std::ptr::null()); @@ -872,7 +872,7 @@ mod tests { let mut cmd = sh("pwd"); cmd.stdout = Some(stdout.try_clone().unwrap().into()); - cmd.cwd = Some("/tmp".to_owned()); + cmd.cwd = Some(ghostty_os::osbytes::EncodedOs::from_encoded("/tmp")); cmd.start().unwrap(); assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(0)); @@ -894,7 +894,7 @@ mod tests { let mut cmd = Command::new("/bin/sh", &["/bin/sh", "-c", "pwd -P; printf %s \"$0\""]); cmd.args.push(bytes_to_config_string(b"a\xffb")); cmd.stdout = Some(stdout.try_clone().unwrap().into()); - cmd.cwd = Some(bytes_to_config_string(&raw)); + cmd.cwd = Some(ghostty_os::osbytes::EncodedOs::from_bytes(&raw)); cmd.start().unwrap(); assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(0)); @@ -915,7 +915,9 @@ mod tests { let mut cmd = sh("echo ok"); cmd.stdout = Some(stdout.try_clone().unwrap().into()); - cmd.cwd = Some("/definitely/not/a/dir".to_owned()); + cmd.cwd = Some(ghostty_os::osbytes::EncodedOs::from_encoded( + "/definitely/not/a/dir", + )); cmd.start().unwrap(); assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(0)); assert_eq!("ok\n", read_all(stdout)); @@ -933,7 +935,7 @@ mod tests { let mut cmd = Command::new("/not/a/binary", &["/not/a/binary", ""]); cmd.stdout = Some(stdout.try_clone().unwrap().into()); cmd.stderr = Some(stderr.try_clone().unwrap().into()); - cmd.cwd = Some("/bin".to_owned()); + cmd.cwd = Some(ghostty_os::osbytes::EncodedOs::from_encoded("/bin")); cmd.start().unwrap(); assert!(cmd.pid.is_some()); assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(1)); diff --git a/crates/ghostty-termio/src/command_windows.rs b/crates/ghostty-termio/src/command_windows.rs index 1ff9318..ca3e04f 100644 --- a/crates/ghostty-termio/src/command_windows.rs +++ b/crates/ghostty-termio/src/command_windows.rs @@ -16,11 +16,13 @@ use std::{ ptr, }; -use ghostty_os::windows::{ - CREATE_UNICODE_ENVIRONMENT, - EXTENDED_STARTUPINFO_PRESENT, - PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, - to_wide_nul, +use ghostty_os::{ + osbytes::EncodedOs, + windows::{ + CREATE_UNICODE_ENVIRONMENT, + EXTENDED_STARTUPINFO_PRESENT, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + }, }; use windows_sys::Win32::{ Foundation::{HANDLE, TRUE, WAIT_FAILED}, @@ -43,7 +45,7 @@ use windows_sys::Win32::{ }, }; -use crate::windows_args::{self, create_windows_env_block, windows_create_command_line}; +use crate::windows_args::{self, create_windows_env_block, windows_create_command_line_wide}; /// Errors from [`Command::start`]. #[derive(Debug, thiserror::Error)] @@ -77,7 +79,7 @@ pub struct Command { /// Working directory for the child. If not set, the working directory of /// the calling process is preserved. - pub cwd: Option, + pub cwd: Option, /// The file handles to set for stdin/out/err. If not set the NUL device /// is used. Ignored if `pseudo_console` is set. @@ -127,12 +129,21 @@ impl Command { /// available. #[allow(unsafe_code)] pub fn start(&mut self) -> Result<(), StartError> { - let application_w = to_wide_nul(&self.path); - let cwd_w = self.cwd.as_deref().map(to_wide_nul); + // `path` and `args` are the config command's argv strings (encoded + // OS strings); decode them to WTF-16 for the Windows API. + let application_w = EncodedOs::from_encoded(&self.path).to_wide_nul(); + let cwd_w = self.cwd.as_ref().map(EncodedOs::to_wide_nul); let mut command_line_w = if self.args.is_empty() { None } else { - Some(to_wide_nul(&windows_create_command_line(&self.args))) + let wide: Vec> = self + .args + .iter() + .map(|a| EncodedOs::from_encoded(a).to_wide()) + .collect(); + let mut line = windows_create_command_line_wide(&wide); + line.push(0); + Some(line) }; let mut env_w = match &self.env { Some(map) => Some(create_windows_env_block(map.iter())?), diff --git a/crates/ghostty-termio/src/exec.rs b/crates/ghostty-termio/src/exec.rs index d68e679..ede533f 100644 --- a/crates/ghostty-termio/src/exec.rs +++ b/crates/ghostty-termio/src/exec.rs @@ -32,7 +32,7 @@ use ghostty_config::{ types_b::{ShellIntegration, ShellIntegrationFeatures}, }; use ghostty_datastruct::blocking_queue::Timeout; -use ghostty_os::file::Rlimit; +use ghostty_os::{file::Rlimit, osbytes::EncodedOs}; use ghostty_renderer::size::{GridSize, ScreenSize}; use ghostty_terminal::terminal::Terminal; use rustix::{ @@ -107,8 +107,8 @@ pub struct Config { pub shell_integration: ShellIntegration, pub shell_integration_features: ShellIntegrationFeatures, pub cursor_blink: Option, - pub working_directory: Option, - pub resources_dir: Option, + pub working_directory: Option, + pub resources_dir: Option, pub term: String, pub rt_pre_exec_info: RtPreExecInfo, @@ -158,8 +158,8 @@ impl Exec { } /// The working directory the subprocess will be started in, if any. - pub fn cwd(&self) -> Option<&str> { - self.subprocess.cwd.as_deref() + pub fn cwd(&self) -> Option<&EncodedOs> { + self.subprocess.cwd.as_ref() } /// The argv that will be executed. @@ -181,7 +181,7 @@ impl Exec { // set that on the terminal now. This allows rapidly initializing // new surfaces to use the proper pwd. if let Some(cwd) = &self.subprocess.cwd - && let Err(err) = term.set_pwd(&ghostty_os::osbytes::config_str_to_bytes(cwd)) + && let Err(err) = term.set_pwd(&cwd.to_bytes()) { log::warn!("error setting initial pwd err={err:?}"); } @@ -766,7 +766,7 @@ struct FlatpakProcess { } struct Subprocess { - cwd: Option, + cwd: Option, env: Option, args: Vec, grid_size: GridSize, @@ -790,11 +790,8 @@ impl Subprocess { // If we have a resources dir then set our env var if let Some(dir) = &cfg.resources_dir { - log::info!("found Ghostty resources dir: {dir}"); - env.insert( - "GHOSTTY_RESOURCES_DIR", - ghostty_os::osbytes::config_str_to_os(dir), - ); + log::info!("found Ghostty resources dir: {}", dir.as_encoded()); + env.insert("GHOSTTY_RESOURCES_DIR", dir.to_os()); } // Set our TERM var. This is a bit complicated because we want to use @@ -923,7 +920,7 @@ impl Subprocess { // https://github.com/kovidgoyal/kitty/issues/1595 // https://github.com/ghostty-org/ghostty/discussions/7769 if let Some(pwd) = &cwd { - env.insert("PWD", ghostty_os::osbytes::config_str_to_os(pwd)); + env.insert("PWD", pwd.to_os()); } Ok(Subprocess { @@ -1002,7 +999,7 @@ impl Subprocess { // If we can't access the cwd, then don't set any cwd and inherit. // This is important because our cwd can be set by the shell (OSC 7) // and we don't want to break new windows. - let cwd: Option = match &self.cwd { + let cwd: Option = match &self.cwd { None => None, Some(proposed) if flatpak => { // Flatpak sandboxing prevents access to certain reserved paths @@ -1035,10 +1032,10 @@ impl Subprocess { } } Some(proposed) => { - if std::path::Path::new(&ghostty_os::osbytes::config_str_to_os(proposed)).exists() { + if proposed.to_path().exists() { Some(proposed.clone()) } else { - log::warn!("cannot access cwd, ignoring: {proposed}"); + log::warn!("cannot access cwd, ignoring: {}", proposed.as_encoded()); None } } @@ -1188,10 +1185,17 @@ fn add_ghostty_path(env: &mut EnvMap) { return; } }; - let Some(exe_dir) = exe.parent().map(|p| p.to_string_lossy().into_owned()) else { + add_ghostty_path_for(env, &exe); +} + +/// [`add_ghostty_path`] for a given executable (testable with non-UTF-8 +/// directories). +fn add_ghostty_path_for(env: &mut EnvMap, exe: &std::path::Path) { + // Keep the exact bytes of the directory (it may be non-UTF-8). + let Some(exe_dir) = exe.parent().map(|p| p.as_os_str().to_owned()) else { return; }; - log::debug!("appending ghostty bin to path dir={exe_dir}"); + log::debug!("appending ghostty bin to path dir={}", exe_dir.display()); // We always set this so that if the shell overwrites the path // scripts still have a way to find the Ghostty binary when @@ -1211,10 +1215,7 @@ fn add_ghostty_path(env: &mut EnvMap) { { return; } - env.insert( - "PATH", - ghostty_os::env::append_env_os(&path, std::ffi::OsStr::new(&exe_dir)), - ); + env.insert("PATH", ghostty_os::env::append_env_os(&path, &exe_dir)); } else { env.insert("PATH", exe_dir); } @@ -1222,8 +1223,8 @@ fn add_ghostty_path(env: &mut EnvMap) { /// The `TERMINFO` directory for a resources dir (reversible config-string /// encoding in, raw OS string out). -fn terminfo_dir(base: &str) -> std::ffi::OsString { - let base = ghostty_os::osbytes::config_str_to_os(base); +fn terminfo_dir(base: &EncodedOs) -> std::ffi::OsString { + let base = base.to_os(); let own = std::path::Path::new(&base).join("terminfo"); if own.join("x/xterm-ghostty").exists() { own.into_os_string() @@ -1238,9 +1239,9 @@ fn terminfo_dir(base: &str) -> std::ffi::OsString { /// macOS: export additional data directories from our application bundle /// (`XDG_DATA_DIRS` and `MANPATH`). -pub fn darwin_bundle_env(env: &mut EnvMap, resources_dir: &str) { +pub fn darwin_bundle_env(env: &mut EnvMap, resources_dir: &EncodedOs) { let xdg_data_dir_key = "XDG_DATA_DIRS"; - let mut data_dir = ghostty_os::osbytes::config_str_to_os(resources_dir); + let mut data_dir = resources_dir.to_os(); data_dir.push("/.."); let current = env.get(xdg_data_dir_key).map_or_else( || std::ffi::OsString::from("/usr/local/share:/usr/share"), @@ -1252,7 +1253,7 @@ pub fn darwin_bundle_env(env: &mut EnvMap, resources_dir: &str) { ); let manpath_key = "MANPATH"; - let mut man_dir = ghostty_os::osbytes::config_str_to_os(resources_dir); + let mut man_dir = resources_dir.to_os(); man_dir.push("/../man"); // Always append with colon in front, as it mean that if // `MANPATH` is empty, then it should be treated as an extra @@ -1439,11 +1440,13 @@ fn darwin_login_command( let hush = match &passwd.home { Some(home) => { - if std::fs::read_dir(home).is_err() { + // The passwd home is an encoded OS string: decode it first. + let home = home.to_path(); + if std::fs::read_dir(&home).is_err() { log::warn!("failed to open home dir, not checking for hushlogin"); false } else { - std::path::Path::new(home).join(".hushlogin").exists() + home.join(".hushlogin").exists() } } None => false, @@ -1507,7 +1510,7 @@ fn darwin_login_command( args.push("-q".into()); } args.push("-flp".into()); - args.push(username); + args.push(username.into_encoded()); match command { // Direct args can be passed directly to login, since @@ -1552,11 +1555,64 @@ impl Drop for ThreadData { mod tests { use super::*; + #[test] + fn ghostty_path_keeps_non_utf8_exe_dir_bytes() { + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + let exe = std::path::PathBuf::from(std::ffi::OsString::from_vec( + b"/opt/gh\xffostty/bin/ghostty".to_vec(), + )); + + // No PATH: PATH becomes the directory itself. + let mut env = EnvMap::new(); + add_ghostty_path_for(&mut env, &exe); + assert_eq!( + env.get("GHOSTTY_BIN_DIR").unwrap().as_bytes(), + b"/opt/gh\xffostty/bin" + ); + assert_eq!(env.get("PATH").unwrap().as_bytes(), b"/opt/gh\xffostty/bin"); + + // Existing PATH: appended byte for byte (no U+FFFD), and not twice. + let mut env = EnvMap::new(); + env.insert("PATH", "/usr/bin"); + add_ghostty_path_for(&mut env, &exe); + assert_eq!( + env.get("PATH").unwrap().as_bytes(), + b"/usr/bin:/opt/gh\xffostty/bin" + ); + add_ghostty_path_for(&mut env, &exe); + assert_eq!( + env.get("PATH").unwrap().as_bytes(), + b"/usr/bin:/opt/gh\xffostty/bin" + ); + } + + #[test] + fn exec_command_darwin_hushlogin_with_non_utf8_home() { + use std::os::unix::ffi::OsStrExt; + let tmp = ghostty_os::TempDir::init().unwrap(); + let home = tmp.path().join(std::ffi::OsStr::from_bytes(b"h\xffome")); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write(home.join(".hushlogin"), "").unwrap(); + // The passwd entry carries the *encoded* home; it must be decoded + // before the lookup. + let enc = EncodedOs::from_path(&home); + assert_ne!(enc.as_encoded().as_bytes(), home.as_os_str().as_bytes()); + let passwd = move || { + Ok(ghostty_os::passwd::Entry { + name: Some(EncodedOs::from_encoded("u")), + home: Some(enc.clone()), + ..Default::default() + }) + }; + let result = exec_command_with(&ConfigCommand::Direct(vec!["x".into()]), true, &passwd); + assert_eq!(result, ["/usr/bin/login", "-q", "-flp", "u", "x"]); + } + fn pw(name: Option<&str>) -> impl Fn() -> std::io::Result { let name = name.map(str::to_owned); move || { Ok(ghostty_os::passwd::Entry { - name: name.clone(), + name: name.clone().map(EncodedOs::from_encoded), ..Default::default() }) } @@ -1602,10 +1658,10 @@ mod tests { let dir = std::env::temp_dir().join(format!("ghostty-hush-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join(".hushlogin"), "").unwrap(); - let home = dir.to_str().unwrap().to_owned(); + let home = EncodedOs::from_path(&dir); let passwd = move || { Ok(ghostty_os::passwd::Entry { - name: Some("u".into()), + name: Some(EncodedOs::from_encoded("u")), home: Some(home.clone()), ..Default::default() }) diff --git a/crates/ghostty-termio/src/exec_windows.rs b/crates/ghostty-termio/src/exec_windows.rs index 952dd45..f1d386d 100644 --- a/crates/ghostty-termio/src/exec_windows.rs +++ b/crates/ghostty-termio/src/exec_windows.rs @@ -35,6 +35,7 @@ use ghostty_config::{ types_b::{ShellIntegration, ShellIntegrationFeatures}, }; use ghostty_datastruct::blocking_queue::Timeout; +use ghostty_os::osbytes::EncodedOs; use ghostty_renderer::size::{GridSize, ScreenSize}; use ghostty_terminal::terminal::Terminal; use windows_sys::Win32::{ @@ -99,8 +100,8 @@ pub struct Config { pub shell_integration: ShellIntegration, pub shell_integration_features: ShellIntegrationFeatures, pub cursor_blink: Option, - pub working_directory: Option, - pub resources_dir: Option, + pub working_directory: Option, + pub resources_dir: Option, pub term: String, } @@ -136,8 +137,8 @@ impl Exec { } /// The working directory the subprocess will be started in, if any. - pub fn cwd(&self) -> Option<&str> { - self.subprocess.cwd.as_deref() + pub fn cwd(&self) -> Option<&EncodedOs> { + self.subprocess.cwd.as_ref() } /// The argv that will be executed. @@ -157,7 +158,7 @@ impl Exec { // set that on the terminal now. This allows rapidly initializing // new surfaces to use the proper pwd. if let Some(cwd) = &self.subprocess.cwd - && let Err(err) = term.set_pwd(cwd.as_bytes()) + && let Err(err) = term.set_pwd(&cwd.to_bytes()) { log::warn!("error setting initial pwd err={err:?}"); } @@ -583,7 +584,7 @@ fn write_thread_main(handle: &OwnedHandle, rx: &Receiver>) { // Subprocess struct Subprocess { - cwd: Option, + cwd: Option, env: Option, args: Vec, grid_size: GridSize, @@ -602,8 +603,10 @@ impl Subprocess { // If we have a resources dir then set our env var if let Some(dir) = &cfg.resources_dir { - log::info!("found Ghostty resources dir: {dir}"); - env.insert("GHOSTTY_RESOURCES_DIR", dir.clone()); + log::info!("found Ghostty resources dir: {}", dir.as_encoded()); + // The resources dir is an encoded OS string: decode it for the + // child's environment. + env.insert("GHOSTTY_RESOURCES_DIR", dir.to_os()); } // Set our TERM var. This is a bit complicated because we want to use @@ -615,10 +618,7 @@ impl Subprocess { // Assume that the resources directory is adjacent to the terminfo // database - let parent = std::path::Path::new(base) - .parent() - .map_or_else(String::new, |p| p.to_string_lossy().into_owned()); - env.insert("TERMINFO", format!("{parent}\\terminfo")); + env.insert("TERMINFO", terminfo_dir(base)); } else { env.insert("TERM", "xterm-256color"); env.insert("COLORTERM", "truecolor"); @@ -693,7 +693,10 @@ impl Subprocess { // Build our args list. If we fail to find the shell, we'd still like // to try to run something so Ghostty can launch. - let args = match exec_command(&shell_command, std::env::var("WINDIR").ok().as_deref()) { + // %WINDIR% may hold lone surrogates: carry it in the encoded form the + // argv strings use (decoded to WTF-16 when the process is created). + let windir = std::env::var_os("WINDIR").map(|v| EncodedOs::from_os(&v).into_encoded()); + let args = match exec_command(&shell_command, windir.as_deref()) { Ok(args) => args, Err(Error::SystemError) => { log::warn!("failed to get WINDIR, cannot run shell command"); @@ -708,7 +711,7 @@ impl Subprocess { // Propagate the current working directory (CWD) to the shell. if let Some(pwd) = &cwd { - env.insert("PWD", pwd.clone()); + env.insert("PWD", pwd.to_os()); } Ok(Subprocess { @@ -777,9 +780,9 @@ impl Subprocess { // If we can't access the cwd, then don't set any cwd and inherit. // This is important because our cwd can be set by the shell (OSC 7) // and we don't want to break new windows. - let cwd: Option = match &self.cwd { + let cwd: Option = match &self.cwd { None => None, - Some(proposed) => match std::fs::metadata(proposed) { + Some(proposed) => match std::fs::metadata(proposed.to_path()) { Ok(_) => Some(proposed.clone()), Err(err) => { log::warn!("cannot access cwd, ignoring: {err}"); @@ -852,10 +855,17 @@ fn add_ghostty_path(env: &mut EnvMap) { return; } }; - let Some(exe_dir) = exe.parent().map(|p| p.to_string_lossy().into_owned()) else { + add_ghostty_path_for(env, &exe); +} + +/// [`add_ghostty_path`] for a given executable (testable with non-UTF-8 +/// directories). +fn add_ghostty_path_for(env: &mut EnvMap, exe: &std::path::Path) { + // Keep the exact OS string (it may hold lone UTF-16 surrogates). + let Some(exe_dir) = exe.parent().map(|p| p.as_os_str().to_owned()) else { return; }; - log::debug!("appending ghostty bin to path dir={exe_dir}"); + log::debug!("appending ghostty bin to path dir={}", exe_dir.display()); // We always set this so that if the shell overwrites the path // scripts still have a way to find the Ghostty binary when @@ -868,19 +878,27 @@ fn add_ghostty_path(env: &mut EnvMap) { if let Some(path) = env.get("PATH").map(std::ffi::OsStr::to_owned) { // Verify that our path doesn't already contain this entry if std::env::split_paths(&path) - .any(|entry| !entry.as_os_str().is_empty() && entry.as_os_str() == exe_dir.as_str()) + .any(|entry| !entry.as_os_str().is_empty() && entry.as_os_str() == exe_dir.as_os_str()) { return; } - env.insert( - "PATH", - ghostty_os::env::append_env_os(&path, std::ffi::OsStr::new(&exe_dir)), - ); + env.insert("PATH", ghostty_os::env::append_env_os(&path, &exe_dir)); } else { env.insert("PATH", exe_dir); } } +/// The `TERMINFO` value for a resources dir: `\\terminfo` as +/// an OS string (the resources dir is an encoded OS string). +fn terminfo_dir(base: &EncodedOs) -> std::ffi::OsString { + let base = base.to_path(); + let mut out = base + .parent() + .map_or_else(std::ffi::OsString::new, |p| p.as_os_str().to_owned()); + out.push("\\terminfo"); + out +} + /// Terminate the underlying subprocess and wait for it to exit /// (`killCommand`: `TerminateProcess(pid, 0)` then wait). fn kill_command(command: &Command) -> Result<(), Error> { @@ -908,6 +926,23 @@ pub fn exec_command(command: &ConfigCommand, windir: Option<&str>) -> Result\share\ghostty -> ...\share\terminfo + let mut wide: Vec = "C:\\Users\\".encode_utf16().collect(); + wide.push(0xD800); + wide.extend("\\share\\ghostty".encode_utf16()); + let base = EncodedOs::from_wide(&wide); + let got = super::terminfo_dir(&base); + let mut want: Vec = "C:\\Users\\".encode_utf16().collect(); + want.push(0xD800); + want.extend("\\share\\terminfo".encode_utf16()); + assert_eq!(got.encode_wide().collect::>(), want); + assert_eq!(std::ffi::OsString::from_wide(&want), got); + } + use super::*; #[test] diff --git a/crates/ghostty-termio/src/shell_integration.rs b/crates/ghostty-termio/src/shell_integration.rs index 9d5d62c..3cf8046 100644 --- a/crates/ghostty-termio/src/shell_integration.rs +++ b/crates/ghostty-termio/src/shell_integration.rs @@ -5,7 +5,7 @@ use std::path::Path; -use ghostty_os::{env::prepend_env_os, homedir, shell::ShellCommandBuilder}; +use ghostty_os::{env::prepend_env_os, homedir, osbytes::EncodedOs, shell::ShellCommandBuilder}; /// The environment passed to the child (Zig `std.process.EnvMap`). pub type EnvMap = ghostty_os::EnvMap; @@ -65,8 +65,8 @@ pub struct ShellIntegration { /// `/` as an OS string. `resource_dir` carries the /// reversible config-string encoding (it may hold non-UTF-8 bytes), so it is /// decoded before reaching the filesystem or the child environment. -fn resource_path(resource_dir: &str, rel: &str) -> std::ffi::OsString { - let mut p = ghostty_os::osbytes::config_str_to_os(resource_dir); +fn resource_path(resource_dir: &EncodedOs, rel: &str) -> std::ffi::OsString { + let mut p = resource_dir.to_os(); p.push("/"); p.push(rel); p @@ -77,7 +77,7 @@ fn resource_path(resource_dir: &str, rel: &str) -> std::ffi::OsString { /// struct describing the integration. If integration fails /// (shell type couldn't be detected, etc.), this will return None. pub fn setup( - resource_dir: &str, + resource_dir: &EncodedOs, command: &Command, env: &mut EnvMap, force_shell: Option, @@ -185,7 +185,7 @@ pub fn setup_features(env: &mut EnvMap, features: ShellIntegrationFeatures, curs /// or None if integration failed. fn setup_bash( command: &Command, - resource_dir: &str, + resource_dir: &EncodedOs, env: &mut EnvMap, ) -> Result, Error> { let mut cmd = ShellCommandBuilder::new(); @@ -271,7 +271,10 @@ fn setup_bash( if !env.contains_key("HISTFILE") && let Some(home) = homedir::home()? { - env.insert("HISTFILE", format!("{home}/.bash_history")); + // `home` is an encoded OS string: build the real OS value. + let mut histfile = home.to_os(); + histfile.push("/.bash_history"); + env.insert("HISTFILE", histfile); env.insert("GHOSTTY_BASH_UNEXPORT_HISTFILE", "1"); } @@ -286,7 +289,7 @@ fn setup_bash( /// It is also saved in the `GHOSTTY_SHELL_INTEGRATION_XDG_DIR` variable /// so that the shell can refer to it and safely remove this directory /// from `XDG_DATA_DIRS` when integration is complete. -fn setup_xdg_data_dirs(resource_dir: &str, env: &mut EnvMap) -> bool { +fn setup_xdg_data_dirs(resource_dir: &EncodedOs, env: &mut EnvMap) -> bool { // Get our path to the shell integration directory. let integ_path = resource_path(resource_dir, "shell-integration"); if let Err(err) = std::fs::read_dir(&integ_path) { @@ -319,7 +322,7 @@ fn setup_xdg_data_dirs(resource_dir: &str, env: &mut EnvMap) -> bool { /// /// We then add `--execute 'use ghostty ...'` to the nu command line to /// automatically enable our shell features. -fn setup_nushell(command: &Command, resource_dir: &str, env: &mut EnvMap) -> Option { +fn setup_nushell(command: &Command, resource_dir: &EncodedOs, env: &mut EnvMap) -> Option { // Add our XDG_DATA_DIRS entry (for nushell/vendor/autoload/). This // makes our 'ghostty' module automatically available, even if any // of the later checks abort the rest of our automatic integration. @@ -377,7 +380,7 @@ fn setup_nushell(command: &Command, resource_dir: &str, env: &mut EnvMap) -> Opt /// Setup the zsh automatic shell integration. This works by setting /// ZDOTDIR to our resources dir so that zsh will load our config. This /// config then loads the true user config. -fn setup_zsh(command: &Command, resource_dir: &str, env: &mut EnvMap) -> Option { +fn setup_zsh(command: &Command, resource_dir: &EncodedOs, env: &mut EnvMap) -> Option { // Preserve an existing ZDOTDIR value. We're about to overwrite it. if let Some(old) = env.get("ZDOTDIR").map(std::ffi::OsStr::to_owned) { env.insert("GHOSTTY_ZSH_ZDOTDIR", old); @@ -419,6 +422,10 @@ mod tests { } impl TmpResourcesDir { + fn enc(&self) -> EncodedOs { + EncodedOs::from_plain_text(&self.path) + } + fn new(shell: Shell) -> Self { let tmp = TempDir::init().unwrap(); let path = std::fs::canonicalize(tmp.path()) @@ -455,7 +462,7 @@ mod tests { let mut env = EnvMap::new(); for shell in Shell::ALL { let res = TmpResourcesDir::new(shell); - let result = setup(&res.path, &sh("sh"), &mut env, Some(shell)).unwrap(); + let result = setup(&res.enc(), &sh("sh"), &mut env, Some(shell)).unwrap(); assert_eq!(shell, result.unwrap().shell); } } @@ -463,7 +470,13 @@ mod tests { #[test] fn shell_integration_failure() { let mut env = EnvMap::new(); - let result = setup("/nonexistent", &sh("sh"), &mut env, None).unwrap(); + let result = setup( + &EncodedOs::from_encoded("/nonexistent"), + &sh("sh"), + &mut env, + None, + ) + .unwrap(); assert!(result.is_none()); assert_eq!(0, env.len()); } @@ -585,7 +598,7 @@ mod tests { let res = TmpResourcesDir::new(Shell::Bash); let mut env = EnvMap::new(); - let command = setup_bash(&sh("bash"), &res.path, &mut env).unwrap(); + let command = setup_bash(&sh("bash"), &res.enc(), &mut env).unwrap(); assert_eq!("bash --posix", shell_str(command.as_ref())); assert_eq!( "1", @@ -609,7 +622,7 @@ mod tests { ] { let mut env = EnvMap::new(); assert!( - setup_bash(&sh(cmdline), &res.path, &mut env) + setup_bash(&sh(cmdline), &res.enc(), &mut env) .unwrap() .is_none() ); @@ -622,7 +635,7 @@ mod tests { let res = TmpResourcesDir::new(Shell::Bash); let mut env = EnvMap::new(); - let command = setup_bash(&sh("bash --norc"), &res.path, &mut env).unwrap(); + let command = setup_bash(&sh("bash --norc"), &res.enc(), &mut env).unwrap(); assert_eq!("bash --posix", shell_str(command.as_ref())); assert_eq!( "1 --norc", @@ -630,7 +643,7 @@ mod tests { ); let mut env = EnvMap::new(); - let command = setup_bash(&sh("bash --noprofile"), &res.path, &mut env).unwrap(); + let command = setup_bash(&sh("bash --noprofile"), &res.enc(), &mut env).unwrap(); assert_eq!("bash --posix", shell_str(command.as_ref())); assert_eq!( "1 --noprofile", @@ -643,14 +656,14 @@ mod tests { let res = TmpResourcesDir::new(Shell::Bash); let mut env = EnvMap::new(); - let command = setup_bash(&sh("bash --rcfile profile.sh"), &res.path, &mut env).unwrap(); + let command = setup_bash(&sh("bash --rcfile profile.sh"), &res.enc(), &mut env).unwrap(); assert_eq!("bash --posix", shell_str(command.as_ref())); assert_eq!( "profile.sh", env.get("GHOSTTY_BASH_RCFILE").unwrap().to_str().unwrap() ); - let command = setup_bash(&sh("bash --init-file profile.sh"), &res.path, &mut env).unwrap(); + let command = setup_bash(&sh("bash --init-file profile.sh"), &res.enc(), &mut env).unwrap(); assert_eq!("bash --posix", shell_str(command.as_ref())); assert_eq!( "profile.sh", @@ -665,7 +678,7 @@ mod tests { // HISTFILE unset (needs a home dir; set HOME so it is deterministic) temp_env::with_var("HOME", Some("/home/test"), || { let mut env = EnvMap::new(); - setup_bash(&sh("bash"), &res.path, &mut env).unwrap(); + setup_bash(&sh("bash"), &res.enc(), &mut env).unwrap(); assert!( env.get("HISTFILE") .unwrap() @@ -685,7 +698,7 @@ mod tests { // HISTFILE set let mut env = EnvMap::new(); env.insert("HISTFILE", "my_history"); - setup_bash(&sh("bash"), &res.path, &mut env).unwrap(); + setup_bash(&sh("bash"), &res.enc(), &mut env).unwrap(); assert_eq!("my_history", env.get("HISTFILE").unwrap().to_str().unwrap()); assert!(!env.contains_key("GHOSTTY_BASH_UNEXPORT_HISTFILE")); } @@ -696,7 +709,7 @@ mod tests { let mut env = EnvMap::new(); env.insert("ENV", "env.sh"); - setup_bash(&sh("bash"), &res.path, &mut env).unwrap(); + setup_bash(&sh("bash"), &res.enc(), &mut env).unwrap(); assert_eq!( "env.sh", env.get("GHOSTTY_BASH_ENV").unwrap().to_str().unwrap() @@ -712,13 +725,13 @@ mod tests { let res = TmpResourcesDir::new(Shell::Bash); let mut env = EnvMap::new(); - let command = setup_bash(&sh("bash - --arg file1 file2"), &res.path, &mut env).unwrap(); + let command = setup_bash(&sh("bash - --arg file1 file2"), &res.enc(), &mut env).unwrap(); assert_eq!( "bash --posix - --arg file1 file2", shell_str(command.as_ref()) ); - let command = setup_bash(&sh("bash -- --arg file1 file2"), &res.path, &mut env).unwrap(); + let command = setup_bash(&sh("bash -- --arg file1 file2"), &res.enc(), &mut env).unwrap(); assert_eq!( "bash --posix -- --arg file1 file2", shell_str(command.as_ref()) @@ -731,7 +744,7 @@ mod tests { let dir = std::fs::canonicalize(tmp.path()).unwrap(); let mut env = EnvMap::new(); assert!( - setup_bash(&sh("bash"), dir.to_str().unwrap(), &mut env) + setup_bash(&sh("bash"), &EncodedOs::from_path(&dir), &mut env) .unwrap() .is_none() ); @@ -743,7 +756,7 @@ mod tests { let res = TmpResourcesDir::new(Shell::Fish); let mut env = EnvMap::new(); - assert!(setup_xdg_data_dirs(&res.path, &mut env)); + assert!(setup_xdg_data_dirs(&res.enc(), &mut env)); assert_eq!( format!("{}/shell-integration", res.path), env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR") @@ -763,7 +776,7 @@ mod tests { let mut env = EnvMap::new(); env.insert("XDG_DATA_DIRS", "/opt/share"); - assert!(setup_xdg_data_dirs(&res.path, &mut env)); + assert!(setup_xdg_data_dirs(&res.enc(), &mut env)); assert_eq!( format!("{}/shell-integration", res.path), env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR") @@ -782,7 +795,7 @@ mod tests { let tmp = TempDir::init().unwrap(); let dir = std::fs::canonicalize(tmp.path()).unwrap(); let mut env = EnvMap::new(); - assert!(!setup_xdg_data_dirs(dir.to_str().unwrap(), &mut env)); + assert!(!setup_xdg_data_dirs(&EncodedOs::from_path(&dir), &mut env)); assert_eq!(0, env.len()); } @@ -791,7 +804,7 @@ mod tests { let res = TmpResourcesDir::new(Shell::Nushell); let mut env = EnvMap::new(); - let command = setup_nushell(&sh("nu"), &res.path, &mut env); + let command = setup_nushell(&sh("nu"), &res.enc(), &mut env); assert_eq!("nu --execute 'use ghostty *'", shell_str(command.as_ref())); assert_eq!( format!("{}/shell-integration", res.path), @@ -819,7 +832,7 @@ mod tests { "nu -ic script.sh", ] { let mut env = EnvMap::new(); - assert!(setup_nushell(&sh(cmdline), &res.path, &mut env).is_none()); + assert!(setup_nushell(&sh(cmdline), &res.enc(), &mut env).is_none()); assert!(env.contains_key("XDG_DATA_DIRS")); assert!(env.contains_key("GHOSTTY_SHELL_INTEGRATION_XDG_DIR")); } @@ -830,7 +843,7 @@ mod tests { let tmp = TempDir::init().unwrap(); let dir = std::fs::canonicalize(tmp.path()).unwrap(); let mut env = EnvMap::new(); - assert!(setup_nushell(&sh("nu"), dir.to_str().unwrap(), &mut env).is_none()); + assert!(setup_nushell(&sh("nu"), &EncodedOs::from_path(&dir), &mut env).is_none()); assert_eq!(0, env.len()); } @@ -839,7 +852,7 @@ mod tests { let res = TmpResourcesDir::new(Shell::Zsh); let mut env = EnvMap::new(); - let command = setup_zsh(&sh("zsh"), &res.path, &mut env); + let command = setup_zsh(&sh("zsh"), &res.enc(), &mut env); assert_eq!("zsh", shell_str(command.as_ref())); assert_eq!( res.shell_path, @@ -854,7 +867,7 @@ mod tests { let mut env = EnvMap::new(); env.insert("ZDOTDIR", "$HOME/.config/zsh"); - let command = setup_zsh(&sh("zsh"), &res.path, &mut env); + let command = setup_zsh(&sh("zsh"), &res.enc(), &mut env); assert_eq!("zsh", shell_str(command.as_ref())); assert_eq!( res.shell_path, @@ -871,7 +884,7 @@ mod tests { let tmp = TempDir::init().unwrap(); let dir = std::fs::canonicalize(tmp.path()).unwrap(); let mut env = EnvMap::new(); - assert!(setup_zsh(&sh("zsh"), dir.to_str().unwrap(), &mut env).is_none()); + assert!(setup_zsh(&sh("zsh"), &EncodedOs::from_path(&dir), &mut env).is_none()); assert_eq!(0, env.len()); } @@ -879,9 +892,11 @@ mod tests { fn bundled_resources_exist() { // The shipped scripts that setup() points shells at. let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../resources"); - let root = root.to_str().unwrap(); + let root_enc = EncodedOs::from_path(&root); let mut env = EnvMap::new(); - let r = setup(root, &sh("bash"), &mut env, None).unwrap().unwrap(); + let r = setup(&root_enc, &sh("bash"), &mut env, None) + .unwrap() + .unwrap(); assert_eq!(r.shell, Shell::Bash); assert!( env.get("ENV") @@ -891,8 +906,7 @@ mod tests { .ends_with("shell-integration/bash/ghostty.bash") ); assert!( - Path::new(root) - .join("shell-integration/nushell/vendor/autoload/ghostty.nu") + root.join("shell-integration/nushell/vendor/autoload/ghostty.nu") .is_file() ); } diff --git a/crates/ghostty-termio/src/stream_handler.rs b/crates/ghostty-termio/src/stream_handler.rs index 3b1c47e..7271d61 100644 --- a/crates/ghostty-termio/src/stream_handler.rs +++ b/crates/ghostty-termio/src/stream_handler.rs @@ -1022,7 +1022,7 @@ impl StreamHandler<'_> { Ok(()) } - fn color_operation(&mut self, requests: &osc_color::List, terminator: Terminator) { + fn color_operation(&mut self, requests: &osc_color::List<'_>, terminator: Terminator) { // return early if there is nothing to do if requests.is_empty() { return; @@ -1030,8 +1030,8 @@ impl StreamHandler<'_> { let mut response: Vec = Vec::new(); - for req in requests { - match *req { + for req in requests.iter() { + match req { Request::Set(set) => { match set.target { Target::Palette(i) => { @@ -1216,13 +1216,13 @@ impl StreamHandler<'_> { } } - fn kitty_color_report(&mut self, request: &kitty::color::OSC) { + fn kitty_color_report(&mut self, request: &kitty::color::OSC<'_>) { use kitty::color::{Kind, Request as KReq, Special}; let mut out: Vec = Vec::new(); - for item in &request.list { - match item { + for item in request.list.iter() { + match &item { KReq::Query(key) => { // If the writer buffer is empty, we need to write our prefix if out.is_empty() { diff --git a/crates/ghostty-termio/src/windows_args.rs b/crates/ghostty-termio/src/windows_args.rs index 00cc7df..f715282 100644 --- a/crates/ghostty-termio/src/windows_args.rs +++ b/crates/ghostty-termio/src/windows_args.rs @@ -90,6 +90,48 @@ pub fn windows_create_command_line>(argv: &[S]) -> String { buf } +/// [`windows_create_command_line`] over UTF-16 arguments (which may hold +/// lone surrogates, so they cannot be a `str`). The quoting rules are the +/// same: only ASCII characters are significant, so the logic is identical on +/// code units. +pub fn windows_create_command_line_wide(argv: &[Vec]) -> Vec { + let (sp, tab, nl, quote, bs) = ( + u16::from(b' '), + u16::from(b'\t'), + u16::from(b'\n'), + u16::from(b'"'), + u16::from(b'\\'), + ); + let mut buf: Vec = Vec::new(); + for (arg_i, arg) in argv.iter().enumerate() { + if arg_i != 0 { + buf.push(sp); + } + if !arg.iter().any(|c| [sp, tab, nl, quote].contains(c)) { + buf.extend_from_slice(arg); + continue; + } + buf.push(quote); + let mut backslash_count = 0usize; + for &c in arg { + if c == bs { + backslash_count += 1; + } else if c == quote { + buf.extend(std::iter::repeat_n(bs, backslash_count * 2 + 1)); + buf.push(quote); + backslash_count = 0; + } else { + buf.extend(std::iter::repeat_n(bs, backslash_count)); + buf.push(c); + backslash_count = 0; + } + } + buf.extend(std::iter::repeat_n(bs, backslash_count * 2)); + buf.push(quote); + } + buf +} + /// The argv for a `shell` command on Windows (`execCommand`): the command is /// run as `%WINDIR%\System32\cmd.exe /C `. pub fn windows_shell_args(windir: &str, command: &str) -> Vec { @@ -115,6 +157,25 @@ mod tests { windows_create_command_line(args) } + #[test] + fn wide_command_line_matches_str_version_and_keeps_lone_surrogates() { + let w = |s: &str| s.encode_utf16().collect::>(); + for args in [ + vec!["a", "b c", "d\"e", "f\\", "g h\\"], + vec!["cmd.exe", "/C", "echo \"hi\" there"], + ] { + let wide: Vec> = args.iter().map(|a| w(a)).collect(); + assert_eq!( + windows_create_command_line_wide(&wide), + w(&windows_create_command_line(&args)) + ); + } + // A lone surrogate inside a quoted argument is carried unchanged. + let lone = vec![u16::from(b'a'), u16::from(b' '), 0xD800]; + let out = windows_create_command_line_wide(&[lone]); + assert_eq!(out, [34, 97, 32, 0xD800, 34]); + } + #[test] fn shell_args_use_windir_cmd() { assert_eq!( diff --git a/crates/ghostty-vt-capi/src/osc.rs b/crates/ghostty-vt-capi/src/osc.rs index 600c5c6..2712173 100644 --- a/crates/ghostty-vt-capi/src/osc.rs +++ b/crates/ghostty-vt-capi/src/osc.rs @@ -6,7 +6,11 @@ use std::{ ptr::NonNull, }; -use ghostty_vt::osc::{Command, Parser}; +use ghostty_vt::osc::{Command, Parser, color::RECORD_LEN}; + +/// Records the color request list holds without growing (Zig: +/// `SegmentedList(Request, 4)`). +const COLOR_LIST_INLINE_RECORDS: usize = 4; use crate::{ alloc::{Alloc, Allocator, CBlock, CByteStorage}, @@ -70,9 +74,16 @@ pub unsafe extern "C" fn ghostty_osc_new( let Some(storage) = CByteStorage::new(alloc, ghostty_vt::osc::MAX_BUF) else { return GhosttyResult::OutOfMemory; }; + // Request lists of the color OSCs live in a second block from the same + // allocator (Zig: a `SegmentedList(Request, 4)` on the parser's + // allocator). Four inline records, like Zig, so short color commands + // never allocate after creation; longer lists grow via the allocator. + let Some(aux) = CByteStorage::new(alloc, COLOR_LIST_INLINE_RECORDS * RECORD_LEN) else { + return GhosttyResult::OutOfMemory; + }; let Some(p) = alloc.create(OscParser { alloc, - parser: Parser::with_storage(storage), + parser: Parser::with_storage(storage, aux), command: OscCommand { key: 0, title_z: None, @@ -320,6 +331,16 @@ mod tests { ("66;s=2;hi", false), ("3008;start=abc;type=shell", false), ("5522;type=read;data", false), + // Color commands: the request lists live in the parser's + // storage, which comes from the caller's allocator. + ("4;1;rgb:ff/00/00;2;?", false), + ("5;0;rgb:00/00/00", false), + ("10;rgb:ff/ff/ff;?", false), + ("11;?", false), + ("104;1;2;3;4", false), + ("104", false), + ("112", false), + ("21;foreground=?;background=rgb:f0/f8/ff;cursor", false), ("0;a title", true), ] { ghostty_osc_reset(p); @@ -346,6 +367,40 @@ mod tests { } } + #[test] + fn long_color_lists_grow_via_the_caller_allocator() { + let caller_allocs = AtomicUsize::new(0); + let a = Allocator { + ctx: (&raw const caller_allocs).cast_mut().cast(), + vtable: &raw const VT, + }; + let mut p = std::ptr::null_mut(); + // SAFETY: valid allocator and out pointer. + assert_eq!( + unsafe { ghostty_osc_new(&raw const a, &mut p) }, + GhosttyResult::Success + ); + // SAFETY: `p` is live for the whole block. + unsafe { + for osc in ["4;0;?;1;?;2;?;3;?;4;?;5;?", "21;1=?;2=?;3=?;4=?;5=?;6=?"] { + ghostty_osc_reset(p); + for b in osc.bytes() { + ghostty_osc_next(p, b); + } + let global_before = global_allocs(); + let caller_before = caller_allocs.load(Ordering::SeqCst); + let cmd = ghostty_osc_end(p, 0x07); + assert_ne!(ghostty_osc_command_type(cmd), 0, "{osc}"); + assert_eq!(global_allocs(), global_before, "global allocation: {osc}"); + assert!( + caller_allocs.load(Ordering::SeqCst) > caller_before, + "list did not grow through the caller allocator: {osc}" + ); + } + ghostty_osc_free(p); + } + } + fn new() -> *mut OscParser { let mut p = std::ptr::null_mut(); // SAFETY: valid out pointer, default allocator. diff --git a/crates/ghostty-vt-capi/tests/c/alloc_test.c b/crates/ghostty-vt-capi/tests/c/alloc_test.c index 7c4686b..1cd3777 100644 --- a/crates/ghostty-vt-capi/tests/c/alloc_test.c +++ b/crates/ghostty-vt-capi/tests/c/alloc_test.c @@ -71,7 +71,7 @@ int main(void) { GhosttyAllocator a = {.ctx = &c, .vtable = &VT}; GhosttyOscParser p; assert(ghostty_osc_new(&a, &p) == GHOSTTY_SUCCESS); - assert(c.allocs >= 2); // the parser object and its buffer + assert(c.allocs >= 3); // the parser object, its buffer and request storage assert(c.live >= 2048); // the fixed payload buffer is ours feed(p, "0;hello", 7); GhosttyOscCommand cmd = ghostty_osc_end(p, 0x07); @@ -88,7 +88,7 @@ int main(void) { // 2. Creation failure at each allocation point is OUT_OF_MEMORY and leaks // nothing. - for (size_t fail_after = 0; fail_after < 2; fail_after++) { + for (size_t fail_after = 0; fail_after < 3; fail_after++) { Counter c = counter(0, fail_after); GhosttyAllocator a = {.ctx = &c, .vtable = &VT}; GhosttyOscParser p = NULL; @@ -175,6 +175,16 @@ int main(void) { {"9;4;1;50", GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT}, {"22;pointer", GHOSTTY_OSC_COMMAND_MOUSE_SHAPE}, {"66;s=2;hi", GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING}, + // Color commands keep their request lists in the parser's storage + // (the caller's allocator), up to 4 requests without growing. + {"4;1;rgb:ff/00/00;2;?", GHOSTTY_OSC_COMMAND_COLOR_OPERATION}, + {"5;0;rgb:00/00/00", GHOSTTY_OSC_COMMAND_COLOR_OPERATION}, + {"10;rgb:ff/ff/ff;?", GHOSTTY_OSC_COMMAND_COLOR_OPERATION}, + {"11;?", GHOSTTY_OSC_COMMAND_COLOR_OPERATION}, + {"104;1;2;3;4", GHOSTTY_OSC_COMMAND_COLOR_OPERATION}, + {"104", GHOSTTY_OSC_COMMAND_COLOR_OPERATION}, + {"112", GHOSTTY_OSC_COMMAND_COLOR_OPERATION}, + {"21;foreground=?;background=rgb:f0/f8/ff;cursor", GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL}, }; Counter c = counter(0, SIZE_MAX); GhosttyAllocator a = {.ctx = &c, .vtable = &VT}; @@ -202,6 +212,35 @@ int main(void) { assert(c.allocs == c.frees); } + // 7. Longer color request lists grow through the caller's allocator (one + // more block here), and fail like Zig's allocation failure (invalid + // command) when the allocator refuses: the global allocator is never + // used as a fallback. + { + static const char *const LONG4 = "4;0;?;1;?;2;?;3;?;4;?;5;?"; + static const char *const LONG21 = "21;1=?;2=?;3=?;4=?;5=?;6=?"; + for (int k = 0; k < 2; k++) { + const char *osc = k ? LONG21 : LONG4; + int type = k ? GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL : GHOSTTY_OSC_COMMAND_COLOR_OPERATION; + Counter c = counter(0, SIZE_MAX); + GhosttyAllocator a = {.ctx = &c, .vtable = &VT}; + GhosttyOscParser p; + assert(ghostty_osc_new(&a, &p) == GHOSTTY_SUCCESS); + size_t base = c.allocs; + feed(p, osc, strlen(osc)); + assert(ghostty_osc_command_type(ghostty_osc_end(p, 0x07)) == type); + assert(c.allocs > base); // the list grew via the caller's allocator + + ghostty_osc_reset(p); + c.fail_after = c.allocs; // refuse any further allocation + feed(p, osc, strlen(osc)); + assert(ghostty_osc_command_type(ghostty_osc_end(p, 0x07)) == GHOSTTY_OSC_COMMAND_INVALID); + ghostty_osc_free(p); + assert(c.live == 0); + assert(c.allocs == c.frees); + } + } + puts("ok"); return 0; } diff --git a/crates/ghostty-vt/build.rs b/crates/ghostty-vt/build.rs new file mode 100644 index 0000000..eaec66b --- /dev/null +++ b/crates/ghostty-vt/build.rs @@ -0,0 +1,37 @@ +//! Generates the sorted, lowercase X11 color table (`x11_table.rs`) from +//! `src/res/rgb.txt`, so name lookups need neither a lazily built map nor a +//! lowercased copy of the query (Zig builds the same table at comptime, so +//! looking up a color never allocates). + +use std::{collections::HashSet, env, fmt::Write as _, fs, path::PathBuf}; + +fn main() { + println!("cargo:rerun-if-changed=src/res/rgb.txt"); + println!("cargo:rerun-if-changed=build.rs"); + + let data = fs::read_to_string("src/res/rgb.txt").expect("read rgb.txt"); + let mut seen = HashSet::new(); + let mut entries: Vec<(String, u8, u8, u8)> = Vec::new(); + for line in data.split('\n').filter(|l| !l.is_empty()) { + let comp = |r: std::ops::Range| -> u8 { + line[r].trim_matches(' ').parse().expect("bad rgb.txt") + }; + let (r, g, b) = (comp(0..3), comp(4..7), comp(8..11)); + let name = line[12..] + .trim_matches(|c| c == ' ' || c == '\t' || c == '\n') + .to_ascii_lowercase(); + // The first spelling of a (case-insensitive) name wins. + if seen.insert(name.clone()) { + entries.push((name, r, g, b)); + } + } + entries.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes())); + + let mut out = String::from("&[\n"); + for (name, r, g, b) in &entries { + writeln!(out, " ({name:?}, {r}, {g}, {b}),").expect("write"); + } + out.push_str("]\n"); + let dest = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")).join("x11_table.rs"); + fs::write(dest, out).expect("write x11_table.rs"); +} diff --git a/crates/ghostty-vt/src/kitty/color.rs b/crates/ghostty-vt/src/kitty/color.rs index f0916d1..e1a74f6 100644 --- a/crates/ghostty-vt/src/kitty/color.rs +++ b/crates/ghostty-vt/src/kitty/color.rs @@ -1,21 +1,88 @@ //! Kitty color protocol (ZIG/src/terminal/kitty/color.zig). -use std::fmt; +use std::{borrow::Cow, fmt}; use crate::{color::Rgb, osc::Terminator}; /// OSC 21 payload. +/// +/// The request list is stored encoded (fixed size records) in the OSC +/// parser's own storage, so parsing it never touches the global allocator +/// when the parser was given a caller-provided allocator (C API). #[derive(Debug, Clone, PartialEq, Default)] -pub struct OSC { +pub struct OSC<'a> { /// list of requests - pub list: Vec, + pub list: List<'a>, /// We must reply with the same string terminator (ST) as used in the /// request. pub terminator: Terminator, } -#[derive(Debug, Clone, PartialEq, Eq)] +impl OSC<'_> { + /// Detach from the parser storage. + #[must_use] + pub fn into_owned(self) -> OSC<'static> { + OSC { + list: self.list.into_owned(), + terminator: self.terminator, + } + } +} + +/// Size of one encoded [`Request`] record. +pub const RECORD_LEN: usize = 8; + +/// A list of kitty color [`Request`]s: borrowed (or owned) encoded records. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct List<'a>(Cow<'a, [u8]>); + +impl<'a> List<'a> { + /// View records encoded with [`Request::encode`] (`bytes.len()` must be a + /// multiple of [`RECORD_LEN`]). + #[must_use] + pub fn from_encoded(bytes: &'a [u8]) -> List<'a> { + debug_assert_eq!(bytes.len() % RECORD_LEN, 0); + List(Cow::Borrowed(bytes)) + } + + #[must_use] + pub fn len(&self) -> usize { + self.0.len() / RECORD_LEN + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + #[must_use] + pub fn get(&self, i: usize) -> Option { + Request::decode(self.0.get(i * RECORD_LEN..(i + 1) * RECORD_LEN)?) + } + + /// Iterate the requests (by value). + pub fn iter(&self) -> impl Iterator + '_ { + self.0 + .as_chunks::() + .0 + .iter() + .filter_map(|rec| Request::decode(rec)) + } + + /// Copy into a `Vec` (allocates; for tests and diagnostics). + #[must_use] + pub fn to_vec(&self) -> Vec { + self.iter().collect() + } + + #[must_use] + pub fn into_owned(self) -> List<'static> { + List(Cow::Owned(self.0.into_owned())) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Request { Query(Kind), Set { key: Kind, color: Rgb }, @@ -35,6 +102,26 @@ pub enum Special { } impl Special { + /// All specials in discriminant order. + const ALL: [Special; 8] = [ + Special::Foreground, + Special::Background, + Special::SelectionForeground, + Special::SelectionBackground, + Special::Cursor, + Special::CursorText, + Special::VisualBell, + Special::SecondTransparentBackground, + ]; + + fn to_u8(self) -> u8 { + Special::ALL.iter().position(|s| *s == self).unwrap_or(0) as u8 + } + + fn from_u8(v: u8) -> Option { + Special::ALL.get(usize::from(v)).copied() + } + /// The tag name (Zig `@tagName`). pub fn name(self) -> &'static str { match self { @@ -92,6 +179,46 @@ impl Kind { } } +impl Request { + /// Encode as a fixed size record: `[tag, kind class, kind value, r, g, b, + /// 0, 0]`. + #[must_use] + pub fn encode(&self) -> [u8; RECORD_LEN] { + let (tag, key, color) = match *self { + Request::Query(k) => (0, k, Rgb::default()), + Request::Set { key, color } => (1, key, color), + Request::Reset(k) => (2, k, Rgb::default()), + }; + let (class, value) = match key { + Kind::Palette(p) => (0, p), + Kind::Special(s) => (1, s.to_u8()), + }; + [tag, class, value, color.r, color.g, color.b, 0, 0] + } + + /// Inverse of [`Request::encode`]. + #[must_use] + pub fn decode(rec: &[u8]) -> Option { + let [tag, class, value, r, g, b, ..] = *rec else { + return None; + }; + let key = match class { + 0 => Kind::Palette(value), + 1 => Kind::Special(Special::from_u8(value)?), + _ => return None, + }; + Some(match tag { + 0 => Request::Query(key), + 1 => Request::Set { + key, + color: Rgb { r, g, b }, + }, + 2 => Request::Reset(key), + _ => return None, + }) + } +} + impl fmt::Display for Kind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -106,6 +233,33 @@ mod tests { #![allow(clippy::unwrap_used)] use super::*; + #[test] + fn request_records_round_trip() { + let mut reqs = vec![]; + for s in Special::ALL { + let k = Kind::Special(s); + reqs.push(Request::Query(k)); + reqs.push(Request::Reset(k)); + reqs.push(Request::Set { + key: k, + color: Rgb { r: 1, g: 2, b: 3 }, + }); + } + reqs.push(Request::Set { + key: Kind::Palette(255), + color: Rgb { r: 255, g: 0, b: 9 }, + }); + reqs.push(Request::Query(Kind::Palette(0))); + let bytes: Vec = reqs.iter().flat_map(Request::encode).collect(); + let list = List::from_encoded(&bytes); + assert_eq!(list.len(), reqs.len()); + assert_eq!(list.to_vec(), reqs); + assert_eq!(list.get(reqs.len()), None); + assert_eq!(list.into_owned().to_vec(), reqs); + assert_eq!(Request::decode(&[9, 0, 0, 0, 0, 0, 0, 0]), None); + assert_eq!(Request::decode(&[0, 1, 200, 0, 0, 0, 0, 0]), None); + } + #[test] fn osc_kitty_color_protocol_kind_string() { assert_eq!("foreground", Kind::Special(Special::Foreground).to_string()); diff --git a/crates/ghostty-vt/src/osc/mod.rs b/crates/ghostty-vt/src/osc/mod.rs index 5c026a9..4c941cb 100644 --- a/crates/ghostty-vt/src/osc/mod.rs +++ b/crates/ghostty-vt/src/osc/mod.rs @@ -82,13 +82,13 @@ pub enum Command<'a> { /// 4, 5, 10-19, 104, 105, 110-119 ColorOperation { op: color::Operation, - requests: color::List, + requests: color::List<'a>, terminator: Terminator, }, /// Kitty color protocol, OSC 21 /// https://sw.kovidgoyal.net/kitty/color-stack/#id1 - KittyColorProtocol(kitty_color::OSC), + KittyColorProtocol(kitty_color::OSC<'a>), /// Show a desktop notification (OSC 9 or OSC 777) ShowDesktopNotification { @@ -228,10 +228,10 @@ impl Command<'_> { terminator, } => Command::ColorOperation { op, - requests, + requests: requests.into_owned(), terminator, }, - Command::KittyColorProtocol(v) => Command::KittyColorProtocol(v), + Command::KittyColorProtocol(v) => Command::KittyColorProtocol(v.into_owned()), Command::ShowDesktopNotification { title, body } => Command::ShowDesktopNotification { title: own(title), body: own(body), @@ -486,6 +486,10 @@ pub struct Parser> { /// Buffer for temporary storage of OSC data buffer: S, + /// Auxiliary storage for the encoded request lists of the color OSCs + /// (Zig: a `SegmentedList` on the parser's allocator). Same allocator as + /// `buffer`. + aux: S, writer: Writer, } @@ -501,6 +505,7 @@ impl Parser { alloc, state: State::Start, buffer: Vec::new(), + aux: Vec::new(), writer: Writer::None, } } @@ -512,11 +517,16 @@ impl Parser { /// Like [`Parser::init`] with an allocator-backed payload buffer /// (`alloc` is true: an allocator is available). - pub fn with_storage(storage: S) -> Parser { + /// + /// `aux` holds the request lists of the color OSCs; give it the same + /// allocator as `storage` (a capacity of a few [`color::RECORD_LEN`] + /// records avoids allocating for common color commands). + pub fn with_storage(storage: S, aux: S) -> Parser { Parser { alloc: true, state: State::Start, buffer: storage, + aux, writer: Writer::None, } } @@ -525,6 +535,7 @@ impl Parser { pub fn reset(&mut self) { self.state = State::Start; self.buffer.reset(); + self.aux.reset(); self.writer = Writer::None; } @@ -608,6 +619,18 @@ impl Parser { self.buffer.bytes() } + /// The payload and the auxiliary request storage at once (disjoint + /// borrows, so a color parser can read the former while filling the + /// latter). + pub(crate) fn payload_and_aux(&mut self) -> (&[u8], &mut S) { + (self.buffer.bytes(), &mut self.aux) + } + + /// The encoded records written to the auxiliary storage. + pub(crate) fn aux_bytes(&self) -> &[u8] { + self.aux.bytes() + } + /// Whether any payload writer has been set up (Zig `parser.writer != null`). pub fn has_writer(&self) -> bool { self.writer != Writer::None @@ -977,10 +1000,16 @@ mod next_slice_tests { #[test] fn storage_failure_invalidates_osc() { - let mut p = Parser::with_storage(Limited { - data: Vec::new(), - cap: 8, - }); + let mut p = Parser::with_storage( + Limited { + data: Vec::new(), + cap: 8, + }, + Limited { + data: Vec::new(), + cap: 64, + }, + ); for b in b"0;abcdefghijkl" { p.next(*b); } diff --git a/crates/ghostty-vt/src/osc/parsers/color.rs b/crates/ghostty-vt/src/osc/parsers/color.rs index 018ae5a..68d2e1e 100644 --- a/crates/ghostty-vt/src/osc/parsers/color.rs +++ b/crates/ghostty-vt/src/osc/parsers/color.rs @@ -1,6 +1,8 @@ //! OSC color operations: 4, 5, 10-19, 104, 105, 110-119 //! (ZIG/src/terminal/osc/parsers/color.zig). +use std::borrow::Cow; + use crate::{ color::{Dynamic as DynamicColor, Rgb, Special as SpecialColor}, osc::{ByteStorage, Command, Parser, State, Terminator}, @@ -49,11 +51,7 @@ pub fn parse( if parser.has_writer() { parser.buffered()?; } - let data: &[u8] = if parser.has_writer() { - parser.payload() - } else { - &[] - }; + let has_writer = parser.has_writer(); // Check and make sure that we're parsing the correct OSCs let op = match parser.state { State::S4 => Operation::Osc4, @@ -84,13 +82,60 @@ pub fn parse( return None; } }; + // The request list is built in the parser's own (allocator-backed) + // auxiliary storage, not on the global allocator. + let (payload, aux) = parser.payload_and_aux(); + let data: &[u8] = if has_writer { payload } else { &[] }; + aux.reset(); + let mut sink = StorageSink { aux, failed: false }; + parse_color_into(op, data, &mut sink); + if sink.failed { + // Allocation failure: the OSC cannot be represented. + log::warn!("unable to allocate the color request list"); + parser.state = State::Invalid; + return None; + } Some(Command::ColorOperation { op, - requests: parse_color(op, data), + requests: List::from_encoded(parser.aux_bytes()), terminator: Terminator::init(terminator_ch), }) } +/// Where parsed requests go: a `Vec` (tests, [`parse_color`]) or the +/// parser's storage ([`StorageSink`]). +pub trait Sink { + fn push(&mut self, request: Request); + fn is_empty(&self) -> bool; +} + +impl Sink for Vec { + fn push(&mut self, request: Request) { + Vec::push(self, request); + } + fn is_empty(&self) -> bool { + Vec::is_empty(self) + } +} + +/// Appends encoded request records to a [`ByteStorage`]. An allocation +/// failure is remembered in `failed` (later pushes are dropped). +pub struct StorageSink<'s, S: ByteStorage> { + aux: &'s mut S, + failed: bool, +} + +impl Sink for StorageSink<'_, S> { + fn push(&mut self, request: Request) { + if !self.failed && self.aux.try_extend(&request.encode()).is_err() { + self.failed = true; + } + } + fn is_empty(&self) -> bool { + self.aux.is_empty() + } +} + /// Zig `std.mem.tokenizeScalar(u8, buf, ';')`: skips empty tokens. fn tokenize(buf: &[u8]) -> impl Iterator { buf.split(|&c| c == b';').filter(|t| !t.is_empty()) @@ -132,33 +177,88 @@ fn parse_u9(s: &[u8]) -> Option { /// request) but grants us an easier to understand and testable implementation. /// /// If color changing ends up being a bottleneck we can optimize this later. -pub fn parse_color(op: Operation, buf: &[u8]) -> List { +pub fn parse_color(op: Operation, buf: &[u8]) -> Vec { + let mut out = Vec::new(); + parse_color_into(op, buf, &mut out); + out +} + +/// [`parse_color`] into any [`Sink`]. +pub fn parse_color_into(op: Operation, buf: &[u8], out: &mut impl Sink) { let mut it = tokenize(buf); match op { - Operation::Osc4 => parse_get_set_ansi_color(Operation::Osc4, &mut it), - Operation::Osc5 => parse_get_set_ansi_color(Operation::Osc5, &mut it), - Operation::Osc104 => parse_reset_ansi_color(Operation::Osc104, &mut it), - Operation::Osc105 => parse_reset_ansi_color(Operation::Osc105, &mut it), - Operation::Osc10 => parse_get_set_dynamic_color(DynamicColor::Foreground, &mut it), - Operation::Osc11 => parse_get_set_dynamic_color(DynamicColor::Background, &mut it), - Operation::Osc12 => parse_get_set_dynamic_color(DynamicColor::Cursor, &mut it), - Operation::Osc13 => parse_get_set_dynamic_color(DynamicColor::PointerForeground, &mut it), - Operation::Osc14 => parse_get_set_dynamic_color(DynamicColor::PointerBackground, &mut it), - Operation::Osc15 => parse_get_set_dynamic_color(DynamicColor::TektronixForeground, &mut it), - Operation::Osc16 => parse_get_set_dynamic_color(DynamicColor::TektronixBackground, &mut it), - Operation::Osc17 => parse_get_set_dynamic_color(DynamicColor::HighlightBackground, &mut it), - Operation::Osc18 => parse_get_set_dynamic_color(DynamicColor::TektronixCursor, &mut it), - Operation::Osc19 => parse_get_set_dynamic_color(DynamicColor::HighlightForeground, &mut it), - Operation::Osc110 => parse_reset_dynamic_color(DynamicColor::Foreground, &mut it), - Operation::Osc111 => parse_reset_dynamic_color(DynamicColor::Background, &mut it), - Operation::Osc112 => parse_reset_dynamic_color(DynamicColor::Cursor, &mut it), - Operation::Osc113 => parse_reset_dynamic_color(DynamicColor::PointerForeground, &mut it), - Operation::Osc114 => parse_reset_dynamic_color(DynamicColor::PointerBackground, &mut it), - Operation::Osc115 => parse_reset_dynamic_color(DynamicColor::TektronixForeground, &mut it), - Operation::Osc116 => parse_reset_dynamic_color(DynamicColor::TektronixBackground, &mut it), - Operation::Osc117 => parse_reset_dynamic_color(DynamicColor::HighlightBackground, &mut it), - Operation::Osc118 => parse_reset_dynamic_color(DynamicColor::TektronixCursor, &mut it), - Operation::Osc119 => parse_reset_dynamic_color(DynamicColor::HighlightForeground, &mut it), + Operation::Osc4 => { + parse_get_set_ansi_color(Operation::Osc4, &mut it, out); + } + Operation::Osc5 => { + parse_get_set_ansi_color(Operation::Osc5, &mut it, out); + } + Operation::Osc104 => { + parse_reset_ansi_color(Operation::Osc104, &mut it, out); + } + Operation::Osc105 => { + parse_reset_ansi_color(Operation::Osc105, &mut it, out); + } + Operation::Osc10 => { + parse_get_set_dynamic_color(DynamicColor::Foreground, &mut it, out); + } + Operation::Osc11 => { + parse_get_set_dynamic_color(DynamicColor::Background, &mut it, out); + } + Operation::Osc12 => { + parse_get_set_dynamic_color(DynamicColor::Cursor, &mut it, out); + } + Operation::Osc13 => { + parse_get_set_dynamic_color(DynamicColor::PointerForeground, &mut it, out); + } + Operation::Osc14 => { + parse_get_set_dynamic_color(DynamicColor::PointerBackground, &mut it, out); + } + Operation::Osc15 => { + parse_get_set_dynamic_color(DynamicColor::TektronixForeground, &mut it, out); + } + Operation::Osc16 => { + parse_get_set_dynamic_color(DynamicColor::TektronixBackground, &mut it, out); + } + Operation::Osc17 => { + parse_get_set_dynamic_color(DynamicColor::HighlightBackground, &mut it, out); + } + Operation::Osc18 => { + parse_get_set_dynamic_color(DynamicColor::TektronixCursor, &mut it, out); + } + Operation::Osc19 => { + parse_get_set_dynamic_color(DynamicColor::HighlightForeground, &mut it, out); + } + Operation::Osc110 => { + parse_reset_dynamic_color(DynamicColor::Foreground, &mut it, out); + } + Operation::Osc111 => { + parse_reset_dynamic_color(DynamicColor::Background, &mut it, out); + } + Operation::Osc112 => { + parse_reset_dynamic_color(DynamicColor::Cursor, &mut it, out); + } + Operation::Osc113 => { + parse_reset_dynamic_color(DynamicColor::PointerForeground, &mut it, out); + } + Operation::Osc114 => { + parse_reset_dynamic_color(DynamicColor::PointerBackground, &mut it, out); + } + Operation::Osc115 => { + parse_reset_dynamic_color(DynamicColor::TektronixForeground, &mut it, out); + } + Operation::Osc116 => { + parse_reset_dynamic_color(DynamicColor::TektronixBackground, &mut it, out); + } + Operation::Osc117 => { + parse_reset_dynamic_color(DynamicColor::HighlightBackground, &mut it, out); + } + Operation::Osc118 => { + parse_reset_dynamic_color(DynamicColor::TektronixCursor, &mut it, out); + } + Operation::Osc119 => { + parse_reset_dynamic_color(DynamicColor::HighlightForeground, &mut it, out); + } } } @@ -179,29 +279,32 @@ fn ansi_target(color: u16, special_only: bool) -> Option { } /// OSC 4/5 -fn parse_get_set_ansi_color<'a>(op: Operation, it: &mut impl Iterator) -> List { +fn parse_get_set_ansi_color<'a>( + op: Operation, + it: &mut impl Iterator, + result: &mut impl Sink, +) { // Note: in ANY error scenario below we return the accumulated results. // This matches the xterm behavior (see misc.c ChangeAnsiColorRequest) - let mut result = List::new(); loop { // We expect a `c; spec` pair. If either doesn't exist then // we return the results up to this point. let Some(color_str) = it.next() else { - return result; + return; }; let Some(spec_str) = it.next() else { - return result; + return; }; // Color must be numeric. u9 because that'll fit our palette + special let Some(color) = parse_u9(color_str) else { - return result; + return; }; // Parse the color. let Some(target) = ansi_target(color, op == Operation::Osc5) else { - return result; + return; }; // "?" always results in a query. @@ -211,20 +314,23 @@ fn parse_get_set_ansi_color<'a>(op: Operation, it: &mut impl Iterator(op: Operation, it: &mut impl Iterator) -> List { +fn parse_reset_ansi_color<'a>( + op: Operation, + it: &mut impl Iterator, + result: &mut impl Sink, +) { // Note: xterm stops parsing the reset list on any error, but we're // more flexible and try the next value. This matches the behavior of // Kitty and I don't see a downside to being more flexible here. Hopefully // no one depends on the exact behavior of xterm. - let mut result = List::new(); loop { let Some(color_str) = it.next() else { // If no parameters are given, we reset the full table. @@ -235,7 +341,7 @@ fn parse_reset_ansi_color<'a>(op: Operation, it: &mut impl Iterator unreachable!(), }); } - return result; + return; }; // Empty color strings are ignored, not treated as an error. @@ -261,22 +367,22 @@ fn parse_reset_ansi_color<'a>(op: Operation, it: &mut impl Iterator( start: DynamicColor, it: &mut impl Iterator, -) -> List { + result: &mut impl Sink, +) { // Note: in ANY error scenario below we return the accumulated results. // This matches the xterm behavior (see misc.c ChangeColorsRequest) - let mut result = List::new(); let mut color = start; loop { let Some(spec_str) = it.next() else { - return result; + return; }; if spec_str == b"?" { result.push(Request::Query(Target::Dynamic(color))); } else { let Ok(rgb) = Rgb::parse_bytes(spec_str) else { - return result; + return; }; result.push(Request::Set(ColoredTarget { target: Target::Dynamic(color), @@ -287,7 +393,7 @@ fn parse_get_set_dynamic_color<'a>( // Each successive value uses the next color so long as it exists. match color.next() { Some(n) => color = n, - None => return result, + None => return, } } } @@ -296,18 +402,117 @@ fn parse_get_set_dynamic_color<'a>( fn parse_reset_dynamic_color<'a>( color: DynamicColor, it: &mut impl Iterator, -) -> List { - let mut result = List::new(); + result: &mut impl Sink, +) { if it.next().is_some() { - return result; + return; } result.push(Request::Reset(Target::Dynamic(color))); - result } -/// The list of requests in a color OSC. (Zig uses a SegmentedList to -/// avoid copying; a `Vec` is equivalent.) -pub type List = Vec; +/// Size of one encoded [`Request`] record. +pub const RECORD_LEN: usize = 8; + +/// The list of requests in a color OSC. (Zig uses a SegmentedList on the +/// parser's allocator.) The requests are stored as fixed size encoded +/// records ([`Request::encode`]) in the parser's own storage, so with a +/// caller-provided allocator (C API) the list is allocated by that allocator +/// and borrowed by the command, like Zig's. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct List<'a>(Cow<'a, [u8]>); + +impl<'a> List<'a> { + /// View records encoded with [`Request::encode`]. + #[must_use] + pub fn from_encoded(bytes: &'a [u8]) -> List<'a> { + debug_assert_eq!(bytes.len() % RECORD_LEN, 0); + List(Cow::Borrowed(bytes)) + } + + #[must_use] + pub fn len(&self) -> usize { + self.0.len() / RECORD_LEN + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + #[must_use] + pub fn get(&self, i: usize) -> Option { + Request::decode(self.0.get(i * RECORD_LEN..(i + 1) * RECORD_LEN)?) + } + + /// Iterate the requests (by value). + pub fn iter(&self) -> impl Iterator + '_ { + self.0 + .as_chunks::() + .0 + .iter() + .filter_map(|rec| Request::decode(rec)) + } + + /// Copy into a `Vec` (allocates; for tests and diagnostics). + #[must_use] + pub fn to_vec(&self) -> Vec { + self.iter().collect() + } + + /// Detach from the parser storage. + #[must_use] + pub fn into_owned(self) -> List<'static> { + List(Cow::Owned(self.0.into_owned())) + } +} + +impl Request { + /// Encode as a fixed size record: `[tag, target class, target value, r, + /// g, b, 0, 0]`. + #[must_use] + pub fn encode(&self) -> [u8; RECORD_LEN] { + fn target(t: Target) -> (u8, u8) { + match t { + Target::Palette(p) => (0, p), + Target::Special(s) => (1, s as u8), + Target::Dynamic(d) => (2, d as u8), + } + } + let (tag, (class, value), c) = match *self { + Request::Set(ColoredTarget { target: t, color }) => (0, target(t), color), + Request::Query(t) => (1, target(t), Rgb::default()), + Request::Reset(t) => (2, target(t), Rgb::default()), + Request::ResetPalette => (3, (0, 0), Rgb::default()), + Request::ResetSpecial => (4, (0, 0), Rgb::default()), + }; + [tag, class, value, c.r, c.g, c.b, 0, 0] + } + + /// Inverse of [`Request::encode`]. + #[must_use] + pub fn decode(rec: &[u8]) -> Option { + let [tag, class, value, r, g, b, ..] = *rec else { + return None; + }; + let target = match class { + 0 => Target::Palette(value), + 1 => Target::Special(SpecialColor::from_u8(value)?), + 2 => Target::Dynamic(DynamicColor::from_u8(value)?), + _ => return None, + }; + Some(match tag { + 0 => Request::Set(ColoredTarget { + target, + color: Rgb { r, g, b }, + }), + 1 => Request::Query(target), + 2 => Request::Reset(target), + 3 => Request::ResetPalette, + 4 => Request::ResetSpecial, + _ => return None, + }) + } +} /// A single operation related to the terminal color palette. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -447,6 +652,32 @@ mod tests { } } + #[test] + fn request_records_round_trip() { + let mut reqs = vec![Request::ResetPalette, Request::ResetSpecial]; + for t in [ + Target::Palette(0), + Target::Palette(255), + Target::Special(SpecialColor::Italic), + Target::Dynamic(DynamicColor::HighlightForeground), + ] { + reqs.push(Request::Query(t)); + reqs.push(Request::Reset(t)); + reqs.push(Request::Set(ColoredTarget { + target: t, + color: Rgb { r: 9, g: 8, b: 7 }, + })); + } + let bytes: Vec = reqs.iter().flat_map(Request::encode).collect(); + let list = List::from_encoded(&bytes); + assert_eq!(list.len(), reqs.len()); + assert_eq!(list.to_vec(), reqs); + assert_eq!(list.iter().count(), reqs.len()); + assert_eq!(list.into_owned().to_vec(), reqs); + assert_eq!(Request::decode(&[7, 0, 0, 0, 0, 0, 0, 0]), None); + assert_eq!(Request::decode(&[1, 2, 99, 0, 0, 0, 0, 0]), None); + } + #[test] fn osc_4_multiple_requests() { let list = parse_color(Operation::Osc4, b"0;red;1;blue"); @@ -566,7 +797,7 @@ mod tests { assert_eq!(Operation::Osc112, op); assert_eq!( vec![Request::Reset(Target::Dynamic(DynamicColor::Cursor))], - requests + requests.to_vec() ); } } diff --git a/crates/ghostty-vt/src/osc/parsers/kitty_color.rs b/crates/ghostty-vt/src/osc/parsers/kitty_color.rs index 7b0af47..80471ec 100644 --- a/crates/ghostty-vt/src/osc/parsers/kitty_color.rs +++ b/crates/ghostty-vt/src/osc/parsers/kitty_color.rs @@ -2,7 +2,7 @@ use crate::{ color::Rgb, - kitty::color::{Kind, OSC, Request}, + kitty::color::{Kind, List, OSC, Request}, osc::{ByteStorage, Command, Parser, State, Terminator}, }; @@ -23,16 +23,18 @@ pub fn parse( } parser.buffered()?; - // The parse only reads the payload; failure marks the parser invalid - // after the borrow ends. - let data = parser.payload(); + // The parse reads the payload and appends encoded records to the + // parser's auxiliary storage (the caller's allocator in the C API); the + // command then borrows them. + let (data, aux) = parser.payload_and_aux(); + aux.reset(); let mut exceeded = false; - let mut osc = OSC { - list: Vec::new(), - terminator: Terminator::init(terminator_ch), + let mut out = Pending { + failed: false, + count: 0, }; for kv in data.split(|&c| c == b';') { - if osc.list.len() >= Kind::MAX * 2 { + if out.count >= Kind::MAX * 2 { log::warn!("exceeded limit for number of keys in kitty color protocol, ignoring"); exceeded = true; break; @@ -54,11 +56,11 @@ pub fn parse( }; let value = rest.trim_ascii_matching(b' '); if value.is_empty() { - osc.list.push(Request::Reset(key)); + out.push(aux, Request::Reset(key)); } else if value == b"?" { - osc.list.push(Request::Query(key)); + out.push(aux, Request::Query(key)); } else if let Ok(color) = Rgb::parse_bytes(value) { - osc.list.push(Request::Set { key, color }); + out.push(aux, Request::Set { key, color }); } else { log::warn!( "invalid color format in kitty color protocol: {}", @@ -66,11 +68,31 @@ pub fn parse( ); } } - if exceeded { + if exceeded || out.failed { parser.state = State::Invalid; return None; } - Some(Command::KittyColorProtocol(osc)) + Some(Command::KittyColorProtocol(OSC { + list: List::from_encoded(parser.aux_bytes()), + terminator: Terminator::init(terminator_ch), + })) +} + +/// Bookkeeping while appending records to the parser's storage. +struct Pending { + /// An allocation failed (the OSC cannot be represented). + failed: bool, + /// Number of requests so far (for the limit). + count: usize, +} + +impl Pending { + fn push(&mut self, aux: &mut S, req: Request) { + if !self.failed && aux.try_extend(&req.encode()).is_err() { + self.failed = true; + } + self.count += 1; + } } trait TrimMatching { @@ -123,7 +145,7 @@ mod tests { let Command::KittyColorProtocol(osc) = cmd else { panic!("wrong command"); }; - let list = osc.list; + let list = osc.list.to_vec(); assert_eq!(9, list.len()); assert_eq!(Request::Query(Kind::Special(Special::Foreground)), list[0]); assert_eq!( diff --git a/crates/ghostty-vt/src/parser.rs b/crates/ghostty-vt/src/parser.rs index 2a29f4e..25c6989 100644 --- a/crates/ghostty-vt/src/parser.rs +++ b/crates/ghostty-vt/src/parser.rs @@ -892,8 +892,10 @@ mod tests { assert_eq!(op, osc::color::Operation::Osc112); assert_eq!(requests.len(), 1); assert_eq!( - requests[0], - osc::color::Request::Reset(osc::color::Target::Dynamic(crate::color::Dynamic::Cursor)) + requests.get(0), + Some(osc::color::Request::Reset(osc::color::Target::Dynamic( + crate::color::Dynamic::Cursor + ))) ); } @@ -921,7 +923,7 @@ mod tests { assert_eq!(terminator, osc::Terminator::Bel); assert_eq!(op, osc::color::Operation::Osc104); assert_eq!(requests.len(), 1); - assert_eq!(requests[0], osc::color::Request::ResetPalette); + assert_eq!(requests.get(0), Some(osc::color::Request::ResetPalette)); } #[test] diff --git a/crates/ghostty-vt/src/stream.rs b/crates/ghostty-vt/src/stream.rs index 780da95..6568240 100644 --- a/crates/ghostty-vt/src/stream.rs +++ b/crates/ghostty-vt/src/stream.rs @@ -151,7 +151,7 @@ define_actions! { MouseShape(MouseShape), ConfigureCharset(ConfigureCharset), SetAttribute(sgr::Attribute<'a>), - KittyColorReport(&'a kitty::color::OSC), + KittyColorReport(&'a kitty::color::OSC<'a>), ColorOperation(ColorOperation<'a>), SemanticPrompt(&'a osc::SemanticPrompt<'a>), } @@ -248,7 +248,7 @@ pub struct ConfigureCharset { #[derive(Debug, Clone, Copy, PartialEq)] pub struct ColorOperation<'a> { pub op: osc::color::Operation, - pub requests: &'a osc::color::List, + pub requests: &'a osc::color::List<'a>, pub terminator: osc::Terminator, } diff --git a/crates/ghostty-vt/src/x11_color.rs b/crates/ghostty-vt/src/x11_color.rs index e53048c..8a6a64e 100644 --- a/crates/ghostty-vt/src/x11_color.rs +++ b/crates/ghostty-vt/src/x11_color.rs @@ -1,6 +1,6 @@ //! X11 color names (ZIG/src/terminal/x11_color.zig). -use std::{collections::HashMap, sync::OnceLock}; +use std::sync::OnceLock; use crate::color::Rgb; @@ -23,17 +23,15 @@ fn parse_entries() -> impl Iterator { }) } -/// The map of all available X11 colors. Keys are lowercase ASCII; lookups -/// are ASCII case-insensitive (see [`get`]). -fn map() -> &'static HashMap { - static MAP: OnceLock> = OnceLock::new(); - MAP.get_or_init(|| { - let mut m = HashMap::new(); - for (name, rgb) in parse_entries() { - m.entry(name.to_ascii_lowercase()).or_insert(rgb); - } - m - }) +/// `(lowercase name, r, g, b)` sorted by name (generated by `build.rs` from +/// `res/rgb.txt`; the first spelling of a name wins). +static TABLE: &[(&str, u8, u8, u8)] = include!(concat!(env!("OUT_DIR"), "/x11_table.rs")); + +/// ASCII case-insensitive comparison of `query` against an already lowercase +/// `key`, without allocating. +fn cmp_lowercase(query: &[u8], key: &[u8]) -> std::cmp::Ordering { + let q = query.iter().map(u8::to_ascii_lowercase); + q.cmp(key.iter().copied()) } /// All X11 color names with their original spelling (e.g. `AliceBlue`, @@ -49,14 +47,18 @@ pub fn names() -> &'static [(&'static str, Rgb)] { }) } -/// Look up an X11 color by name (ASCII case-insensitive). +/// Look up an X11 color by name (ASCII case-insensitive). Never allocates. pub fn get(name: &str) -> Option { - map().get(&name.to_ascii_lowercase()).copied() + get_bytes(name.as_bytes()) } /// Byte-slice variant of [`get`]. pub fn get_bytes(name: &[u8]) -> Option { - get(std::str::from_utf8(name).ok()?) + let i = TABLE + .binary_search_by(|(key, ..)| cmp_lowercase(name, key.as_bytes()).reverse()) + .ok()?; + let (_, r, g, b) = TABLE[i]; + Some(Rgb { r, g, b }) } #[cfg(test)] diff --git a/docs/rewrite/notes/final-Z7.md b/docs/rewrite/notes/final-Z7.md new file mode 100644 index 0000000..dc0158b --- /dev/null +++ b/docs/rewrite/notes/final-Z7.md @@ -0,0 +1,118 @@ +# Z7 final pass: typed OS strings + RV5 findings + +Owner: Z7. Fixes the RV5 rows (`review-rv5-codex.md`) structurally: escaped OS +strings can no longer reach an OS boundary undecoded, and plain text can no +longer be decoded by accident. + +## Structural change: `ghostty_os::osbytes::EncodedOs` + +`EncodedOs` is a newtype over the reversible `U+FDD0` escape encoding (Z1/Z5). +It is deliberately **not** a `str`, not `Display`, and not `AsRef`: + +* Producing: `from_os`, `from_path`, `from_bytes`, `from_wide` (Windows WTF-16), + `from_plain_text` (escapes a literal `U+FDD0`, i.e. text is never decoded), + `from_encoded` (adopt a CLI/config string that is *already* encoded; the only + "trust me" constructor). +* Consuming (explicit decode at the boundary): `to_os`, `to_path`, `to_bytes`, + `to_wide`, `to_wide_nul`. `as_encoded` is for config files/`+show-config` + output (unchanged format). + +Typed APIs (compiler-enforced): + +| Area | Before | After | +|---|---|---| +| `os::env::getenv[_not_empty]`, `homedir::home`, `expand_home[_with_capacity]`, `xdg::{config,cache,state}` (+`Options.home`), `path::expand`, `passwd::Entry` fields, `ResourcesDir` fields/`app()`/`host()` | `String` | `EncodedOs` | +| `os::open::open(kind, url)` | `&str` (always decoded) | `&OsStr`: callers decide; `apprt::action::OpenUrl.url` and `Pwd.pwd` are `EncodedOs` | +| `os::i18n::init` | `&str` | `&Path` | +| `os::flatpak::FlatpakHostCommand.cwd`, termio `Command.cwd`, termio/exec `Config.{working_directory,resources_dir}`, `shell_integration::setup*` resource dir, `darwin_bundle_env` | `String` | `EncodedOs` | +| `config::Path` (Optional/Required), `WorkingDirectory::Path`, `file_load::*`, `edit::open_path`, `theme::{open,Location,LocationIterator,OpenedTheme}`, `Config::{load_file,load_optional_file}`, `ReplayStep::Expand` | `String` | `EncodedOs` | +| `Surface::pwd()`, `apprt::surface::new_config(prev_pwd)`, GTK surface/window/app `working_directory` overrides, cli `ThemeListElement.path`, list-themes `Effect::Copy` | `String` | `EncodedOs` | +| renderer `PathItem` (custom shaders) | `String` | `PathBuf` (decoded once in `DerivedConfig`) | +| `RuntimeSurface::set_clipboard` contents | `(String, String)` | `(String, Vec)` (Zig `[:0]const u8`) | +| crash `Report.name`, bench `Args` values/data path | `String` | `OsString`/`PathBuf` | + +Config file output/format is byte-identical (`as_encoded`); no config key +changed its syntax. + +Boundaries that still hold encoded `String`s on purpose (each decodes at its +own boundary): `Command`/argv items (`config::Command`, termio `Command.args`, +`exec_command`) and `RepeatableStringMap` env pairs (decoded in +`Subprocess::new`/`prepare`); the GObject `pwd` property of the GTK surface +(a GLib string, converted in `get_pwd`/`set_pwd`); `CValue::{String,Path}` (now +decoded by `config_capi::cstring_value` when handed to C, previously the +escapes leaked to the embedder); `macos_custom_icon` (a string field, goes +through that C decode). + +## RV5 findings -> fixes + +| Finding | Fix | Test | +|---|---|---| +| Surface copy (`binding.rs:635`) | `write_screen_temp_file` returns a `PathBuf`; copy/paste send `os_bytes(path)` (clipboard contents are now bytes), open sends `EncodedOs::from_path` | `screen_temp_file_keeps_non_utf8_path_bytes` (TMPDIR non-UTF-8) | +| open.rs regression (`open.rs:33`, `mouse.rs:1663`) | `open` takes `&OsStr`; regex links use `open_link_target` -> `EncodedOs::from_plain_text` (never decoded); resolved relative paths use `from_path`; OSC 8 URIs `from_bytes(raw)` | `open_link_plain_text_is_never_decoded`, `open_link_resolves_against_non_utf8_pwd`, `osc8_uri_bytes_are_carried_unchanged`, `os::open::encoded_paths_decode_but_plain_urls_do_not` | +| Windows WTF-8 / lone surrogates in osbytes | `os_bytes`/`os_from_bytes` on Windows use `encode_wide`/`from_wide` through portable `wide_to_wtf8`/`wtf8_to_wide` (lone surrogates are 3-byte WTF-8, escaped by the encoding); `EncodedOs::{from_wide,to_wide}` | `wtf8_round_trips_unpaired_surrogates`, `wtf8_invalid_bytes_become_replacement`, `encoded_os_wide_round_trips_lone_surrogates` (platform independent) | +| Windows termio `GHOSTTY_RESOURCES_DIR`/`TERMINFO` (`exec_windows.rs:606,618`) | decoded with `to_os()`; `terminfo_dir` builds the OS string (no lossy) | `terminfo_dir_is_sibling_of_resources_and_lossless` (cfg(windows), compiled by `check-windows`) | +| Windows exe dir / PATH (`exec_windows.rs:855`), `%WINDIR%`, `%HOMEDRIVE%%HOMEPATH%`, cwd, argv | exe dir kept as `OsString`; `home_windows` concatenates `OsStr`s; `CreateProcessW` gets `to_wide_nul` for path/cwd/args via new `windows_create_command_line_wide` | `wide_command_line_matches_str_version_and_keeps_lone_surrogates`, `home_windows_joins_drive_and_path` | +| OSC color request lists allocate globally (`color.rs:232`, `kitty_color.rs:57`, `capi/osc.rs:144`) | request lists are stored as fixed 8-byte records in a second parser storage (`Parser.aux`, same allocator as the payload); `Command::{ColorOperation,KittyColorProtocol}` borrow them (`color::List<'a>`, `kitty::color::List<'a>`, `into_owned` for the stream). C parser: 4 inline records (Zig `SegmentedList(Request, 4)`), longer lists grow through the caller's allocator, failure -> invalid command. X11 name lookup no longer allocates (build.rs generates a sorted lowercase table; previously a lowercased `String` + lazy global map) | Rust `end_makes_no_global_allocations` extended with OSC 4/5/10/11/104/112/21; `long_color_lists_grow_via_the_caller_allocator`; C `alloc_test.c` section 6 (frozen allocator, color commands) and new section 7 (growth + refusal) | +| macOS Mach handler hang (`mach_handler.rs:289,305`) | `SuspendGuard` (platform independent, `suspend.rs`) resumes every suspended thread, the faulting one included, before ports are restored and `KERN_FAILURE` is replied (Breakpad behaviour) | `suspend::tests::*` (mock threads, incl. panic path) | +| macOS SIGABRT minidump (`signal.rs:215`) | Breakpad design: SIGABRT handler sends an abort request (`exception::ABORT_MSG_ID`) to the exception-port thread and waits on a reply port (bounded); the thread dumps with `EXC_SOFTWARE/EXC_SOFT_SIGNAL(SIGABRT)` and replies. Falls back to the event-only report if the port is unavailable | `exception::tests::{abort_request_round_trips,sigabrt_is_described_as_a_soft_signal}`; compile via `just check-macos` (both targets). Not run on real macOS. | +| 1 Open config (`edit.rs`) | `open_path` returns `EncodedOs`; candidates and create-dir/file use decoded paths | `open_path_uses_decoded_non_utf8_xdg_dir` | +| 2 Background image (`generic.rs:1099`) | `load_background_image(&Path)` from `path().to_path()` | `background_image_loads_from_non_utf8_config_path` | +| 3 Crash dir (`dir.rs:22`) | `default_dir` uses `to_path()`; sentry `State.dir` stores `os_bytes` and decodes with `os_from_bytes` (was lossy on non-Unix) | `default_dir_decodes_non_utf8_xdg_state`, `non_utf8_report_name_is_kept` | +| 4 SSH cache (`disk_cache.rs:62`) | `to_path()` | `default_path_decodes_non_utf8_xdg_state` | +| 5 Exe dir / PATH (`exec.rs:1191`) | `OsString` end to end (`add_ghostty_path_for`) | `ghostty_path_keeps_non_utf8_exe_dir_bytes` | +| 6 Fontconfig filenames (`fontconfig.rs:226`) | `Pattern::get_os_string`/`add_string_bytes`; `Face::init_file(impl AsRef)` | `file_name_keeps_non_utf8_bytes` | +| 7 File links (`mouse.rs:405,1614`) | see open.rs row; `osc8_uri` returns raw bytes; `copy_url_to_clipboard` writes bytes | as above | +| 8 Kitty TMPDIR (`graphics_image.rs:591`) | `OsString` temp dir compared by bytes (`is_path_in_temp_dir_with`) | `temp_dir_check_keeps_non_utf8_tmpdir_bytes` | +| 9 `.hushlogin` (`exec.rs:1442`) | `passwd.home.to_path()` | `exec_command_darwin_hushlogin_with_non_utf8_home` | +| 10 Benchmarks (`benchmark.rs:65`, `args.rs:82`) | `Args` values are `OsString`, `--data` is a `PathBuf`; C args are split on raw bytes | `data_path_keeps_non_utf8_bytes`, `split_args_keeps_non_utf8_bytes` | + +Other bugs found by the type change (not in RV5): GTK `gtk-custom-css` and +bell-audio paths used the escaped string as a path; `HISTFILE` for bash +integration was built from the escaped home; OSC 52 clipboard writes were +lossily converted to UTF-8 (now bytes); `ghostty_config_get` leaked escapes to +C; `ghostty_config_open_path` returned escaped bytes. + +## Lossy-call audit (non-test code) + +Grep: `to_string_lossy|from_utf8_lossy|to_str().unwrap|Path::new(|PathBuf::from(` +over `crates/*/src` (non-test modules). Result: all OS-derived values are typed; +what remains is justified below. + +* **Display/log only** (never reaches a syscall): `os/open.rs` stderr of the + opener, `termio/shell_integration.rs` and `termio/exec.rs` log messages, + `terminal/kitty/graphics_image.rs` shm path log, `lib/embedded.rs` `wd_disp`, + `apprt-gtk/debug_input.rs`, `cli/crash_report.rs` (listing), inspector widgets + (`pwd`, hyperlink ids/uris), `core/surface/mouse.rs:1816` (link-preview + status text), `gtk class/command_palette.rs`, `class/surface.rs` clipboard + confirmation text (the clipboard itself gets the bytes). +* **Text, not OS data**: VT/OSC/tmux parsers (`ghostty-vt`, `terminal`), CLI + config parsing (`config/command.rs` splitter works on already-valid encoded + text), fonts' name/tag strings (`freetype.rs`, `feature.rs`, + `fontconfig.rs::get_string` documents UTF-8-only properties), shadertoy + source text, `terminfo/source.rs`, `build-data`, `datastruct`, `os/uri.rs` + `to_raw` (documented: host names; paths use `to_raw_bytes`), `os/kernel_info`, + `os/locale`, `os/hostname`, `os/desktop` (env values parsed as numbers/lists), + `lib/embedded.rs` key text/preedit/IME strings (C API text), `lib/inspector.rs`, + `lib/benchmark.rs:60` (action *name*, compared with the ASCII action table), + `ssh_cache/disk_cache.rs:280` (cache file of host names), `bench/args.rs` + messages and the action word. +* **Windows-only fallbacks**: `os/osbytes.rs` non-unix/non-windows branch + (wasm: UTF-8 only), `os/env.rs:31` case-insensitive key compare on Windows + (comparison only; lone-surrogate keys may collide, never rewritten), + `os/windows.rs:75` computer name (ANSI API). +* **D-Bus**: `os/flatpak.rs:152` env `a{ss}` values are UTF-8 by D-Bus type + (unchanged from Z5). cwd/argv use `ay` bytes. +* **Plain text paths**: `core/surface/mouse.rs:402,407` (`Path::new(text)` of + the selected link text; the pwd side is `os_from_bytes`), + `core/surface/mod.rs` screenshot helpers (developer aid, `var_os`), + `os/path.rs`, `os/macos.rs` (`PathBuf::from` of an already-OS value), + `os/resourcesdir.rs:152` (compile-time constant), `kitty/graphics_image.rs` + (`Path::new(OsStr::from_bytes(path))`: bytes from the protocol). +* Sprite `Path::new()` hits are the vector `Path` type, not `std::path`. + +Remaining known gaps: argv items of `config::Command`/termio `Command` and +`RepeatableStringMap` stay encoded `String`s (decoded at exec); Windows +`CreateProcessW` argv/path use WTF-16 via `EncodedOs::from_encoded` of those +items; macOS and Windows behaviour is compile-checked (`check-macos` for both +Apple targets, `check-windows`) plus platform independent unit tests, not run +natively.