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:
1 parent
1ff5db0527
commit
e6564cb838
40 files changed
+986
-167
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("", &[]));
|
||||
|
||||
@@ -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\\");
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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` +
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user