refactor(config): parse config values from raw bytes like Zig

Zig parses config values (cli/args.zig, Config.zig field parsers,
config/string.zig) from raw byte slices, and each field type decides
whether it requires UTF-8 (InvalidValue otherwise) or stores bytes. The
port escape-encoded raw bytes into Strings at the line reader and let
fields parse the escaped text, so fields could observe encoding
artifacts; successive audits kept finding such leaks (RV5-RV8).

Field parsers now receive the original bytes: text fields validate UTF-8
where Zig does, byte/path fields store bytes (EncodedOs/OsString). This
resolves all 9 RV8 findings (docs/rewrite/notes/review-rv8-codex.md),
each with a regression test: Windows path conversion fails with
BadPathName instead of substituting U+FFFD; Windows process launch uses
strict UTF-8 like Zig's Command; the WTF-8 decoder accepts paired-
surrogate encodings like Zig; literal U+FDD0 Unicode key triggers;
selection-word-chars and clipboard-codepoint-map reject invalid UTF-8;
font style / codepoint-map family names use original bytes; C title
actions forward bytes; D-Bus new-window/Flatpak conversions match Zig.
Design, sweep and validation: docs/rewrite/notes/final-bytes-refactor.md.

Also moves the bundled iTerm2-Color-Schemes license out of
resources/themes/ so +list-themes no longer lists it as a theme
(output is now byte-identical to Ghostty 1.3.1).

Done by Codex (gpt-6-astra), reviewed and verified by the orchestrator.

Test Plan:
- cargo test --workspace --no-fail-fast (3428 passed, exit 0)
- just check-macos (both), just check-windows, just check-wasm
- +show-config --default --docs, +list-keybinds, +list-actions --docs,
  +list-themes --plain compared with /usr/bin/ghostty
This commit is contained in:
ddidderr committed 2026-10-12 11:57:01 +02:00
1 parent 70c250cf2d
commit 6f9b3129e1
100 files changed
+2310 -1054

No files matched your search

