libghostty: add ghostty_terminal_paste C API with paste events example

This commit is contained in:
Mitchell Hashimoto
2026-08-22 15:00:22 -07:00
parent dda8e6f314
commit 60a1ae2df7
11 changed files with 662 additions and 31 deletions

View File

@@ -1,7 +1,11 @@
# Example: `ghostty-vt` Paste Utilities
# Example: `ghostty-vt` Paste
This contains a simple example of how to use the `ghostty-vt` paste
utilities to check if paste data is safe and encode it for terminal input.
This contains a simple example of how to paste into a `ghostty-vt`
terminal with `ghostty_terminal_paste`: plain and bracketed (mode 2004)
text pastes, the unsafe-paste confirmation flow, and Kitty clipboard
protocol paste events (mode 5522) including the program's follow-up
clipboard read. It also shows the terminal-free building blocks for
checking paste safety and encoding paste data.
This uses a `build.zig` and `Zig` to build the C program so that we
can reuse a lot of our build logic and depend directly on our source

View File

@@ -1,7 +1,130 @@
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <ghostty/vt.h>
#define GS(s) ((GhosttyString){.ptr = (const uint8_t*)(s), .len = sizeof(s) - 1})
// Print bytes destined for the pty with control characters made visible.
static void print_escaped(const uint8_t* data, size_t len) {
for (size_t i = 0; i < len; i++) {
switch (data[i]) {
case 0x1b: printf("ESC"); break;
case '\r': printf("\\r"); break;
case '\n': printf("\\n"); break;
default: putchar(data[i]); break;
}
}
}
// The base64 password of the last paste event, captured from the OK
// packet so the example can play the program's side of the protocol.
static char event_pw[128];
// Everything the terminal writes to the running program: the pasted
// text, or the paste event packets when mode 5522 is enabled.
static void on_write_pty(GhosttyTerminal terminal,
void* userdata,
const uint8_t* data,
size_t len) {
(void)terminal;
(void)userdata;
printf(" -> pty (%zu bytes): ", len);
print_escaped(data, len);
printf("\n");
// A paste event's OK packet: OSC 5522 ; type=read:status=OK:pw=<b64> ST
const char* prefix = "\x1b]5522;type=read:status=OK:pw=";
size_t prefix_len = strlen(prefix);
if (len > prefix_len && memcmp(data, prefix, prefix_len) == 0) {
size_t end = prefix_len;
while (end < len && data[end] != 0x1b) end++;
size_t pw_len = end - prefix_len;
if (pw_len < sizeof(event_pw)) {
memcpy(event_pw, data + prefix_len, pw_len);
event_pw[pw_len] = 0;
}
}
}
// Serves clipboard reads. After a paste event the program's read arrives
// with `granted` set, because it carries the event's one-time password,
// so the embedder skips its permission prompt.
static void on_clipboard_read(GhosttyTerminal terminal,
void* userdata,
const GhosttyClipboardRead* read) {
(void)terminal;
(void)userdata;
printf(" clipboard read: name=\"");
fwrite(read->name.ptr, 1, read->name.len, stdout);
printf("\" granted=%s\n", read->granted ? "yes (no prompt needed)" : "no");
const char* text = "hello from the clipboard";
GhosttyClipboardContent content = {
.mime = GS("text/plain"),
.data = {.ptr = (const uint8_t*)text, .len = strlen(text)},
};
GhosttyClipboardReadReply reply = {
.size = sizeof(reply),
.result = GHOSTTY_CLIPBOARD_READ_RESULT_SUCCESS,
.contents = &content,
.contents_len = 1,
.available = NULL,
.available_len = 0,
.remember = false,
};
read->reply(read, &reply);
}
// A real embedder would show a dialog here.
static bool confirm_with_user(void) {
printf(" paste could inject commands; user confirmed\n");
return true;
}
//! [terminal-paste]
// Paste whatever the clipboard holds. The terminal applies its own
// state: bracketed paste framing (mode 2004) or a Kitty paste event
// (mode 5522) instead of the text.
static void paste_clipboard(GhosttyTerminal terminal, const char* text) {
GhosttyClipboardContent contents[] = {
// The first text representation is what a text paste writes.
{.mime = GS("text/plain"),
.data = {.ptr = (const uint8_t*)text, .len = strlen(text)}},
// Listed on a paste event, never written, so no data is needed.
{.mime = GS("image/png"), .data = {.ptr = NULL, .len = 0}},
};
GhosttyPaste paste = {
.size = sizeof(paste),
.location = GHOSTTY_CLIPBOARD_LOCATION_STANDARD,
.source = GHOSTTY_PASTE_SOURCE_CLIPBOARD,
.contents = contents,
.contents_len = sizeof(contents) / sizeof(contents[0]),
.allow_unsafe = false,
};
bool written = false;
GhosttyResult result = ghostty_terminal_paste(terminal, &paste, &written);
if (result == GHOSTTY_REJECTED) {
// The text could inject commands (e.g. a newline outside of a
// bracketed paste). Nothing was written; ask, then retry.
if (!confirm_with_user()) return;
paste.allow_unsafe = true;
result = ghostty_terminal_paste(terminal, &paste, &written);
}
if (result != GHOSTTY_SUCCESS) {
fprintf(stderr, "paste failed: %d\n", (int)result);
return;
}
// Whether the pty got the text or a paste event depends on the
// terminal's modes; either way it went through write_pty above.
printf(" %s\n", written ? "written" : "nothing to paste");
}
//! [terminal-paste]
//! [paste-safety]
void safety_example() {
const char* safe_data = "hello world";
@@ -29,27 +152,89 @@ void encode_example() {
if (result == GHOSTTY_SUCCESS) {
printf("Encoded %zu bytes: ", written);
fwrite(buf, 1, written, stdout);
print_escaped((const uint8_t*)buf, written);
printf("\n");
}
}
//! [paste-encode]
static void vt_write(GhosttyTerminal terminal, const char* seq) {
ghostty_terminal_vt_write(terminal, (const uint8_t*)seq, strlen(seq));
}
int main() {
GhosttyTerminal terminal = NULL;
if (ghostty_terminal_new(NULL, &terminal, 80, 24) != GHOSTTY_SUCCESS) {
fprintf(stderr, "Failed to create terminal\n");
return 1;
}
// Pasted bytes and paste events go to write_pty. Serving clipboard
// reads is what lets the terminal send paste events at all: without
// this callback the program could never read the clipboard, so pastes
// stay text even when mode 5522 is enabled.
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_WRITE_PTY,
(const void*)on_write_pty);
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ,
(const void*)on_clipboard_read);
printf("Plain paste:\n");
paste_clipboard(terminal, "hello world");
printf("Paste with a newline (refused, then confirmed):\n");
paste_clipboard(terminal, "echo hi\n");
// The program enables bracketed paste: newlines are safe inside the
// frame and are preserved.
printf("Bracketed paste (mode 2004):\n");
vt_write(terminal, "\x1b[?2004h");
paste_clipboard(terminal, "line one\nline two");
// The program enables paste events: the clipboard's MIME types are
// listed with a one-time password instead of writing the data.
printf("Paste event (mode 5522):\n");
vt_write(terminal, "\x1b[?5522h");
paste_clipboard(terminal, "hello world");
// Play the program's side: read the clipboard with the password from
// the event. The read arrives granted and the data is served through
// write_pty as base64 without any permission prompt.
if (event_pw[0] != 0) {
printf("Program reads with the event password:\n");
char read_seq[256];
snprintf(read_seq, sizeof(read_seq),
"\x1b]5522;type=read:pw=%s:name=UGFzdGUgZXZlbnQ=;dGV4dC9wbGFpbg==\x1b\\",
event_pw);
vt_write(terminal, read_seq);
}
// Text inserted by other means (IME, drag and drop) is never an event.
printf("IME text with mode 5522 enabled:\n");
{
const char* text = "committed";
GhosttyClipboardContent content = {
.mime = GS("text/plain"),
.data = {.ptr = (const uint8_t*)text, .len = strlen(text)},
};
GhosttyPaste paste = {
.size = sizeof(paste),
.location = GHOSTTY_CLIPBOARD_LOCATION_STANDARD,
.source = GHOSTTY_PASTE_SOURCE_TEXT,
.contents = &content,
.contents_len = 1,
.allow_unsafe = true,
};
bool written = false;
if (ghostty_terminal_paste(terminal, &paste, &written) == GHOSTTY_SUCCESS &&
written) {
printf(" written\n");
}
}
ghostty_terminal_free(terminal);
printf("\nTerminal-free building blocks:\n");
safety_example();
// Test unsafe paste data with bracketed paste end sequence
const char *unsafe_escape = "evil\x1b[201~code";
if (!ghostty_paste_is_safe(unsafe_escape, strlen(unsafe_escape))) {
printf("Data with escape sequence is UNSAFE\n");
}
// Test empty data
const char *empty_data = "";
if (ghostty_paste_is_safe(empty_data, 0)) {
printf("Empty data is safe\n");
}
encode_example();
return 0;

