Files
ghostty-rs/crates/ghostty-core/src/surface/clipboard.rs
T
ddidderr f2ccb04aa4 fix: C action buffer lengths, byte-faithful callbacks, runtime finalize
Resolves the 8 gaps in docs/rewrite/notes/review-rv10-codex.md
(final-Z13.md):
- P1 memory safety: the C key-table action (and hovered-link/open-URL
  payloads) could advertise a length larger than the NUL-truncated
  allocation. A counted_bytes helper now passes the full bytes plus a
  trailing NUL with len == byte count, like Zig's backing slices; all
  C pointer+len structs were audited and tested to read exactly len.
- ghostty-bench args follow cli/args.zig (ordered duplicates, empty
  value resets, first bad value errors, parseBool spellings); the C
  benchmark tokenizer uses the ArgIteratorGeneral port.
- OSC 7 derived titles, desktop notifications and OSC 8 hover links
  forward raw bytes to the runtime; GTK converts only for display.
- C diagnostic getter exports original bytes; escaped surrogates report
  Utf8CannotEncodeSurrogateHalf; config float formatters print `nan`.
- Config::finalize_for(AppRuntime): GTK-only finalization (gtk-single-
  instance=detect) and termio's cgroup hooks apply only to the GTK
  runtime, so libghostty keeps `detect` like Zig.

Test Plan:
- cargo test --workspace --no-fail-fast (exit 0)
- cargo test -p ghostty-lib --features test-platform
- just check-wasm (macOS/Windows clippy re-run after the concurrent
  terminal changes land)
2026-10-12 13:09:04 +02:00

435 lines
16 KiB
Rust

