refactor: type escaped OS strings and fix the final audit findings

Escaped config strings (ghostty_os::osbytes) kept reaching OS
boundaries undecoded, and decoding was occasionally applied to plain
text. The root cause was that both were plain `String`s. Now:
- `EncodedOs` is a newtype (no Display/AsRef<Path>) that decodes only
  via to_os/to_path/to_bytes/to_wide; plain text enters via
  from_plain_text. OS helpers (env, home, xdg, passwd, resources dir,
  path expansion), config::Path, WorkingDirectory, theme/file_load/edit
  paths, terminal pwd, OpenUrl/Pwd actions and termio cwd use it, so the
  compiler forces decoding at every boundary. os::open takes &OsStr.
  Config output is unchanged.

Fixes all rows of docs/rewrite/notes/review-rv5-codex.md (final-Z7.md):
screen-file copy; regex/OSC 8 URLs no longer decoded (regression);
lossless Windows WTF-8/WTF-16 for args, env and CreateProcessW; Windows
resources dir/TERMINFO; color OSC request lists and X11 color lookup no
longer allocate globally (C zero-allocation test extended); macOS Mach
handler resumes all threads before KERN_FAILURE (hang regression) and
SIGABRT minidumps go through the exception thread; plus open-config,
background images, crash dir, ssh cache, exe dir/PATH, fontconfig
filenames, file links, kitty TMPDIR, .hushlogin and benchmark paths.
The typing also surfaced and fixed gtk-custom-css, bell audio, bash
HISTFILE, OSC 52 writes and the C config getters.

Test Plan:
- cargo test --workspace (3376 passed)
- just check-macos (both), just check-windows, just check-wasm
- diff <(ghostty-rs +show-config --default --docs) \
  <(ghostty +show-config --default --docs)
This commit is contained in:
ddidderr committed 2026-10-12 10:06:07 +02:00
1 parent 0f898d3e41
commit 8f3e206d9f
92 files changed
+2967 -992

No files matched your search

+158 -4
View File
@@ -1,21 +1,88 @@
//! Kitty color protocol (ZIG/src/terminal/kitty/color.zig).
use std::fmt;
use std::{borrow::Cow, fmt};
use crate::{color::Rgb, osc::Terminator};
/// OSC 21 payload.
///
/// The request list is stored encoded (fixed size records) in the OSC
/// parser's own storage, so parsing it never touches the global allocator
/// when the parser was given a caller-provided allocator (C API).
#[derive(Debug, Clone, PartialEq, Default)]
pub struct OSC {
pub struct OSC<'a> {
/// list of requests
pub list: Vec<Request>,
pub list: List<'a>,
/// We must reply with the same string terminator (ST) as used in the
/// request.
pub terminator: Terminator,
}
#[derive(Debug, Clone, PartialEq, Eq)]
impl OSC<'_> {
/// Detach from the parser storage.
#[must_use]
pub fn into_owned(self) -> OSC<'static> {
OSC {
list: self.list.into_owned(),
terminator: self.terminator,
}
}
}
/// Size of one encoded [`Request`] record.
pub const RECORD_LEN: usize = 8;
/// A list of kitty color [`Request`]s: borrowed (or owned) encoded records.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct List<'a>(Cow<'a, [u8]>);
impl<'a> List<'a> {
/// View records encoded with [`Request::encode`] (`bytes.len()` must be a
/// multiple of [`RECORD_LEN`]).
#[must_use]
pub fn from_encoded(bytes: &'a [u8]) -> List<'a> {
debug_assert_eq!(bytes.len() % RECORD_LEN, 0);
List(Cow::Borrowed(bytes))
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len() / RECORD_LEN
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub fn get(&self, i: usize) -> Option<Request> {
Request::decode(self.0.get(i * RECORD_LEN..(i + 1) * RECORD_LEN)?)
}
/// Iterate the requests (by value).
pub fn iter(&self) -> impl Iterator<Item = Request> + '_ {
self.0
.as_chunks::<RECORD_LEN>()
.0
.iter()
.filter_map(|rec| Request::decode(rec))
}
/// Copy into a `Vec` (allocates; for tests and diagnostics).
#[must_use]
pub fn to_vec(&self) -> Vec<Request> {
self.iter().collect()
}
#[must_use]
pub fn into_owned(self) -> List<'static> {
List(Cow::Owned(self.0.into_owned()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Request {
Query(Kind),
Set { key: Kind, color: Rgb },
@@ -35,6 +102,26 @@ pub enum Special {
}
impl Special {
/// All specials in discriminant order.
const ALL: [Special; 8] = [
Special::Foreground,
Special::Background,
Special::SelectionForeground,
Special::SelectionBackground,
Special::Cursor,
Special::CursorText,
Special::VisualBell,
Special::SecondTransparentBackground,
];
fn to_u8(self) -> u8 {
Special::ALL.iter().position(|s| *s == self).unwrap_or(0) as u8
}
fn from_u8(v: u8) -> Option<Special> {
Special::ALL.get(usize::from(v)).copied()
}
/// The tag name (Zig `@tagName`).
pub fn name(self) -> &'static str {
match self {
@@ -92,6 +179,46 @@ impl Kind {
}
}
impl Request {
/// Encode as a fixed size record: `[tag, kind class, kind value, r, g, b,
/// 0, 0]`.
#[must_use]
pub fn encode(&self) -> [u8; RECORD_LEN] {
let (tag, key, color) = match *self {
Request::Query(k) => (0, k, Rgb::default()),
Request::Set { key, color } => (1, key, color),
Request::Reset(k) => (2, k, Rgb::default()),
};
let (class, value) = match key {
Kind::Palette(p) => (0, p),
Kind::Special(s) => (1, s.to_u8()),
};
[tag, class, value, color.r, color.g, color.b, 0, 0]
}
/// Inverse of [`Request::encode`].
#[must_use]
pub fn decode(rec: &[u8]) -> Option<Request> {
let [tag, class, value, r, g, b, ..] = *rec else {
return None;
};
let key = match class {
0 => Kind::Palette(value),
1 => Kind::Special(Special::from_u8(value)?),
_ => return None,
};
Some(match tag {
0 => Request::Query(key),
1 => Request::Set {
key,
color: Rgb { r, g, b },
},
2 => Request::Reset(key),
_ => return None,
})
}
}
impl fmt::Display for Kind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -106,6 +233,33 @@ mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn request_records_round_trip() {
let mut reqs = vec![];
for s in Special::ALL {
let k = Kind::Special(s);
reqs.push(Request::Query(k));
reqs.push(Request::Reset(k));
reqs.push(Request::Set {
key: k,
color: Rgb { r: 1, g: 2, b: 3 },
});
}
reqs.push(Request::Set {
key: Kind::Palette(255),
color: Rgb { r: 255, g: 0, b: 9 },
});
reqs.push(Request::Query(Kind::Palette(0)));
let bytes: Vec<u8> = reqs.iter().flat_map(Request::encode).collect();
let list = List::from_encoded(&bytes);
assert_eq!(list.len(), reqs.len());
assert_eq!(list.to_vec(), reqs);
assert_eq!(list.get(reqs.len()), None);
assert_eq!(list.into_owned().to_vec(), reqs);
assert_eq!(Request::decode(&[9, 0, 0, 0, 0, 0, 0, 0]), None);
assert_eq!(Request::decode(&[0, 1, 200, 0, 0, 0, 0, 0]), None);
}
#[test]
fn osc_kitty_color_protocol_kind_string() {
assert_eq!("foreground", Kind::Special(Special::Foreground).to_string());