View File

@@ -34,7 +34,7 @@
* - @ref snapshot "Terminal Snapshot" - Encode and incrementally restore terminal state
* - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences
* - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences
* - @ref paste "Paste Utilities" - Validate paste data safety
* - @ref paste "Paste" - Paste into a terminal, validate and encode paste data
* - @ref unicode "Unicode Utilities" - Codepoint properties for text layout
* - @ref build_info "Build Info" - Query compile-time build configuration
* - @ref allocator "Memory Management" - Memory management and custom allocators
@@ -53,7 +53,7 @@
* - @ref c-vt/src/main.c - OSC parser example
* - @ref c-vt-encode-key/src/main.c - Key encoding example
* - @ref c-vt-encode-mouse/src/main.c - Mouse encoding example
* - @ref c-vt-paste/src/main.c - Paste safety check example
* - @ref c-vt-paste/src/main.c - Paste example
* - @ref c-vt-sgr/src/main.c - SGR parser example
* - @ref c-vt-formatter/src/main.c - Terminal formatter example
* - @ref c-vt-grid-traverse/src/main.c - Grid traversal example using grid refs
@@ -83,8 +83,10 @@
*/
/** @example c-vt-paste/src/main.c
* This example demonstrates how to use the paste utilities to check if
* paste data is safe before sending it to the terminal.
* This example demonstrates how to paste into a terminal, including the
* unsafe-paste confirmation flow and Kitty clipboard protocol paste events
* (mode 5522), as well as the terminal-free paste safety and encoding
* utilities.
*/
/** @example c-vt-sgr/src/main.c

View File

@@ -1,26 +1,57 @@
/**
* @file paste.h
*
* Paste utilities - validate and encode paste data for terminal input.
* Paste - paste into a terminal, and validate and encode paste data.
*/
#ifndef GHOSTTY_VT_PASTE_H
#define GHOSTTY_VT_PASTE_H
/** @defgroup paste Paste Utilities
/** @defgroup paste Paste
*
* Utilities for validating and encoding paste data for terminal input.
* Pasting into a terminal, plus the terminal-free utilities for
* validating and encoding paste data.
*
* ## Basic Usage
* ## Pasting into a Terminal
*
* Use ghostty_paste_is_safe() to check if paste data contains potentially
* dangerous sequences before sending it to the terminal.
* What a paste writes to the pty depends on the terminal's state, so
* the recommended way to paste is ghostty_terminal_paste(). The embedder
* hands over what the clipboard holds as MIME-typed contents (just
* `text/plain` for an ordinary paste) and where it came from, and the
* terminal decides how its current modes apply:
*
* Use ghostty_paste_encode() to encode paste data for writing to the pty,
* - If Kitty clipboard protocol paste events (mode 5522,
* GHOSTTY_MODE_PASTE_EVENTS) are enabled, the paste was user-initiated
* (GHOSTTY_PASTE_SOURCE_CLIPBOARD), and a clipboard_read callback is
* installed, the terminal sends the program a paste event listing the
* clipboard's MIME types with a one-time password instead of the data.
* The program then reads what it wants through the clipboard_read
* callback, which arrives with `granted` set so no permission prompt
* is needed.
* - Otherwise the first text representation is written: unsafe control
* bytes are replaced with spaces, and it is wrapped in bracketed paste
* sequences if mode 2004 (GHOSTTY_MODE_BRACKETED_PASTE) is enabled, or
* has its newlines converted to carriage returns if not.
*
* Text that could inject commands (a newline when unbracketed, or the
* bracketed paste terminator when bracketed) is refused with
* GHOSTTY_REJECTED unless GhosttyPaste::allow_unsafe is set. The usual
* flow is to call once, confirm with the user on GHOSTTY_REJECTED, and
* call again with `allow_unsafe` set.
*
* Output is delivered through the write_pty callback
* (GHOSTTY_TERMINAL_OPT_WRITE_PTY) in a single call.
*
* @snippet c-vt-paste/src/main.c terminal-paste
*
* ## Building Blocks
*
* For embedders that encode without a terminal, ghostty_paste_is_safe()
* checks if paste data contains potentially dangerous sequences
* (conservatively, regardless of terminal state) and
* ghostty_paste_encode() encodes paste data for writing to the pty,
* including bracketed paste wrapping and unsafe byte stripping.
*
* ## Examples
*
* ### Safety Check
*
* @snippet c-vt-paste/src/main.c paste-safety
@@ -35,11 +66,99 @@
#include <stdbool.h>
#include <stddef.h>
#include <ghostty/vt/types.h>
#include <ghostty/vt/terminal.h>
#ifdef __cplusplus
extern "C" {
#endif
/**
* Why a paste happened.
*/
typedef enum GHOSTTY_ENUM_TYPED {
/** The user pasted from a clipboard: keybind, menu, middle click. */
GHOSTTY_PASTE_SOURCE_CLIPBOARD = 0,
/**
* Text inserted some other way: IME commit, drag and drop, scripted
* input. Always written as text, never as a paste event, matching
* kitty. This is not a way to opt out of paste events; an embedder
* that doesn't want them doesn't install a clipboard_read callback.
*/
GHOSTTY_PASTE_SOURCE_TEXT = 1,
GHOSTTY_PASTE_SOURCE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyPasteSource;
/**
* A paste of clipboard contents into the terminal.
*
* This is a sized struct; set `size` to `sizeof(GhosttyPaste)`. The
* contents array and the strings it points to are borrowed only for the
* duration of the ghostty_terminal_paste() call.
*/
typedef struct {
/** Size of this struct in bytes. */
size_t size;
/**
* The clipboard the contents came from. Reported to the program on a
* paste event (the selection and primary locations are both reported
* as the primary selection, the protocol knows only two); no effect
* on a text paste.
*/
GhosttyClipboardLocation location;
/** Why this paste happened. */
GhosttyPasteSource source;
/**
* Borrowed array of the representations available, in preferred
* order. A text paste writes the first entry with a text MIME type
* such as "text/plain" and ignores the rest. A paste event lists every
* entry's MIME type and never reads data, so non-text entries may have
* empty data. May be NULL when contents_len is zero.
*/
const GhosttyClipboardContent* contents;
/** Number of entries in contents. */
size_t contents_len;
/**
* Write text that could inject commands. Call with false, confirm
* with the user on GHOSTTY_REJECTED, and call again with true.
*/
bool allow_unsafe;
} GhosttyPaste;
/**
* Paste into the terminal according to its current state: a Kitty
* clipboard protocol paste event if mode 5522 is enabled and a
* clipboard_read callback is installed, otherwise the text framed per
* mode 2004. See the group documentation for the full behavior. Output
* goes through the write_pty callback in a single call. The viewport is
* not scrolled; that is up to the embedder, as for key input.
*
* @param terminal The terminal handle
* @param paste The paste request, borrowed for the duration of the call
* @param[out] out_written On success, whether anything was written to
* the pty (the encoded text or a paste event). False means
* there was nothing to paste: no non-empty text
* representation. May be NULL.
* @return GHOSTTY_SUCCESS on success (see @p out_written);
* GHOSTTY_REJECTED if the text could inject commands and
* GhosttyPaste::allow_unsafe is false (nothing was written);
* GHOSTTY_INVALID_VALUE for a NULL terminal or paste, or when no
* write_pty callback is installed; GHOSTTY_OUT_OF_MEMORY;
* GHOSTTY_IO_ERROR if there is no secure entropy source to mint
* a paste event password (wasm32-freestanding without
* GHOSTTY_SYS_OPT_RANDOM_SECURE set), in which case nothing was
* written and no grant was recorded.
*/
GHOSTTY_API GhosttyResult ghostty_terminal_paste(
GhosttyTerminal terminal,
const GhosttyPaste* paste,
bool* out_written);
/**
* Check if paste data is safe to paste into the terminal.
*
@@ -49,7 +168,9 @@ extern "C" {
* to exit bracketed paste mode and inject commands
*
* This check is conservative and considers data unsafe regardless of
* current terminal state.
* current terminal state. ghostty_terminal_paste() applies the
* terminal-state-aware rule itself (newlines are safe inside a
* bracketed paste); use this to apply the stricter rule on top.
*
* @param data The paste data to check (must not be NULL)
* @param len The length of the data in bytes
@@ -74,6 +195,9 @@ GHOSTTY_API bool ghostty_paste_is_safe(const char* data, size_t len);
* GHOSTTY_OUT_OF_SPACE and sets the required size in @p out_written.
* The caller can then retry with a sufficiently sized buffer.
*
* This is the encoder ghostty_terminal_paste() uses for a text paste;
* use it directly when there is no terminal to paste into.
*
* @param data The paste data to encode (modified in place, may be NULL)
* @param data_len The length of the input data in bytes
* @param bracketed Whether bracketed paste mode is active

View File

@@ -724,6 +724,11 @@ struct GhosttyClipboardRead {
* serves a request for only the targets listing (`list` with no `mimes`)
* without prompting.
*
* Installing this callback also enables Kitty paste events (mode 5522):
* ghostty_terminal_paste() sends the program an event instead of the text,
* and the program's follow-up read arrives here with `granted` set since
* the user already pasted. See ghostty_terminal_paste().
*
* @param terminal The terminal handle
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
* @param read Borrowed clipboard read request

View File

@@ -100,6 +100,12 @@ typedef enum GHOSTTY_ENUM_TYPED {
GHOSTTY_IO_ERROR = -5,
/** Operation failed because encoded input exceeded a configured limit */
GHOSTTY_LIMIT_EXCEEDED = -6,
/**
* Operation was rejected by a safety check (e.g. pasted text that could
* inject commands). Nothing was done. Confirm with the user and retry
* with the operation's allow flag set.
*/
GHOSTTY_REJECTED = -7,
GHOSTTY_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyResult;

View File

@@ -215,6 +215,7 @@ comptime {
@export(&c.focus_encode, .{ .name = "ghostty_focus_encode" });
@export(&c.paste_is_safe, .{ .name = "ghostty_paste_is_safe" });
@export(&c.paste_encode, .{ .name = "ghostty_paste_encode" });
@export(&c.terminal_paste, .{ .name = "ghostty_terminal_paste" });
@export(&c.mouse_event_new, .{ .name = "ghostty_mouse_event_new" });
@export(&c.mouse_event_free, .{ .name = "ghostty_mouse_event_free" });
@export(&c.mouse_event_set_action, .{ .name = "ghostty_mouse_event_set_action" });

View File

@@ -160,6 +160,7 @@ pub const mouse_encoder_encode = mouse_encode.encode;
pub const paste_is_safe = paste.is_safe;
pub const paste_encode = paste.encode;
pub const terminal_paste = paste.terminal_paste;
pub const alloc_alloc = allocator.alloc;
pub const alloc_free = allocator.free;

View File

@@ -1,8 +1,84 @@
const std = @import("std");
const lib = @import("../lib.zig");
const paste = @import("../../input/paste.zig");
const terminal_paste_pkg = @import("../paste.zig");
const clipboard = @import("../clipboard.zig");
const terminal_c = @import("terminal.zig");
const Terminal = terminal_c.Terminal;
const ClipboardContent = terminal_c.ClipboardContent;
const ClipboardRead = terminal_c.ClipboardRead;
const ClipboardReadReply = terminal_c.ClipboardReadReply;
const Result = @import("result.zig").Result;
/// Why a paste happened.
///
/// C: GhosttyPasteSource
pub const Source = terminal_paste_pkg.Source;
/// A paste of clipboard contents into the terminal. Sized struct.
///
/// C: GhosttyPaste
pub const Request = extern struct {
size: usize = @sizeOf(Request),
location: clipboard.Location,
source: Source,
contents: ?[*]const ClipboardContent,
contents_len: usize,
allow_unsafe: bool,
};
pub fn terminal_paste(
terminal_: Terminal,
req_: ?*const Request,
out_written: ?*bool,
) callconv(lib.calling_conv) Result {
const wrapper = terminal_ orelse return .invalid_value;
const req = req_ orelse return .invalid_value;
// Every field is required; a smaller size is a caller from a
// different ABI version than any this struct has had.
if (req.size < @sizeOf(Request)) return .invalid_value;
// The handler always has a write_pty trampoline that no-ops without
// a C callback, so the "nothing can be written" check is ours.
if (wrapper.effects.write_pty == null) return .invalid_value;
const c_contents: []const ClipboardContent = if (req.contents) |ptr|
ptr[0..req.contents_len]
else
&.{};
// A paste carries a handful of representations, so keep the common
// case allocation-free.
var sfa = std.heap.stackFallback(256, wrapper.terminal.gpa());
const alloc = sfa.get();
const contents = alloc.alloc(
clipboard.Content,
c_contents.len,
) catch return .out_of_memory;
defer alloc.free(contents);
for (contents, c_contents) |*content, c_content| {
content.* = .{
.mime = c_content.mime.ptr[0..c_content.mime.len],
.data = c_content.data.ptr[0..c_content.data.len],
};
}
const written = wrapper.stream.handler.paste(.{
.location = req.location,
.source = req.source,
.contents = contents,
.allow_unsafe = req.allow_unsafe,
}) catch |err| return switch (err) {
error.UnsafePaste => .rejected,
error.NoWritePty => .invalid_value,
error.OutOfMemory => .out_of_memory,
error.EntropyUnavailable, error.Canceled => .io_error,
};
if (out_written) |ptr| ptr.* = written;
return .success;
}
pub fn is_safe(data: ?[*]const u8, len: usize) callconv(lib.calling_conv) bool {
const slice: []const u8 = if (data) |v| v[0..len] else &.{};
return paste.isSafe(slice);
@@ -127,3 +203,225 @@ test "is_safe with null empty data" {
const testing = std.testing;
try testing.expect(is_safe(null, 0));
}
/// Capture state for the terminal_paste tests: every pty write and the
/// clipboard reads that follow a paste event.
const TerminalPasteCapture = struct {
var written: [1024]u8 = undefined;
var written_len: usize = 0;
var write_count: usize = 0;
var read_count: usize = 0;
var last_read_granted: bool = false;
fn reset() void {
written_len = 0;
write_count = 0;
read_count = 0;
last_read_granted = false;
}
fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void {
@memcpy(written[written_len..][0..len], ptr[0..len]);
written_len += len;
write_count += 1;
}
fn clipboardRead(_: Terminal, _: ?*anyopaque, request: *const ClipboardRead) callconv(lib.calling_conv) void {
read_count += 1;
last_read_granted = request.granted;
const contents = [_]ClipboardContent{.{
.mime = .init(@as([]const u8, "text/plain")),
.data = .init(@as([]const u8, "Ghostty")),
}};
request.reply(request, &.{
.size = @sizeOf(ClipboardReadReply),
.result = .success,
.contents = &contents,
.contents_len = contents.len,
.available = null,
.available_len = 0,
.remember = false,
});
}
fn writtenSlice() []const u8 {
return written[0..written_len];
}
/// A single text/plain request; the caller provides the content
/// storage since the request borrows it.
fn textRequest(content: *ClipboardContent, text: []const u8) Request {
content.* = .{
.mime = .init(@as([]const u8, "text/plain")),
.data = .init(text),
};
return .{
.location = .standard,
.source = .clipboard,
.contents = content[0..1],
.contents_len = 1,
.allow_unsafe = false,
};
}
};
test "terminal_paste null handling" {
const testing = std.testing;
const S = TerminalPasteCapture;
var t: Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(&lib.alloc.test_allocator, &t, 80, 24));
defer terminal_c.free(t);
var written: bool = true;
var content: ClipboardContent = undefined;
const req: Request = S.textRequest(&content, "hello");
try testing.expectEqual(Result.invalid_value, terminal_paste(null, &req, &written));
try testing.expectEqual(Result.invalid_value, terminal_paste(t, null, &written));
try testing.expect(written);
// A size smaller than the struct is rejected.
var small = req;
small.size = @sizeOf(usize);
try testing.expectEqual(Result.invalid_value, terminal_paste(t, &small, &written));
}
test "terminal_paste without write_pty is invalid" {
const testing = std.testing;
const S = TerminalPasteCapture;
S.reset();
var t: Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(&lib.alloc.test_allocator, &t, 80, 24));
defer terminal_c.free(t);
var content: ClipboardContent = undefined;
const req: Request = S.textRequest(&content, "hello");
try testing.expectEqual(Result.invalid_value, terminal_paste(t, &req, null));
}
test "terminal_paste text and unsafe" {
const testing = std.testing;
const S = TerminalPasteCapture;
S.reset();
var t: Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(&lib.alloc.test_allocator, &t, 80, 24));
defer terminal_c.free(t);
try testing.expectEqual(Result.success, terminal_c.set(t, .write_pty, @ptrCast(&S.writePty)));
// Plain text, NULL out_written pointer is fine.
var content: ClipboardContent = undefined;
const req: Request = S.textRequest(&content, "hel\x1blo");
try testing.expectEqual(Result.success, terminal_paste(t, &req, null));
try testing.expectEqualStrings("hel lo", S.writtenSlice());
try testing.expectEqual(@as(usize, 1), S.write_count);
// Unsafe is refused with nothing written, then allowed.
S.reset();
var written: bool = false;
var unsafe_content: ClipboardContent = undefined;
var unsafe: Request = S.textRequest(&unsafe_content, "rm -rf /\n");
try testing.expectEqual(Result.rejected, terminal_paste(t, &unsafe, &written));
try testing.expectEqual(@as(usize, 0), S.write_count);
try testing.expect(!written);
unsafe.allow_unsafe = true;
try testing.expectEqual(Result.success, terminal_paste(t, &unsafe, &written));
try testing.expect(written);
try testing.expectEqualStrings("rm -rf /\r", S.writtenSlice());
// Bracketed paste mode frames the text through the real mode path.
S.reset();
const decset = "\x1b[?2004h";
terminal_c.vt_write(t, decset, decset.len);
try testing.expectEqual(Result.success, terminal_paste(t, &req, &written));
try testing.expect(written);
try testing.expectEqualStrings("\x1b[200~hel lo\x1b[201~", S.writtenSlice());
// No text representation writes nothing. NULL contents with a zero
// length is an empty list.
S.reset();
var empty_content: ClipboardContent = undefined;
var empty: Request = S.textRequest(&empty_content, "");
empty.contents = null;
empty.contents_len = 0;
try testing.expectEqual(Result.success, terminal_paste(t, &empty, &written));
try testing.expect(!written);
try testing.expectEqual(@as(usize, 0), S.write_count);
}
test "terminal_paste event" {
const testing = std.testing;
const S = TerminalPasteCapture;
S.reset();
var t: Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(&lib.alloc.test_allocator, &t, 80, 24));
defer terminal_c.free(t);
try testing.expectEqual(Result.success, terminal_c.set(t, .write_pty, @ptrCast(&S.writePty)));
t.?.terminal.modes.set(.kitty_paste_events, true);
// Without a clipboard_read callback the paste stays text.
var written: bool = false;
const contents = [_]ClipboardContent{
.{
.mime = .init(@as([]const u8, "text/plain")),
.data = .init(@as([]const u8, "secret")),
},
.{
.mime = .init(@as([]const u8, "image/png")),
.data = .init(@as([]const u8, "")),
},
};
const req: Request = .{
.location = .primary,
.source = .clipboard,
.contents = &contents,
.contents_len = contents.len,
.allow_unsafe = false,
};
try testing.expectEqual(Result.success, terminal_paste(t, &req, &written));
try testing.expect(written);
try testing.expectEqualStrings("secret", S.writtenSlice());
// With one, an event is sent listing every MIME type and the data
// is never written.
S.reset();
try testing.expectEqual(Result.success, terminal_c.set(t, .clipboard_read, @ptrCast(&S.clipboardRead)));
try testing.expectEqual(Result.success, terminal_paste(t, &req, &written));
try testing.expect(written);
try testing.expectEqual(@as(usize, 1), S.write_count);
try testing.expectEqual(@as(usize, 3), std.mem.count(u8, S.writtenSlice(), "\x1b]5522;"));
try testing.expect(std.mem.startsWith(u8, S.writtenSlice(), "\x1b]5522;type=read:status=OK:loc=primary:pw="));
try testing.expect(std.mem.indexOf(u8, S.writtenSlice(), "secret") == null);
try testing.expect(std.mem.indexOf(u8, S.writtenSlice(), ";dGV4dC9wbGFpbiBpbWFnZS9wbmcK\x1b\\") != null);
// The program's read with the event password is granted once.
const ok_prefix = "\x1b]5522;type=read:status=OK:loc=primary:pw=";
const pw_end = std.mem.indexOfPos(u8, S.writtenSlice(), ok_prefix.len, "\x1b\\").?;
var read_buf: [256]u8 = undefined;
const read = try std.fmt.bufPrint(
&read_buf,
"\x1b]5522;type=read:pw={s}:name=UGFzdGUgZXZlbnQ=;dGV4dC9wbGFpbg==\x1b\\",
.{S.writtenSlice()[ok_prefix.len..pw_end]},
);
S.reset();
terminal_c.vt_write(t, read.ptr, read.len);
try testing.expectEqual(@as(usize, 1), S.read_count);
try testing.expect(S.last_read_granted);
try testing.expect(std.mem.indexOf(u8, S.writtenSlice(), ";R2hvc3R0eQ==\x1b\\") != null);
S.reset();
terminal_c.vt_write(t, read.ptr, read.len);
try testing.expectEqual(@as(usize, 1), S.read_count);
try testing.expect(!S.last_read_granted);
// Text sources never become events.
S.reset();
var ime = req;
ime.source = .text;
try testing.expectEqual(Result.success, terminal_paste(t, &ime, &written));
try testing.expect(written);
try testing.expectEqualStrings("secret", S.writtenSlice());
}