+4 -1
View File
@@ -234,7 +234,10 @@ fn init_with(opts: InitOptions, default_logging: Logging) -> &'static GlobalStat
// Setup i18n
if let Some(v) = resources_dir.app()
&& let Err(err) = ghostty_os::i18n::init(&v.to_path())
&& let Err(err) = v
.to_path()
.map_err(|e| e.to_string())
.and_then(|path| ghostty_os::i18n::init(&path).map_err(|e| e.to_string()))
{
log::warn!("failed to init i18n, translations will not be available err={err}");
}
+7 -13
View File
@@ -720,7 +720,7 @@ impl Surface {
};
// 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));
full.extend(data.to_bytes());
// 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());
@@ -734,10 +734,10 @@ impl Surface {
Action::Text(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) {
let text = match ghostty_config::string::parse_bytes(data.to_bytes()) {
Ok(t) => t,
Err(err) => {
log::warn!("error parsing text binding text={data} err={err:?}");
log::warn!("error parsing text binding text={data:?} err={err:?}");
return Ok(true);
}
};
@@ -824,9 +824,7 @@ impl Surface {
s.handle
// 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),
));
.send(SearchMessage::ChangeNeedle(text.to_bytes()));
s.handle.wakeup();
}
}
@@ -934,15 +932,11 @@ impl Surface {
}
Action::SetSurfaceTitle(v) => {
return Ok(self.perform(RtAction::SetTitle(SetTitle {
title: ghostty_os::osbytes::config_str_to_text(v),
})));
return Ok(self.perform(RtAction::SetTitle(SetTitle { title: v.clone() })));
}
Action::SetTabTitle(v) => {
return Ok(self.perform(RtAction::SetTabTitle(SetTitle {
title: ghostty_os::osbytes::config_str_to_text(v),
})));
return Ok(self.perform(RtAction::SetTabTitle(SetTitle { title: v.clone() })));
}
Action::ClearScreen => {
@@ -1330,7 +1324,7 @@ mod tests {
// action's EncodedOs decodes back to the same path.
assert_eq!(path_bytes(&path), path.as_os_str().as_bytes());
let url = ghostty_os::osbytes::EncodedOs::from_path(&path);
assert_eq!(url.to_path(), path);
assert_eq!(url.to_path().unwrap(), path);
}
#[test]
+4 -17
View File
@@ -409,21 +409,9 @@ impl Surface {
}
}
/// 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).
/// Clipboard replacements are validated UTF-8 by the config field parser.
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()
list.to_vec()
}
#[cfg(test)]
@@ -431,9 +419,8 @@ 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}");
fn string_replacements_are_plain_text() {
let enc = "a\u{fdd0}".to_owned();
let list = [formatter::CodepointMap {
range: [1, 2],
replacement: formatter::Replacement::String(enc),
+1 -1
View File
@@ -55,7 +55,7 @@ impl DerivedConfig {
let mut links = Vec::with_capacity(config.link.links.len());
for link in &config.link.links {
links.push(Link {
regex: Regex::new(&ghostty_os::osbytes::config_str_to_text(&link.regex))?,
regex: Regex::new(&link.regex)?,
action: link.action,
highlight: link.highlight,
});
+13 -8
View File
@@ -24,6 +24,7 @@ use ghostty_input::{
key_encode,
};
use ghostty_inspector as inspector;
use ghostty_os::osbytes::EncodedOs;
use ghostty_terminal::terminal::{MouseEvents, ScrollViewport, Terminal};
use ghostty_termio::message::Message as IoMessage;
use ghostty_vt::{modes::Mode, mouse_shape::MouseShape};
@@ -40,7 +41,7 @@ pub enum SetRoot {
/// The root keybinding set.
Root,
/// A named key table.
Table(String),
Table(EncodedOs),
}
/// The currently active key sequence: the set it started in plus the
@@ -54,7 +55,7 @@ pub struct SequencePath {
/// An entry of the key table stack.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableEntry {
pub name: String,
pub name: EncodedOs,
pub once: bool,
}
@@ -796,12 +797,16 @@ impl Surface {
/// Activate a key table by name (Zig: `activate_key_table[_once]`).
/// Returns whether the table was activated.
#[allow(dead_code, clippy::unnecessary_wraps)] // used by the binding-actions module
pub(crate) fn activate_key_table(&self, name: &str, once: bool) -> Result<bool, CoreError> {
pub(crate) fn activate_key_table(
&self,
name: &EncodedOs,
once: bool,
) -> Result<bool, CoreError> {
let cfg = self.cfg();
// Look up the table in our config
if root_set(&cfg, &SetRoot::Table(name.to_owned())).is_none() {
log::debug!("key table not found: {name}");
log::debug!("key table not found: {name:?}");
return Ok(false);
}
@@ -810,14 +815,14 @@ impl Surface {
// If this is the same table as is currently active, then do
// nothing.
if kb.table_stack.last().is_some_and(|t| t.name == name) {
log::debug!("ignoring duplicate activate table: {name}");
if kb.table_stack.last().is_some_and(|t| &t.name == name) {
log::debug!("ignoring duplicate activate table: {name:?}");
return Ok(false);
}
// If we're already at the max, ignore it.
if kb.table_stack.len() >= MAX_ACTIVE_KEY_TABLES {
log::info!("ignoring activate table, max depth reached: {name}");
log::info!("ignoring activate table, max depth reached: {name:?}");
return Ok(false);
}
@@ -831,7 +836,7 @@ impl Surface {
// Notify the UI.
self.perform(RtAction::KeyTable(KeyTable::Activate(name.to_owned())));
log::debug!("key table activated: {name}");
log::debug!("key table activated: {name:?}");
Ok(true)
}
+14 -14
View File
@@ -459,10 +459,7 @@ impl Surface {
// don't leak GHOSTTY_LOG to any subprocesses
env.remove("GHOSTTY_LOG");
let mut ecfg = exec::Config::new(
env,
ghostty_os::osbytes::EncodedOs::from_encoded(config.term.clone()),
);
let mut ecfg = exec::Config::new(env, config.term.clone());
ecfg.command = command.clone();
ecfg.env_override = config.env.clone();
ecfg.shell_integration = config.shell_integration;
@@ -607,7 +604,7 @@ impl Surface {
rt_app.perform_action(
Target::Surface(id),
RtAction::SetTitle(SetTitle {
title: ghostty_os::osbytes::config_str_to_text(title),
title: title.clone(),
}),
);
} else if config.xdg_terminal_exec
@@ -620,7 +617,7 @@ impl Surface {
rt_app.perform_action(
Target::Surface(id),
RtAction::SetTitle(SetTitle {
title: ghostty_os::osbytes::config_str_to_text(&cmd.string()),
title: ghostty_os::osbytes::EncodedOs::from_encoded(cmd.string()),
}),
);
} else if let Some(cmd) = &command {
@@ -636,7 +633,7 @@ impl Surface {
rt_app.perform_action(
Target::Surface(id),
RtAction::SetTitle(SetTitle {
title: ghostty_os::osbytes::config_str_to_text(first),
title: first.clone(),
}),
);
}
@@ -872,8 +869,8 @@ impl Surface {
}
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
let title = String::from_utf8_lossy(&buf[..end]).into_owned();
log::debug!("changing title \"{title}\"");
let title = ghostty_os::osbytes::EncodedOs::from_bytes(&buf[..end]);
log::debug!("changing title {title:?}");
self.perform(RtAction::SetTitle(SetTitle { title }));
}
@@ -885,11 +882,14 @@ impl Surface {
let title = self.rt_surface.get_title();
let data = match style {
ghostty_apprt::surface::ReportTitleStyle::Csi21T => {
format!("\x1b]l{}\x1b\\", title.unwrap_or_default())
}
ghostty_apprt::surface::ReportTitleStyle::Csi21T => [
b"\x1b]l".as_slice(),
&title.unwrap_or_default().to_bytes(),
b"\x1b\\",
]
.concat(),
};
self.queue_io(IoMessage::write_req(data.as_bytes()));
self.queue_io(IoMessage::write_req(data.as_slice()));
}
Message::ColorChange(change) => {
@@ -1306,7 +1306,7 @@ impl Surface {
// configured title.
if let Some(title) = &config.title {
self.perform(RtAction::SetTitle(SetTitle {
title: ghostty_os::osbytes::config_str_to_text(title),
title: title.clone(),
}));
}
+2 -2
View File
@@ -2543,8 +2543,8 @@ mod tests {
// The OSC 7 pwd is raw bytes.
let pwd = dir.as_os_str().as_bytes().to_vec();
let t = open_link_target(Some(&pwd), "file.txt");
assert_eq!(t.to_path(), dir.join("file.txt"));
assert!(t.to_path().exists());
assert_eq!(t.to_path().unwrap(), dir.join("file.txt"));
assert!(t.to_path().unwrap().exists());
}
#[test]
+8 -4
View File
@@ -92,7 +92,7 @@ impl RtApp for FakeRt {
let ret = match &action {
Action::ShowChildExited(_) => self.show_child_exited.get(),
Action::SetTitle(t) => {
*self.title.borrow_mut() = Some(t.title.clone());
*self.title.borrow_mut() = Some(t.title.to_text_lossy());
true
}
_ => true,
@@ -109,8 +109,8 @@ impl RtSurface for FakeRt {
fn cgroup(&self) -> Option<String> {
None
}
fn get_title(&self) -> Option<String> {
self.title.borrow().clone()
fn get_title(&self) -> Option<ghostty_os::osbytes::EncodedOs> {
self.title.borrow().clone().map(Into::into)
}
fn get_content_scale(&self) -> ContentScale {
self.scale.get()
@@ -262,7 +262,11 @@ impl Harness {
config.load_iter(&mut it).expect("config args");
if let Some(script) = &opts.script {
let script = script.replace("{out}", out.to_str().unwrap());
config.command = Some(Command::Direct(vec!["sh".into(), "-c".into(), script]));
config.command = Some(Command::Direct(vec![
"sh".into(),
"-c".into(),
script.into(),
]));
}
config.finalize().expect("finalize");
+2 -4
View File
@@ -369,10 +369,7 @@ fn text_csi_esc_and_ignore_bindings() {
#[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 [
@@ -386,7 +383,8 @@ fn config_loaded_text_csi_esc_write_decoded_bytes() {
("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();
let a = ghostty_input::binding::Action::parse_bytes(&[kind.as_bytes(), b":", raw].concat())
.unwrap();
assert!(h.surface.perform_binding_action(&a).unwrap());
h.expect_input(&want);
}