diff --git a/example/c-vt-paste/README.md b/example/c-vt-paste/README.md index 377cd3c3b..4db5297a8 100644 --- a/example/c-vt-paste/README.md +++ b/example/c-vt-paste/README.md @@ -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 diff --git a/example/c-vt-paste/src/main.c b/example/c-vt-paste/src/main.c index e6e4b3d61..64799f10b 100644 --- a/example/c-vt-paste/src/main.c +++ b/example/c-vt-paste/src/main.c @@ -1,7 +1,130 @@ +#include +#include +#include #include #include #include +#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= 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; diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 7519a3f25..57f0a0e65 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -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 diff --git a/include/ghostty/vt/paste.h b/include/ghostty/vt/paste.h index b3df5be4e..9c6774429 100644 --- a/include/ghostty/vt/paste.h +++ b/include/ghostty/vt/paste.h @@ -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 #include #include +#include #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 diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index b23303605..4f8999a03 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -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 diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index 87856ed01..2184ec95c 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -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; diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 53762f775..325f0e138 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -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" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index a7b72aa05..562fe40e6 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -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; diff --git a/src/terminal/c/paste.zig b/src/terminal/c/paste.zig index bce6a5658..53d371f33 100644 --- a/src/terminal/c/paste.zig +++ b/src/terminal/c/paste.zig @@ -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()); +} diff --git a/src/terminal/c/result.zig b/src/terminal/c/result.zig index a127d4386..174e973ba 100644 --- a/src/terminal/c/result.zig +++ b/src/terminal/c/result.zig @@ -7,4 +7,5 @@ pub const Result = enum(c_int) { no_value = -4, io_error = -5, limit_exceeded = -6, + rejected = -7, }; diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index 0367a35b6..933edcdcf 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -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_"),