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 7a3f0b0f2c
commit f7fec6f44d
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());
+38 -9
View File
@@ -82,13 +82,13 @@ pub enum Command<'a> {
/// 4, 5, 10-19, 104, 105, 110-119
ColorOperation {
op: color::Operation,
requests: color::List,
requests: color::List<'a>,
terminator: Terminator,
},
/// Kitty color protocol, OSC 21
/// https://sw.kovidgoyal.net/kitty/color-stack/#id1
KittyColorProtocol(kitty_color::OSC),
KittyColorProtocol(kitty_color::OSC<'a>),
/// Show a desktop notification (OSC 9 or OSC 777)
ShowDesktopNotification {
@@ -228,10 +228,10 @@ impl Command<'_> {
terminator,
} => Command::ColorOperation {
op,
requests,
requests: requests.into_owned(),
terminator,
},
Command::KittyColorProtocol(v) => Command::KittyColorProtocol(v),
Command::KittyColorProtocol(v) => Command::KittyColorProtocol(v.into_owned()),
Command::ShowDesktopNotification { title, body } => Command::ShowDesktopNotification {
title: own(title),
body: own(body),
@@ -486,6 +486,10 @@ pub struct Parser<S: ByteStorage = Vec<u8>> {
/// Buffer for temporary storage of OSC data
buffer: S,
/// Auxiliary storage for the encoded request lists of the color OSCs
/// (Zig: a `SegmentedList` on the parser's allocator). Same allocator as
/// `buffer`.
aux: S,
writer: Writer,
}
@@ -501,6 +505,7 @@ impl Parser {
alloc,
state: State::Start,
buffer: Vec::new(),
aux: Vec::new(),
writer: Writer::None,
}
}
@@ -512,11 +517,16 @@ impl<S: ByteStorage> Parser<S> {
/// Like [`Parser::init`] with an allocator-backed payload buffer
/// (`alloc` is true: an allocator is available).
pub fn with_storage(storage: S) -> Parser<S> {
///
/// `aux` holds the request lists of the color OSCs; give it the same
/// allocator as `storage` (a capacity of a few [`color::RECORD_LEN`]
/// records avoids allocating for common color commands).
pub fn with_storage(storage: S, aux: S) -> Parser<S> {
Parser {
alloc: true,
state: State::Start,
buffer: storage,
aux,
writer: Writer::None,
}
}
@@ -525,6 +535,7 @@ impl<S: ByteStorage> Parser<S> {
pub fn reset(&mut self) {
self.state = State::Start;
self.buffer.reset();
self.aux.reset();
self.writer = Writer::None;
}
@@ -608,6 +619,18 @@ impl<S: ByteStorage> Parser<S> {
self.buffer.bytes()
}
/// The payload and the auxiliary request storage at once (disjoint
/// borrows, so a color parser can read the former while filling the
/// latter).
pub(crate) fn payload_and_aux(&mut self) -> (&[u8], &mut S) {
(self.buffer.bytes(), &mut self.aux)
}
/// The encoded records written to the auxiliary storage.
pub(crate) fn aux_bytes(&self) -> &[u8] {
self.aux.bytes()
}
/// Whether any payload writer has been set up (Zig `parser.writer != null`).
pub fn has_writer(&self) -> bool {
self.writer != Writer::None
@@ -977,10 +1000,16 @@ mod next_slice_tests {
#[test]
fn storage_failure_invalidates_osc() {
let mut p = Parser::with_storage(Limited {
data: Vec::new(),
cap: 8,
});
let mut p = Parser::with_storage(
Limited {
data: Vec::new(),
cap: 8,
},
Limited {
data: Vec::new(),
cap: 64,
},
);
for b in b"0;abcdefghijkl" {
p.next(*b);
}
+285 -54
View File
@@ -1,6 +1,8 @@
//! OSC color operations: 4, 5, 10-19, 104, 105, 110-119
//! (ZIG/src/terminal/osc/parsers/color.zig).
use std::borrow::Cow;
use crate::{
color::{Dynamic as DynamicColor, Rgb, Special as SpecialColor},
osc::{ByteStorage, Command, Parser, State, Terminator},
@@ -49,11 +51,7 @@ pub fn parse<S: ByteStorage>(
if parser.has_writer() {
parser.buffered()?;
}
let data: &[u8] = if parser.has_writer() {
parser.payload()
} else {
&[]
};
let has_writer = parser.has_writer();
// Check and make sure that we're parsing the correct OSCs
let op = match parser.state {
State::S4 => Operation::Osc4,
@@ -84,13 +82,60 @@ pub fn parse<S: ByteStorage>(
return None;
}
};
// The request list is built in the parser's own (allocator-backed)
// auxiliary storage, not on the global allocator.
let (payload, aux) = parser.payload_and_aux();
let data: &[u8] = if has_writer { payload } else { &[] };
aux.reset();
let mut sink = StorageSink { aux, failed: false };
parse_color_into(op, data, &mut sink);
if sink.failed {
// Allocation failure: the OSC cannot be represented.
log::warn!("unable to allocate the color request list");
parser.state = State::Invalid;
return None;
}
Some(Command::ColorOperation {
op,
requests: parse_color(op, data),
requests: List::from_encoded(parser.aux_bytes()),
terminator: Terminator::init(terminator_ch),
})
}
/// Where parsed requests go: a `Vec` (tests, [`parse_color`]) or the
/// parser's storage ([`StorageSink`]).
pub trait Sink {
fn push(&mut self, request: Request);
fn is_empty(&self) -> bool;
}
impl Sink for Vec<Request> {
fn push(&mut self, request: Request) {
Vec::push(self, request);
}
fn is_empty(&self) -> bool {
Vec::is_empty(self)
}
}
/// Appends encoded request records to a [`ByteStorage`]. An allocation
/// failure is remembered in `failed` (later pushes are dropped).
pub struct StorageSink<'s, S: ByteStorage> {
aux: &'s mut S,
failed: bool,
}
impl<S: ByteStorage> Sink for StorageSink<'_, S> {
fn push(&mut self, request: Request) {
if !self.failed && self.aux.try_extend(&request.encode()).is_err() {
self.failed = true;
}
}
fn is_empty(&self) -> bool {
self.aux.is_empty()
}
}
/// Zig `std.mem.tokenizeScalar(u8, buf, ';')`: skips empty tokens.
fn tokenize(buf: &[u8]) -> impl Iterator<Item = &[u8]> {
buf.split(|&c| c == b';').filter(|t| !t.is_empty())
@@ -132,33 +177,88 @@ fn parse_u9(s: &[u8]) -> Option<u16> {
/// request) but grants us an easier to understand and testable implementation.
///
/// If color changing ends up being a bottleneck we can optimize this later.
pub fn parse_color(op: Operation, buf: &[u8]) -> List {
pub fn parse_color(op: Operation, buf: &[u8]) -> Vec<Request> {
let mut out = Vec::new();
parse_color_into(op, buf, &mut out);
out
}
/// [`parse_color`] into any [`Sink`].
pub fn parse_color_into(op: Operation, buf: &[u8], out: &mut impl Sink) {
let mut it = tokenize(buf);
match op {
Operation::Osc4 => parse_get_set_ansi_color(Operation::Osc4, &mut it),
Operation::Osc5 => parse_get_set_ansi_color(Operation::Osc5, &mut it),
Operation::Osc104 => parse_reset_ansi_color(Operation::Osc104, &mut it),
Operation::Osc105 => parse_reset_ansi_color(Operation::Osc105, &mut it),
Operation::Osc10 => parse_get_set_dynamic_color(DynamicColor::Foreground, &mut it),
Operation::Osc11 => parse_get_set_dynamic_color(DynamicColor::Background, &mut it),
Operation::Osc12 => parse_get_set_dynamic_color(DynamicColor::Cursor, &mut it),
Operation::Osc13 => parse_get_set_dynamic_color(DynamicColor::PointerForeground, &mut it),
Operation::Osc14 => parse_get_set_dynamic_color(DynamicColor::PointerBackground, &mut it),
Operation::Osc15 => parse_get_set_dynamic_color(DynamicColor::TektronixForeground, &mut it),
Operation::Osc16 => parse_get_set_dynamic_color(DynamicColor::TektronixBackground, &mut it),
Operation::Osc17 => parse_get_set_dynamic_color(DynamicColor::HighlightBackground, &mut it),
Operation::Osc18 => parse_get_set_dynamic_color(DynamicColor::TektronixCursor, &mut it),
Operation::Osc19 => parse_get_set_dynamic_color(DynamicColor::HighlightForeground, &mut it),
Operation::Osc110 => parse_reset_dynamic_color(DynamicColor::Foreground, &mut it),
Operation::Osc111 => parse_reset_dynamic_color(DynamicColor::Background, &mut it),
Operation::Osc112 => parse_reset_dynamic_color(DynamicColor::Cursor, &mut it),
Operation::Osc113 => parse_reset_dynamic_color(DynamicColor::PointerForeground, &mut it),
Operation::Osc114 => parse_reset_dynamic_color(DynamicColor::PointerBackground, &mut it),
Operation::Osc115 => parse_reset_dynamic_color(DynamicColor::TektronixForeground, &mut it),
Operation::Osc116 => parse_reset_dynamic_color(DynamicColor::TektronixBackground, &mut it),
Operation::Osc117 => parse_reset_dynamic_color(DynamicColor::HighlightBackground, &mut it),
Operation::Osc118 => parse_reset_dynamic_color(DynamicColor::TektronixCursor, &mut it),
Operation::Osc119 => parse_reset_dynamic_color(DynamicColor::HighlightForeground, &mut it),
Operation::Osc4 => {
parse_get_set_ansi_color(Operation::Osc4, &mut it, out);
}
Operation::Osc5 => {
parse_get_set_ansi_color(Operation::Osc5, &mut it, out);
}
Operation::Osc104 => {
parse_reset_ansi_color(Operation::Osc104, &mut it, out);
}
Operation::Osc105 => {
parse_reset_ansi_color(Operation::Osc105, &mut it, out);
}
Operation::Osc10 => {
parse_get_set_dynamic_color(DynamicColor::Foreground, &mut it, out);
}
Operation::Osc11 => {
parse_get_set_dynamic_color(DynamicColor::Background, &mut it, out);
}
Operation::Osc12 => {
parse_get_set_dynamic_color(DynamicColor::Cursor, &mut it, out);
}
Operation::Osc13 => {
parse_get_set_dynamic_color(DynamicColor::PointerForeground, &mut it, out);
}
Operation::Osc14 => {
parse_get_set_dynamic_color(DynamicColor::PointerBackground, &mut it, out);
}
Operation::Osc15 => {
parse_get_set_dynamic_color(DynamicColor::TektronixForeground, &mut it, out);
}
Operation::Osc16 => {
parse_get_set_dynamic_color(DynamicColor::TektronixBackground, &mut it, out);
}
Operation::Osc17 => {
parse_get_set_dynamic_color(DynamicColor::HighlightBackground, &mut it, out);
}
Operation::Osc18 => {
parse_get_set_dynamic_color(DynamicColor::TektronixCursor, &mut it, out);
}
Operation::Osc19 => {
parse_get_set_dynamic_color(DynamicColor::HighlightForeground, &mut it, out);
}
Operation::Osc110 => {
parse_reset_dynamic_color(DynamicColor::Foreground, &mut it, out);
}
Operation::Osc111 => {
parse_reset_dynamic_color(DynamicColor::Background, &mut it, out);
}
Operation::Osc112 => {
parse_reset_dynamic_color(DynamicColor::Cursor, &mut it, out);
}
Operation::Osc113 => {
parse_reset_dynamic_color(DynamicColor::PointerForeground, &mut it, out);
}
Operation::Osc114 => {
parse_reset_dynamic_color(DynamicColor::PointerBackground, &mut it, out);
}
Operation::Osc115 => {
parse_reset_dynamic_color(DynamicColor::TektronixForeground, &mut it, out);
}
Operation::Osc116 => {
parse_reset_dynamic_color(DynamicColor::TektronixBackground, &mut it, out);
}
Operation::Osc117 => {
parse_reset_dynamic_color(DynamicColor::HighlightBackground, &mut it, out);
}
Operation::Osc118 => {
parse_reset_dynamic_color(DynamicColor::TektronixCursor, &mut it, out);
}
Operation::Osc119 => {
parse_reset_dynamic_color(DynamicColor::HighlightForeground, &mut it, out);
}
}
}
@@ -179,29 +279,32 @@ fn ansi_target(color: u16, special_only: bool) -> Option<Target> {
}
/// OSC 4/5
fn parse_get_set_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a [u8]>) -> List {
fn parse_get_set_ansi_color<'a>(
op: Operation,
it: &mut impl Iterator<Item = &'a [u8]>,
result: &mut impl Sink,
) {
// Note: in ANY error scenario below we return the accumulated results.
// This matches the xterm behavior (see misc.c ChangeAnsiColorRequest)
let mut result = List::new();
loop {
// We expect a `c; spec` pair. If either doesn't exist then
// we return the results up to this point.
let Some(color_str) = it.next() else {
return result;
return;
};
let Some(spec_str) = it.next() else {
return result;
return;
};
// Color must be numeric. u9 because that'll fit our palette + special
let Some(color) = parse_u9(color_str) else {
return result;
return;
};
// Parse the color.
let Some(target) = ansi_target(color, op == Operation::Osc5) else {
return result;
return;
};
// "?" always results in a query.
@@ -211,20 +314,23 @@ fn parse_get_set_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a
}
let Ok(rgb) = Rgb::parse_bytes(spec_str) else {
return result;
return;
};
result.push(Request::Set(ColoredTarget { target, color: rgb }));
}
}
/// OSC 104/105: Reset ANSI Colors
fn parse_reset_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a [u8]>) -> List {
fn parse_reset_ansi_color<'a>(
op: Operation,
it: &mut impl Iterator<Item = &'a [u8]>,
result: &mut impl Sink,
) {
// Note: xterm stops parsing the reset list on any error, but we're
// more flexible and try the next value. This matches the behavior of
// Kitty and I don't see a downside to being more flexible here. Hopefully
// no one depends on the exact behavior of xterm.
let mut result = List::new();
loop {
let Some(color_str) = it.next() else {
// If no parameters are given, we reset the full table.
@@ -235,7 +341,7 @@ fn parse_reset_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a [
_ => unreachable!(),
});
}
return result;
return;
};
// Empty color strings are ignored, not treated as an error.
@@ -261,22 +367,22 @@ fn parse_reset_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a [
fn parse_get_set_dynamic_color<'a>(
start: DynamicColor,
it: &mut impl Iterator<Item = &'a [u8]>,
) -> List {
result: &mut impl Sink,
) {
// Note: in ANY error scenario below we return the accumulated results.
// This matches the xterm behavior (see misc.c ChangeColorsRequest)
let mut result = List::new();
let mut color = start;
loop {
let Some(spec_str) = it.next() else {
return result;
return;
};
if spec_str == b"?" {
result.push(Request::Query(Target::Dynamic(color)));
} else {
let Ok(rgb) = Rgb::parse_bytes(spec_str) else {
return result;
return;
};
result.push(Request::Set(ColoredTarget {
target: Target::Dynamic(color),
@@ -287,7 +393,7 @@ fn parse_get_set_dynamic_color<'a>(
// Each successive value uses the next color so long as it exists.
match color.next() {
Some(n) => color = n,
None => return result,
None => return,
}
}
}
@@ -296,18 +402,117 @@ fn parse_get_set_dynamic_color<'a>(
fn parse_reset_dynamic_color<'a>(
color: DynamicColor,
it: &mut impl Iterator<Item = &'a [u8]>,
) -> List {
let mut result = List::new();
result: &mut impl Sink,
) {
if it.next().is_some() {
return result;
return;
}
result.push(Request::Reset(Target::Dynamic(color)));
result
}
/// The list of requests in a color OSC. (Zig uses a SegmentedList to
/// avoid copying; a `Vec` is equivalent.)
pub type List = Vec<Request>;
/// Size of one encoded [`Request`] record.
pub const RECORD_LEN: usize = 8;
/// The list of requests in a color OSC. (Zig uses a SegmentedList on the
/// parser's allocator.) The requests are stored as fixed size encoded
/// records ([`Request::encode`]) in the parser's own storage, so with a
/// caller-provided allocator (C API) the list is allocated by that allocator
/// and borrowed by the command, like Zig's.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct List<'a>(Cow<'a, [u8]>);
impl<'a> List<'a> {
/// View records encoded with [`Request::encode`].
#[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()
}
/// Detach from the parser storage.
#[must_use]
pub fn into_owned(self) -> List<'static> {
List(Cow::Owned(self.0.into_owned()))
}
}
impl Request {
/// Encode as a fixed size record: `[tag, target class, target value, r,
/// g, b, 0, 0]`.
#[must_use]
pub fn encode(&self) -> [u8; RECORD_LEN] {
fn target(t: Target) -> (u8, u8) {
match t {
Target::Palette(p) => (0, p),
Target::Special(s) => (1, s as u8),
Target::Dynamic(d) => (2, d as u8),
}
}
let (tag, (class, value), c) = match *self {
Request::Set(ColoredTarget { target: t, color }) => (0, target(t), color),
Request::Query(t) => (1, target(t), Rgb::default()),
Request::Reset(t) => (2, target(t), Rgb::default()),
Request::ResetPalette => (3, (0, 0), Rgb::default()),
Request::ResetSpecial => (4, (0, 0), Rgb::default()),
};
[tag, class, value, c.r, c.g, c.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 target = match class {
0 => Target::Palette(value),
1 => Target::Special(SpecialColor::from_u8(value)?),
2 => Target::Dynamic(DynamicColor::from_u8(value)?),
_ => return None,
};
Some(match tag {
0 => Request::Set(ColoredTarget {
target,
color: Rgb { r, g, b },
}),
1 => Request::Query(target),
2 => Request::Reset(target),
3 => Request::ResetPalette,
4 => Request::ResetSpecial,
_ => return None,
})
}
}
/// A single operation related to the terminal color palette.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -447,6 +652,32 @@ mod tests {
}
}
#[test]
fn request_records_round_trip() {
let mut reqs = vec![Request::ResetPalette, Request::ResetSpecial];
for t in [
Target::Palette(0),
Target::Palette(255),
Target::Special(SpecialColor::Italic),
Target::Dynamic(DynamicColor::HighlightForeground),
] {
reqs.push(Request::Query(t));
reqs.push(Request::Reset(t));
reqs.push(Request::Set(ColoredTarget {
target: t,
color: Rgb { r: 9, g: 8, b: 7 },
}));
}
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.iter().count(), reqs.len());
assert_eq!(list.into_owned().to_vec(), reqs);
assert_eq!(Request::decode(&[7, 0, 0, 0, 0, 0, 0, 0]), None);
assert_eq!(Request::decode(&[1, 2, 99, 0, 0, 0, 0, 0]), None);
}
#[test]
fn osc_4_multiple_requests() {
let list = parse_color(Operation::Osc4, b"0;red;1;blue");
@@ -566,7 +797,7 @@ mod tests {
assert_eq!(Operation::Osc112, op);
assert_eq!(
vec![Request::Reset(Target::Dynamic(DynamicColor::Cursor))],
requests
requests.to_vec()
);
}
}
@@ -2,7 +2,7 @@
use crate::{
color::Rgb,
kitty::color::{Kind, OSC, Request},
kitty::color::{Kind, List, OSC, Request},
osc::{ByteStorage, Command, Parser, State, Terminator},
};
@@ -23,16 +23,18 @@ pub fn parse<S: ByteStorage>(
}
parser.buffered()?;
// The parse only reads the payload; failure marks the parser invalid
// after the borrow ends.
let data = parser.payload();
// The parse reads the payload and appends encoded records to the
// parser's auxiliary storage (the caller's allocator in the C API); the
// command then borrows them.
let (data, aux) = parser.payload_and_aux();
aux.reset();
let mut exceeded = false;
let mut osc = OSC {
list: Vec::new(),
terminator: Terminator::init(terminator_ch),
let mut out = Pending {
failed: false,
count: 0,
};
for kv in data.split(|&c| c == b';') {
if osc.list.len() >= Kind::MAX * 2 {
if out.count >= Kind::MAX * 2 {
log::warn!("exceeded limit for number of keys in kitty color protocol, ignoring");
exceeded = true;
break;
@@ -54,11 +56,11 @@ pub fn parse<S: ByteStorage>(
};
let value = rest.trim_ascii_matching(b' ');
if value.is_empty() {
osc.list.push(Request::Reset(key));
out.push(aux, Request::Reset(key));
} else if value == b"?" {
osc.list.push(Request::Query(key));
out.push(aux, Request::Query(key));
} else if let Ok(color) = Rgb::parse_bytes(value) {
osc.list.push(Request::Set { key, color });
out.push(aux, Request::Set { key, color });
} else {
log::warn!(
"invalid color format in kitty color protocol: {}",
@@ -66,11 +68,31 @@ pub fn parse<S: ByteStorage>(
);
}
}
if exceeded {
if exceeded || out.failed {
parser.state = State::Invalid;
return None;
}
Some(Command::KittyColorProtocol(osc))
Some(Command::KittyColorProtocol(OSC {
list: List::from_encoded(parser.aux_bytes()),
terminator: Terminator::init(terminator_ch),
}))
}
/// Bookkeeping while appending records to the parser's storage.
struct Pending {
/// An allocation failed (the OSC cannot be represented).
failed: bool,
/// Number of requests so far (for the limit).
count: usize,
}
impl Pending {
fn push<S: ByteStorage>(&mut self, aux: &mut S, req: Request) {
if !self.failed && aux.try_extend(&req.encode()).is_err() {
self.failed = true;
}
self.count += 1;
}
}
trait TrimMatching {
@@ -123,7 +145,7 @@ mod tests {
let Command::KittyColorProtocol(osc) = cmd else {
panic!("wrong command");
};
let list = osc.list;
let list = osc.list.to_vec();
assert_eq!(9, list.len());
assert_eq!(Request::Query(Kind::Special(Special::Foreground)), list[0]);
assert_eq!(
+5 -3
View File
@@ -892,8 +892,10 @@ mod tests {
assert_eq!(op, osc::color::Operation::Osc112);
assert_eq!(requests.len(), 1);
assert_eq!(
requests[0],
osc::color::Request::Reset(osc::color::Target::Dynamic(crate::color::Dynamic::Cursor))
requests.get(0),
Some(osc::color::Request::Reset(osc::color::Target::Dynamic(
crate::color::Dynamic::Cursor
)))
);
}
@@ -921,7 +923,7 @@ mod tests {
assert_eq!(terminator, osc::Terminator::Bel);
assert_eq!(op, osc::color::Operation::Osc104);
assert_eq!(requests.len(), 1);
assert_eq!(requests[0], osc::color::Request::ResetPalette);
assert_eq!(requests.get(0), Some(osc::color::Request::ResetPalette));
}
#[test]
+2 -2
View File
@@ -151,7 +151,7 @@ define_actions! {
MouseShape(MouseShape),
ConfigureCharset(ConfigureCharset),
SetAttribute(sgr::Attribute<'a>),
KittyColorReport(&'a kitty::color::OSC),
KittyColorReport(&'a kitty::color::OSC<'a>),
ColorOperation(ColorOperation<'a>),
SemanticPrompt(&'a osc::SemanticPrompt<'a>),
}
@@ -248,7 +248,7 @@ pub struct ConfigureCharset {
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ColorOperation<'a> {
pub op: osc::color::Operation,
pub requests: &'a osc::color::List,
pub requests: &'a osc::color::List<'a>,
pub terminator: osc::Terminator,
}
+17 -15
View File
@@ -1,6 +1,6 @@
//! X11 color names (ZIG/src/terminal/x11_color.zig).
use std::{collections::HashMap, sync::OnceLock};
use std::sync::OnceLock;
use crate::color::Rgb;
@@ -23,17 +23,15 @@ fn parse_entries() -> impl Iterator<Item = (&'static str, Rgb)> {
})
}
/// The map of all available X11 colors. Keys are lowercase ASCII; lookups
/// are ASCII case-insensitive (see [`get`]).
fn map() -> &'static HashMap<String, Rgb> {
static MAP: OnceLock<HashMap<String, Rgb>> = OnceLock::new();
MAP.get_or_init(|| {
let mut m = HashMap::new();
for (name, rgb) in parse_entries() {
m.entry(name.to_ascii_lowercase()).or_insert(rgb);
}
m
})
/// `(lowercase name, r, g, b)` sorted by name (generated by `build.rs` from
/// `res/rgb.txt`; the first spelling of a name wins).
static TABLE: &[(&str, u8, u8, u8)] = include!(concat!(env!("OUT_DIR"), "/x11_table.rs"));
/// ASCII case-insensitive comparison of `query` against an already lowercase
/// `key`, without allocating.
fn cmp_lowercase(query: &[u8], key: &[u8]) -> std::cmp::Ordering {
let q = query.iter().map(u8::to_ascii_lowercase);
q.cmp(key.iter().copied())
}
/// All X11 color names with their original spelling (e.g. `AliceBlue`,
@@ -49,14 +47,18 @@ pub fn names() -> &'static [(&'static str, Rgb)] {
})
}
/// Look up an X11 color by name (ASCII case-insensitive).
/// Look up an X11 color by name (ASCII case-insensitive). Never allocates.
pub fn get(name: &str) -> Option<Rgb> {
map().get(&name.to_ascii_lowercase()).copied()
get_bytes(name.as_bytes())
}
/// Byte-slice variant of [`get`].
pub fn get_bytes(name: &[u8]) -> Option<Rgb> {
get(std::str::from_utf8(name).ok()?)
let i = TABLE
.binary_search_by(|(key, ..)| cmp_lowercase(name, key.as_bytes()).reverse())
.ok()?;
let (_, r, g, b) = TABLE[i];
Some(Rgb { r, g, b })
}
#[cfg(test)]