fix: systematic byte fidelity for config strings and RV7 gaps

Resolves the 9 gap groups in docs/rewrite/notes/review-rv7-codex.md and
audits every string-typed config field against its consumers
(field -> consumers -> decode status table in final-Z9.md):
- Windows EnvMap keys use a port of Zig's nls.zig uppercase table
  (upcaseW); Windows locale init follows Zig's chain (setlocale +
  _putenv_s); malformed WTF-8 now fails process launch conversions like
  Zig instead of substituting U+FFFD; kitty file paths convert WTF-8.
- bash --rcfile/--init-file, configured TERM and inherited GTK LANG
  keep their original bytes.
- C API clipboard completion and ghostty_surface_binding_action take
  bytes; complete_clipboard_request is byte-typed through the core.
- text:/csi:/esc: bindings, enquiry-response, input = path:, embedded
  initial_input, search needles, titles, link regexes, clipboard
  codepoint map, command palette text and GTK font/namespace values
  decode the escape encoding before use.

Remaining by design: D-Bus a{ss} values and +new-window arguments are
UTF-8 by D-Bus type. C API key/preedit text follows separately.

Test Plan:
- cargo test --workspace
- just check-macos (both), just check-windows, just check-wasm
This commit is contained in:
ddidderr committed 2026-10-12 10:50:20 +02:00
1 parent 1ff5db0527
commit e6564cb838
40 files changed
+986 -167

No files matched your search

+1 -1
View File
@@ -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());
@@ -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<std::ffi::OsStr>) {
// 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<Option<CssProviders>>,
/// Copy of the LANG variable provided by the system, if any.
pub saved_language: RefCell<Option<String>>,
pub saved_language: RefCell<Option<std::ffi::OsString>>,
}
#[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<String> {
pub fn saved_language(&self) -> Option<std::ffi::OsString> {
self.imp().saved_language.borrow().clone()
}
@@ -110,7 +110,9 @@ mod command_imp {
fn prop_get_title(&self) -> Option<String> {
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<String> {
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,
+33 -9
View File
@@ -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<String>, 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.
+1 -1
View File
@@ -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)));
}
}
+1
View File
@@ -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"
@@ -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,
+3 -7
View File
@@ -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);
+6 -2
View File
@@ -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() {
+26 -1
View File
@@ -53,7 +53,11 @@ fn run_inner(opts: &Options, stdout: &mut dyn Write) -> Result<u8, Error> {
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 {
+24 -6
View File
@@ -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]
+37 -1
View File
@@ -75,8 +75,21 @@ fn parse_escape_sequence(bytes: &[u8], i: &mut usize) -> Option<u32> {
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<Vec<u8>, 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<String, StringError> {
let b = bytes.as_bytes();
let mut out = String::with_capacity(bytes.len());
@@ -95,7 +108,12 @@ pub fn parse(bytes: &str) -> Result<String, StringError> {
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]
+22 -9
View File
@@ -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<u8> = 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 => {
+44 -6
View File
@@ -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<formatter::CodepointMap> =
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<formatter::CodepointMap> {
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())
);
}
}
+1 -1
View File
@@ -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,
});
+9 -6
View File
@@ -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)
+4 -1
View File
@@ -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
+26 -1
View File
@@ -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("", &[]));
+2 -2
View File
@@ -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::<u8>::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\\");
+1 -3
View File
@@ -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).
+4 -4
View File
@@ -501,7 +501,7 @@ impl Key {
let mut descriptors: Vec<Descriptor> = 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,
+53 -24
View File
@@ -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::<u8>(), 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<ClipboardRequest>` leaked by
// `clipboard_request`; it stays alive when confirmation is requested.
let req = unsafe { *state.cast::<ClipboardRequest>() };
// 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]
+26
View File
@@ -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");
+15 -23
View File
@@ -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<u16> {
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<u16> {
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]
+1
View File
@@ -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;
+58 -6
View File
@@ -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<String> {
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<String> {
None
}
@@ -53,15 +56,52 @@ pub fn preferred_language_value<S: AsRef<str>>(preferred: &[S]) -> Option<String
(!out.is_empty()).then_some(out)
}
#[cfg(unix)]
#[cfg(any(unix, windows))]
#[allow(unsafe_code)]
mod posix {
#[cfg(windows)]
use std::ffi::c_char;
use std::ffi::{CStr, CString};
use super::FALLBACK_LOCALE;
#[cfg(target_vendor = "apple")]
use super::{format_lang, preferred_language_value};
/// Zig's `LC_ALL` constant (`os/locale.zig`, "from locale.h"). It is
/// the value the Zig code passes on every platform, Windows included.
#[cfg(windows)]
const LC_ALL: i32 = 6;
#[cfg(windows)]
unsafe extern "C" {
fn setlocale(category: i32, locale: *const c_char) -> *mut c_char;
fn _putenv_s(name: *const c_char, value: *const c_char) -> i32;
}
#[cfg(windows)]
fn setlocale_all(locale: &str) -> Option<String> {
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<String> {
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) };
+90
View File
@@ -174,6 +174,28 @@ pub fn wtf8_to_wide(b: &[u8]) -> Vec<u16> {
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<Vec<u16>, 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<Vec<u16>, InvalidWtf8> {
try_wtf8_to_wide(&self.to_bytes())
}
/// [`EncodedOs::try_to_wide`] plus a trailing NUL.
pub fn try_to_wide_nul(&self) -> Result<Vec<u16>, 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<OsString, InvalidWtf8> {
#[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<u16> {
@@ -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]);
+201
View File
@@ -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);
}
}
+1 -1
View File
@@ -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,
});
}
+1 -1
View File
@@ -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]
@@ -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<std::ffi::OsString> {
Some(std::ffi::OsStr::from_bytes(bytes).to_owned())
}
#[cfg(windows)]
fn os_path_from_bytes(bytes: &[u8]) -> Option<std::ffi::OsString> {
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::ffi::OsString> {
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() {
+12 -4
View File
@@ -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<Vec<u16>> = self
.args
.iter()
.map(|a| EncodedOs::from_encoded(a).to_wide())
.collect();
.map(|a| EncodedOs::from_encoded(a).try_to_wide())
.collect::<Result<_, _>>()?;
let mut line = windows_create_command_line_wide(&wide);
line.push(0);
Some(line)
+17 -4
View File
@@ -109,7 +109,7 @@ pub struct Config {
pub cursor_blink: Option<bool>,
pub working_directory: Option<EncodedOs>,
pub resources_dir: Option<EncodedOs>,
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<String>) -> 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;
+35 -11
View File
@@ -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<bool>,
pub working_directory: Option<EncodedOs>,
pub resources_dir: Option<EncodedOs>,
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<String>) -> 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<Vec<u8>>) {
/// 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};
+21 -1
View File
@@ -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);
+1 -1
View File
@@ -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;
+30 -5
View File
@@ -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<u8>,
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);
}
}
@@ -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 {
+4 -1
View File
@@ -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` +
+96
View File
@@ -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<OsString>` read with `var_os`; the `language` config override is decoded; `set_env` takes `AsRef<OsStr>`; 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<u8>`; 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<u8>`),
`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.