//! Clipboard handling for a surface (Zig `Surface.zig`: `clipboardWrite`,
//! `copySelectionToClipboards`, clipboard requests, paste, OSC 52 and
//! desktop notifications).
use std::{
hash::{Hash, Hasher},
rc::Rc,
sync::Arc,
time::{Duration, Instant},
};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use ghostty_apprt::{
action::{Action as RtAction, DesktopNotification},
runtime_api::ClipboardError,
structs::{Clipboard, ClipboardRequest},
};
use ghostty_config::types_b::ClipboardAccess;
use ghostty_input::{binding::CopyToClipboard, paste};
use ghostty_terminal::{
formatter::{self, Format, ScreenFormatter},
selection::Selection,
terminal::Terminal,
};
use ghostty_termio::message::Message as IoMessage;
use ghostty_vt::modes::Mode;
use super::{Surface, config::DerivedConfig};
/// `input.paste.Options.fromTerminal`.
#[must_use]
pub fn paste_options_from_terminal(t: &Terminal) -> paste::Options {
paste::Options {
bracketed: t.modes.get(Mode::BracketedPaste),
}
}
impl Surface {
/// Handle an OSC 52 write: `data` is base64 (Zig `clipboardWrite`).
pub(crate) fn clipboard_write(&self, data: &[u8], loc: Clipboard) {
let cfg = self.cfg();
if cfg.clipboard_write == ClipboardAccess::Deny {
log::info!(
"application attempted to write clipboard, but 'clipboard-write' is set to deny"
);
return;
}
// Zig validates the size first (`calcSizeForSlice` on the padded
// standard alphabet rejects lengths that aren't a multiple of 4),
// then decodes ignoring only the `InvalidPadding` error (e.g. nonzero
// trailing bits as in "YR==", which still decodes to "a").
if !data.len().is_multiple_of(4) {
log::info!("application sent invalid base64 data for OSC 52");
return;
}
let lenient = base64::engine::GeneralPurpose::new(
&base64::alphabet::STANDARD,
base64::engine::GeneralPurposeConfig::new()
.with_decode_padding_mode(base64::engine::DecodePaddingMode::RequireCanonical)
.with_decode_allow_trailing_bits(true),
);
let Ok(decoded) = lenient.decode(data) else {
log::info!("application sent invalid base64 data for OSC 52");
return;
};
// OSC 52 data is written as the application sent it (bytes).
let text = decoded;
// When clipboard-write is "ask" a prompt is displayed to the user
// asking them to confirm the clipboard access. Each app runtime
// handles this differently.
let confirm = cfg.clipboard_write == ClipboardAccess::Ask;
self.rt_surface
.set_clipboard(loc, &[("text/plain".to_owned(), text)], confirm);
}
/// Format the selection and put it on the given clipboards (Zig
/// `copySelectionToClipboards`). The caller holds the terminal lock.
pub(crate) fn copy_selection_to_clipboards(
&self,
t: &Terminal,
sel: Selection,
clipboards: &[Clipboard],
format: CopyToClipboard,
) {
let cfg = self.cfg();
// The options we'll use for all formatting. We'll just override the
// emit format.
// Replacement strings are config strings (reversible byte encoding);
// the formatter produces text, so decode them to UTF-8 text first.
let codepoint_map = decode_codepoint_map(&cfg.clipboard_codepoint_map.map.list);
let opts = formatter::Options {
emit: Format::Plain, // We'll override this below
unwrap: true,
trim: cfg.clipboard_trim_trailing_spaces,
codepoint_map: Some(codepoint_map),
background: t.colors.background.get(),
foreground: t.colors.foreground.get(),
palette: Some(t.colors.palette.current),
};
let run = |opts: formatter::Options| -> Option<String> {
let mut f = ScreenFormatter::new(t.screen(), opts);
f.content = formatter::Content::Selection(Some(sel));
match f.format_string() {
Ok(s) => Some(s),
Err(err) => {
log::error!("error formatting selection for clipboard err={err:?}");
None
}
}
};
let mut contents: Vec<(String, Vec<u8>)> = Vec::new();
match format {
CopyToClipboard::Plain => {
let Some(s) = run(opts) else { return };
contents.push(("text/plain".to_owned(), s.into_bytes()));
}
CopyToClipboard::Vt => {
let mut o = opts;
o.emit = Format::Vt;
// Note: We don't apply codepoint mappings to VT format since
// it contains escape sequences that should be preserved
// as-is
let Some(s) = run(o) else { return };
contents.push(("text/plain".to_owned(), s.into_bytes()));
}
CopyToClipboard::Html => {
let mut o = opts;
o.emit = Format::Html;
// Note: We don't apply codepoint mappings to HTML format
// since HTML has its own character encoding and entity
// system
let Some(s) = run(o) else { return };
contents.push(("text/html".to_owned(), s.into_bytes()));
}
CopyToClipboard::Mixed => {
// First, generate plain text with codepoint mappings applied
let Some(plain) = run(opts.clone()) else {
return;
};
contents.push(("text/plain".to_owned(), plain.into_bytes()));
// Second, generate HTML without codepoint mappings
let mut o = opts;
o.emit = Format::Html;
// We purposely don't emit background/foreground for mixed
// mode because the HTML contents is often used for rich text
// input and with trimmed spaces it looks pretty bad.
o.background = None;
o.foreground = None;
let Some(html) = run(o) else { return };
contents.push(("text/html".to_owned(), html.into_bytes()));
}
}
for clipboard in clipboards {
self.rt_surface.set_clipboard(*clipboard, &contents, false);
}
}
/// Start a clipboard request, with some basic validation. For example,
/// an OSC 52 request is not actually requested if OSC 52 is disabled.
///
/// Returns true if the request was started, false if it was not (e.g.,
/// clipboard doesn't contain text for paste requests). This allows
/// performable keybinds to pass through when the action cannot be
/// performed.
pub(crate) fn start_clipboard_request(&self, loc: Clipboard, req: ClipboardRequest) -> bool {
match req {
ClipboardRequest::Paste => {} // always allowed
ClipboardRequest::Osc52Read(_) => {
if self.cfg().clipboard_read == ClipboardAccess::Deny {
log::info!(
"application attempted to read clipboard, but 'clipboard-read' is set to deny"
);
return false;
}
}
// No clipboard write code paths travel through this function
ClipboardRequest::Osc52Write(_) => unreachable!(),
}
self.rt_surface.clipboard_request(loc, req)
}
/// The runtime answers a clipboard request (Zig:
/// `completeClipboardRequest`).
pub fn complete_clipboard_request(
&self,
req: ClipboardRequest,
data: &[u8],
confirmed: bool,
) -> Result<(), ClipboardError> {
match req {
ClipboardRequest::Paste => self.complete_clipboard_paste(data, confirmed),
ClipboardRequest::Osc52Read(clipboard) => {
self.complete_clipboard_read_osc52(data, clipboard, confirmed)
}
ClipboardRequest::Osc52Write(clipboard) => {
self.rt_surface.set_clipboard(
clipboard,
&[("text/plain".to_owned(), data.to_vec())],
!confirmed,
);
Ok(())
}
}
}
/// [`Surface::complete_clipboard_request`] for a paste of raw bytes
/// (Zig passes `[]const u8`; e.g. dropped non-UTF-8 file names).
pub fn complete_paste_bytes(&self, data: &[u8], confirmed: bool) -> Result<(), ClipboardError> {
self.complete_clipboard_paste(data, confirmed)
}
/// Paste text (also the target of `text_callback`).
pub(crate) fn complete_clipboard_paste(
&self,
data: &[u8],
allow_unsafe: bool,
) -> Result<(), ClipboardError> {
if data.is_empty() {
return Ok(());
}
let cfg = self.cfg();
let encode_opts = {
let mut t = self.lock_terminal();
let opts = paste_options_from_terminal(&t);
// If we have paste protection enabled, we detect unsafe pastes
// and return an error. The error approach allows apprt to
// attempt to complete the paste before falling back to
// requesting confirmation.
//
// We do not do this for bracketed pastes because bracketed
// pastes are by definition safe since they're framed.
let unsafe_paste = 'unsafe_paste: {
// If we've disabled paste protection then we always allow
// the paste.
if !cfg.clipboard_paste_protection {
break 'unsafe_paste false;
}
// If we're allowed to paste unsafe data then we always allow
// the paste. This is set during confirmation usually.
if allow_unsafe {
break 'unsafe_paste false;
}
if opts.bracketed {
// If we're bracketed and the paste contains and ending
// bracket then something naughty might be going on and
// we never trust it.
if data.windows(6).any(|w| w == b"\x1b[201~") {
break 'unsafe_paste true;
}
// If we are bracketed and configured to trust that then
// the paste is not unsafe.
if cfg.clipboard_paste_bracketed_safe {
break 'unsafe_paste false;
}
}
!paste::is_safe(data)
};
if unsafe_paste {
log::info!("potentially unsafe paste detected, rejecting until confirmation");
return Err(ClipboardError::UnsafePaste);
}
// With the lock held, we must scroll to the bottom. We always
// scroll to the bottom for these inputs.
self.scroll_to_bottom(&mut t);
opts
};
// Encode the data. In most cases this doesn't require any copies,
// so we optimize for that case.
let mut data_duped: Vec<u8>;
let vecs = match paste::encode(data, encode_opts) {
Ok(v) => v,
Err(paste::Error::MutableRequired) => {
data_duped = data.to_vec();
paste::encode_mut(&mut data_duped, encode_opts)
}
};
for vec in vecs {
if !vec.is_empty() {
self.queue_io(IoMessage::write_req(vec));
}
}
Ok(())
}
fn complete_clipboard_read_osc52(
&self,
data: &[u8],
clipboard_type: Clipboard,
confirmed: bool,
) -> Result<(), ClipboardError> {
let cfg = self.cfg();
// We should never get here if clipboard-read is set to deny
debug_assert_ne!(cfg.clipboard_read, ClipboardAccess::Deny);
// If clipboard-read is set to ask and we haven't confirmed with the
// user, do that now
if cfg.clipboard_read == ClipboardAccess::Ask && !confirmed {
return Err(ClipboardError::UnauthorizedPaste);
}
// Even if the clipboard data is empty we reply, since presumably the
// client app is expecting a reply.
let kind = match clipboard_type {
Clipboard::Standard => 'c',
Clipboard::Selection => 's',
Clipboard::Primary => 'p',
};
// Wrap our data with the OSC code
let mut buf = format!("\x1b]52;{kind};").into_bytes();
buf.extend_from_slice(STANDARD.encode(data).as_bytes());
buf.extend_from_slice(b"\x1b\\");
self.queue_io(IoMessage::write_req(&buf));
Ok(())
}
/// Set `clipboard-read` / `clipboard-write` to a fixed value for this
/// surface (the runtime calls this after the user chose "remember").
pub(crate) fn set_config_clipboard(&self, read: bool, access: ClipboardAccess) {
let mut config = (*self.cfg().config).clone();
if read {
config.clipboard_read = access;
} else {
config.clipboard_write = access;
}
match DerivedConfig::new(Arc::new(config)) {
Ok(d) => *self.config.borrow_mut() = Rc::new(d),
Err(err) => log::warn!("failed to update clipboard access err={err}"),
}
}
/// Show a desktop notification, rate limited (Zig
/// `showDesktopNotification`).
pub(crate) fn show_desktop_notification(&self, title: &[u8], body: &[u8]) {
let now = Instant::now();
// Set a limit of one desktop notification per second so that the OS
// doesn't kill us when we run out of resources.
let last = self.app.last_notification_time.get();
if let Some(last) = last
&& now.duration_since(last) < Duration::from_secs(1)
{
log::warn!("rate limiting desktop notifications");
return;
}
let new_digest = {
let mut hash = std::collections::hash_map::DefaultHasher::new();
title.hash(&mut hash);
body.hash(&mut hash);
hash.finish()
};
// Set a limit of one notification per five seconds for desktop
// notifications with identical content.
if let Some(last) = last
&& self.app.last_notification_digest.get() == new_digest
&& now.duration_since(last) < Duration::from_secs(5)
{
log::warn!("suppressing identical desktop notification");
return;
}
self.app.last_notification_time.set(Some(now));
self.app.last_notification_digest.set(new_digest);
self.perform(RtAction::DesktopNotification(DesktopNotification {
title: ghostty_os::osbytes::EncodedOs::from_bytes(title),
body: ghostty_os::osbytes::EncodedOs::from_bytes(body),
}));
}
/// `textCallback`: text input that is pasted verbatim.
pub fn text_callback(&self, text: &str) -> Result<(), ClipboardError> {
self.text_callback_bytes(text.as_bytes())
}
/// [`Surface::text_callback`] for raw bytes (Zig takes `[]const u8`;
/// the embedded `ghostty_surface_text` forwards the host bytes as is).
pub fn text_callback_bytes(&self, text: &[u8]) -> Result<(), ClipboardError> {
let _crash = self.crash_guard();
self.complete_clipboard_paste(text, true)
}
}
/// Clipboard replacements are validated UTF-8 by the config field parser.
fn decode_codepoint_map(list: &[formatter::CodepointMap]) -> Vec<formatter::CodepointMap> {
list.to_vec()
}
#[cfg(test)]
mod decode_map_tests {
use super::*;
#[test]
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),
}];
let out = decode_codepoint_map(&list);
assert_eq!(
out[0].replacement,
formatter::Replacement::String("a\u{fdd0}".to_owned())
);
}
}