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:
1 parent
70c250cf2d
commit
6f9b3129e1
100 files changed
+2310
-1054
No files matched your search
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user