Files
ghostty-rs/crates/ghostty-vt-capi/src/osc.rs
T
ddidderr f7fec6f44d 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)
2026-10-12 10:06:07 +02:00

458 lines
15 KiB
Rust

//! OSC parser C API (ZIG/src/terminal/c/osc.zig, `include/ghostty/vt/osc.h`).
#![allow(unsafe_code)]
use std::{
ffi::{c_int, c_void},
ptr::NonNull,
};
use ghostty_vt::osc::{Command, Parser, color::RECORD_LEN};
/// Records the color request list holds without growing (Zig:
/// `SegmentedList(Request, 4)`).
const COLOR_LIST_INLINE_RECORDS: usize = 4;
use crate::{
alloc::{Alloc, Allocator, CBlock, CByteStorage},
result::GhosttyResult,
};
/// C: `GhosttyOscParser` (opaque).
pub struct OscParser {
alloc: Alloc,
parser: Parser<CByteStorage>,
/// Backing store for the last command returned by `ghostty_osc_end`.
command: OscCommand,
}
/// C: `GhosttyOscCommand` (opaque).
///
/// Zig hands out a pointer to the parser's own command; the Rust `Command`
/// is returned by value, so it is parked here (with a NUL-terminated copy of
/// string payloads, as Zig's sentinel slices are) and stays valid until the
/// next `ghostty_osc_end`/`ghostty_osc_reset`/`ghostty_osc_free`.
pub struct OscCommand {
/// `GhosttyOscCommandType` of the last command.
key: c_int,
/// NUL-terminated window title (only for `ChangeWindowTitle`), allocated
/// with the caller's allocator.
title_z: Option<CBlock>,
}
/// C: `GhosttyOscCommandData`
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i32)]
pub enum OscCommandData {
Invalid = 0,
ChangeWindowTitleStr = 1,
}
impl OscCommandData {
fn from_c(v: c_int) -> Option<Self> {
match v {
0 => Some(Self::Invalid),
1 => Some(Self::ChangeWindowTitleStr),
_ => None,
}
}
}
/// # Safety
/// `allocator` must be null or valid; `result` must be valid for writes.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_osc_new(
allocator: *const Allocator,
result: *mut *mut OscParser,
) -> GhosttyResult {
// SAFETY: forwarded caller contract.
let alloc = unsafe { Alloc::from_c(allocator) };
// C-built parsers have an allocator (Zig: `.init(alloc)`), so commands
// that need unbounded buffers (OSC 52, ...) are accepted.
// The parser's payload buffer (Zig's inline fixed buffer / allocating
// writer) lives in memory from the same allocator; if either allocation
// fails the caller gets OUT_OF_MEMORY.
let Some(storage) = CByteStorage::new(alloc, ghostty_vt::osc::MAX_BUF) else {
return GhosttyResult::OutOfMemory;
};
// Request lists of the color OSCs live in a second block from the same
// allocator (Zig: a `SegmentedList(Request, 4)` on the parser's
// allocator). Four inline records, like Zig, so short color commands
// never allocate after creation; longer lists grow via the allocator.
let Some(aux) = CByteStorage::new(alloc, COLOR_LIST_INLINE_RECORDS * RECORD_LEN) else {
return GhosttyResult::OutOfMemory;
};
let Some(p) = alloc.create(OscParser {
alloc,
parser: Parser::with_storage(storage, aux),
command: OscCommand {
key: 0,
title_z: None,
},
}) else {
return GhosttyResult::OutOfMemory;
};
// SAFETY: caller guarantees `result` is valid for writes.
unsafe { *result = p.as_ptr() };
GhosttyResult::Success
}
/// # Safety
/// `parser` must be null or a live parser from `ghostty_osc_new`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_osc_free(parser: *mut OscParser) {
let Some(p) = NonNull::new(parser) else {
return;
};
// SAFETY: `p` came from `Alloc::create` in `ghostty_osc_new`.
unsafe {
let alloc = p.as_ref().alloc;
alloc.destroy(p);
}
}
/// # Safety
/// `parser` must be null or a live parser.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_osc_reset(parser: *mut OscParser) {
// SAFETY: caller guarantees a live parser (or null).
if let Some(p) = unsafe { parser.as_mut() } {
p.parser.reset();
}
}
/// # Safety
/// `parser` must be null or a live parser.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_osc_next(parser: *mut OscParser, byte: u8) {
// SAFETY: caller guarantees a live parser (or null).
if let Some(p) = unsafe { parser.as_mut() } {
p.parser.next(byte);
}
}
/// # Safety
/// `parser` must be null or a live parser. The returned command is owned by
/// the parser.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_osc_end(
parser: *mut OscParser,
terminator: u8,
) -> *mut OscCommand {
// SAFETY: caller guarantees a live parser (or null).
let Some(p) = (unsafe { parser.as_mut() }) else {
return std::ptr::null_mut();
};
// The command borrows its payload from the parser's own (caller
// allocated) storage, so nothing is copied or allocated here except the
// NUL-terminated title handed to C.
p.command.title_z = None;
let cmd = p.parser.end(Some(terminator)).unwrap_or(Command::Invalid);
p.command.key = cmd.key() as c_int;
if let Command::ChangeWindowTitle(t) = &cmd {
match CBlock::from_bytes_z(p.alloc, t) {
Some(b) => p.command.title_z = Some(b),
// The title cannot be materialized with the caller's allocator:
// report an invalid command rather than a dangling title.
None => p.command.key = Command::Invalid.key() as c_int,
}
}
&raw mut p.command
}
/// Returns the `GhosttyOscCommandType` (an `int` enum).
///
/// # Safety
/// `command` must be null or a command returned by `ghostty_osc_end`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_osc_command_type(command: *mut OscCommand) -> c_int {
// SAFETY: caller guarantees validity (or null).
match unsafe { command.as_ref() } {
Some(c) => c.key,
None => 0, // invalid
}
}
/// # Safety
/// `command` must be null or valid; `out` must point to storage of the type
/// documented for `data` (for the title: a `const char *`).
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_osc_command_data(
command: *mut OscCommand,
data: c_int,
out: *mut c_void,
) -> bool {
let Some(data) = OscCommandData::from_c(data) else {
log::warn!("commandData invalid data value={data}");
return false;
};
// SAFETY: caller guarantees validity (or null).
let Some(command) = (unsafe { command.as_ref() }) else {
return false;
};
match data {
OscCommandData::Invalid => false,
OscCommandData::ChangeWindowTitleStr => match &command.title_z {
Some(title) if !out.is_null() => {
// SAFETY: caller guarantees `out` is a `const char **`.
unsafe {
out.cast::<*const u8>().write_unaligned(title.as_ptr());
}
true
}
_ => false,
},
}
}
#[cfg(test)]
mod tests {
use std::{
alloc::{GlobalAlloc, Layout, System},
cell::Cell,
ffi::CStr,
};
use super::*;
/// Counts Rust global-allocator calls made by the current thread, so the
/// tests can prove that OSC parsing and `ghostty_osc_end` use only the
/// caller's allocator.
struct CountingGlobal;
thread_local! {
static GLOBAL_ALLOCS: Cell<usize> = const { Cell::new(0) };
}
// SAFETY: forwards to the system allocator.
unsafe impl GlobalAlloc for CountingGlobal {
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
let _ = GLOBAL_ALLOCS.try_with(|c| c.set(c.get() + 1));
// SAFETY: same contract.
unsafe { System.alloc(l) }
}
unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
// SAFETY: same contract.
unsafe { System.dealloc(p, l) }
}
unsafe fn realloc(&self, p: *mut u8, l: Layout, n: usize) -> *mut u8 {
let _ = GLOBAL_ALLOCS.try_with(|c| c.set(c.get() + 1));
// SAFETY: same contract.
unsafe { System.realloc(p, l, n) }
}
}
#[global_allocator]
static GLOBAL: CountingGlobal = CountingGlobal;
fn global_allocs() -> usize {
GLOBAL_ALLOCS.with(Cell::get)
}
use std::{
ffi::c_void,
sync::atomic::{AtomicUsize, Ordering},
};
use crate::alloc::{Allocator, AllocatorVtable};
/// A caller allocator backed by the system allocator that counts calls.
unsafe extern "C" fn c_alloc(
ctx: *mut c_void,
len: usize,
align_log2: u8,
_: usize,
) -> *mut c_void {
// SAFETY: `ctx` is the `AtomicUsize` owned by the test.
unsafe { &*ctx.cast::<AtomicUsize>() }.fetch_add(1, Ordering::SeqCst);
let l = Layout::from_size_align(len, 1 << align_log2).unwrap();
// SAFETY: non-zero size (the crate never calls with zero).
unsafe { System.alloc(l) }.cast()
}
unsafe extern "C" fn c_resize(
_: *mut c_void,
_: *mut c_void,
_: usize,
_: u8,
_: usize,
_: usize,
) -> bool {
false
}
unsafe extern "C" fn c_remap(
_: *mut c_void,
_: *mut c_void,
_: usize,
_: u8,
_: usize,
_: usize,
) -> *mut c_void {
std::ptr::null_mut()
}
unsafe extern "C" fn c_free(
_: *mut c_void,
m: *mut c_void,
len: usize,
align_log2: u8,
_: usize,
) {
let l = Layout::from_size_align(len, 1 << align_log2).unwrap();
// SAFETY: allocated by `c_alloc` with the same layout.
unsafe { System.dealloc(m.cast(), l) };
}
static VT: AllocatorVtable = AllocatorVtable {
alloc: c_alloc,
resize: c_resize,
remap: c_remap,
free: c_free,
};
#[test]
fn end_makes_no_global_allocations() {
let caller_allocs = AtomicUsize::new(0);
let a = Allocator {
ctx: (&raw const caller_allocs).cast_mut().cast(),
vtable: &raw const VT,
};
let mut p = std::ptr::null_mut();
// SAFETY: valid allocator and out pointer.
assert_eq!(
unsafe { ghostty_osc_new(&raw const a, &mut p) },
GhosttyResult::Success
);
// SAFETY: `p` is live for the whole block.
unsafe {
for (osc, title) in [
("7;file:///tmp/x", false),
("8;id=a;https://example.com", false),
("8;;", false),
("52;c;aGVsbG8=", false),
("133;A", false),
("777;notify;title;body", false),
("9;4;1;50", false),
("9;2;message", false),
("22;pointer", false),
("66;s=2;hi", false),
("3008;start=abc;type=shell", false),
("5522;type=read;data", false),
// Color commands: the request lists live in the parser's
// storage, which comes from the caller's allocator.
("4;1;rgb:ff/00/00;2;?", false),
("5;0;rgb:00/00/00", false),
("10;rgb:ff/ff/ff;?", false),
("11;?", false),
("104;1;2;3;4", false),
("104", false),
("112", false),
("21;foreground=?;background=rgb:f0/f8/ff;cursor", false),
("0;a title", true),
] {
ghostty_osc_reset(p);
for b in osc.bytes() {
ghostty_osc_next(p, b);
}
let global_before = global_allocs();
let caller_before = caller_allocs.load(Ordering::SeqCst);
let cmd = ghostty_osc_end(p, 0x07);
assert_ne!(ghostty_osc_command_type(cmd), 0, "{osc}");
assert_eq!(
global_allocs(),
global_before,
"global allocation ending {osc}"
);
// Only the NUL-terminated title handed to C is allocated.
assert_eq!(
caller_allocs.load(Ordering::SeqCst) - caller_before,
usize::from(title),
"caller allocations ending {osc}"
);
}
ghostty_osc_free(p);
}
}
#[test]
fn long_color_lists_grow_via_the_caller_allocator() {
let caller_allocs = AtomicUsize::new(0);
let a = Allocator {
ctx: (&raw const caller_allocs).cast_mut().cast(),
vtable: &raw const VT,
};
let mut p = std::ptr::null_mut();
// SAFETY: valid allocator and out pointer.
assert_eq!(
unsafe { ghostty_osc_new(&raw const a, &mut p) },
GhosttyResult::Success
);
// SAFETY: `p` is live for the whole block.
unsafe {
for osc in ["4;0;?;1;?;2;?;3;?;4;?;5;?", "21;1=?;2=?;3=?;4=?;5=?;6=?"] {
ghostty_osc_reset(p);
for b in osc.bytes() {
ghostty_osc_next(p, b);
}
let global_before = global_allocs();
let caller_before = caller_allocs.load(Ordering::SeqCst);
let cmd = ghostty_osc_end(p, 0x07);
assert_ne!(ghostty_osc_command_type(cmd), 0, "{osc}");
assert_eq!(global_allocs(), global_before, "global allocation: {osc}");
assert!(
caller_allocs.load(Ordering::SeqCst) > caller_before,
"list did not grow through the caller allocator: {osc}"
);
}
ghostty_osc_free(p);
}
}
fn new() -> *mut OscParser {
let mut p = std::ptr::null_mut();
// SAFETY: valid out pointer, default allocator.
assert_eq!(
unsafe { ghostty_osc_new(std::ptr::null(), &mut p) },
GhosttyResult::Success
);
p
}
#[test]
fn alloc() {
let p = new();
// SAFETY: `p` is live.
unsafe { ghostty_osc_free(p) };
}
#[test]
fn command_type_null() {
// SAFETY: null is allowed.
assert_eq!(unsafe { ghostty_osc_command_type(std::ptr::null_mut()) }, 0);
}
#[test]
fn change_window_title() {
let p = new();
// SAFETY: `p` is live; `title` is a valid out slot.
unsafe {
ghostty_osc_next(p, b'0');
ghostty_osc_next(p, b';');
ghostty_osc_next(p, b'a');
let cmd = ghostty_osc_end(p, 0);
assert_eq!(ghostty_osc_command_type(cmd), 1);
let mut title: *const u8 = std::ptr::null();
assert!(ghostty_osc_command_data(cmd, 1, (&raw mut title).cast()));
assert_eq!(CStr::from_ptr(title.cast()).to_str().unwrap(), "a");
assert!(!ghostty_osc_command_data(cmd, 0, std::ptr::null_mut()));
assert!(!ghostty_osc_command_data(cmd, 99, std::ptr::null_mut()));
ghostty_osc_free(p);
}
}
#[test]
fn invalid_command_is_type_zero() {
let p = new();
// SAFETY: `p` is live.
unsafe {
ghostty_osc_next(p, b'x');
let cmd = ghostty_osc_end(p, 0);
assert_eq!(ghostty_osc_command_type(cmd), 0);
ghostty_osc_free(p);
}
}
}