diff --git a/crates/ghostty-apprt-gtk/examples/demo.rs b/crates/ghostty-apprt-gtk/examples/demo.rs index aae6fa9..5e453f5 100644 --- a/crates/ghostty-apprt-gtk/examples/demo.rs +++ b/crates/ghostty-apprt-gtk/examples/demo.rs @@ -203,7 +203,7 @@ impl CoreSurface for DemoSurface { fn complete_clipboard_request( &self, _req: ClipboardRequest, - data: &str, + data: &[u8], _confirmed: bool, ) -> Result<(), ClipboardError> { log::info!("demo: paste of {} bytes", data.len()); diff --git a/crates/ghostty-apprt-gtk/src/class/application.rs b/crates/ghostty-apprt-gtk/src/class/application.rs index 5a93169..d436a8f 100644 --- a/crates/ghostty-apprt-gtk/src/class/application.rs +++ b/crates/ghostty-apprt-gtk/src/class/application.rs @@ -87,11 +87,11 @@ pub struct WindowOverrides { /// Set an environment variable. #[allow(unsafe_code)] -fn set_env(key: &str, value: &str) { +fn set_env(key: &str, value: impl AsRef) { // SAFETY: only called during application construction on the main // thread, before GTK is initialized and before we spawn any child // processes; no other thread in this crate reads the environment. - unsafe { std::env::set_var(key, value) } + unsafe { std::env::set_var(key, value.as_ref()) } } /// Funnel GLib/GObject/GTK log messages into the `log` crate rather than @@ -284,7 +284,7 @@ mod imp { pub css: RefCell>, /// Copy of the LANG variable provided by the system, if any. - pub saved_language: RefCell>, + pub saved_language: RefCell>, } #[glib::object_subclass] @@ -398,9 +398,13 @@ impl Application { } }; - let saved_language = std::env::var("LANG").ok(); + // Keep the inherited bytes: LANG need not be UTF-8. + let saved_language = std::env::var_os("LANG"); if let Some(language) = &config.language { - set_env("LANG", language); + set_env( + "LANG", + ghostty_os::osbytes::EncodedOs::from_encoded(language.clone()).to_os(), + ); } // Set gettext global domain to be our app so that our unqualified @@ -560,7 +564,7 @@ impl Application { } /// The original `LANG` Ghostty was launched with (None if unset). - pub fn saved_language(&self) -> Option { + pub fn saved_language(&self) -> Option { self.imp().saved_language.borrow().clone() } diff --git a/crates/ghostty-apprt-gtk/src/class/command_palette.rs b/crates/ghostty-apprt-gtk/src/class/command_palette.rs index c6b7ec6..2b4b115 100644 --- a/crates/ghostty-apprt-gtk/src/class/command_palette.rs +++ b/crates/ghostty-apprt-gtk/src/class/command_palette.rs @@ -110,7 +110,9 @@ mod command_imp { fn prop_get_title(&self) -> Option { let mut data = self.data.borrow_mut(); match &mut *data { - CommandData::Regular { command } => Some(command.title.clone()), + CommandData::Regular { command } => { + Some(ghostty_os::osbytes::config_str_to_text(&command.title)) + } CommandData::Jump { surface, title, .. } => { if let Some(t) = title { return Some(t.clone()); @@ -125,7 +127,9 @@ mod command_imp { fn prop_get_description(&self) -> Option { let mut data = self.data.borrow_mut(); match &mut *data { - CommandData::Regular { command } => Some(command.description.clone()), + CommandData::Regular { command } => Some(ghostty_os::osbytes::config_str_to_text( + &command.description, + )), CommandData::Jump { surface, description, diff --git a/crates/ghostty-apprt-gtk/src/class/surface.rs b/crates/ghostty-apprt-gtk/src/class/surface.rs index 3aa6382..da26c85 100644 --- a/crates/ghostty-apprt-gtk/src/class/surface.rs +++ b/crates/ghostty-apprt-gtk/src/class/surface.rs @@ -180,6 +180,16 @@ impl Default for Surface { // --------------------------------------------------------------------- // Construction / lookup +/// Restore the `LANG` the application was launched with (as bytes; it need +/// not be UTF-8), or remove it if it was unset. +fn restore_language(env: &mut ghostty_os::EnvMap, saved: Option<&std::ffi::OsStr>) { + if let Some(language) = saved { + env.insert("LANG", language); + } else { + env.remove("LANG"); + } +} + impl Surface { /// Create a new surface widget (Zig: `Surface.new`). pub fn new(overrides: SurfaceOverrides) -> Self { @@ -1441,11 +1451,7 @@ impl Surface { // Byte-preserving environment (Zig `internal_os.getEnvMap`). let mut env = ghostty_os::env::get_env_map(); - if let Some(language) = application.saved_language() { - env.insert("LANG", language); - } else { - env.remove("LANG"); - } + restore_language(&mut env, application.saved_language().as_deref()); // Don't leak these GTK environment variables to child processes. env.remove("GDK_DEBUG"); @@ -2610,7 +2616,7 @@ mod clipboard { }; let Some(core) = this.core() else { return }; - match core.complete_clipboard_request(state, &text, false) { + match core.complete_clipboard_request(state, text.as_bytes(), false) { Ok(()) => {} Err(ClipboardError::UnsafePaste | ClipboardError::UnauthorizedPaste) => { show_clipboard_confirmation(&this, state, &text, false); @@ -2636,7 +2642,7 @@ mod clipboard { } let Some(core) = surface.core() else { return }; - match core.complete_clipboard_request(ClipboardRequest::Paste, text, false) { + match core.complete_clipboard_request(ClipboardRequest::Paste, text.as_bytes(), false) { Ok(()) => {} Err(ClipboardError::UnsafePaste | ClipboardError::UnauthorizedPaste) => { show_clipboard_confirmation(surface, ClipboardRequest::Paste, text, false); @@ -2725,7 +2731,7 @@ mod clipboard { // Undo the byte escape only for non-UTF-8 pastes. core.complete_paste_bytes(&confirmed_paste_bytes(&text, encoded), true) } else { - core.complete_clipboard_request(req, &text, true) + core.complete_clipboard_request(req, text.as_bytes(), true) }; if let Err(err) = result { log::warn!("failed to complete clipboard request: {err}"); @@ -3379,7 +3385,11 @@ mod imp { fn search_changed(_overlay: &SearchOverlay, needle: Option, this: &super::Surface) { let Some(surface) = this.core() else { return }; if let Err(err) = surface.perform_binding_action( - &ghostty_input::binding::Action::Search(needle.unwrap_or_default()), + // Binding payloads are config strings: the needle is plain + // UI text, so escape it instead of decoding it. + &ghostty_input::binding::Action::Search( + ghostty_os::osbytes::valid_str_to_config_string(&needle.unwrap_or_default()), + ), ) { log::warn!("unable to perform search action err={err}"); } @@ -3908,6 +3918,20 @@ mod imp { mod tests { use super::*; + #[test] + #[allow(clippy::unwrap_used)] + fn inherited_lang_keeps_non_utf8_bytes() { + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + + let mut env = ghostty_os::EnvMap::new(); + env.insert("LANG", "zz"); + let saved = std::ffi::OsString::from_vec(b"C\xff".to_vec()); + restore_language(&mut env, Some(&saved)); + assert_eq!(env.get("LANG").unwrap().as_bytes(), b"C\xff"); + restore_language(&mut env, None); + assert!(env.get("LANG").is_none()); + } + #[test] fn confirmed_paste_decodes_only_encoded_text() { // Ordinary text containing the escape char + U+00FF stays intact. diff --git a/crates/ghostty-apprt-gtk/src/class/window.rs b/crates/ghostty-apprt-gtk/src/class/window.rs index d6876fe..8125f3a 100644 --- a/crates/ghostty-apprt-gtk/src/class/window.rs +++ b/crates/ghostty-apprt-gtk/src/class/window.rs @@ -313,7 +313,7 @@ mod imp { // so that any applications inspecting the window states see // an immediate title set when the window appears. if let Some(title) = &config.title { - obj.set_title(Some(title)); + obj.set_title(Some(&ghostty_os::osbytes::config_str_to_text(title))); } } diff --git a/crates/ghostty-apprt-gtk/src/css.rs b/crates/ghostty-apprt-gtk/src/css.rs index 30055ad..73dfdba 100644 --- a/crates/ghostty-apprt-gtk/src/css.rs +++ b/crates/ghostty-apprt-gtk/src/css.rs @@ -215,6 +215,7 @@ pub fn runtime_css(config: &Config, gtk_416: bool) -> String { } if let Some(family) = &config.window_title_font_family { + let family = ghostty_os::osbytes::config_str_to_text(family); let _ = write!( out, ".window headerbar {{\n font-family: \"{family}\";\n}}\n" diff --git a/crates/ghostty-apprt-gtk/src/winproto/wayland.rs b/crates/ghostty-apprt-gtk/src/winproto/wayland.rs index df1ac1f..f5786f1 100644 --- a/crates/ghostty-apprt-gtk/src/winproto/wayland.rs +++ b/crates/ghostty-apprt-gtk/src/winproto/wayland.rs @@ -524,7 +524,9 @@ impl Window { QuickTerminalLayer::Bottom => layer_shell::Layer::Bottom, QuickTerminalLayer::Background => layer_shell::Layer::Background, }); - window.set_namespace(Some(config.gtk_quick_terminal_namespace.as_str())); + window.set_namespace(Some( + ghostty_os::osbytes::config_str_to_text(&config.gtk_quick_terminal_namespace).as_str(), + )); window.set_keyboard_mode(match config.quick_terminal_keyboard_interactivity { QuickTerminalKeyboardInteractivity::None => layer_shell::KeyboardMode::None, diff --git a/crates/ghostty-apprt/src/runtime_api.rs b/crates/ghostty-apprt/src/runtime_api.rs index abbf9e7..4cba831 100644 --- a/crates/ghostty-apprt/src/runtime_api.rs +++ b/crates/ghostty-apprt/src/runtime_api.rs @@ -198,17 +198,13 @@ pub trait CoreSurface { fn complete_clipboard_request( &self, req: ClipboardRequest, - data: &str, + data: &[u8], confirmed: bool, ) -> Result<(), ClipboardError>; /// [`CoreSurface::complete_clipboard_request`] for a paste of raw bytes - /// (Zig data is `[]const u8`). The default converts lossily. + /// (Zig data is `[]const u8`). fn complete_paste_bytes(&self, data: &[u8], confirmed: bool) -> Result<(), ClipboardError> { - self.complete_clipboard_request( - ClipboardRequest::Paste, - &String::from_utf8_lossy(data), - confirmed, - ) + self.complete_clipboard_request(ClipboardRequest::Paste, data, confirmed) } /// After the user confirms (`surface.config.clipboard_read = .allow`). fn set_clipboard_read_allowed(&self, allow: bool); diff --git a/crates/ghostty-cli/src/show_face.rs b/crates/ghostty-cli/src/show_face.rs index 2caa5d1..27c9499 100644 --- a/crates/ghostty-cli/src/show_face.rs +++ b/crates/ghostty-cli/src/show_face.rs @@ -100,7 +100,9 @@ fn run_args( continue; } if FIELDS.contains(&d.key.as_str()) { - writeln!(stderr, "config error: {d}")?; + stderr.write_all(&ghostty_os::osbytes::config_str_to_bytes(&format!( + "config error: {d}\n" + )))?; exit = true; } } @@ -133,7 +135,9 @@ fn run_args( { continue; } - writeln!(stderr, "config error: {d}")?; + stderr.write_all(&ghostty_os::osbytes::config_str_to_bytes(&format!( + "config error: {d}\n" + )))?; } let font_grid_set = match SharedGridSet::new() { diff --git a/crates/ghostty-cli/src/validate_config.rs b/crates/ghostty-cli/src/validate_config.rs index b10e18f..a9265b3 100644 --- a/crates/ghostty-cli/src/validate_config.rs +++ b/crates/ghostty-cli/src/validate_config.rs @@ -53,7 +53,11 @@ fn run_inner(opts: &Options, stdout: &mut dyn Write) -> Result { if !cfg.diagnostics.items().is_empty() { for diag in cfg.diagnostics.items() { - writeln!(stdout, "{diag}")?; + // Diagnostics embed config values in the reversible byte + // encoding; print the original bytes. + stdout.write_all(&ghostty_os::osbytes::config_str_to_bytes(&format!( + "{diag}\n" + )))?; } return Ok(1); } @@ -105,6 +109,27 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn diagnostics_print_original_bytes() { + use std::os::unix::ffi::OsStrExt; + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config"); + std::fs::write(&path, b"bad\xffkey = 1\n").unwrap(); + let opts = Options { + config_file: Some(path.to_string_lossy().into_owned()), + }; + let mut out = Vec::new(); + assert_eq!(1, run_inner(&opts, &mut out).unwrap()); + assert!( + out.windows(7).any(|w| w == b"bad\xffkey"), + "{:?}", + String::from_utf8_lossy(&out) + ); + assert!(!out.contains(&0xef), "no escape encoding in the output"); + let _ = path.as_os_str().as_bytes(); + } + #[test] fn validate_missing_file_is_error() { let opts = Options { diff --git a/crates/ghostty-config/src/io.rs b/crates/ghostty-config/src/io.rs index 11c33ab..97ea38b 100644 --- a/crates/ghostty-config/src/io.rs +++ b/crates/ghostty-config/src/io.rs @@ -2,6 +2,8 @@ use std::fmt; +use ghostty_os::osbytes::EncodedOs; + use crate::{ cli_args::{ArgError, ConfigField, split_tagged_union}, formatter::EntryFormatter, @@ -16,8 +18,11 @@ use crate::{ /// might be produced in an intermediate state. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ReadableIO { + /// Raw bytes in the reversible OS-byte encoding (decode with + /// `osbytes::config_str_to_bytes` after [`ReadableIO::clone_parsed`]). Raw(String), - Path(String), + /// A file path (decode with `EncodedOs::to_path` before opening). + Path(EncodedOs), } impl Default for ReadableIO { @@ -34,7 +39,9 @@ impl ReadableIO { // Parsing can't fail because we validate it in parse_cli match self { ReadableIO::Raw(v) => ReadableIO::Raw(string::parse(v).unwrap_or_default()), - ReadableIO::Path(v) => ReadableIO::Path(string::parse(v).unwrap_or_default()), + ReadableIO::Path(v) => ReadableIO::Path(EncodedOs::from_encoded( + string::parse(v.as_encoded()).unwrap_or_default(), + )), } } } @@ -55,7 +62,7 @@ impl ConfigField for ReadableIO { let (tag, rest) = split_tagged_union(input); *self = match tag { "raw" => ReadableIO::Raw(rest.to_string()), - "path" => ReadableIO::Path(rest.to_string()), + "path" => ReadableIO::Path(EncodedOs::from_encoded(rest)), _ => ReadableIO::Raw(input.to_string()), }; Ok(()) @@ -63,8 +70,8 @@ impl ConfigField for ReadableIO { fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { let (tag, v) = match self { - ReadableIO::Raw(v) => ("raw", v), - ReadableIO::Path(v) => ("path", v), + ReadableIO::Raw(v) => ("raw", v.as_str()), + ReadableIO::Path(v) => ("path", v.as_encoded()), }; writeln!(f.writer, "{} = {tag}:{v}", f.name) } @@ -126,7 +133,18 @@ mod tests { io.parse_cli(Some("raw:foo")).unwrap(); assert_eq!(io, ReadableIO::Raw("foo".into())); io.parse_cli(Some("path:foo")).unwrap(); - assert_eq!(io, ReadableIO::Path("foo".into())); + assert_eq!(io, ReadableIO::Path(EncodedOs::from_plain_text("foo"))); + } + + #[test] + fn readable_io_path_decodes_to_the_original_bytes() { + let mut io = ReadableIO::default(); + let enc = EncodedOs::from_bytes(b"/tmp/\xffin").into_encoded(); + io.parse_cli(Some(&format!("path:{enc}"))).unwrap(); + let ReadableIO::Path(p) = io.clone_parsed() else { + panic!("path expected") + }; + assert_eq!(p.to_bytes(), b"/tmp/\xffin"); } #[test] diff --git a/crates/ghostty-config/src/string.rs b/crates/ghostty-config/src/string.rs index 6944fa0..f746bcc 100644 --- a/crates/ghostty-config/src/string.rs +++ b/crates/ghostty-config/src/string.rs @@ -75,8 +75,21 @@ fn parse_escape_sequence(bytes: &[u8], i: &mut usize) -> Option { Some(cp) } +/// Parse a string literal into the original bytes (Zig `parse`'s output). +/// `input` is a config string in the reversible OS-byte encoding; the result +/// is decoded, so it can be written to the pty / OS directly. +pub fn parse_bytes(input: &str) -> Result, StringError> { + parse(input).map(|s| ghostty_os::osbytes::config_str_to_bytes(&s)) +} + /// Parse a string literal into a string. The string can contain /// any valid Zig string literal escape sequences. +/// +/// The input and output are in the reversible OS-byte encoding (see +/// `ghostty_os::osbytes`): bytes are copied through unchanged, and a +/// code point produced by an escape is encoded as plain text, so a +/// literal `U+FDD0` from `\u{fdd0}` stays distinct from the escape +/// character. Use [`parse_bytes`] for the raw bytes. pub fn parse(bytes: &str) -> Result { let b = bytes.as_bytes(); let mut out = String::with_capacity(bytes.len()); @@ -95,7 +108,12 @@ pub fn parse(bytes: &str) -> Result { let cp = parse_escape_sequence(b, &mut i).ok_or(StringError::InvalidString)?; // Surrogates cannot be encoded. - out.push(char::from_u32(cp).ok_or(StringError::InvalidString)?); + let ch = char::from_u32(cp).ok_or(StringError::InvalidString)?; + if ch == '\u{FDD0}' { + out.push_str("\u{FDD0}\u{FDD0}"); + } else { + out.push(ch); + } } Ok(out) } @@ -138,6 +156,24 @@ impl CodepointIterator<'_> { #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { + #[test] + fn parse_bytes_decodes_the_reversible_encoding() { + use ghostty_os::osbytes::EncodedOs; + + use super::{parse, parse_bytes}; + + // Raw non-UTF-8 bytes pass through as the same bytes. + let enc = EncodedOs::from_bytes(b"a\xffb\\n").into_encoded(); + assert_eq!(parse_bytes(&enc).unwrap(), b"a\xffb\n"); + // A literal U+FDD0 (typed or via an escape) is that character. + let lit = EncodedOs::from_plain_text("\u{fdd0}").into_encoded(); + assert_eq!(parse_bytes(&lit).unwrap(), "\u{fdd0}".as_bytes()); + assert_eq!(parse_bytes("\\u{fdd0}").unwrap(), "\u{fdd0}".as_bytes()); + // An escape producing U+FF is the UTF-8 encoding, not byte FF. + assert_eq!(parse_bytes("\\xff").unwrap(), "\u{ff}".as_bytes()); + assert!(parse("\\u{d800}").is_err()); + } + use super::*; #[test] diff --git a/crates/ghostty-core/src/surface/binding.rs b/crates/ghostty-core/src/surface/binding.rs index 0493442..754b982 100644 --- a/crates/ghostty-core/src/surface/binding.rs +++ b/crates/ghostty-core/src/surface/binding.rs @@ -713,30 +713,35 @@ impl Surface { // We need to send the CSI/ESC sequence as a single write // request. If you split it across two then the shell can // interpret it as two literals. - let full = if matches!(action, Action::Csi(_)) { - format!("\x1b[{data}") + let mut full: Vec = if matches!(action, Action::Csi(_)) { + b"\x1b[".to_vec() } else { - format!("\x1b{data}") + b"\x1b".to_vec() }; + // The payload is a config string in the reversible OS-byte + // encoding: the pty gets the original bytes. + full.extend(ghostty_os::osbytes::config_str_to_bytes(data)); // Zig formats into a fixed 128 byte buffer (`NoSpaceLeft`). if full.len() > 128 { return Err("csi/esc binding sequence too long (max 128 bytes)".into()); } - self.queue_io(IoMessage::write_req(full.as_bytes())); + self.queue_io(IoMessage::write_req(full.as_slice())); // CSI/ESC triggers a scroll. self.scroll_to_bottom(&mut self.lock_terminal()); } Action::Text(data) => { - let text = match ghostty_config::string::parse(data) { + // Decoded bytes: escapes produce UTF-8, everything else is + // copied byte for byte (Zig `config/string.zig`). + let text = match ghostty_config::string::parse_bytes(data) { Ok(t) => t, Err(err) => { log::warn!("error parsing text binding text={data} err={err:?}"); return Ok(true); } }; - self.queue_io(IoMessage::write_req(text.as_bytes())); + self.queue_io(IoMessage::write_req(text.as_slice())); // Text triggers a scroll. self.scroll_to_bottom(&mut self.lock_terminal()); @@ -817,7 +822,11 @@ impl Surface { } } else if let Some(s) = self.search.borrow().as_ref() { s.handle - .send(SearchMessage::ChangeNeedle(text.as_bytes().to_vec())); + // The payload is a config string in the reversible + // encoding: search for the original bytes. + .send(SearchMessage::ChangeNeedle( + ghostty_os::osbytes::config_str_to_bytes(text), + )); s.handle.wakeup(); } } @@ -925,11 +934,15 @@ impl Surface { } Action::SetSurfaceTitle(v) => { - return Ok(self.perform(RtAction::SetTitle(SetTitle { title: v.clone() }))); + return Ok(self.perform(RtAction::SetTitle(SetTitle { + title: ghostty_os::osbytes::config_str_to_text(v), + }))); } Action::SetTabTitle(v) => { - return Ok(self.perform(RtAction::SetTabTitle(SetTitle { title: v.clone() }))); + return Ok(self.perform(RtAction::SetTabTitle(SetTitle { + title: ghostty_os::osbytes::config_str_to_text(v), + }))); } Action::ClearScreen => { diff --git a/crates/ghostty-core/src/surface/clipboard.rs b/crates/ghostty-core/src/surface/clipboard.rs index 82abc7a..6347e42 100644 --- a/crates/ghostty-core/src/surface/clipboard.rs +++ b/crates/ghostty-core/src/surface/clipboard.rs @@ -88,8 +88,9 @@ impl Surface { // The options we'll use for all formatting. We'll just override the // emit format. - let codepoint_map: Vec = - cfg.clipboard_codepoint_map.map.list.clone(); + // Replacement strings are config strings (reversible byte encoding); + // the formatter produces text, so decode them to UTF-8 text first. + let codepoint_map = decode_codepoint_map(&cfg.clipboard_codepoint_map.map.list); let opts = formatter::Options { emit: Format::Plain, // We'll override this below unwrap: true, @@ -195,20 +196,20 @@ impl Surface { pub fn complete_clipboard_request( &self, req: ClipboardRequest, - data: &str, + data: &[u8], confirmed: bool, ) -> Result<(), ClipboardError> { match req { - ClipboardRequest::Paste => self.complete_clipboard_paste(data.as_bytes(), confirmed), + ClipboardRequest::Paste => self.complete_clipboard_paste(data, confirmed), ClipboardRequest::Osc52Read(clipboard) => { - self.complete_clipboard_read_osc52(data.as_bytes(), clipboard, confirmed) + self.complete_clipboard_read_osc52(data, clipboard, confirmed) } ClipboardRequest::Osc52Write(clipboard) => { self.rt_surface.set_clipboard( clipboard, - &[("text/plain".to_owned(), data.as_bytes().to_vec())], + &[("text/plain".to_owned(), data.to_vec())], !confirmed, ); Ok(()) @@ -407,3 +408,40 @@ impl Surface { self.complete_clipboard_paste(text, true) } } + +/// The clipboard codepoint map with string replacements decoded from the +/// config encoding (a literal `U+FDD0` is restored; bytes that are not +/// UTF-8 can't be represented in the formatter's text output). +fn decode_codepoint_map(list: &[formatter::CodepointMap]) -> Vec { + list.iter() + .map(|e| formatter::CodepointMap { + range: e.range, + replacement: match &e.replacement { + formatter::Replacement::String(s) => { + formatter::Replacement::String(ghostty_os::osbytes::config_str_to_text(s)) + } + r @ formatter::Replacement::Codepoint(_) => r.clone(), + }, + }) + .collect() +} + +#[cfg(test)] +mod decode_map_tests { + use super::*; + + #[test] + fn string_replacements_are_decoded() { + let enc = ghostty_os::osbytes::valid_str_to_config_string("a\u{fdd0}"); + assert_ne!(enc, "a\u{fdd0}"); + let list = [formatter::CodepointMap { + range: [1, 2], + replacement: formatter::Replacement::String(enc), + }]; + let out = decode_codepoint_map(&list); + assert_eq!( + out[0].replacement, + formatter::Replacement::String("a\u{fdd0}".to_owned()) + ); + } +} diff --git a/crates/ghostty-core/src/surface/config.rs b/crates/ghostty-core/src/surface/config.rs index 695998c..17bd9dc 100644 --- a/crates/ghostty-core/src/surface/config.rs +++ b/crates/ghostty-core/src/surface/config.rs @@ -55,7 +55,7 @@ impl DerivedConfig { let mut links = Vec::with_capacity(config.link.links.len()); for link in &config.link.links { links.push(Link { - regex: Regex::new(&link.regex)?, + regex: Regex::new(&ghostty_os::osbytes::config_str_to_text(&link.regex))?, action: link.action, highlight: link.highlight, }); diff --git a/crates/ghostty-core/src/surface/mod.rs b/crates/ghostty-core/src/surface/mod.rs index 0dc7712..785cc8e 100644 --- a/crates/ghostty-core/src/surface/mod.rs +++ b/crates/ghostty-core/src/surface/mod.rs @@ -459,7 +459,10 @@ impl Surface { // don't leak GHOSTTY_LOG to any subprocesses env.remove("GHOSTTY_LOG"); - let mut ecfg = exec::Config::new(env, config.term.clone()); + let mut ecfg = exec::Config::new( + env, + ghostty_os::osbytes::EncodedOs::from_encoded(config.term.clone()), + ); ecfg.command = command.clone(); ecfg.env_override = config.env.clone(); ecfg.shell_integration = config.shell_integration; @@ -604,7 +607,7 @@ impl Surface { rt_app.perform_action( Target::Surface(id), RtAction::SetTitle(SetTitle { - title: title.clone(), + title: ghostty_os::osbytes::config_str_to_text(title), }), ); } else if config.xdg_terminal_exec @@ -617,7 +620,7 @@ impl Surface { rt_app.perform_action( Target::Surface(id), RtAction::SetTitle(SetTitle { - title: cmd.string(), + title: ghostty_os::osbytes::config_str_to_text(&cmd.string()), }), ); } else if let Some(cmd) = &command { @@ -633,7 +636,7 @@ impl Surface { rt_app.perform_action( Target::Surface(id), RtAction::SetTitle(SetTitle { - title: first.clone(), + title: ghostty_os::osbytes::config_str_to_text(first), }), ); } @@ -1303,7 +1306,7 @@ impl Surface { // configured title. if let Some(title) = &config.title { self.perform(RtAction::SetTitle(SetTitle { - title: title.clone(), + title: ghostty_os::osbytes::config_str_to_text(title), })); } @@ -2062,7 +2065,7 @@ impl CoreSurface for Surface { fn complete_clipboard_request( &self, req: ClipboardRequest, - data: &str, + data: &[u8], confirmed: bool, ) -> Result<(), ClipboardError> { Surface::complete_clipboard_request(self, req, data, confirmed) diff --git a/crates/ghostty-core/tests/common/mod.rs b/crates/ghostty-core/tests/common/mod.rs index b737df9..de42da7 100644 --- a/crates/ghostty-core/tests/common/mod.rs +++ b/crates/ghostty-core/tests/common/mod.rs @@ -326,7 +326,10 @@ impl Harness { .get(&cb_key(clipboard)) .cloned() .unwrap_or_default(); - match self.surface.complete_clipboard_request(req, &text, false) { + match self + .surface + .complete_clipboard_request(req, text.as_bytes(), false) + { Ok(()) => {} Err(ClipboardError::UnsafePaste | ClipboardError::UnauthorizedPaste) => { self.rt diff --git a/crates/ghostty-core/tests/e2e_bindings.rs b/crates/ghostty-core/tests/e2e_bindings.rs index b0ab596..b886d4f 100644 --- a/crates/ghostty-core/tests/e2e_bindings.rs +++ b/crates/ghostty-core/tests/e2e_bindings.rs @@ -111,7 +111,7 @@ fn unbracketed_paste_converts_newlines_and_requires_confirmation_when_unsafe() { // Confirm it. let c = h.rt.confirmations.borrow()[0].clone(); h.surface - .complete_clipboard_request(c.request, &c.text, true) + .complete_clipboard_request(c.request, c.text.as_bytes(), true) .unwrap(); h.expect_input(b"rm -rf x\rls"); } @@ -367,6 +367,31 @@ fn text_csi_esc_and_ignore_bindings() { h.expect_input(b"hi!\x1b[1;5H\x1bx"); } +#[test] +fn config_loaded_text_csi_esc_write_decoded_bytes() { + use ghostty_os::osbytes::EncodedOs; + + let h = harness!(Harness::recorder("", &[])); + let enc = |raw: &[u8]| EncodedOs::from_bytes(raw).into_encoded(); + // Byte FF and a literal U+FDD0 must reach the pty unchanged, not as + // the reversible escape encoding. + for (kind, raw, want) in [ + ("text", &b"a\xffb"[..], b"a\xffb".to_vec()), + ( + "text", + "\u{fdd0}".as_bytes(), + "\u{fdd0}".as_bytes().to_vec(), + ), + ("csi", &b"\xff"[..], b"\x1b[\xff".to_vec()), + ("esc", "\u{fdd0}".as_bytes(), b"\x1b\xef\xb7\x90".to_vec()), + ] { + h.mark(); + let a = ghostty_input::binding::Action::parse(&format!("{kind}:{}", enc(raw))).unwrap(); + assert!(h.surface.perform_binding_action(&a).unwrap()); + h.expect_input(&want); + } +} + #[test] fn csi_binding_longer_than_the_zig_buffer_is_rejected() { let h = harness!(Harness::recorder("", &[])); diff --git a/crates/ghostty-core/tests/e2e_term.rs b/crates/ghostty-core/tests/e2e_term.rs index b6a0c5f..2db46c8 100644 --- a/crates/ghostty-core/tests/e2e_term.rs +++ b/crates/ghostty-core/tests/e2e_term.rs @@ -162,7 +162,7 @@ fn osc52_write_ask_requires_confirmation_and_deny_ignores() { // The user confirms: the apprt calls complete_clipboard_request. let c = h.rt.confirmations.borrow()[0].clone(); h.surface - .complete_clipboard_request(c.request, &c.text, true) + .complete_clipboard_request(c.request, c.text.as_bytes(), true) .unwrap(); assert_eq!(h.rt.writes.borrow().len(), 2); @@ -208,7 +208,7 @@ fn osc52_read_requires_confirmation_by_default() { assert_eq!(h.recorded(), Vec::::new()); let c = h.rt.confirmations.borrow()[0].clone(); h.surface - .complete_clipboard_request(c.request, &c.text, true) + .complete_clipboard_request(c.request, c.text.as_bytes(), true) .unwrap(); // base64("secret") = c2VjcmV0 h.expect_input(b"\x1b]52;c;c2VjcmV0\x1b\\"); diff --git a/crates/ghostty-font/Cargo.toml b/crates/ghostty-font/Cargo.toml index ec69a43..abe73a5 100644 --- a/crates/ghostty-font/Cargo.toml +++ b/crates/ghostty-font/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true license.workspace = true [dependencies] +ghostty-os.workspace = true tiny-skia = "0.12.0" png = "0.18.1" thiserror = "2.0.21" @@ -33,9 +34,6 @@ harfbuzz-sys = "0.8.0" freetype-sys = { version = "0.23.0", features = ["bundled"] } harfbuzz-sys = { version = "0.8.0", features = ["bundled"] } -[target.'cfg(target_arch = "wasm32")'.dependencies] -ghostty-os.workspace = true - # Apple targets use CoreText (`coretext` backend) by default; FreeType and # HarfBuzz are only linked for the `coretext-freetype` / `coretext-harfbuzz` # backends (see `build.rs` and the `[features]` table). diff --git a/crates/ghostty-font/src/shared_grid_set.rs b/crates/ghostty-font/src/shared_grid_set.rs index 58cd23f..c743a2a 100644 --- a/crates/ghostty-font/src/shared_grid_set.rs +++ b/crates/ghostty-font/src/shared_grid_set.rs @@ -501,7 +501,7 @@ impl Key { let mut descriptors: Vec = Vec::new(); for family in &config.font_family.list { descriptors.push(Descriptor { - family: Some(family.clone()), + family: Some(ghostty_os::osbytes::config_str_to_text(family)), style: config.font_style.name_value().map(str::to_string), size: font_size.points, variations: config.font_variation.list.clone(), @@ -520,7 +520,7 @@ impl Key { for family in &config.font_family_bold.list { let style = config.font_style_bold.name_value().map(str::to_string); descriptors.push(Descriptor { - family: Some(family.clone()), + family: Some(ghostty_os::osbytes::config_str_to_text(family)), bold: style.is_none(), style, size: font_size.points, @@ -531,7 +531,7 @@ impl Key { for family in &config.font_family_italic.list { let style = config.font_style_italic.name_value().map(str::to_string); descriptors.push(Descriptor { - family: Some(family.clone()), + family: Some(ghostty_os::osbytes::config_str_to_text(family)), italic: style.is_none(), style, size: font_size.points, @@ -545,7 +545,7 @@ impl Key { .name_value() .map(str::to_string); descriptors.push(Descriptor { - family: Some(family.clone()), + family: Some(ghostty_os::osbytes::config_str_to_text(family)), bold: style.is_none(), italic: style.is_none(), style, diff --git a/crates/ghostty-lib/src/embedded.rs b/crates/ghostty-lib/src/embedded.rs index e81e3d3..f71e541 100644 --- a/crates/ghostty-lib/src/embedded.rs +++ b/crates/ghostty-lib/src/embedded.rs @@ -856,24 +856,32 @@ unsafe fn platform_view_from_c( /// Zig's `std.zig.stringEscape` with non-ASCII code points written as /// `\u{...}` (the config value is parsed as a Zig string; the bytes of -/// valid UTF-8 round trip either way). -fn string_escape(input: &str) -> String { +/// valid UTF-8 round trip either way). Bytes that are not valid UTF-8 are +/// emitted in the reversible OS-byte encoding, which the string parser +/// copies through unchanged, so `string::parse_bytes(string_escape(b)) == b`. +fn string_escape(input: &[u8]) -> String { let mut out = String::with_capacity(input.len()); - for c in input.chars() { - match c { - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - '\\' => out.push_str("\\\\"), - '"' => out.push_str("\\\""), - ' ' | '!' | '#'..='&' | '\'' | '('..='[' | ']'..='~' => out.push(c), - c if c.is_ascii() => { - let _ = write!(out, "\\x{:02x}", c as u32); - } - c => { - let _ = write!(out, "\\u{{{:x}}}", c as u32); + for chunk in input.utf8_chunks() { + for c in chunk.valid().chars() { + match c { + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + ' ' | '!' | '#'..='&' | '\'' | '('..='[' | ']'..='~' => out.push(c), + c if c.is_ascii() => { + let _ = write!(out, "\\x{:02x}", c as u32); + } + c => { + let _ = write!(out, "\\u{{{:x}}}", c as u32); + } } } + for &b in chunk.invalid() { + out.push('\u{FDD0}'); + out.push(char::from(b)); + } } out } @@ -939,9 +947,10 @@ unsafe fn apply_surface_options(config: &mut Config, opts: &ghostty_surface_conf // We need to escape the string because the "raw" field expects a // Zig string. config.input.list.clear(); - config.input.list.push(ReadableIO::Raw(string_escape( - &osbytes::bytes_to_config_string(input.to_bytes()), - ))); + config + .input + .list + .push(ReadableIO::Raw(string_escape(input.to_bytes()))); } // Wait after command @@ -1935,7 +1944,9 @@ pub unsafe extern "C" fn ghostty_surface_binding_action( } // SAFETY: `action` points to `len` readable bytes. let bytes = unsafe { std::slice::from_raw_parts(action.cast::(), len) }; - let action_str = String::from_utf8_lossy(bytes); + // The action text is bytes (Zig `[]const u8`); the parser works on the + // reversible OS-byte encoding so a `text:` payload keeps invalid UTF-8. + let action_str = ghostty_os::osbytes::bytes_to_config_string(bytes); let action = match BindingAction::parse(&action_str) { Ok(a) => a, Err(err) => { @@ -1976,13 +1987,14 @@ pub unsafe extern "C" fn ghostty_surface_complete_clipboard_request( let Some(core) = s.core() else { return; }; - let str = str.to_string_lossy(); + // The clipboard contents are bytes (Zig `[:0]const u8`), not text. + let str = str.to_bytes(); // SAFETY: the pointer is a `Box` leaked by // `clipboard_request`; it stays alive when confirmation is requested. let req = unsafe { *state.cast::() }; // Attempt to complete the request, but we may request confirmation. - match core.complete_clipboard_request(req, &str, confirmed) { + match core.complete_clipboard_request(req, str, confirmed) { Err( ghostty_apprt::runtime_api::ClipboardError::UnsafePaste | ghostty_apprt::runtime_api::ClipboardError::UnauthorizedPaste, @@ -1993,10 +2005,10 @@ pub unsafe extern "C" fn ghostty_surface_complete_clipboard_request( ghostty_apprt::structs::ClipboardRequestType::Osc52Read => 1, ghostty_apprt::structs::ClipboardRequestType::Osc52Write => 2, }; - let c = CString::new(str.replace('\0', "")).unwrap_or_default(); - // SAFETY: embedder callback; `state` is preserved for the + // SAFETY: embedder callback; `text` is the caller's NUL + // terminated string and `state` is preserved for the // confirmation route. - unsafe { cb(s.rt.userdata, c.as_ptr(), state, ty) }; + unsafe { cb(s.rt.userdata, text, state, ty) }; } return; } @@ -2172,6 +2184,23 @@ mod tests { use super::*; use crate::types::ghostty_env_var_s; + #[test] + fn initial_input_escape_round_trips_bytes() { + for raw in [ + &b"a\xffb\n"[..], + "t\u{fdd0}\u{e9}\"\\".as_bytes(), + b"\xfd\xfd\x1b[0m", + "\u{fdd0}\u{ff}".as_bytes(), + ] { + let esc = string_escape(raw); + assert_eq!( + ghostty_config::string::parse_bytes(&esc).unwrap(), + raw, + "{esc:?}" + ); + } + } + /// Surface options are raw C bytes in Zig; non-UTF-8 values must reach /// the config unchanged (reversible byte escape). #[test] diff --git a/crates/ghostty-lib/tests/surface.rs b/crates/ghostty-lib/tests/surface.rs index 50ef5b3..e316eda 100644 --- a/crates/ghostty-lib/tests/surface.rs +++ b/crates/ghostty-lib/tests/surface.rs @@ -516,6 +516,32 @@ fn selection_and_clipboard_round_trip() { unsafe { ghostty_surface_free(s) }; } +/// A child that prints the hex of the first two bytes it receives raw. +const HEX_TWO: &str = "stty raw -echo; dd bs=1 count=2 2>/dev/null | od -An -tx1 | tr -d ' \\n'; printf '\\nHEX-DONE\\n'; sleep 5"; + +#[test] +fn clipboard_completion_and_binding_action_keep_raw_bytes() { + // Clipboard contents are bytes: 0xFF must reach the child as 0xFF. + let h = host!(""); + let s = h.surface(HEX_TWO); + let act = b"paste_from_clipboard"; + assert!(unsafe { ghostty_surface_binding_action(s, act.as_ptr().cast(), act.len()) }); + let (_, state) = rec(|r| r.reads.last().copied()).expect("clipboard read"); + let pasted = CString::new(&b"a\xff"[..]).unwrap(); + unsafe { + ghostty_surface_complete_clipboard_request(s, pasted.as_ptr(), state as *mut c_void, false); + }; + h.wait_screen(s, "61ff"); + unsafe { ghostty_surface_free(s) }; + + // Binding action text is bytes too: `text:` followed by 0xFF. + let s = h.surface(HEX_TWO); + let act = b"text:b\xff"; + assert!(unsafe { ghostty_surface_binding_action(s, act.as_ptr().cast(), act.len()) }); + h.wait_screen(s, "62ff"); + unsafe { ghostty_surface_free(s) }; +} + #[test] fn osc_52_and_title_and_pwd_actions() { let h = host!("clipboard-write = allow\n"); diff --git a/crates/ghostty-os/src/env.rs b/crates/ghostty-os/src/env.rs index 40b600f..075667e 100644 --- a/crates/ghostty-os/src/env.rs +++ b/crates/ghostty-os/src/env.rs @@ -12,20 +12,6 @@ const DELIMITER: char = ';'; #[cfg(not(windows))] const DELIMITER: char = ':'; -/// Uppercase one UTF-16 code unit like Windows' `RtlUpcaseUnicodeChar` -/// (Unicode simple case mapping, per unit): surrogate halves and characters -/// whose uppercase is not a single BMP code unit are unchanged. -fn upcase_unit(u: u16) -> u16 { - let Some(c) = char::from_u32(u32::from(u)) else { - return u; // lone surrogate half - }; - let mut up = c.to_uppercase(); - match (up.next(), up.next()) { - (Some(m), None) => u16::try_from(u32::from(m)).unwrap_or(u), - _ => u, - } -} - /// The comparison form of an environment key on Windows: its WTF-16 code /// units, uppercased per unit. Never lossy: distinct lone surrogates stay /// distinct. @@ -33,7 +19,7 @@ fn upcase_unit(u: u16) -> u16 { fn windows_key(key: &OsStr) -> Vec { crate::osbytes::wtf8_to_wide(&crate::osbytes::os_bytes(key)) .into_iter() - .map(upcase_unit) + .map(crate::windows_nls::upcase_w) .collect() } @@ -400,20 +386,26 @@ mod tests { #[cfg(test)] mod windows_key_tests { + #[cfg(unix)] use super::*; fn key(units: &[u16]) -> Vec { - units.iter().copied().map(upcase_unit).collect() + units + .iter() + .copied() + .map(crate::windows_nls::upcase_w) + .collect() } #[test] - fn upcase_is_unicode_simple_per_unit() { - assert_eq!(upcase_unit(u16::from(b'a')), u16::from(b'A')); - assert_eq!(upcase_unit(0x00E9), 0x00C9); // e acute - assert_eq!(upcase_unit(0x03C9), 0x03A9); // omega - assert_eq!(upcase_unit(0x00DF), 0x00DF); // sharp s has no simple upper - assert_eq!(upcase_unit(0xD800), 0xD800); - assert_eq!(upcase_unit(0xDFFF), 0xDFFF); + fn keys_use_the_windows_table_not_unicode_mappings() { + // U+1F80 and U+1F88 are the same key under Windows/Zig's table even + // though Unicode's uppercase of U+1F80 is a two-unit sequence. + assert_eq!(key(&[0x1F80]), key(&[0x1F88])); + // The Georgian letters gained uppercase after the table froze. + assert_ne!(key(&[0x10D0]), key(&[0x1C90])); + // U+00DF stays distinct from "SS" (and from U+1E9E). + assert_ne!(key(&[0xDF]), key(&[0x53, 0x53])); } #[test] diff --git a/crates/ghostty-os/src/lib.rs b/crates/ghostty-os/src/lib.rs index efef642..59891a2 100644 --- a/crates/ghostty-os/src/lib.rs +++ b/crates/ghostty-os/src/lib.rs @@ -111,6 +111,7 @@ pub mod uri; pub mod wasm; #[cfg(windows)] pub mod windows; +pub mod windows_nls; #[cfg(target_arch = "wasm32")] mod unsupported; diff --git a/crates/ghostty-os/src/locale.rs b/crates/ghostty-os/src/locale.rs index 650a30b..654ca8c 100644 --- a/crates/ghostty-os/src/locale.rs +++ b/crates/ghostty-os/src/locale.rs @@ -5,7 +5,10 @@ //! comments): `setlocale(LC_ALL, "")`, then with `LANG` unset, then //! `en_US.UTF-8` (exported as `LANG`). On Darwin (`target_vendor = "apple"`) //! `LANG`/`LANGUAGE` are additionally pre-populated from Cocoa when unset. -//! Other targets (Windows, wasm) have no `setlocale` handling. +//! Windows runs the same chain against the C runtime: `setlocale` (with +//! Zig's literal `LC_ALL = 6`, which the UCRT rejects, so the chain proceeds +//! exactly as it does in Zig) and `_putenv_s` for the `LANG` updates. +//! wasm has no `setlocale` handling. /// The locale Ghostty falls back to (and exports as `LANG`) when the /// environment's locale can't be set. @@ -14,14 +17,14 @@ pub const FALLBACK_LOCALE: &str = "en_US.UTF-8"; /// Ensure that the locale is set (`ensureLocale`). /// Returns `Some("en_US.UTF-8")` if we had to fall back to it (and exported /// it as `LANG`), otherwise `None`. -#[cfg(unix)] +#[cfg(any(unix, windows))] pub fn ensure_locale() -> Option { posix::ensure_locale() } -/// Ensure that the locale is set: nothing to do on targets without libc -/// locales (Windows, wasm). -#[cfg(not(unix))] +/// Ensure that the locale is set: nothing to do on targets without a C +/// runtime locale (wasm). +#[cfg(not(any(unix, windows)))] pub fn ensure_locale() -> Option { None } @@ -53,15 +56,52 @@ pub fn preferred_language_value>(preferred: &[S]) -> Option *mut c_char; + fn _putenv_s(name: *const c_char, value: *const c_char) -> i32; + } + + #[cfg(windows)] + fn setlocale_all(locale: &str) -> Option { + let c = CString::new(locale).ok()?; + // SAFETY: valid NUL-terminated string; the C runtime returns a + // pointer to a static string (or null) which we copy immediately. + unsafe { + let r = setlocale(LC_ALL, c.as_ptr()); + (!r.is_null()).then(|| CStr::from_ptr(r).to_string_lossy().into_owned()) + } + } + + /// Zig's Windows `setenv`/`unsetenv`: `_putenv_s` (an empty value + /// removes the variable). + #[cfg(windows)] + fn putenv_s(key: &str, value: &str) { + let (Ok(k), Ok(v)) = (CString::new(key), CString::new(value)) else { + return; + }; + // SAFETY: both pointers are valid NUL-terminated strings that the + // C runtime copies; called during early startup like the Zig code. + unsafe { _putenv_s(k.as_ptr(), v.as_ptr()) }; + } + + #[cfg(unix)] fn setlocale_all(locale: &str) -> Option { let c = CString::new(locale).ok()?; // SAFETY: valid NUL-terminated string; setlocale returns a pointer to @@ -72,12 +112,24 @@ mod posix { } } + #[cfg(windows)] + fn set_env(key: &str, value: &str) { + putenv_s(key, value); + } + + #[cfg(windows)] + fn unset_env(key: &str) { + putenv_s(key, ""); + } + + #[cfg(unix)] fn set_env(key: &str, value: &str) { // SAFETY: called during early process startup (locale setup) before // other threads read the environment, mirroring the Zig code. unsafe { std::env::set_var(key, value) }; } + #[cfg(unix)] fn unset_env(key: &str) { // SAFETY: see `set_env`. unsafe { std::env::remove_var(key) }; diff --git a/crates/ghostty-os/src/osbytes.rs b/crates/ghostty-os/src/osbytes.rs index b7857a4..4bf6a41 100644 --- a/crates/ghostty-os/src/osbytes.rs +++ b/crates/ghostty-os/src/osbytes.rs @@ -174,6 +174,28 @@ pub fn wtf8_to_wide(b: &[u8]) -> Vec { out } +/// The bytes are not well-formed WTF-8 (Zig: `error.InvalidWtf8` / +/// `error.InvalidUtf8` from `std.unicode.utf8ToUtf16Le*`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("invalid wtf-8")] +pub struct InvalidWtf8; + +/// Strict [`wtf8_to_wide`]: malformed input is an error instead of U+FFFD. +/// Lone surrogates encoded as 3-byte sequences are accepted (WTF-8). This is +/// the conversion used where Zig's is fallible (process launch). +pub fn try_wtf8_to_wide(b: &[u8]) -> Result, InvalidWtf8> { + let wide = wtf8_to_wide(b); + // The lossy decoder yields U+FFFD only for malformed bytes; a genuine + // U+FFFD in the input is the 3-byte sequence EF BF BD. Re-encoding is + // the cheapest exact check: it reproduces the input iff it was + // well-formed. + if wide_to_wtf8(&wide) == b { + Ok(wide) + } else { + Err(InvalidWtf8) + } +} + /// 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 { @@ -187,6 +209,14 @@ pub fn valid_str_to_config_string(s: &str) -> String { bytes_to_config_string(s.as_bytes()) } +/// Decode a config string for a UI-text boundary that requires UTF-8 (window +/// titles, CSS font names, toolkit namespaces): the original bytes, with +/// anything that is not valid UTF-8 replaced by U+FFFD. A literal `U+FDD0` +/// in the text is restored (not shown as the doubled escape). +pub fn config_str_to_text(s: &str) -> String { + String::from_utf8_lossy(&config_str_to_bytes(s)).into_owned() +} + /// Inverse of [`os_to_config_string`]. pub fn config_str_to_os(s: &str) -> OsString { os_from_bytes(config_str_to_bytes(s)) @@ -286,6 +316,33 @@ impl EncodedOs { wtf8_to_wide(&self.to_bytes()) } + /// Strict [`EncodedOs::to_wide`] for process launch: malformed WTF-8 is + /// an error (Zig's `utf8ToUtf16Le*` fail) instead of becoming U+FFFD. + pub fn try_to_wide(&self) -> Result, InvalidWtf8> { + try_wtf8_to_wide(&self.to_bytes()) + } + + /// [`EncodedOs::try_to_wide`] plus a trailing NUL. + pub fn try_to_wide_nul(&self) -> Result, InvalidWtf8> { + let mut w = self.try_to_wide()?; + w.push(0); + Ok(w) + } + + /// Strict [`EncodedOs::to_os`]: on Windows malformed WTF-8 is an error; + /// elsewhere every byte string is a valid OS string. + pub fn try_to_os(&self) -> Result { + #[cfg(windows)] + { + use std::os::windows::ffi::OsStringExt; + Ok(OsString::from_wide(&self.try_to_wide()?)) + } + #[cfg(not(windows))] + { + Ok(self.to_os()) + } + } + /// [`EncodedOs::to_wide`] plus a trailing NUL. #[must_use] pub fn to_wide_nul(&self) -> Vec { @@ -508,6 +565,39 @@ mod tests { assert_eq!(wtf8_to_wide(&config_str_to_bytes(&s)), wide); } + #[test] + fn config_str_to_text_restores_literal_escape_char() { + assert_eq!( + config_str_to_text(&valid_str_to_config_string("a\u{fdd0}b")), + "a\u{fdd0}b" + ); + assert_eq!( + config_str_to_text(&bytes_to_config_string(b"a\xffb")), + "a\u{fffd}b" + ); + } + + #[test] + fn strict_wtf8_rejects_malformed_input() { + assert_eq!(try_wtf8_to_wide(b"a\xffb"), Err(InvalidWtf8)); + assert_eq!(try_wtf8_to_wide(b"\xe0\x80\x80"), Err(InvalidWtf8)); + assert_eq!(try_wtf8_to_wide(b"\xc3"), Err(InvalidWtf8)); + // A real U+FFFD and lone surrogates are well-formed. + assert_eq!(try_wtf8_to_wide("\u{FFFD}".as_bytes()), Ok(vec![0xFFFD])); + assert_eq!(try_wtf8_to_wide(b"\xed\xa0\x80"), Ok(vec![0xD800])); + assert_eq!( + try_wtf8_to_wide("\u{e9}\u{1F600}".as_bytes()), + Ok(vec![0xE9, 0xD83D, 0xDE00]) + ); + let bad = EncodedOs::from_bytes(b"C:\\\xff"); + assert_eq!(bad.try_to_wide(), Err(InvalidWtf8)); + assert_eq!(bad.try_to_wide_nul(), Err(InvalidWtf8)); + // The lossy form still substitutes (non-launch uses). + assert_eq!(bad.to_wide().last(), Some(&0xFFFD)); + let ok = EncodedOs::from_plain_text("C:\\x"); + assert_eq!(ok.try_to_wide_nul().unwrap().last(), Some(&0)); + } + #[test] fn wtf8_invalid_bytes_become_replacement() { assert_eq!(wtf8_to_wide(b"a\xffb"), vec![0x61, 0xFFFD, 0x62]); diff --git a/crates/ghostty-os/src/windows_nls.rs b/crates/ghostty-os/src/windows_nls.rs new file mode 100644 index 0000000..2171e82 --- /dev/null +++ b/crates/ghostty-os/src/windows_nls.rs @@ -0,0 +1,201 @@ +//! Windows' per-code-unit uppercase mapping (`RtlUpcaseUnicodeChar`), a port +//! of `std/os/windows/nls.zig` (`upcaseW` and its `uppercase_table`). +//! +//! The table is the locale-independent `l_intl.nls` data, unchanged since +//! Windows 8.1 and therefore out of sync with the Unicode standard. Zig's +//! `EnvMap` hashes and compares Windows environment keys with it, so this is +//! what decides whether two keys name the same variable. + +/// This corresponds to the uppercase table within the locale-independent +/// l_intl.nls data (found at system32\l_intl.nls). +const UPPERCASE_TABLE: [u16; 2544] = [ + 272, 288, 304, 320, 336, 352, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, + 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 368, 384, 400, 256, 416, 256, 256, 432, 256, + 256, 256, 256, 256, 256, 256, 448, 464, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, + 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, + 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, + 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, + 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, + 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, + 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 480, 496, 256, 256, 256, + 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, + 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, + 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, + 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, + 256, 256, 256, 256, 256, 256, 256, 256, 512, 528, 528, 528, 528, 528, 528, 528, 528, 528, 528, + 528, 528, 528, 528, 528, 528, 528, 528, 528, 528, 528, 528, 544, 560, 528, 528, 528, 576, 528, + 528, 592, 608, 624, 640, 656, 672, 688, 704, 720, 736, 752, 768, 784, 800, 816, 832, 848, 864, + 880, 896, 912, 928, 944, 960, 976, 992, 1008, 1024, 528, 528, 528, 528, 528, 528, 528, 528, + 528, 528, 1040, 528, 528, 1056, 528, 528, 1072, 1088, 1104, 1120, 1136, 1152, 528, 528, 528, + 1168, 1184, 1200, 1216, 1232, 1248, 1264, 1280, 1296, 1312, 1328, 1344, 1360, 1376, 1392, 1408, + 528, 528, 528, 1424, 1440, 1456, 528, 528, 528, 528, 528, 528, 528, 528, 528, 528, 528, 528, + 528, 528, 1472, 528, 528, 528, 528, 528, 528, 528, 528, 1488, 1504, 1520, 1536, 1552, 1568, + 1584, 1600, 1616, 1632, 1648, 1664, 1680, 1696, 1712, 1728, 1744, 1760, 1776, 1792, 1808, 1824, + 1840, 1856, 1872, 1888, 1904, 1920, 1936, 1952, 1968, 1984, 528, 528, 528, 528, 2000, 528, 528, + 2016, 2032, 528, 528, 528, 528, 528, 528, 528, 528, 528, 528, 528, 528, 528, 528, 528, 528, + 528, 528, 528, 528, 2048, 2064, 528, 528, 528, 528, 2080, 2096, 2112, 2128, 2144, 2160, 2176, + 2192, 2208, 2224, 2240, 2256, 528, 2272, 2288, 2304, 528, 528, 528, 528, 528, 528, 528, 528, + 528, 528, 528, 528, 528, 528, 528, 528, 528, 2320, 2336, 2352, 528, 2368, 2384, 528, 528, 528, + 528, 528, 528, 528, 528, 2400, 2416, 2432, 2448, 2464, 2480, 2496, 528, 528, 528, 528, 528, + 528, 528, 528, 528, 528, 528, 2512, 2528, 528, 528, 528, 528, 528, 528, 528, 528, 528, 528, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65504, 65504, 65504, 65504, 65504, 65504, + 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, + 65504, 65504, 65504, 65504, 65504, 65504, 65504, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, + 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 0, 65504, + 65504, 65504, 65504, 65504, 65504, 65504, 121, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 0, 0, 65535, 0, 65535, 0, 65535, 0, 0, 65535, 0, 65535, 0, 65535, 0, 65535, + 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 0, + 65535, 0, 65535, 0, 65535, 0, 195, 0, 0, 65535, 0, 65535, 0, 0, 65535, 0, 0, 0, 65535, 0, 0, 0, + 0, 0, 65535, 0, 0, 97, 0, 0, 0, 65535, 163, 0, 0, 0, 130, 0, 0, 65535, 0, 65535, 0, 65535, 0, + 0, 65535, 0, 0, 0, 0, 65535, 0, 0, 65535, 0, 0, 0, 65535, 0, 65535, 0, 0, 65535, 0, 0, 0, + 65535, 0, 56, 0, 0, 0, 0, 0, 0, 65534, 0, 0, 65534, 0, 0, 65534, 0, 65535, 0, 65535, 0, 65535, + 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 65457, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 0, 0, 65534, 0, 65535, 0, 0, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 0, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 0, 0, 0, 0, 0, 0, 0, 65535, 0, 0, 0, 0, 0, 65535, 0, 0, 0, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 10783, 10780, 0, 65326, 65330, 0, 65331, 65331, 0, 65334, + 0, 65333, 0, 0, 0, 0, 65331, 0, 0, 65329, 0, 0, 0, 0, 65327, 65325, 0, 10743, 0, 0, 0, 65325, + 0, 10749, 65323, 0, 0, 65322, 0, 0, 0, 0, 0, 0, 0, 10727, 0, 0, 65318, 0, 0, 65318, 0, 0, 0, 0, + 65318, 65467, 65319, 65319, 65465, 0, 0, 0, 0, 0, 65317, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65535, 0, 65535, 0, 0, 0, 65535, 0, 0, 0, + 130, 130, 130, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65498, 65499, 65499, 65499, 0, 65504, + 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, + 65504, 65504, 65504, 0, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65472, + 65473, 65473, 0, 0, 0, 0, 0, 0, 0, 0, 65528, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, + 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 0, 7, 0, 0, 0, 0, 0, + 65535, 0, 0, 65535, 0, 0, 0, 0, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, + 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, + 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65456, 65456, 65456, + 65456, 65456, 65456, 65456, 65456, 65456, 65456, 65456, 65456, 65456, 65456, 65456, 65456, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, + 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 65521, 0, 65535, 0, 65535, + 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65488, 65488, + 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, + 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, + 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 35332, 0, 0, 0, 3814, 0, 0, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 8, 8, 8, 8, 8, 8, 8, 8, 0, 0, 0, 0, 0, 0, 0, 0, + 8, 8, 8, 8, 8, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, 8, 8, 8, 8, 8, 8, 8, 0, 0, 0, 0, 0, 0, 0, 0, + 8, 8, 8, 8, 8, 8, 8, 8, 0, 0, 0, 0, 0, 0, 0, 0, 8, 8, 8, 8, 8, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 8, 0, 8, 0, 8, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 8, 8, 8, 8, 8, 8, 8, 8, 0, 0, 0, 0, 0, 0, 0, 0, + 74, 74, 86, 86, 86, 86, 100, 100, 128, 128, 112, 112, 126, 126, 0, 0, 8, 8, 8, 8, 8, 8, 8, 8, + 0, 0, 0, 0, 0, 0, 0, 0, 8, 8, 8, 8, 8, 8, 8, 8, 0, 0, 0, 0, 0, 0, 0, 0, 8, 8, 8, 8, 8, 8, 8, 8, + 0, 0, 0, 0, 0, 0, 0, 0, 8, 8, 0, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 8, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, 8, 0, 0, 0, 7, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 65508, 0, 65520, 65520, 65520, 65520, 65520, 65520, 65520, 65520, 65520, + 65520, 65520, 65520, 65520, 65520, 65520, 65520, 0, 0, 0, 0, 65535, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 65510, 65510, 65510, 65510, 65510, 65510, 65510, 65510, 65510, 65510, 65510, 65510, + 65510, 65510, 65510, 65510, 65510, 65510, 65510, 65510, 65510, 65510, 65510, 65510, 65510, + 65510, 0, 0, 0, 0, 0, 0, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, + 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, + 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, + 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 65488, 0, 0, 65535, 0, 0, + 0, 54741, 54744, 0, 65535, 0, 65535, 0, 65535, 0, 0, 0, 0, 0, 0, 65535, 0, 0, 65535, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 58272, 58272, 58272, 58272, 58272, 58272, 58272, 58272, 58272, 58272, 58272, 58272, + 58272, 58272, 58272, 58272, 58272, 58272, 58272, 58272, 58272, 58272, 58272, 58272, 58272, + 58272, 58272, 58272, 58272, 58272, 58272, 58272, 58272, 58272, 58272, 58272, 58272, 58272, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 0, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 0, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 0, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, + 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 65535, 0, 65535, 0, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 65535, 0, 0, 0, 0, 65535, 0, + 0, 0, 0, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, + 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, 65504, + 65504, 0, 0, 0, 0, 0, +]; + +/// Cross-platform implementation of `ntdll.RtlUpcaseUnicodeChar`. +/// Transforms the UTF-16 code unit in `c` to its uppercased version if there +/// is one. Otherwise, returns `c` unmodified. +#[must_use] +pub fn upcase_w(c: u16) -> u16 { + if c < u16::from(b'a') { + return c; + } + if c <= u16::from(b'z') { + return c - (u16::from(b'a') - u16::from(b'A')); + } + if c >= 0xC0 { + let mut offset: u16 = 0; + + offset = offset.wrapping_add(u16::from((c >> 8) as u8)); + offset = UPPERCASE_TABLE[usize::from(offset)]; + offset = offset.wrapping_add((c >> 4) & 0xF); + offset = UPPERCASE_TABLE[usize::from(offset)]; + offset = offset.wrapping_add(c & 0xF); + offset = UPPERCASE_TABLE[usize::from(offset)]; + + return c.wrapping_add(offset); + } + c +} + +#[cfg(test)] +mod tests { + use super::upcase_w; + + #[test] + fn ascii_and_latin() { + assert_eq!(upcase_w(u16::from(b'a')), u16::from(b'A')); + assert_eq!(upcase_w(u16::from(b'Z')), u16::from(b'Z')); + assert_eq!(upcase_w(u16::from(b'_')), u16::from(b'_')); + assert_eq!(upcase_w(0xE9), 0xC9); // e acute + assert_eq!(upcase_w(0xFF), 0x178); // y diaeresis + assert_eq!(upcase_w(0xDF), 0xDF); // sharp s has no simple uppercase + } + + #[test] + fn matches_the_windows_table_not_modern_unicode() { + // Windows folds the Greek prosgegrammeni forms U+1F80 -> U+1F88. + assert_eq!(upcase_w(0x1F88), 0x1F88); + assert_eq!(upcase_w(0x1F80), 0x1F88); + // U+1E9E (capital sharp s) and U+A64A-era additions are unchanged. + assert_eq!(upcase_w(0x1E9E), 0x1E9E); + // Georgian Mtavruli (U+1C90) is newer than the table: its lowercase + // U+10D0 maps to nothing under Windows but to U+1C90 in Unicode. + assert_eq!(upcase_w(0x10D0), 0x10D0); + // Surrogate halves are untouched. + assert_eq!(upcase_w(0xD800), 0xD800); + assert_eq!(upcase_w(0xDFFF), 0xDFFF); + } +} diff --git a/crates/ghostty-renderer/src/link.rs b/crates/ghostty-renderer/src/link.rs index b9638e1..c46c115 100644 --- a/crates/ghostty-renderer/src/link.rs +++ b/crates/ghostty-renderer/src/link.rs @@ -43,7 +43,7 @@ impl Set { let mut links = Vec::with_capacity(config.len()); for link in config { links.push(Link { - regex: Regex::new(&link.regex)?, + regex: Regex::new(&ghostty_os::osbytes::config_str_to_text(&link.regex))?, highlight: link.highlight, }); } diff --git a/crates/ghostty-terminal/Cargo.toml b/crates/ghostty-terminal/Cargo.toml index 18371e9..04abf44 100644 --- a/crates/ghostty-terminal/Cargo.toml +++ b/crates/ghostty-terminal/Cargo.toml @@ -25,7 +25,7 @@ ghostty-onig.workspace = true [target.'cfg(not(any(target_arch = "wasm32", windows)))'.dependencies] rustix = { version = "1.1.5", features = ["shm", "fs"] } -[target.'cfg(target_vendor = "apple")'.dependencies] +[target.'cfg(any(target_vendor = "apple", windows))'.dependencies] ghostty-os.workspace = true [dev-dependencies] diff --git a/crates/ghostty-terminal/src/kitty/graphics_image.rs b/crates/ghostty-terminal/src/kitty/graphics_image.rs index 28b0de6..bb7411a 100644 --- a/crates/ghostty-terminal/src/kitty/graphics_image.rs +++ b/crates/ghostty-terminal/src/kitty/graphics_image.rs @@ -11,24 +11,40 @@ use std::{ time::Instant, }; -/// wasm32 has no `std::os::unix`; paths are treated as UTF-8 there. +/// wasm32 has no `std::os::unix`; on Windows `as_encoded_bytes` is WTF-8. #[cfg(not(unix))] trait OsStrExt { - fn from_bytes(bytes: &[u8]) -> &std::ffi::OsStr; fn as_bytes(&self) -> &[u8]; } #[cfg(not(unix))] impl OsStrExt for std::ffi::OsStr { - fn from_bytes(bytes: &[u8]) -> &std::ffi::OsStr { - std::ffi::OsStr::new(std::str::from_utf8(bytes).unwrap_or("")) - } - fn as_bytes(&self) -> &[u8] { self.as_encoded_bytes() } } +/// The OS path named by protocol payload bytes. Unix paths are the bytes; +/// on Windows they are WTF-8 (lone surrogates are valid, malformed input is +/// not a path); elsewhere (wasm) they must be UTF-8. +#[cfg(unix)] +#[allow(clippy::unnecessary_wraps)] // same signature as the fallible targets +fn os_path_from_bytes(bytes: &[u8]) -> Option { + Some(std::ffi::OsStr::from_bytes(bytes).to_owned()) +} + +#[cfg(windows)] +fn os_path_from_bytes(bytes: &[u8]) -> Option { + ghostty_os::osbytes::EncodedOs::from_bytes(bytes) + .try_to_os() + .ok() +} + +#[cfg(not(any(unix, windows)))] +fn os_path_from_bytes(bytes: &[u8]) -> Option { + std::str::from_utf8(bytes).ok().map(Into::into) +} + use ghostty_vt::kitty::graphics_command::{ Command, Display, @@ -137,9 +153,11 @@ impl LoadingImage { match t.medium { TransmissionMedium::Direct => unreachable!("handled above"), TransmissionMedium::File | TransmissionMedium::TemporaryFile => { - let path = match std::fs::canonicalize(Path::new(std::ffi::OsStr::from_bytes( - &cmd.data, - ))) { + let Some(os_path) = os_path_from_bytes(&cmd.data) else { + log::warn!("failed to get absolute path: BadPathName"); + return Err(Error::InvalidData); + }; + let path = match std::fs::canonicalize(Path::new(&os_path)) { Ok(p) => p, Err(err) => { log::warn!("failed to get absolute path: {err}"); @@ -298,7 +316,10 @@ impl LoadingImage { } } - let os_path = Path::new(std::ffi::OsStr::from_bytes(path)); + let Some(os_path) = os_path_from_bytes(path) else { + return Err(Error::InvalidData); + }; + let os_path = Path::new(&os_path); let result = self.read_file_inner(os_path, offset, size); if temporary && let Err(err) = std::fs::remove_file(os_path) { log::warn!("failed to delete temporary file: {err}"); @@ -662,6 +683,24 @@ pub struct Rect { #[cfg(test)] mod tests { + #[test] + fn payload_path_bytes_are_kept() { + // Unix keeps arbitrary bytes; Windows accepts WTF-8 (lone + // surrogates) and rejects malformed input instead of using an + // empty path. + #[cfg(unix)] + { + let p = super::os_path_from_bytes(b"/tmp/\xff-tty-graphics-protocol").unwrap(); + assert_eq!(p.as_encoded_bytes(), b"/tmp/\xff-tty-graphics-protocol"); + } + #[cfg(windows)] + { + assert!(super::os_path_from_bytes(b"C:\\\xed\xa0\x80").is_some()); + assert!(super::os_path_from_bytes(b"C:\\\xff").is_none()); + } + assert!(super::os_path_from_bytes(b"plain").is_some()); + } + #[cfg(unix)] #[test] fn temp_dir_check_keeps_non_utf8_tmpdir_bytes() { diff --git a/crates/ghostty-termio/src/command_windows.rs b/crates/ghostty-termio/src/command_windows.rs index ca3e04f..6d96283 100644 --- a/crates/ghostty-termio/src/command_windows.rs +++ b/crates/ghostty-termio/src/command_windows.rs @@ -50,6 +50,8 @@ use crate::windows_args::{self, create_windows_env_block, windows_create_command /// Errors from [`Command::start`]. #[derive(Debug, thiserror::Error)] pub enum StartError { + #[error("malformed wtf-8 in a path, argument or environment value")] + InvalidWtf8(#[from] ghostty_os::osbytes::InvalidWtf8), #[error("string or environment is not valid unicode")] Encode(#[from] windows_args::EncodeError), #[error("windows error: {0}")] @@ -131,16 +133,22 @@ impl Command { pub fn start(&mut self) -> Result<(), StartError> { // `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); + // Like Zig's fallible `utf8ToUtf16Le*`, malformed input fails the + // launch instead of selecting a U+FFFD-substituted path or argument. + let application_w = EncodedOs::from_encoded(&self.path).try_to_wide_nul()?; + let cwd_w = self + .cwd + .as_ref() + .map(EncodedOs::try_to_wide_nul) + .transpose()?; let mut command_line_w = if self.args.is_empty() { None } else { let wide: Vec> = self .args .iter() - .map(|a| EncodedOs::from_encoded(a).to_wide()) - .collect(); + .map(|a| EncodedOs::from_encoded(a).try_to_wide()) + .collect::>()?; let mut line = windows_create_command_line_wide(&wide); line.push(0); Some(line) diff --git a/crates/ghostty-termio/src/exec.rs b/crates/ghostty-termio/src/exec.rs index ede533f..ddee46e 100644 --- a/crates/ghostty-termio/src/exec.rs +++ b/crates/ghostty-termio/src/exec.rs @@ -109,7 +109,7 @@ pub struct Config { pub cursor_blink: Option, pub working_directory: Option, pub resources_dir: Option, - pub term: String, + pub term: EncodedOs, pub rt_pre_exec_info: RtPreExecInfo, pub rt_post_fork_info: RtPostForkInfo, @@ -124,7 +124,7 @@ pub struct Config { impl Config { /// A config with Zig-struct-style defaults for everything optional. - pub fn new(env: EnvMap, term: impl Into) -> Config { + pub fn new(env: EnvMap, term: EncodedOs) -> Config { Config { command: None, env, @@ -134,7 +134,7 @@ impl Config { cursor_blink: None, working_directory: None, resources_dir: None, - term: term.into(), + term, rt_pre_exec_info: RtPreExecInfo::default(), rt_post_fork_info: RtPostForkInfo::default(), create_scope: None, @@ -801,7 +801,8 @@ impl Subprocess { // For now, we just look up a bundled dir but in the future we should // also load the terminfo database and look for it. if let Some(base) = &cfg.resources_dir { - env.insert("TERM", cfg.term.clone()); + // The configured TERM is an encoded OS string: export its bytes. + env.insert("TERM", cfg.term.to_os()); env.insert("COLORTERM", "truecolor"); // Assume that the resources directory is adjacent to the terminfo @@ -1586,6 +1587,18 @@ mod tests { ); } + #[test] + fn configured_term_is_exported_decoded() { + use std::os::unix::ffi::OsStrExt; + let dir = ghostty_os::TempDir::init().unwrap(); + for raw in [&b"x\xfft"[..], "t\u{fdd0}".as_bytes()] { + let mut cfg = Config::new(EnvMap::new(), EncodedOs::from_bytes(raw)); + cfg.resources_dir = Some(EncodedOs::from_path(&dir.path())); + let exec = Exec::new(cfg).unwrap(); + assert_eq!(exec.env().unwrap().get("TERM").unwrap().as_bytes(), raw); + } + } + #[test] fn exec_command_darwin_hushlogin_with_non_utf8_home() { use std::os::unix::ffi::OsStrExt; diff --git a/crates/ghostty-termio/src/exec_windows.rs b/crates/ghostty-termio/src/exec_windows.rs index 070fdde..eca4960 100644 --- a/crates/ghostty-termio/src/exec_windows.rs +++ b/crates/ghostty-termio/src/exec_windows.rs @@ -79,6 +79,10 @@ pub enum Error { Start(#[from] command::StartError), #[error(transparent)] ShellIntegration(#[from] shell_integration::Error), + /// A configured path, argument or environment value is not well-formed + /// WTF-8 (Zig's process launch conversions fail on such input). + #[error(transparent)] + InvalidWtf8(#[from] ghostty_os::osbytes::InvalidWtf8), #[error("io error: {0}")] Io(#[from] io::Error), #[error("failed to get WINDIR, cannot run shell command")] @@ -102,12 +106,12 @@ pub struct Config { pub cursor_blink: Option, pub working_directory: Option, pub resources_dir: Option, - pub term: String, + pub term: EncodedOs, } impl Config { /// A config with Zig-struct-style defaults for everything optional. - pub fn new(env: EnvMap, term: impl Into) -> Config { + pub fn new(env: EnvMap, term: EncodedOs) -> Config { Config { command: None, env, @@ -117,7 +121,7 @@ impl Config { cursor_blink: None, working_directory: None, resources_dir: None, - term: term.into(), + term, } } } @@ -585,13 +589,20 @@ fn write_thread_main(handle: &OwnedHandle, rx: &Receiver>) { /// Insert the config `env` overrides, decoding the encoded key/value text /// to OS strings (WTF-16 on Windows) like the Unix implementation. -fn apply_env_overrides(env: &mut EnvMap, overrides: &RepeatableStringMap) { +/// +/// Malformed WTF-8 fails (Zig's conversions for the environment block are +/// fallible) instead of being substituted with U+FFFD. +fn apply_env_overrides( + env: &mut EnvMap, + overrides: &RepeatableStringMap, +) -> Result<(), ghostty_os::osbytes::InvalidWtf8> { for (k, v) in overrides.iter() { env.insert( - ghostty_os::osbytes::config_str_to_os(k), - ghostty_os::osbytes::config_str_to_os(v), + EncodedOs::from_encoded(k).try_to_os()?, + EncodedOs::from_encoded(v).try_to_os()?, ); } + Ok(()) } struct Subprocess { @@ -617,18 +628,20 @@ impl Subprocess { 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()); + env.insert("GHOSTTY_RESOURCES_DIR", dir.try_to_os()?); } // Set our TERM var. This is a bit complicated because we want to use // the ghostty TERM value but we want to only do that if we have // ghostty in the TERMINFO database. if let Some(base) = &cfg.resources_dir { - env.insert("TERM", cfg.term.clone()); + // The configured TERM is an encoded OS string: export its bytes. + env.insert("TERM", cfg.term.try_to_os()?); env.insert("COLORTERM", "truecolor"); // Assume that the resources directory is adjacent to the terminfo // database + base.try_to_os()?; // malformed WTF-8 fails like Zig env.insert("TERMINFO", terminfo_dir(base)); } else { env.insert("TERM", "xterm-256color"); @@ -698,7 +711,7 @@ impl Subprocess { }; // Add the environment variables that override any others. - apply_env_overrides(&mut env, &cfg.env_override); + apply_env_overrides(&mut env, &cfg.env_override)?; // Build our args list. If we fail to find the shell, we'd still like // to try to run something so Ghostty can launch. @@ -720,7 +733,7 @@ impl Subprocess { // Propagate the current working directory (CWD) to the shell. if let Some(pwd) = &cwd { - env.insert("PWD", pwd.to_os()); + env.insert("PWD", pwd.try_to_os()?); } Ok(Subprocess { @@ -948,7 +961,7 @@ mod tests { m.parse_cli(Some(&format!("A={lone}"))).unwrap(); m.parse_cli(Some(&format!("{lit}=v"))).unwrap(); let mut env = EnvMap::new(); - super::apply_env_overrides(&mut env, &m); + super::apply_env_overrides(&mut env, &m).unwrap(); assert_eq!( env.get("A").unwrap(), std::ffi::OsString::from_wide(&[0x61, 0xD800]) @@ -956,6 +969,17 @@ mod tests { assert_eq!(env.get("K\u{fdd0}").unwrap(), "v"); } + #[test] + fn malformed_wtf8_env_overrides_fail() { + use ghostty_config::cli_args::ConfigField; + + let bad = EncodedOs::from_bytes(b"a\xff").into_encoded(); + let mut m = RepeatableStringMap::default(); + m.parse_cli(Some(&format!("A={bad}"))).unwrap(); + let mut env = EnvMap::new(); + assert!(super::apply_env_overrides(&mut env, &m).is_err()); + } + #[test] fn terminfo_dir_is_sibling_of_resources_and_lossless() { use std::os::windows::ffi::{OsStrExt, OsStringExt}; diff --git a/crates/ghostty-termio/src/shell_integration.rs b/crates/ghostty-termio/src/shell_integration.rs index 3cf8046..1c62c37 100644 --- a/crates/ghostty-termio/src/shell_integration.rs +++ b/crates/ghostty-termio/src/shell_integration.rs @@ -263,7 +263,9 @@ fn setup_bash( env.insert("GHOSTTY_BASH_INJECT", inject); if let Some(v) = rcfile { - env.insert("GHOSTTY_BASH_RCFILE", v); + // The intercepted argument is an encoded OS string; the env var gets + // its original bytes (Zig inserts the argument slice unchanged). + env.insert("GHOSTTY_BASH_RCFILE", EncodedOs::from_encoded(v).to_os()); } // In POSIX mode, HISTFILE defaults to ~/.sh_history, so unless we're @@ -671,6 +673,24 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn bash_rcfile_keeps_original_bytes() { + use std::os::unix::ffi::OsStrExt; + + let res = TmpResourcesDir::new(Shell::Bash); + for raw in [&b"/home/x/\xffrc"[..], "/home/x/\u{fdd0}rc".as_bytes()] { + let mut env = EnvMap::new(); + let command = Command::Direct(vec![ + "bash".to_owned(), + "--rcfile".to_owned(), + EncodedOs::from_bytes(raw).into_encoded(), + ]); + setup_bash(&command, &res.enc(), &mut env).unwrap(); + assert_eq!(env.get("GHOSTTY_BASH_RCFILE").unwrap().as_bytes(), raw); + } + } + #[test] fn bash_histfile() { let res = TmpResourcesDir::new(Shell::Bash); diff --git a/crates/ghostty-termio/src/stream_handler.rs b/crates/ghostty-termio/src/stream_handler.rs index 7271d61..37524c0 100644 --- a/crates/ghostty-termio/src/stream_handler.rs +++ b/crates/ghostty-termio/src/stream_handler.rs @@ -348,7 +348,7 @@ impl TerminalStream { let st = &mut self.state; st.osc_color_report_format = config.osc_color_report_format; st.clipboard_write = config.clipboard_write; - st.enquiry_response = config.enquiry_response.clone().into_bytes(); + st.enquiry_response.clone_from(&config.enquiry_response); st.default_cursor_style = config.cursor_style; st.default_cursor_blink = config.cursor_blink; diff --git a/crates/ghostty-termio/src/termio.rs b/crates/ghostty-termio/src/termio.rs index 1d94e1f..34a48dc 100644 --- a/crates/ghostty-termio/src/termio.rs +++ b/crates/ghostty-termio/src/termio.rs @@ -108,7 +108,8 @@ pub struct DerivedConfig { pub background: Color, pub osc_color_report_format: OSCColorReportFormat, pub clipboard_write: ClipboardAccess, - pub enquiry_response: String, + /// The decoded bytes of `enquiry-response` (written to the pty as is). + pub enquiry_response: Vec, pub conditional_state: conditional::State, } @@ -142,7 +143,7 @@ impl DerivedConfig { background: config.background, osc_color_report_format: config.osc_color_report_format, clipboard_write: config.clipboard_write, - enquiry_response: config.enquiry_response.clone(), + enquiry_response: ghostty_os::osbytes::config_str_to_bytes(&config.enquiry_response), conditional_state: config.conditional_state, } } @@ -175,8 +176,9 @@ impl ThreadEnterState { match item { ReadableIO::Raw(v) => out.push(ghostty_os::osbytes::config_str_to_bytes(v)), ReadableIO::Path(path) => { - let f = std::fs::File::open(path).map_err(|err| { - log::warn!("failed to open input file={path} err={err}"); + // The path is an encoded OS string: decode it to open. + let f = std::fs::File::open(path.to_path()).map_err(|err| { + log::warn!("failed to open input file={} err={err}", path.as_encoded()); Error::InputNotFound })?; let mut buf = Vec::new(); @@ -419,7 +421,7 @@ impl Termio { renderer_wakeup: renderer_wakeup.clone(), default_cursor_style: config.cursor_style, default_cursor_blink: config.cursor_blink, - enquiry_response: config.enquiry_response.clone().into_bytes(), + enquiry_response: config.enquiry_response.clone(), osc_color_report_format: config.osc_color_report_format, clipboard_write: config.clipboard_write, }); @@ -791,3 +793,26 @@ fn set_image_limits(term: &mut Terminal, limit: usize) { } } } + +#[cfg(test)] +mod derived_config_tests { + use super::*; + + #[test] + fn enquiry_response_is_decoded_to_bytes() { + let mut cfg = Config { + enquiry_response: ghostty_os::osbytes::EncodedOs::from_bytes( + "a\u{fdd0}\u{ff}\u{e9}".as_bytes(), + ) + .into_encoded(), + ..Config::default() + }; + assert_eq!( + DerivedConfig::new(&cfg).enquiry_response, + "a\u{fdd0}\u{ff}\u{e9}".as_bytes() + ); + let raw = b"x\xffy"; + cfg.enquiry_response = ghostty_os::osbytes::EncodedOs::from_bytes(raw).into_encoded(); + assert_eq!(DerivedConfig::new(&cfg).enquiry_response, raw); + } +} diff --git a/crates/ghostty-termio/tests/exec_integration.rs b/crates/ghostty-termio/tests/exec_integration.rs index 65f303c..490c8f8 100644 --- a/crates/ghostty-termio/tests/exec_integration.rs +++ b/crates/ghostty-termio/tests/exec_integration.rs @@ -103,7 +103,10 @@ fn start(command: ConfigCommand, tweak: impl FnOnce(&mut Config)) -> Fixture { let mut config = base_config(); tweak(&mut config); - let mut ecfg = exec::Config::new(ghostty_os::env::get_env_map(), "xterm-256color"); + let mut ecfg = exec::Config::new( + ghostty_os::env::get_env_map(), + ghostty_os::osbytes::EncodedOs::from_plain_text("xterm-256color"), + ); ecfg.command = Some(command); ecfg.shell_integration = ShellIntegration::None; // The default config has `linux-cgroup = single-instance` + @@ -360,9 +363,9 @@ fn initial_input_is_written_after_start() { #[test] fn missing_input_file_shows_error_and_thread_keeps_draining() { let mut f = start(sh("true"), |c| { - c.input - .list - .push(ReadableIO::Path("/nonexistent/ghostty-input".into())); + c.input.list.push(ReadableIO::Path( + ghostty_os::osbytes::EncodedOs::from_plain_text("/nonexistent/ghostty-input"), + )); }); f.wait_text("A configured `input` path was not found"); assert!( @@ -575,7 +578,10 @@ fn change_config_message_updates_palette_and_colors() { fn process_output_manual_api() { // Termio can also be fed manually (no child involved). let config = base_config(); - let mut ecfg = exec::Config::new(ghostty_os::EnvMap::new(), "xterm-256color"); + let mut ecfg = exec::Config::new( + ghostty_os::EnvMap::new(), + ghostty_os::osbytes::EncodedOs::from_plain_text("xterm-256color"), + ); ecfg.command = Some(sh("true")); let (tx, _rx) = unbounded(); let termio = Termio::init(Options { diff --git a/crates/ghostty-termio/tests/flatpak_exec.rs b/crates/ghostty-termio/tests/flatpak_exec.rs index eb9aad6..8fb2df2 100644 --- a/crates/ghostty-termio/tests/flatpak_exec.rs +++ b/crates/ghostty-termio/tests/flatpak_exec.rs @@ -108,7 +108,10 @@ fn start(command: ConfigCommand, tweak: impl FnOnce(&mut Config)) -> Fixture { let mut config = base_config(); tweak(&mut config); - let mut ecfg = exec::Config::new(ghostty_os::env::get_env_map(), "xterm-256color"); + let mut ecfg = exec::Config::new( + ghostty_os::env::get_env_map(), + ghostty_os::osbytes::EncodedOs::from_plain_text("xterm-256color"), + ); ecfg.command = Some(command); ecfg.shell_integration = ShellIntegration::None; // The default config has `linux-cgroup = single-instance` + diff --git a/docs/rewrite/notes/final-Z9.md b/docs/rewrite/notes/final-Z9.md new file mode 100644 index 0000000..484a633 --- /dev/null +++ b/docs/rewrite/notes/final-Z9.md @@ -0,0 +1,96 @@ +# Z9: RV7 gaps and the string-field decode audit + +Builds on `final-Z7.md` / `final-Z8.md` (`EncodedOs`, the reversible `U+FDD0` +escape in `ghostty_os::osbytes`). Contract used below: + +* **encoded** = a config/CLI `String` in the reversible encoding. It must be + decoded (`EncodedOs::{to_os,to_path,to_bytes,to_wide}`, + `config_str_to_bytes`, or for UTF-8-only UI text `config_str_to_text`) before it + reaches the OS, the pty, the clipboard, or C. +* **plain** = real text; it may only become an encoded value through + `from_plain_text` / `valid_str_to_config_string` (escapes a literal `U+FDD0`). + +New helpers: `osbytes::{InvalidWtf8, try_wtf8_to_wide, config_str_to_text}`, +`EncodedOs::{try_to_wide, try_to_wide_nul, try_to_os}`, +`ghostty_config::string::parse_bytes`, `ghostty_os::windows_nls::upcase_w`. + +## 1. The nine RV7 gaps + +| # | Fix | Test | +|---|---|---| +| 1 Windows env key casing | `windows_nls.rs` is a port of `std/os/windows/nls.zig` (the 2544-entry `l_intl.nls` table and `upcaseW`); `EnvMap` keys use it per UTF-16 unit instead of `char::to_uppercase` | `windows_nls::tests::*`, `env::windows_key_tests::keys_use_the_windows_table_not_unicode_mappings` (U+1F80 == U+1F88, Georgian distinct, no `ß` == `SS`) | +| 2 Windows locale init | `locale.rs` `posix` module now also builds on Windows: `setlocale` with Zig's literal `LC_ALL = 6` (the UCRT rejects it exactly like Zig, so the chain continues) and `_putenv_s` for the `LANG` updates (unsafe with SAFETY comments). `ensure_locale` is only a no-op on wasm | the mock-`Sys` chain tests run everywhere; Windows glue compiled by `check-windows` | +| 3 bash `--rcfile` | `GHOSTTY_BASH_RCFILE` = `EncodedOs::from_encoded(arg).to_os()` | `bash_rcfile_keeps_original_bytes` (byte FF, literal U+FDD0) | +| 4 configured `TERM` | termio `Config.term` is now `EncodedOs`; Unix exports `to_os()`, Windows `try_to_os()?` | `configured_term_is_exported_decoded` | +| 5 GTK inherited `LANG` | `saved_language` is `Option` read with `var_os`; the `language` config override is decoded; `set_env` takes `AsRef`; env restore factored into `restore_language` | `inherited_lang_keeps_non_utf8_bytes` | +| 6 Windows Kitty paths | `os_path_from_bytes`: Unix bytes, Windows WTF-8 via `EncodedOs::try_to_os` (lone surrogates ok, malformed -> `InvalidData`, no empty-path fallback) | `payload_path_bytes_are_kept` | +| 7 malformed WTF-8 | `try_wtf8_to_wide` (strict; lossy `wtf8_to_wide` kept for non-launch use). `Command::start` (path, cwd, argv) uses `try_to_wide*` -> `StartError::InvalidWtf8`; the Windows exec env block/overrides/`GHOSTTY_RESOURCES_DIR`/`TERM`/`TERMINFO`/`PWD` use `try_to_os` -> `Error::InvalidWtf8` | `strict_wtf8_rejects_malformed_input`, `malformed_wtf8_env_overrides_fail` (cfg(windows)) | +| 8 embedded C API | `ghostty_surface_complete_clipboard_request` passes bytes (`CoreSurface::complete_clipboard_request` / `Surface::complete_clipboard_request` now take `&[u8]`; the confirm callback gets the caller's `text` pointer unchanged); `ghostty_surface_binding_action` encodes the bytes with `bytes_to_config_string` before `Action::parse` | `clipboard_completion_and_binding_action_keep_raw_bytes` (child receives `61 ff` / `62 ff`) | +| 9 text/csi/esc bindings | `Text` uses new `string::parse_bytes`; `Csi`/`Esc` write `config_str_to_bytes(data)` (128-byte limit applies to bytes). `string::parse` now escapes a literal `U+FDD0` that an escape sequence produces | `config_loaded_text_csi_esc_write_decoded_bytes`, `string::tests::parse_bytes_decodes_the_reversible_encoding` | + +## 2. Systematic audit: config fields -> consumers -> decode status + +"OK" = decoded at the boundary (or is the typed `EncodedOs`/bytes); "plain" = +guaranteed text with no OS meaning; "FIXED" = changed in Z9. + +| Field / type | Consumers that reach OS / pty / clipboard / C | Status | +|---|---|---| +| `language` (String) | GTK `LANG` override | FIXED (decoded; was raw encoded) | +| `font-family*` (RepeatableString) | `Descriptor.family` -> fontconfig / CoreText | FIXED (`config_str_to_text` in `shared_grid_set::Key::new`; font names are UTF-8 for every backend) | +| `font-feature` | HarfBuzz/CoreText feature tags | plain (ASCII tags parsed from text) | +| `font-variation*`, `font-codepoint-map`, `adjust-*`, `font-style*` | descriptors/metrics | plain (tags, numbers, names) | +| `clipboard-codepoint-map` replacement | clipboard text from the formatter | FIXED (`decode_codepoint_map`; the formatter emits text, so non-UTF-8 replacement bytes can only become U+FFFD, a literal U+FDD0 is restored) | +| `title` | `SetTitle` action (all apprts, C `set_title`) | FIXED (`config_str_to_text` in core + GTK window) | +| `class` | GTK application id (`id_is_valid`) | plain (must be an ASCII-ish app id; encoded junk is rejected as invalid) | +| `window-title-font-family` | GTK CSS | FIXED (decoded) | +| `gtk-quick-terminal-namespace` | layer-shell namespace (`&str` API) | FIXED (decoded text) | +| `macos-custom-icon` | only via C `ghostty_config_get` | OK (`config_capi::cstring_value` decodes) | +| `term` | child env `TERM` (Unix, Windows) | FIXED (`EncodedOs` through `exec::Config`) | +| `enquiry-response` | pty write on ENQ | FIXED (was `String::into_bytes()` of the encoded text; `DerivedConfig.enquiry_response` is now decoded `Vec`; test `enquiry_response_is_decoded_to_bytes`) | +| `command`, `initial-command` (`Command`) | exec argv/shell string, Windows `CreateProcessW`, flatpak `ay`, macOS login | OK (decoded in `command.rs` `config_str_to_bytes`; Windows strict `try_to_wide`); window title from argv[0]/`xdg-terminal-exec` is FIXED (decoded text) | +| `env` (RepeatableStringMap) | child environment | OK (Unix `config_str_to_os`; Windows strict `try_to_os`) | +| `input` (RepeatableReadableIO) | pty write; file read | `Raw`: OK (decoded after `clone_parsed`). `Path`: FIXED, now typed `EncodedOs` (was opened undecoded); embedded `initial_input` escape works on bytes (`string_escape`) so `parse_bytes(string_escape(b)) == b`; test `initial_input_escape_round_trips_bytes`, `readable_io_path_decodes_to_the_original_bytes` | +| `link` regex | onig regex over terminal text | FIXED (decoded text; a pattern byte >0x7F that is not UTF-8 cannot match terminal text anyway) | +| `working-directory`, `background-image`, `custom-shader`, `gtk-custom-css`, `bell-audio-path`, `config-file`, `theme` | filesystem | OK (typed `EncodedOs` since Z7) | +| `command-palette-entry` title/description | GTK palette labels | FIXED (decoded text); its `action` follows the keybind row | +| `keybind` payloads: `text`, `csi`, `esc` | pty | FIXED (gap 9) | +| `keybind` `search:` | search needle bytes | FIXED (decoded; the GTK search entry encodes its plain UI text with `valid_str_to_config_string`) | +| `keybind` `set_surface_title`, `set_tab_title` | `SetTitle` action | FIXED (decoded text) | +| `keybind` `activate_key_table*` | table-name lookup (config vs config) | plain/consistent (both sides encoded) | +| `keybind` `cursor_key` | pty | not config-parsable (raw `String` built in code) | +| `selection-word-chars` | codepoints | plain | +| `Diagnostic.{key,message}` | `+validate-config` stdout, `+show-face` stderr | FIXED (print decoded bytes); GUI diagnostics are display text | +| CLI/C: `ghostty_init` argv, `ghostty_config_load_file`, surface options (`working_directory`, `command`, `env_vars`, `initial_input`), `ghostty_surface_text`, clipboard completion, binding action, `ghostty_config_get`/`open_path` | | OK (bytes in, encoded or bytes) | +| C key event `text`, preedit | `String` (lossy) | documented limit: UTF-8 text by the `ghostty.h` contract (IME/key text); would need a byte `KeyEvent.utf8` across `ghostty-input` | + +## 3. OS data entering the system (byte preserving) + +| Source | Entry point | Status | +|---|---|---| +| process environment | `std::env::var_os` / `vars_os` everywhere (`get_env_map`, `getenv`, `homedir`, `xdg`, `shell_from_env`, TMPDIR/TMP, GTK `LANG`, `PATH`); `std::env::var` remains only for ASCII-only developer switches (`GHOSTTY_LOG`, `RUST_LOG`, `COLORFGBG`, `GHOSTTY_DEBUG_*`) | OK | +| argv | `os::args` (`args_os` / C `ghostty_init` bytes) -> `collect_config_strings` | OK | +| config files, theme files | read as bytes -> encoded strings | OK | +| passwd (`getpwuid`), `uname` | bytes -> `EncodedOs` (hostname: text for comparison) | OK | +| OSC 7 pwd, OSC 8 URIs, kitty file paths | bytes kept (`set_pwd(&[u8])`, `os_path_from_bytes`) | OK | +| GTK file drops | `os_bytes(path)` into the shell-escaped paste | OK | +| clipboard (C API, OSC 52, GTK text) | bytes (`&[u8]` in `complete_clipboard_request`; GTK text is UTF-8) | OK | +| D-Bus / flatpak | cwd and argv `ay` bytes; environment is `a{ss}` (UTF-8 by D-Bus type) | known limit | +| `+new-window` over D-Bus | arguments are GVariant strings | known limit (UTF-8 by type, as Zig) | +| Windows APIs | WTF-16 via `EncodedOs::{from_wide,to_wide}`; process launch is strict | OK | + +## 4. Typed where practical + +`exec::Config.term` (`EncodedOs`), `DerivedConfig.enquiry_response` (`Vec`), +`ReadableIO::Path` (`EncodedOs`), `ghostty_apprt::CoreSurface::complete_clipboard_request` +(`&[u8]`). The remaining `String` config fields are either UI/plain text (above) +or go through the generic config machinery (macro, formatter, C value) where a +newtype would touch every field; each consumer is listed and decoded at its +boundary instead. + +## 5. Gate + +`cargo test --workspace`, `clippy --workspace --all-targets -D warnings`, +`cargo +nightly fmt --all`, `just check-macos` (both targets), `just check-windows`, +`just check-wasm`, `+show-config --default --docs` vs `/usr/bin/ghostty` (only the +`gtk-opengl-debug` line differs): see the final report. Windows and macOS code is +compile-checked, not run natively.