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

+6 -6
View File
@@ -50,8 +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("invalid utf-8 in a path, argument or environment value")]
InvalidUtf8(#[from] ghostty_os::osbytes::InvalidUtf8),
#[error("string or environment is not valid unicode")]
Encode(#[from] windows_args::EncodeError),
#[error("windows error: {0}")]
@@ -132,14 +132,14 @@ impl Command {
#[allow(unsafe_code)]
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.
// OS strings); validate UTF-8 and convert to UTF-16 for the Windows API.
// 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 application_w = EncodedOs::from_encoded(&self.path).try_to_utf16_nul()?;
let cwd_w = self
.cwd
.as_ref()
.map(EncodedOs::try_to_wide_nul)
.map(EncodedOs::try_to_utf16_nul)
.transpose()?;
let mut command_line_w = if self.args.is_empty() {
None
@@ -147,7 +147,7 @@ impl Command {
let wide: Vec<Vec<u16>> = self
.args
.iter()
.map(|a| EncodedOs::from_encoded(a).try_to_wide())
.map(|a| EncodedOs::from_encoded(a).try_to_utf16())
.collect::<Result<_, _>>()?;
let mut line = windows_create_command_line_wide(&wide);
line.push(0);
+9 -12
View File
@@ -790,7 +790,7 @@ impl Subprocess {
// If we have a resources dir then set our env var
if let Some(dir) = &cfg.resources_dir {
log::info!("found Ghostty resources dir: {}", dir.as_encoded());
log::info!("found Ghostty resources dir: {}", dir.to_text_lossy());
env.insert("GHOSTTY_RESOURCES_DIR", dir.to_os());
}
@@ -900,10 +900,7 @@ impl Subprocess {
// Add the environment variables that override any others.
for (k, v) in cfg.env_override.iter() {
env.insert(
ghostty_os::osbytes::config_str_to_os(k),
ghostty_os::osbytes::config_str_to_os(v),
);
env.insert(k.to_os(), v.to_os());
}
// Build our args list
@@ -1033,10 +1030,10 @@ impl Subprocess {
}
}
Some(proposed) => {
if proposed.to_path().exists() {
if proposed.to_path().is_ok_and(|p| p.exists()) {
Some(proposed.clone())
} else {
log::warn!("cannot access cwd, ignoring: {}", proposed.as_encoded());
log::warn!("cannot access cwd, ignoring: {}", proposed.to_text_lossy());
None
}
}
@@ -1399,7 +1396,7 @@ pub fn exec_command_with(
match command {
// We need to clone the command since there's no guarantee the config remains valid.
ConfigCommand::Direct(args) => args.clone(),
ConfigCommand::Direct(args) => args.iter().map(|v| v.as_encoded().to_owned()).collect(),
ConfigCommand::Shell(v) => {
let mut args: Vec<String> = Vec::with_capacity(4);
@@ -1414,7 +1411,7 @@ pub fn exec_command_with(
args.push("-l".into());
}
args.push("-c".into());
args.push(v.clone());
args.push(v.as_encoded().to_owned());
args
}
}
@@ -1442,7 +1439,7 @@ fn darwin_login_command(
let hush = match &passwd.home {
Some(home) => {
// The passwd home is an encoded OS string: decode it first.
let home = home.to_path();
let home = home.to_path().ok()?;
if std::fs::read_dir(&home).is_err() {
log::warn!("failed to open home dir, not checking for hushlogin");
false
@@ -1517,13 +1514,13 @@ fn darwin_login_command(
// Direct args can be passed directly to login, since
// login uses execvp we don't need to worry about PATH
// searching.
ConfigCommand::Direct(v) => args.extend(v.iter().cloned()),
ConfigCommand::Direct(v) => args.extend(v.iter().map(|v| v.as_encoded().to_owned())),
ConfigCommand::Shell(v) => {
// Use "exec" to replace the bash process with
// our intended command so we don't have a parent
// process hanging around.
let cmd = format!("exec -l {v}");
let cmd = format!("exec -l {}", v.as_encoded());
// We execute bash with "--noprofile --norc" so that it doesn't
// load startup files so that (1) our shell integration doesn't
+36 -50
View File
@@ -80,9 +80,9 @@ pub enum Error {
#[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).
/// UTF-8 (Zig's process launch conversions fail on such input).
#[error(transparent)]
InvalidWtf8(#[from] ghostty_os::osbytes::InvalidWtf8),
InvalidUtf8(#[from] ghostty_os::osbytes::InvalidUtf8),
#[error("io error: {0}")]
Io(#[from] io::Error),
#[error("failed to get WINDIR, cannot run shell command")]
@@ -588,19 +588,16 @@ fn write_thread_main(handle: &OwnedHandle, rx: &Receiver<Vec<u8>>) {
// Subprocess
/// Insert the config `env` overrides, decoding the encoded key/value text
/// to OS strings (WTF-16 on Windows) like the Unix implementation.
/// to OS strings after strict UTF-8 validation, as in Zig.
///
/// Malformed WTF-8 fails (Zig's conversions for the environment block are
/// Invalid UTF-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> {
) -> Result<(), ghostty_os::osbytes::InvalidUtf8> {
for (k, v) in overrides.iter() {
env.insert(
EncodedOs::from_encoded(k).try_to_os()?,
EncodedOs::from_encoded(v).try_to_os()?,
);
env.insert(k.try_to_utf8_os()?, v.try_to_utf8_os()?);
}
Ok(())
}
@@ -625,10 +622,10 @@ impl Subprocess {
// If we have a resources dir then set our env var
if let Some(dir) = &cfg.resources_dir {
log::info!("found Ghostty resources dir: {}", dir.as_encoded());
log::info!("found Ghostty resources dir: {}", dir.to_text_lossy());
// The resources dir is an encoded OS string: decode it for the
// child's environment.
env.insert("GHOSTTY_RESOURCES_DIR", dir.try_to_os()?);
env.insert("GHOSTTY_RESOURCES_DIR", dir.try_to_utf8_os()?);
}
// Set our TERM var. This is a bit complicated because we want to use
@@ -636,13 +633,13 @@ impl Subprocess {
// ghostty in the TERMINFO database.
if let Some(base) = &cfg.resources_dir {
// The configured TERM is an encoded OS string: export its bytes.
env.insert("TERM", cfg.term.try_to_os()?);
env.insert("TERM", cfg.term.try_to_utf8_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));
base.try_to_utf8_os()?; // invalid UTF-8 fails like Zig
env.insert("TERMINFO", terminfo_dir(base)?);
} else {
env.insert("TERM", "xterm-256color");
env.insert("COLORTERM", "truecolor");
@@ -716,7 +713,7 @@ impl Subprocess {
// Build our args list. If we fail to find the shell, we'd still like
// to try to run something so Ghostty can launch.
// %WINDIR% may hold lone surrogates: carry it in the encoded form the
// argv strings use (decoded to WTF-16 when the process is created).
// argv strings use (validated as UTF-8 when the process is created).
let windir = std::env::var_os("WINDIR").map(|v| EncodedOs::from_os(&v).into_encoded());
let args = match exec_command(&shell_command, windir.as_deref()) {
Ok(args) => args,
@@ -733,7 +730,7 @@ impl Subprocess {
// Propagate the current working directory (CWD) to the shell.
if let Some(pwd) = &cwd {
env.insert("PWD", pwd.try_to_os()?);
env.insert("PWD", pwd.try_to_utf8_os()?);
}
Ok(Subprocess {
@@ -804,7 +801,7 @@ impl Subprocess {
// and we don't want to break new windows.
let cwd: Option<EncodedOs> = match &self.cwd {
None => None,
Some(proposed) => match std::fs::metadata(proposed.to_path()) {
Some(proposed) => match proposed.to_path().and_then(std::fs::metadata) {
Ok(_) => Some(proposed.clone()),
Err(err) => {
log::warn!("cannot access cwd, ignoring: {err}");
@@ -912,13 +909,13 @@ fn add_ghostty_path_for(env: &mut EnvMap, exe: &std::path::Path) {
/// The `TERMINFO` value for a resources dir: `<parent of base>\\terminfo` as
/// an OS string (the resources dir is an encoded OS string).
fn terminfo_dir(base: &EncodedOs) -> std::ffi::OsString {
let base = base.to_path();
fn terminfo_dir(base: &EncodedOs) -> std::io::Result<std::ffi::OsString> {
let base = base.to_path()?;
let mut out = base
.parent()
.map_or_else(std::ffi::OsString::new, |p| p.as_os_str().to_owned());
out.push("\\terminfo");
out
Ok(out)
}
/// Terminate the underlying subprocess and wait for it to exit
@@ -937,11 +934,11 @@ fn kill_command(command: &Command) -> Result<(), Error> {
pub fn exec_command(command: &ConfigCommand, windir: Option<&str>) -> Result<Vec<String>, Error> {
match command {
// We need to clone the command since there's no guarantee the config remains valid.
ConfigCommand::Direct(args) => Ok(args.clone()),
ConfigCommand::Direct(args) => Ok(args.iter().map(|v| v.as_encoded().to_owned()).collect()),
ConfigCommand::Shell(v) => {
let windir = windir.ok_or(Error::SystemError)?;
Ok(windows_shell_args(windir, v))
Ok(windows_shell_args(windir, v.as_encoded()))
}
}
}
@@ -949,35 +946,24 @@ pub fn exec_command(command: &ConfigCommand, windir: Option<&str>) -> Result<Vec
#[cfg(test)]
mod tests {
#[test]
fn env_overrides_are_decoded() {
use std::os::windows::ffi::OsStringExt;
fn env_overrides_keep_literal_fdd0_and_reject_surrogates() {
use ghostty_config::cli_args::ConfigField;
// A lone surrogate (escaped) value and a literal U+FDD0 key.
let lone = EncodedOs::from_wide(&[0x61, 0xD800]).into_encoded();
let lit = EncodedOs::from_plain_text("K\u{fdd0}").into_encoded();
let mut m = RepeatableStringMap::default();
m.parse_cli(Some(&format!("A={lone}"))).unwrap();
m.parse_cli(Some(&format!("{lit}=v"))).unwrap();
let mut overrides = RepeatableStringMap::default();
overrides
.parse_cli(Some("K\u{fdd0}é=value\u{fdd0}é"))
.unwrap();
let mut env = EnvMap::new();
super::apply_env_overrides(&mut env, &m).unwrap();
assert_eq!(
env.get("A").unwrap(),
std::ffi::OsString::from_wide(&[0x61, 0xD800])
);
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());
super::apply_env_overrides(&mut env, &overrides).unwrap();
assert_eq!(env.get("K\u{fdd0}é").unwrap(), "value\u{fdd0}é");
for raw in [
b"A=\xff".as_slice(),
b"A=\xed\xa0\x80",
b"\xed\xa0\x80=value",
] {
let mut overrides = RepeatableStringMap::default();
overrides.parse_bytes(Some(raw)).unwrap();
assert!(super::apply_env_overrides(&mut env, &overrides).is_err());
}
}
#[test]
@@ -989,7 +975,7 @@ mod tests {
wide.push(0xD800);
wide.extend("\\share\\ghostty".encode_utf16());
let base = EncodedOs::from_wide(&wide);
let got = super::terminfo_dir(&base);
let got = super::terminfo_dir(&base).unwrap();
let mut want: Vec<u16> = "C:\\Users\\".encode_utf16().collect();
want.push(0xD800);
want.extend("\\share\\terminfo".encode_utf16());
+70 -28
View File
@@ -48,6 +48,9 @@ pub use ghostty_config::{command::Command, types_b::ShellIntegrationFeatures};
pub enum Error {
#[error("unable to determine home directory: {0}")]
Home(#[from] homedir::Error),
#[cfg(windows)]
#[error(transparent)]
InvalidUtf8(#[from] ghostty_os::osbytes::InvalidUtf8),
}
/// The result of setting up a shell integration.
@@ -65,11 +68,17 @@ pub struct ShellIntegration {
/// `<resource_dir>/<rel>` as an OS string. `resource_dir` carries the
/// reversible config-string encoding (it may hold non-UTF-8 bytes), so it is
/// decoded before reaching the filesystem or the child environment.
fn resource_path(resource_dir: &EncodedOs, rel: &str) -> std::ffi::OsString {
let mut p = resource_dir.to_os();
p.push("/");
p.push(rel);
p
fn resource_path(resource_dir: &EncodedOs, rel: &str) -> Option<std::ffi::OsString> {
let mut path = resource_dir.to_bytes();
path.push(b'/');
path.extend_from_slice(rel.as_bytes());
match EncodedOs::from_bytes(&path).to_path() {
Ok(path) => Some(path.into_os_string()),
Err(error) => {
log::warn!("invalid shell integration path: {error}");
None
}
}
}
/// Set up the command execution environment for automatic
@@ -118,7 +127,7 @@ fn detect_shell(command: &Command) -> Option<Shell> {
fn detect_shell_for(command: &Command, darwin: bool) -> Option<Shell> {
let mut it = command.arg_iterator();
let arg0 = it.next()?;
let exe = basename(&arg0);
let exe = basename(arg0.as_encoded());
match exe {
"bash" => {
@@ -195,7 +204,7 @@ fn setup_bash(
// Start accumulating arguments with the executable and initial flags.
match iter.next() {
Some(exe) => cmd.append_arg(&exe),
Some(exe) => cmd.append_arg(exe.as_encoded()),
None => return Ok(None),
}
cmd.append_arg("--posix");
@@ -216,6 +225,7 @@ fn setup_bash(
// --posix POSIX mode (a la /bin/sh)
let mut rcfile: Option<String> = None;
while let Some(arg) = iter.next() {
let arg = arg.as_encoded();
if arg == "--posix" {
return Ok(None);
} else if arg == "--norc" {
@@ -223,23 +233,23 @@ fn setup_bash(
} else if arg == "--noprofile" {
inject.push_str(" --noprofile");
} else if arg == "--rcfile" || arg == "--init-file" {
rcfile = iter.next();
rcfile = iter.next().map(EncodedOs::into_encoded);
} else if arg.len() > 1 && arg.starts_with('-') && !arg[1..].starts_with('-') {
// '-c command' is always non-interactive
if arg.contains('c') {
return Ok(None);
}
cmd.append_arg(&arg);
cmd.append_arg(arg);
} else if arg == "-" || arg == "--" {
// All remaining arguments should be passed directly to the shell
// command. We shouldn't perform any further option processing.
cmd.append_arg(&arg);
cmd.append_arg(arg);
for remaining in iter.by_ref() {
cmd.append_arg(&remaining);
cmd.append_arg(remaining.as_encoded());
}
break;
} else {
cmd.append_arg(&arg);
cmd.append_arg(arg);
}
}
@@ -249,7 +259,11 @@ fn setup_bash(
}
// Set our new ENV to point to our integration script.
let script_path = resource_path(resource_dir, "shell-integration/bash/ghostty.bash");
let Some(script_path) = resource_path(resource_dir, "shell-integration/bash/ghostty.bash")
else {
env.remove("GHOSTTY_BASH_ENV");
return Ok(None);
};
match std::fs::File::open(&script_path) {
Ok(_) => {
env.insert("ENV", script_path);
@@ -265,7 +279,12 @@ fn setup_bash(
if let Some(v) = rcfile {
// 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());
let value = EncodedOs::from_encoded(v);
#[cfg(windows)]
let value = value.try_to_utf8_os()?;
#[cfg(not(windows))]
let value = value.to_os();
env.insert("GHOSTTY_BASH_RCFILE", value);
}
// In POSIX mode, HISTFILE defaults to ~/.sh_history, so unless we're
@@ -281,7 +300,7 @@ fn setup_bash(
}
// Return a copy of our modified command line to use as the shell command.
Ok(Some(Command::Shell(cmd.written().to_owned())))
Ok(Some(Command::Shell(EncodedOs::from_encoded(cmd.written()))))
}
/// Setup automatic shell integration for shells that include
@@ -293,7 +312,9 @@ fn setup_bash(
/// from `XDG_DATA_DIRS` when integration is complete.
fn setup_xdg_data_dirs(resource_dir: &EncodedOs, env: &mut EnvMap) -> bool {
// Get our path to the shell integration directory.
let integ_path = resource_path(resource_dir, "shell-integration");
let Some(integ_path) = resource_path(resource_dir, "shell-integration") else {
return false;
};
if let Err(err) = std::fs::read_dir(&integ_path) {
log::warn!("unable to open {}: {err}", integ_path.to_string_lossy());
return false;
@@ -336,7 +357,7 @@ fn setup_nushell(command: &Command, resource_dir: &EncodedOs, env: &mut EnvMap)
let mut iter = command.arg_iterator();
// Start accumulating arguments with the executable and initial flags.
cmd.append_arg(&iter.next()?);
cmd.append_arg(iter.next()?.as_encoded());
// Tell nu to immediately "use" all of the exported functions in our
// 'ghostty' module.
@@ -355,28 +376,29 @@ fn setup_nushell(command: &Command, resource_dir: &EncodedOs, env: &mut EnvMap)
// -c / --command -c is always non-interactive
// --lsp --lsp starts the language server
while let Some(arg) = iter.next() {
let arg = arg.as_encoded();
if arg == "--command" || arg == "--lsp" {
return None;
} else if arg.len() > 1 && arg.starts_with('-') && !arg[1..].starts_with('-') {
if arg.contains('c') {
return None;
}
cmd.append_arg(&arg);
cmd.append_arg(arg);
} else if arg == "-" || arg == "--" {
// All remaining arguments should be passed directly to the shell
// command. We shouldn't perform any further option processing.
cmd.append_arg(&arg);
cmd.append_arg(arg);
for remaining in iter.by_ref() {
cmd.append_arg(&remaining);
cmd.append_arg(remaining.as_encoded());
}
break;
} else {
cmd.append_arg(&arg);
cmd.append_arg(arg);
}
}
// Return a copy of our modified command line to use as the shell command.
Some(Command::Shell(cmd.written().to_owned()))
Some(Command::Shell(EncodedOs::from_encoded(cmd.written())))
}
/// Setup the zsh automatic shell integration. This works by setting
@@ -389,7 +411,7 @@ fn setup_zsh(command: &Command, resource_dir: &EncodedOs, env: &mut EnvMap) -> O
}
// Set our new ZDOTDIR to point to our shell resource directory.
let integ_path = resource_path(resource_dir, "shell-integration/zsh");
let integ_path = resource_path(resource_dir, "shell-integration/zsh")?;
if !Path::new(&integ_path).is_dir() {
// Mirrors openDirAbsolute failing (missing or not a directory).
log::warn!("unable to open {}", integ_path.to_string_lossy());
@@ -449,12 +471,12 @@ mod tests {
}
fn sh(s: &str) -> Command {
Command::Shell(s.to_owned())
Command::Shell(s.into())
}
fn shell_str(c: Option<&Command>) -> &str {
match c.unwrap() {
Command::Shell(s) => s,
Command::Shell(s) => s.as_encoded(),
Command::Direct(_) => panic!("expected shell"),
}
}
@@ -682,15 +704,35 @@ mod tests {
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(),
"bash".into(),
"--rcfile".into(),
EncodedOs::from_bytes(raw),
]);
setup_bash(&command, &res.enc(), &mut env).unwrap();
assert_eq!(env.get("GHOSTTY_BASH_RCFILE").unwrap().as_bytes(), raw);
}
}
#[cfg(windows)]
#[test]
fn windows_bash_rcfile_rejects_invalid_utf8_before_env_conversion() {
let res = TmpResourcesDir::new(Shell::Bash);
for raw in [b"bad\xff".as_slice(), b"bad\xed\xa0\x80"] {
let command = Command::Direct(vec![
"bash".into(),
"--rcfile".into(),
EncodedOs::from_bytes(raw),
]);
let mut env = EnvMap::new();
assert!(matches!(
setup_bash(&command, &res.enc(), &mut env),
Err(Error::InvalidUtf8(_))
));
assert!(!env.contains_key("GHOSTTY_BASH_RCFILE"));
}
assert!(resource_path(&EncodedOs::from_bytes(b"bad\xff"), "shell-integration").is_none());
}
#[test]
fn bash_histfile() {
let res = TmpResourcesDir::new(Shell::Bash);
+14 -9
View File
@@ -143,7 +143,7 @@ impl DerivedConfig {
background: config.background,
osc_color_report_format: config.osc_color_report_format,
clipboard_write: config.clipboard_write,
enquiry_response: ghostty_os::osbytes::config_str_to_bytes(&config.enquiry_response),
enquiry_response: config.enquiry_response.to_bytes(),
conditional_state: config.conditional_state,
}
}
@@ -174,13 +174,19 @@ impl ThreadEnterState {
let mut out = Vec::with_capacity(self.input.len());
for item in &self.input {
match item {
ReadableIO::Raw(v) => out.push(ghostty_os::osbytes::config_str_to_bytes(v)),
ReadableIO::Raw(v) => out.push(v.to_bytes()),
ReadableIO::Path(path) => {
// 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 f = path
.to_path()
.and_then(std::fs::File::open)
.map_err(|err| {
log::warn!(
"failed to open input file={} err={err}",
path.to_text_lossy()
);
Error::InputNotFound
})?;
let mut buf = Vec::new();
// 10 MiB max
let limit = 10 * 1024 * 1024;
@@ -803,8 +809,7 @@ mod derived_config_tests {
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!(
@@ -812,7 +817,7 @@ mod derived_config_tests {
"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();
cfg.enquiry_response = ghostty_os::osbytes::EncodedOs::from_bytes(raw);
assert_eq!(DerivedConfig::new(&cfg).enquiry_response, raw);
}
}
+17 -14
View File
@@ -15,21 +15,11 @@ pub enum EncodeError {
InvalidUtf8,
}
/// Convert an OS string to UTF-16 code units. On Windows this is lossless
/// (`encode_wide`); elsewhere the string must be valid UTF-8.
/// Zig's Windows Command validates UTF-8, including inherited environment strings.
/// An unpaired UTF-16 surrogate in an OS string is therefore an error.
pub fn os_str_to_utf16(s: &OsStr) -> Result<Vec<u16>, EncodeError> {
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
Ok(s.encode_wide().collect())
}
#[cfg(not(windows))]
{
Ok(s.to_str()
.ok_or(EncodeError::InvalidUtf8)?
.encode_utf16()
.collect())
}
ghostty_os::osbytes::try_utf8_to_wide(&ghostty_os::osbytes::os_bytes(s))
.map_err(|_| EncodeError::InvalidUtf8)
}
/// Create a UTF-16 environment block for `CreateProcessW` with
@@ -157,6 +147,19 @@ mod tests {
windows_create_command_line(args)
}
#[test]
fn inherited_environment_rejects_non_utf8() {
let bad = ghostty_os::osbytes::EncodedOs::from_wide(&[0xd800]).to_os();
assert_eq!(
create_windows_env_block([(std::ffi::OsStr::new("KEY"), bad.as_os_str())]),
Err(EncodeError::InvalidUtf8)
);
assert_eq!(
create_windows_env_block([(bad.as_os_str(), std::ffi::OsStr::new("value"))]),
Err(EncodeError::InvalidUtf8)
);
}
#[test]
fn wide_command_line_matches_str_version_and_keeps_lone_surrogates() {
let w = |s: &str| s.encode_utf16().collect::<Vec<u16>>();