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

+72 -58
View File
@@ -56,7 +56,7 @@ crate::config_struct! {
///
/// GTK only.
/// Available since 1.3.0.
"language" => language: Option<String> = None,
"language" => language: Option<EncodedOs> = None,
/// The font families to use.
///
@@ -1444,7 +1444,7 @@ crate::config_struct! {
/// sequence will be honored but previous changes will not retroactively
/// be set. This latter case may require you to restart programs such as Neovim
/// to get the new title.
"title" => title: Option<String> = None,
"title" => title: Option<EncodedOs> = None,
/// The setting that will change the application class value.
///
@@ -1466,7 +1466,7 @@ crate::config_struct! {
/// The default is `com.mitchellh.ghostty`.
///
/// This only affects GTK builds.
"class" => class: Option<String> = None,
"class" => class: Option<EncodedOs> = None,
/// This controls the instance name field of the `WM_CLASS` X11 property when
/// running under X11. It has no effect otherwise.
@@ -1474,7 +1474,7 @@ crate::config_struct! {
/// The default is `ghostty`.
///
/// This only affects GTK builds.
"x11-instance-name" => x11_instance_name: Option<String> = None,
"x11-instance-name" => x11_instance_name: Option<EncodedOs> = None,
/// The directory to change to after starting the command.
///
@@ -2049,7 +2049,7 @@ crate::config_struct! {
/// required to be a fixed-width font.
///
/// Available since: 1.0.0 on macOS, 1.1.0 on GTK
"window-title-font-family" => window_title_font_family: Option<String> = None,
"window-title-font-family" => window_title_font_family: Option<EncodedOs> = None,
/// The text that will be displayed in the subtitle of the window. Valid values:
///
@@ -2626,7 +2626,7 @@ crate::config_struct! {
/// GTK Wayland only.
///
/// Available since: 1.2.0
"gtk-quick-terminal-namespace" => gtk_quick_terminal_namespace: String = "ghostty-quick-terminal".to_string(),
"gtk-quick-terminal-namespace" => gtk_quick_terminal_namespace: EncodedOs = EncodedOs::from_plain_text("ghostty-quick-terminal"),
/// The screen where the quick terminal should show up.
///
@@ -3360,7 +3360,7 @@ crate::config_struct! {
/// Supported formats include PNG, JPEG, and ICNS.
///
/// Defaults to `~/.config/ghostty/Ghostty.icns`
"macos-custom-icon" => macos_custom_icon: Option<String> = None,
"macos-custom-icon" => macos_custom_icon: Option<EncodedOs> = None,
/// The material to use for the frame of the macOS app icon.
///
@@ -3648,11 +3648,11 @@ crate::config_struct! {
/// features. An option exists in vim to modify this: `:set
/// keyprotocol=ghostty:kitty`, however a bug in the implementation prevents it
/// from working properly. https://github.com/vim/vim/pull/13211 fixes this.
"term" => term: String = "xterm-ghostty".to_string(),
"term" => term: EncodedOs = EncodedOs::from_plain_text("xterm-ghostty"),
/// String to send when we receive `ENQ` (`0x05`) from the command that we are
/// running. Defaults to an empty string if not set.
"enquiry-response" => enquiry_response: String = String::new(),
"enquiry-response" => enquiry_response: EncodedOs = EncodedOs::new(),
/// Configures the low-level API to use for async IO, eventing, etc.
///
@@ -3764,7 +3764,7 @@ pub enum Error {
#[derive(Debug, Clone, PartialEq)]
pub enum ReplayStep {
/// An argument to parse as if it came from the CLI or file.
Arg(String),
Arg(Vec<u8>),
/// A base path to expand relative paths against.
Expand(EncodedOs),
@@ -3774,7 +3774,7 @@ pub enum ReplayStep {
/// having multiple conditional arg entries.
ConditionalArg {
conditions: Vec<Conditional>,
arg: String,
arg: Vec<u8>,
},
/// A diagnostic to be added to the new configuration when
@@ -3815,7 +3815,7 @@ impl Default for Config {
/// bytes are carried as encoded command text (so non-UTF-8 bytes and a
/// literal U+FDD0 round-trip exactly, like Zig's byte slice).
fn shell_from_env(value: &std::ffi::OsStr) -> Command {
Command::Shell(ghostty_os::osbytes::EncodedOs::from_os(value).into_encoded())
Command::Shell(ghostty_os::osbytes::EncodedOs::from_os(value))
}
impl Config {
@@ -3886,13 +3886,13 @@ impl Config {
///
/// `path` must be resolved and absolute.
pub fn load_file(&mut self, path: &EncodedOs) -> Result<(), Error> {
assert!(path.to_path().is_absolute());
assert!(path.to_path()?.is_absolute());
let file = match crate::file_load::open(path) {
Ok(f) => f,
Err(crate::file_load::OpenFileError::NotAFile) => {
log::warn!(
"config-file {}: not reading because it is not a file",
path.as_encoded()
path.to_text_lossy()
);
return Ok(());
}
@@ -3907,7 +3907,7 @@ impl Config {
/// Load config from the given File.
fn load_fs_file(&mut self, file: std::fs::File, path: &EncodedOs) -> Result<(), Error> {
log::info!("reading configuration file path={}", path.as_encoded());
log::info!("reading configuration file path={}", path.to_text_lossy());
let mut reader = std::io::BufReader::with_capacity(2048, file);
self.load_reader(&mut reader, path)
}
@@ -3929,7 +3929,7 @@ impl Config {
let mut iter = cli_args::LineIterator::with_path(reader, path.as_encoded());
self.load_iter(&mut iter)?;
let dir = path
.to_path()
.to_path()?
.parent()
.map(EncodedOs::from_path)
.unwrap_or_default();
@@ -3949,7 +3949,7 @@ impl Config {
Err(err) => {
log::warn!(
"error reading optional config file, not loading err={err} path={}",
path.as_encoded()
path.to_text_lossy()
);
OptionalFileAction::Error
}
@@ -3971,8 +3971,11 @@ impl Config {
}
fn write_config_template(path: &EncodedOs) -> std::io::Result<()> {
log::info!("creating template config file: path={}", path.as_encoded());
let os_path = path.to_path();
log::info!(
"creating template config file: path={}",
path.to_text_lossy()
);
let os_path = path.to_path()?;
if let Some(dir) = os_path.parent() {
std::fs::create_dir_all(dir)?;
}
@@ -3999,8 +4002,8 @@ impl Config {
{
log::warn!(
"both config files `{}` and `{}` exist.",
legacy_xdg_path.as_encoded(),
xdg_path.as_encoded()
legacy_xdg_path.to_text_lossy(),
xdg_path.to_text_lossy()
);
log::warn!("loading them both in that order");
true
@@ -4034,8 +4037,8 @@ impl Config {
{
log::warn!(
"both config files `{}` and `{}` exist.",
legacy_app_support_path.as_encoded(),
app_support_path.as_encoded()
legacy_app_support_path.to_text_lossy(),
app_support_path.to_text_lossy()
);
log::warn!("loading them both in that order");
true
@@ -4102,10 +4105,11 @@ impl Config {
// Next, take all remaining args and use that to build up
// a command to execute.
let mut builder: Vec<String> = Vec::new();
let mut builder: Vec<EncodedOs> = Vec::new();
for arg in args {
let copy = ghostty_os::osbytes::bytes_to_config_string(arg.to_bytes());
self.replay_steps.push(ReplayStep::Arg(copy.clone()));
let copy = EncodedOs::from_bytes(arg.to_bytes());
self.replay_steps
.push(ReplayStep::Arg(arg.to_bytes().to_vec()));
builder.push(copy);
}
@@ -4207,7 +4211,7 @@ impl Config {
// All paths should already be absolute at this point because
// they're fixed up after each load.
assert!(path.to_path().is_absolute());
assert!(path.to_path()?.is_absolute());
// We must only load a unique file once
if !loaded.insert(path.clone()) {
@@ -4220,7 +4224,7 @@ impl Config {
continue;
}
let file = match std::fs::File::open(path.to_path()) {
let file = match path.to_path().and_then(std::fs::File::open) {
Ok(f) => f,
Err(err) => {
if err.kind() != std::io::ErrorKind::NotFound || !optional {
@@ -4334,7 +4338,7 @@ impl Config {
ReplayStep::DashE => {
// The "-e" hook consumes every remaining argument.
let mut args = vec!["-e".to_string()];
let mut args = vec![b"-e".to_vec()];
while i < steps.len() {
match &steps[i] {
ReplayStep::Expand(base) => {
@@ -4351,7 +4355,7 @@ impl Config {
}
}
ReplayStep::Arg(arg) => args.push(arg.clone()),
ReplayStep::DashE => args.push("-e".to_string()),
ReplayStep::DashE => args.push(b"-e".to_vec()),
}
i += 1;
}
@@ -4363,8 +4367,8 @@ impl Config {
Ok(())
}
fn load_one_arg(&mut self, arg: &str) -> Result<(), ArgError> {
let mut it = cli_args::VecIterator::new(vec![arg.to_string()]);
fn load_one_arg(&mut self, arg: &[u8]) -> Result<(), ArgError> {
let mut it = cli_args::VecIterator::new(vec![arg.to_vec()]);
self.load_iter(&mut it)
}
@@ -4391,7 +4395,7 @@ impl Config {
// Load the correct theme depending on the conditional state.
// Dark/light themes were programmed prior to conditional configuration
// so when we introduce that we probably want to replace this.
let name: &str = match self.conditional_state.theme {
let name = match self.conditional_state.theme {
conditional::Theme::Light => &theme.light,
conditional::Theme::Dark => &theme.dark,
};
@@ -4399,7 +4403,7 @@ impl Config {
// Find our theme file and open it. See the open function for details.
let resources_dir = ghostty_os::resources_dir().ok();
let Some(themefile) = crate::theme::open(
&EncodedOs::from_encoded(name),
name,
resources_dir.as_ref().and_then(|r| r.app()),
&mut self.diagnostics,
) else {
@@ -4520,7 +4524,7 @@ impl Config {
// Prevent setting TERM to an empty string
if self.term.is_empty() {
// HACK: See comment above at definition
self.term = "xterm-ghostty".to_string();
self.term = "xterm-ghostty".into();
}
// The default for the working directory depends on the system.
@@ -4647,7 +4651,7 @@ impl Config {
fn platform_defaults(&mut self, wd: &mut WorkingDirectory) -> Result<(), Error> {
if self.command.is_none() {
log::warn!("no default shell found, will default to using cmd");
self.command = Some(Command::Shell("cmd.exe".to_owned()));
self.command = Some(Command::Shell("cmd.exe".into()));
}
if *wd == WorkingDirectory::Home {
@@ -4665,8 +4669,8 @@ impl Config {
let pw = ghostty_os::passwd::get()?;
if self.command.is_none() {
if let Some(sh) = pw.shell {
log::info!("default shell src=passwd value={}", sh.as_encoded());
self.command = Some(Command::Shell(sh.into_encoded()));
log::info!("default shell src=passwd value={}", sh.to_text_lossy());
self.command = Some(Command::Shell(sh));
}
}
@@ -4674,7 +4678,7 @@ impl Config {
if let Some(home) = pw.home {
log::info!(
"default working directory src=passwd value={}",
home.as_encoded()
home.to_text_lossy()
);
*wd = WorkingDirectory::Path(home);
} else {
@@ -4809,8 +4813,8 @@ fn probable_cli_environment() -> bool {
}
impl CliTarget for Config {
fn parse_into_field(&mut self, key: &str, value: Option<&str>) -> Result<(), ArgError> {
self.parse_field(key, value)
fn parse_into_field(&mut self, key: &str, value: Option<&[u8]>) -> Result<(), ArgError> {
self.parse_field_bytes(key, value)
}
fn valid_values(&self, key: &str) -> Option<&'static [&'static str]> {
@@ -4823,24 +4827,28 @@ impl CliTarget for Config {
/// Callback for the arg parser to allow us to handle special cases
/// like `-e`. Returns `Ok(false)` if the CLI parsing should halt.
fn parse_manually_hook(&mut self, arg: &str, iter: &mut dyn ArgIter) -> Result<bool, ArgError> {
if arg == "-e" {
fn parse_manually_hook(
&mut self,
arg: &[u8],
iter: &mut dyn ArgIter,
) -> Result<bool, ArgError> {
if arg == b"-e" {
// Add the special -e marker. This prevents:
// (1) config-file from adding args to the end (see #2908)
// (2) dark/light theme from making this conditional
self.replay_steps.push(ReplayStep::DashE);
// Build up the command.
let mut command: Vec<String> = Vec::new();
let mut command: Vec<EncodedOs> = Vec::new();
while let Some(param) = iter.next_arg() {
self.replay_steps.push(ReplayStep::Arg(param.clone()));
command.push(param);
command.push(EncodedOs::from_bytes(&param));
}
if command.is_empty() {
self.diagnostics.append(Diagnostic {
location: iter.location().unwrap_or_default(),
message: format!("missing command after {arg}"),
message: "missing command after -e".to_owned(),
..Diagnostic::default()
});
@@ -4861,13 +4869,13 @@ impl CliTarget for Config {
}
// Keep track of our input args for replay
self.replay_steps.push(ReplayStep::Arg(arg.to_string()));
self.replay_steps.push(ReplayStep::Arg(arg.to_vec()));
// If we didn't find a special case, continue parsing normally
Ok(true)
}
fn compatibility(&mut self, key: &str, value: Option<&str>) -> bool {
fn compatibility(&mut self, key: &str, value: Option<&[u8]>) -> bool {
match key {
// Ghostty 1.1 introduced background-blur support for Linux which
// doesn't support a specific radius value. The renaming is to let
@@ -4886,7 +4894,7 @@ impl CliTarget for Config {
// Ghostty 1.2 removed the `hidden` value from `gtk-tabs-location` and
// moved it to `window-show-tab-bar`.
"gtk-tabs-location" => {
if value.unwrap_or("") == "hidden" {
if value.unwrap_or(b"") == b"hidden" {
self.window_show_tab_bar = WindowShowTabBar::Never;
return true;
}
@@ -4907,7 +4915,9 @@ impl CliTarget for Config {
// Realistically, these fields were mutually exclusive so anyone
// relying on that behavior should just upgrade to the new
// cursor-color/cursor-text fields.
let Ok(set) = cli_args::parse_bool(value.unwrap_or("t")) else {
let Ok(set) = cli_args::text_value(value)
.and_then(|v| cli_args::parse_bool(v.unwrap_or("t")))
else {
return false;
};
if set {
@@ -4917,7 +4927,9 @@ impl CliTarget for Config {
true
}
"selection-invert-fg-bg" => {
let Ok(set) = cli_args::parse_bool(value.unwrap_or("t")) else {
let Ok(set) = cli_args::text_value(value)
.and_then(|v| cli_args::parse_bool(v.unwrap_or("t")))
else {
return false;
};
if set {
@@ -4930,7 +4942,9 @@ impl CliTarget for Config {
// Ghostty 1.2 merged `bold-is-bright` into the new `bold-color`
// by setting the value to "bright".
"bold-is-bright" => {
let Ok(isset) = cli_args::parse_bool(value.unwrap_or("t")) else {
let Ok(isset) = cli_args::text_value(value)
.and_then(|v| cli_args::parse_bool(v.unwrap_or("t")))
else {
return false;
};
if isset {
@@ -4942,7 +4956,7 @@ impl CliTarget for Config {
// Ghostty 1.2 removed the "desktop" option and renamed it to "detect".
// The semantics also changed slightly but this is the correct mapping.
"gtk-single-instance" => {
if value.unwrap_or("") == "desktop" {
if value.unwrap_or(b"") == b"desktop" {
self.gtk_single_instance = GtkSingleInstance::Detect;
return true;
}
@@ -4952,7 +4966,7 @@ impl CliTarget for Config {
// Ghostty 1.3 rename the "window" option to "new-window".
// See: https://github.com/ghostty-org/ghostty/pull/9764
"macos-dock-drop-behavior" => {
if value.unwrap_or("") == "window" {
if value.unwrap_or(b"") == b"window" {
self.macos_dock_drop_behavior = MacOSDockDropBehavior::NewWindow;
return true;
}
@@ -4983,7 +4997,7 @@ mod tests {
let Command::Shell(text) = shell_from_env(std::ffi::OsStr::from_bytes(raw)) else {
panic!("not a shell command");
};
assert_eq!(ghostty_os::osbytes::config_str_to_bytes(&text), raw);
assert_eq!(text.to_bytes(), raw);
}
let Command::Shell(plain) = shell_from_env(std::ffi::OsStr::new("/bin/zsh")) else {
panic!()
@@ -5063,7 +5077,7 @@ mod tests {
fn parse_hook_invalid_command() {
let mut cfg = Config::default();
let mut i = it(&["foo"]);
assert!(cfg.parse_manually_hook("--command", &mut i).unwrap());
assert!(cfg.parse_manually_hook(b"--command", &mut i).unwrap());
assert!(cfg.command.is_none());
}
@@ -5071,7 +5085,7 @@ mod tests {
fn parse_e_command_only() {
let mut cfg = Config::default();
let mut i = it(&["foo"]);
assert!(!cfg.parse_manually_hook("-e", &mut i).unwrap());
assert!(!cfg.parse_manually_hook(b"-e", &mut i).unwrap());
let Some(Command::Direct(cmd)) = &cfg.initial_command else {
panic!("expected direct");
@@ -5083,7 +5097,7 @@ mod tests {
fn parse_e_command_and_args() {
let mut cfg = Config::default();
let mut i = it(&["echo", "foo", "bar baz"]);
assert!(!cfg.parse_manually_hook("-e", &mut i).unwrap());
assert!(!cfg.parse_manually_hook(b"-e", &mut i).unwrap());
let Some(Command::Direct(cmd)) = &cfg.initial_command else {
panic!("expected direct");