View File

@@ -7,4 +7,5 @@ pub const Result = enum(c_int) {
no_value = -4,
io_error = -5,
limit_exceeded = -6,
rejected = -7,
};

View File

@@ -18,6 +18,7 @@ const formatter_pkg = @import("../formatter.zig");
const modes_pkg = @import("../modes.zig");
const mouse_pkg = @import("../mouse.zig");
const page = @import("../page.zig");
const paste_pkg = @import("../paste.zig");
const point = @import("../point.zig");
const Selection = @import("../Selection.zig");
const sgr = @import("../sgr.zig");
@@ -37,6 +38,7 @@ const kitty_graphics = @import("kitty_graphics.zig");
const mouse_encode = @import("mouse_encode.zig");
const mouse_event = @import("mouse_event.zig");
const osc = @import("osc.zig");
const paste = @import("paste.zig");
const render = @import("render.zig");
const result = @import("result.zig");
const row = @import("row.zig");
@@ -192,6 +194,7 @@ const type_decls = [_]TypeDecl{
.initStruct("GhosttyKittyGraphicsPlacementRenderInfo", kitty_graphics.PlacementRenderInfo),
.initStruct("GhosttyMouseEncoderSize", mouse_encode.Size),
.initStruct("GhosttyMousePosition", mouse_event.Position),
.initStruct("GhosttyPaste", paste.Request),
.initTaggedStruct("GhosttyPoint", point.Point.C, "tag", "value", .generated),
.initStruct("GhosttyPointCoordinate", point.Coordinate),
.initUnion("GhosttyPointValue", point.Point.CValue, point.Point.C),
@@ -272,6 +275,7 @@ const type_decls = [_]TypeDecl{
"GHOSTTY_OSC_COMMAND_",
"TYPE_MAX_VALUE",
),
.initEnum("GhosttyPasteSource", paste_pkg.Source, "GHOSTTY_PASTE_SOURCE_"),
.initEnum("GhosttyPointTag", point.Tag, "GHOSTTY_POINT_TAG_"),
.initEnum("GhosttyRenderStateCursorVisualStyle", render.CursorVisualStyle, "GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_"),
.initEnum("GhosttyRenderStateData", render.Data, "GHOSTTY_RENDER_STATE_DATA_"),