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

+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
[dependencies]
ghostty-vt.workspace = true
ghostty-os.workspace = true
ghostty-unicode.workspace = true
indexmap = "2.14.2"
thiserror = "2.0.21"
+56 -35
View File
@@ -8,6 +8,8 @@ use std::{
hash::{Hash, Hasher},
};
use ghostty_os::osbytes::EncodedOs;
use super::Error;
/// Define a fieldless enum whose variants are named in snake_case in the
@@ -314,9 +316,9 @@ macro_rules! num_param {
num_param!(f32, usize, isize, i16);
/// Escape in the style of Zig's `std.zig.stringEscape`.
pub(super) fn string_escape(s: &str, out: &mut String) {
pub(super) fn string_escape(s: &[u8], out: &mut String) {
use std::fmt::Write;
for &b in s.as_bytes() {
for &b in s {
match b {
b'\n' => out.push_str("\\n"),
b'\r' => out.push_str("\\r"),
@@ -353,18 +355,37 @@ impl Action {
/// action name and value is the action parameter. The parameter
/// is optional depending on the action.
pub fn parse(input: &str) -> Result<Action, Error> {
// Split our action by colon. A colon may not exist for some
// actions so it is optional. The part preceding the colon is the
// action name.
let colon_idx = input.find(':');
let action = &input[..colon_idx.unwrap_or(input.len())];
Self::parse_bytes(input.as_bytes())
}
// An action name is always required
if action.is_empty() {
/// Parse raw action bytes. Only enum/numeric parameters require UTF-8;
/// byte payloads are stored without interpreting the OS escape encoding.
pub fn parse_bytes(input: &[u8]) -> Result<Action, Error> {
let idx = input.iter().position(|b| *b == b':');
let name = std::str::from_utf8(&input[..idx.unwrap_or(input.len())])
.map_err(|_| Error::InvalidAction)?;
if name.is_empty() {
return Err(Error::InvalidFormat);
}
Action::parse_named(action, colon_idx.map(|i| &input[i + 1..]))
let param = idx.map(|i| &input[i + 1..]);
let bytes = || param.map(EncodedOs::from_bytes).ok_or(Error::InvalidFormat);
match name {
"csi" => Ok(Self::Csi(bytes()?)),
"esc" => Ok(Self::Esc(bytes()?)),
"text" => Ok(Self::Text(bytes()?)),
"search" => Ok(Self::Search(bytes()?)),
"set_surface_title" => Ok(Self::SetSurfaceTitle(bytes()?)),
"set_tab_title" => Ok(Self::SetTabTitle(bytes()?)),
"activate_key_table" => Ok(Self::ActivateKeyTable(bytes()?)),
"activate_key_table_once" => Ok(Self::ActivateKeyTableOnce(bytes()?)),
_ => Self::parse_named(
name,
param
.map(std::str::from_utf8)
.transpose()
.map_err(|_| Error::InvalidAction)?,
),
}
}
/// The action name (Zig `@tagName`).
@@ -423,15 +444,15 @@ pub enum Action {
/// `\x1b[`).
///
/// For example, `csi:0m` can be sent to reset all styles of the current text.
Csi(String),
Csi(EncodedOs),
/// Send an `ESC` sequence.
Esc(String),
Esc(EncodedOs),
/// Send the specified text.
///
/// Uses Zig string literal syntax. This is currently not validated.
/// If the text is invalid (i.e. contains an invalid escape sequence),
/// the error will currently only show up in logs.
Text(String),
Text(EncodedOs),
/// Send data to the pty depending on whether cursor key mode is enabled
/// (`application`) or disabled (`normal`).
CursorKey(CursorKey),
@@ -478,7 +499,7 @@ pub enum Action {
/// should be used.
///
/// If a previous search is active, it is replaced.
Search(String),
Search(EncodedOs),
/// Start a search for the current text selection. If there is no
/// selection, this does nothing. If a search is already active, this
/// changes the search terms.
@@ -639,11 +660,11 @@ pub enum Action {
/// Set the title for the current focused surface.
///
/// If the title is empty, the surface title is reset to an empty title.
SetSurfaceTitle(String),
SetSurfaceTitle(EncodedOs),
/// Set the title for the current focused tab.
///
/// If the title is empty, the tab title override is cleared.
SetTabTitle(String),
SetTabTitle(EncodedOs),
/// Create a new split in the specified direction.
///
/// Valid arguments:
@@ -912,7 +933,7 @@ pub enum Action {
///
/// If the named key table is already the currently active key table,
/// this action has no effect and performable will report false.
ActivateKeyTable(String),
ActivateKeyTable(EncodedOs),
/// Same as activate_key_table, but the key table will only be active
/// until the first valid keybinding from that table is used (including
/// any defined `catch_all` bindings).
@@ -921,7 +942,7 @@ pub enum Action {
/// key table. If another key table is activated later, then this
/// table will remain active until it pops back out to being the
/// active key table.
ActivateKeyTableOnce(String),
ActivateKeyTableOnce(EncodedOs),
/// Deactivate the currently active key table, if any. The next most
/// recently activated key table (if any) will become active again.
/// If no key table is active, this action has no effect.
@@ -1353,15 +1374,15 @@ impl Action {
}
"csi" => {
let p = param.ok_or(Error::InvalidFormat)?;
Ok(Action::Csi(p.to_string()))
Ok(Action::Csi(p.into()))
}
"esc" => {
let p = param.ok_or(Error::InvalidFormat)?;
Ok(Action::Esc(p.to_string()))
Ok(Action::Esc(p.into()))
}
"text" => {
let p = param.ok_or(Error::InvalidFormat)?;
Ok(Action::Text(p.to_string()))
Ok(Action::Text(p.into()))
}
"cursor_key" => Err(Error::InvalidAction),
"reset" => {
@@ -1426,7 +1447,7 @@ impl Action {
}
"search" => {
let p = param.ok_or(Error::InvalidFormat)?;
Ok(Action::Search(p.to_string()))
Ok(Action::Search(p.into()))
}
"search_selection" => {
if param.is_some() {
@@ -1604,11 +1625,11 @@ impl Action {
}
"set_surface_title" => {
let p = param.ok_or(Error::InvalidFormat)?;
Ok(Action::SetSurfaceTitle(p.to_string()))
Ok(Action::SetSurfaceTitle(p.into()))
}
"set_tab_title" => {
let p = param.ok_or(Error::InvalidFormat)?;
Ok(Action::SetTabTitle(p.to_string()))
Ok(Action::SetTabTitle(p.into()))
}
"new_split" => {
let Some(p) = param else {
@@ -1798,11 +1819,11 @@ impl Action {
}
"activate_key_table" => {
let p = param.ok_or(Error::InvalidFormat)?;
Ok(Action::ActivateKeyTable(p.to_string()))
Ok(Action::ActivateKeyTable(p.into()))
}
"activate_key_table_once" => {
let p = param.ok_or(Error::InvalidFormat)?;
Ok(Action::ActivateKeyTableOnce(p.to_string()))
Ok(Action::ActivateKeyTableOnce(p.into()))
}
"deactivate_key_table" => {
if param.is_some() {
@@ -1892,15 +1913,15 @@ impl Action {
| Action::Quit => {}
Action::Csi(v) => {
out.push(':');
string_escape(v, out);
string_escape(&v.to_bytes(), out);
}
Action::Esc(v) => {
out.push(':');
string_escape(v, out);
string_escape(&v.to_bytes(), out);
}
Action::Text(v) => {
out.push(':');
string_escape(v, out);
string_escape(&v.to_bytes(), out);
}
Action::CursorKey(_) => panic!("formatting not supported"),
Action::CopyToClipboard(v) => {
@@ -1921,7 +1942,7 @@ impl Action {
}
Action::Search(v) => {
out.push(':');
string_escape(v, out);
string_escape(&v.to_bytes(), out);
}
Action::NavigateSearch(v) => {
out.push(':');
@@ -1969,11 +1990,11 @@ impl Action {
}
Action::SetSurfaceTitle(v) => {
out.push(':');
string_escape(v, out);
string_escape(&v.to_bytes(), out);
}
Action::SetTabTitle(v) => {
out.push(':');
string_escape(v, out);
string_escape(&v.to_bytes(), out);
}
Action::NewSplit(v) => {
out.push(':');
@@ -2001,11 +2022,11 @@ impl Action {
}
Action::ActivateKeyTable(v) => {
out.push(':');
string_escape(v, out);
string_escape(&v.to_bytes(), out);
}
Action::ActivateKeyTableOnce(v) => {
out.push(':');
string_escape(v, out);
string_escape(&v.to_bytes(), out);
}
Action::Crash(v) => {
out.push(':');
+20 -12
View File
@@ -129,7 +129,11 @@ pub struct Parser<'a> {
impl<'a> Parser<'a> {
pub fn new(raw_input: &'a str) -> Result<Parser<'a>, Error> {
let (flags, start_idx) = parse_flags(raw_input)?;
Self::from_bytes(raw_input.as_bytes())
}
pub fn from_bytes(raw_input: &'a [u8]) -> Result<Parser<'a>, Error> {
let (flags, start_idx) = parse_flags_bytes(raw_input)?;
let input = &raw_input[start_idx..];
// Find the equal sign. This is more complicated than it seems on
@@ -140,8 +144,8 @@ impl<'a> Parser<'a> {
// based parser that parses the trigger fully, then yields the
// action after. The loop below is a total mess.
let mut offset = 0usize;
let bytes = input.as_bytes();
while let Some(offset_idx) = input[offset..].find('=') {
let bytes = input;
while let Some(offset_idx) = input[offset..].iter().position(|b| *b == b'=') {
// Find: '=+ctrl' or '==action'
let idx = offset + offset_idx;
if idx + 1 < bytes.len() && (bytes[idx + 1] == b'+' || bytes[idx + 1] == b'=') {
@@ -157,7 +161,7 @@ impl<'a> Parser<'a> {
};
// Detect chains. Chains must not have flags.
let chain = &input[..eql_idx] == "chain";
let chain = &input[..eql_idx] == b"chain";
if chain && start_idx > 0 {
return Err(Error::InvalidFormat);
}
@@ -169,10 +173,14 @@ impl<'a> Parser<'a> {
// This is kind of hacky but we put a dummy trigger
// for chained inputs. The `next` will never yield this
// because we have chain set.
input: if chain { "a" } else { &input[..eql_idx] },
input: if chain {
"a"
} else {
std::str::from_utf8(&input[..eql_idx]).map_err(|_| Error::InvalidFormat)?
},
i: 0,
},
action: Action::parse(&input[eql_idx + 1..])?,
action: Action::parse_bytes(&input[eql_idx + 1..])?,
flags,
chain,
})
@@ -211,36 +219,36 @@ impl<'a> Parser<'a> {
}
}
fn parse_flags(raw_input: &str) -> Result<(Flags, usize), Error> {
fn parse_flags_bytes(raw_input: &[u8]) -> Result<(Flags, usize), Error> {
let mut flags = Flags::default();
let mut start_idx = 0usize;
let mut input = raw_input;
// Find the next prefix
while let Some(idx) = input.find(':') {
while let Some(idx) = input.iter().position(|b| *b == b':') {
let prefix = &input[..idx];
// If the prefix is one of our flags then set it.
match prefix {
"all" => {
b"all" => {
if flags.all {
return Err(Error::InvalidFormat);
}
flags.all = true;
}
"global" => {
b"global" => {
if flags.global {
return Err(Error::InvalidFormat);
}
flags.global = true;
}
"unconsumed" => {
b"unconsumed" => {
if !flags.consumed {
return Err(Error::InvalidFormat);
}
flags.consumed = false;
}
"performable" => {
b"performable" => {
if flags.performable {
return Err(Error::InvalidFormat);
}
+10 -2
View File
@@ -162,7 +162,11 @@ impl Value {
let pos = buffer.len();
for (k, v) in &set.bindings {
buffer.truncate(pos);
let _ = write!(buffer, ">{}", k.0);
let _ = write!(
buffer,
">{}",
ghostty_os::osbytes::valid_str_to_config_string(&k.0.to_string())
);
v.format_entries(buffer, formatter);
}
buffer.truncate(pos);
@@ -285,9 +289,13 @@ impl Set {
///
/// Any Error returned is validated before any set modifications are made.
pub fn parse_and_put(&mut self, input: &str) -> Result<(), Error> {
self.parse_and_put_bytes(input.as_bytes())
}
pub fn parse_and_put_bytes(&mut self, input: &[u8]) -> Result<(), Error> {
// To make cleanup easier, we ensure that the full sequence is
// valid before making any set modifications.
let mut it = Parser::new(input)?;
let mut it = Parser::from_bytes(input)?;
while it.next_elem()?.is_some() {}
it.reset();
+1 -1
View File
@@ -824,7 +824,7 @@ fn set_clone_with_leaf_chained_containing_allocated_data() {
) else {
panic!("expected text");
};
assert_ne!(orig.as_ptr(), copy.as_ptr());
assert_ne!(orig.as_encoded().as_ptr(), copy.as_encoded().as_ptr());
}
#[test]
+13 -10
View File
@@ -31,16 +31,16 @@ use crate::binding::{
#[derive(Debug, Clone, PartialEq)]
pub struct Command {
pub action: Action,
pub title: String,
pub description: String,
pub title: ghostty_os::osbytes::EncodedOs,
pub description: ghostty_os::osbytes::EncodedOs,
}
impl Command {
pub fn new(action: Action, title: &str, description: &str) -> Command {
Command {
action,
title: title.to_string(),
description: description.to_string(),
title: title.into(),
description: description.into(),
}
}
@@ -75,14 +75,17 @@ impl Command {
pub struct CommandC {
pub action_key: String,
pub action: String,
pub title: String,
pub description: String,
pub title: ghostty_os::osbytes::EncodedOs,
pub description: ghostty_os::osbytes::EncodedOs,
}
/// `std.ascii.orderIgnoreCase`
fn cmp_ignore_case(a: &str, b: &str) -> std::cmp::Ordering {
let a = a.bytes().map(|c| c.to_ascii_lowercase());
let b = b.bytes().map(|c| c.to_ascii_lowercase());
fn cmp_ignore_case(
a: &ghostty_os::osbytes::EncodedOs,
b: &ghostty_os::osbytes::EncodedOs,
) -> std::cmp::Ordering {
let a = a.to_bytes().into_iter().map(|c| c.to_ascii_lowercase());
let b = b.to_bytes().into_iter().map(|c| c.to_ascii_lowercase());
a.cmp(b)
}
@@ -562,7 +565,7 @@ fn default_commands() -> Vec<Command> {
Command::new(Action::Redo, "Redo", "Redo the last undone action."),
Command::new(Action::Quit, "Quit", "Quit the application."),
Command::new(
Action::Text("👻".to_string()),
Action::Text("👻".into()),
"Ghostty",
"Put a little Ghostty in your terminal.",
),