libghostty: centralize pasting to ghostty_terminal_paste, enable mode 5522 (#13978)

**Note: this has no changes for Ghostty GUI yet.** This only impacts
libghostty-vt.

This introduces a new `ghostty_terminal_paste` C API along with a
central `terminal.paste.paste` function that handles (1) mode 5522
(Kitty clipboard) (2) bracketed paste (3) normal paste all in one place,
combined with unsafe value detection and proper xterm-style newline
handling.

Terminal pasting is now stateful because for the Kitty clipboard
protocol in particular, it must mint "grants" that stay with the
terminal. Previously, paste encoding was stateless.

To start, this is only exposed/used by libghostty to enable Kitty
clipboard handling.

Other changes:

- **IO: randomSecure.** This also adds the `io.randomSecure`
implementation to `TinyIo` and a global sys override for it because
Kitty clipboard requires the ability to create one-time passwords and
the implementation (following Kitty) requires a crypto random source.
The sys model is for libghostty embedders.

- **New C result value: rejected.** This introduces a new C result enum
value "rejected" for values that are valid but rejected for some reason.
Its very possible that prior "invalid value" users will have to update
to this, and I recognize that its close to both but it fills an
important semantic difference.

Also note this still _eagerly_ requires all clipboard contents. I want
to move to a callback based model but it made the PR much more
complicated. I plan on playing with that before converting apprt's to
this.
This commit is contained in:
Mitchell Hashimoto
2026-08-23 07:23:30 -07:00
committed by GitHub
23 changed files with 1840 additions and 44 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

@@ -124,6 +124,24 @@ typedef bool (*GhosttySysDecodePngFn)(
size_t data_len,
GhosttySysImage* out);
/**
* Callback type for secure random bytes.
*
* Fills @p buf with @p len cryptographically secure random bytes. The
* library uses this for secrets, so it must be a real CSPRNG (getrandom,
* arc4random_buf, BCryptGenRandom, crypto.getRandomValues, ...); a
* predictable source is a security hole.
*
* @param userdata The userdata pointer set via GHOSTTY_SYS_OPT_USERDATA
* @param buf Buffer to fill
* @param len Number of bytes to fill
* @return true if the buffer was filled, false if no entropy is available
*/
typedef bool (*GhosttySysRandomSecureFn)(
void* userdata,
uint8_t* buf,
size_t len);
/**
* System option identifiers for ghostty_sys_set().
*/
@@ -165,6 +183,21 @@ typedef enum GHOSTTY_ENUM_TYPED {
* Input type: GhosttySysLogFn (function pointer, or NULL)
*/
GHOSTTY_SYS_OPT_LOG = 2,
/**
* Override the secure random source.
*
* By default the library draws secure random bytes from the
* platform (getrandom or arc4random_buf on POSIX, CNG on Windows).
* Targets without one, such as wasm32-freestanding, have no default
* and operations that need entropy fail with GHOSTTY_IO_ERROR until
* this is set. When set,
* it is used instead of the platform source on every target. When
* cleared (NULL value), the platform default is restored.
*
* Input type: GhosttySysRandomSecureFn (function pointer, or NULL)
*/
GHOSTTY_SYS_OPT_RANDOM_SECURE = 3,
GHOSTTY_SYS_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttySysOption;

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

@@ -1,6 +1,14 @@
const std = @import("std");
const Terminal = @import("../terminal/Terminal.zig");
/// The bracketed paste (mode 2004) frame written around the data.
pub const bracketed_prefix = "\x1b[200~";
pub const bracketed_suffix = "\x1b[201~";
/// The maximum number of bytes `encode` adds around the data, so callers
/// can size a buffer for the full encoded result.
pub const max_frame_size = bracketed_prefix.len + bracketed_suffix.len;
pub const Options = struct {
/// True if bracketed paste mode is on.
bracketed: bool,
@@ -93,8 +101,8 @@ pub fn encode(
// Bracketed paste mode (mode 2004) wraps pasted data in
// fenceposts so that the terminal can ignore things like newlines.
if (opts.bracketed) {
result[0] = "\x1b[200~";
result[2] = "\x1b[201~";
result[0] = bracketed_prefix;
result[2] = bracketed_suffix;
return result;
}
@@ -116,6 +124,42 @@ pub const Error = error{
MutableRequired,
};
/// Encode the given data for pasting directly into a writer. This is
/// the same transformation as `encode` (unsafe bytes replaced, bracketed
/// frame or newline conversion per `opts`) but the data is copied
/// exactly once: into the writer's buffer, where it is modified in place.
/// This is the form to use when the data is const and the result is
/// being assembled into a single buffer anyway.
///
/// The data is copied in chunks sized to the writer's buffer, so any
/// writer works; a writer with less total capacity than the writer
/// needs to hold at once reports `error.WriteFailed` as usual.
///
/// WARNING: The input data is not checked for safety. See `isSafe`
/// and `isSafeWith` to check if the data is safe to paste.
pub fn encodeWriter(
writer: *std.Io.Writer,
data: []const u8,
opts: Options,
) std.Io.Writer.Error!void {
if (opts.bracketed) try writer.writeAll(bracketed_prefix);
// The byte transformations are position-independent, so the data
// can be copied and encoded chunk by chunk. The frame returned by
// encode is ignored since it's written around the whole data here.
var remaining = data;
while (remaining.len > 0) {
const dest = try writer.writableSliceGreedy(1);
const n = @min(dest.len, remaining.len);
@memcpy(dest[0..n], remaining[0..n]);
_ = encode(dest[0..n], opts);
writer.advance(n);
remaining = remaining[n..];
}
if (opts.bracketed) try writer.writeAll(bracketed_suffix);
}
/// Returns true if the data looks safe to paste. Data is considered
/// unsafe if it contains any of the following:
///
@@ -133,6 +177,22 @@ pub fn isSafe(data: []const u8) bool {
std.mem.indexOf(u8, data, "\x1b[201~") == null;
}
/// Returns true if the data looks safe to paste given how it will be
/// encoded. This is the terminal-state-aware counterpart of `isSafe`:
///
/// - Bracketed (mode 2004 on): the program receives the data as one
/// framed unit, so newlines are fine. The data is unsafe only if it
/// contains the end of the frame (`\x1b[201~`), which would let the
/// rest of the data escape the frame and inject commands.
/// - Unbracketed: the same rule as `isSafe`.
///
/// Callers wanting the conservative rule regardless of terminal state
/// should use `isSafe` instead.
pub fn isSafeWith(data: []const u8, opts: Options) bool {
if (opts.bracketed) return std.mem.indexOf(u8, data, bracketed_suffix) == null;
return isSafe(data);
}
test isSafe {
const testing = std.testing;
try testing.expect(isSafe("hello"));
@@ -141,6 +201,110 @@ test isSafe {
try testing.expect(!isSafe("he\x1b[201~llo"));
}
test isSafeWith {
const testing = std.testing;
// Bracketed: newlines are fine, the frame terminator is not.
try testing.expect(isSafeWith("hello", .{ .bracketed = true }));
try testing.expect(isSafeWith("hello\nworld", .{ .bracketed = true }));
try testing.expect(!isSafeWith("he\x1b[201~llo", .{ .bracketed = true }));
try testing.expect(!isSafeWith("hello\n\x1b[201~", .{ .bracketed = true }));
// Unbracketed: the conservative rule.
try testing.expect(isSafeWith("hello", .{ .bracketed = false }));
try testing.expect(!isSafeWith("hello\nworld", .{ .bracketed = false }));
try testing.expect(!isSafeWith("he\x1b[201~llo", .{ .bracketed = false }));
}
test "encodeWriter bracketed" {
const testing = std.testing;
var buf: [64]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try encodeWriter(&writer, "hel\x1blo\nworld", .{ .bracketed = true });
try testing.expectEqualStrings("\x1b[200~hel lo\nworld\x1b[201~", writer.buffered());
}
test "encodeWriter unbracketed" {
const testing = std.testing;
var buf: [64]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try encodeWriter(&writer, "hel\x00lo\r\nworld", .{ .bracketed = false });
try testing.expectEqualStrings("hel lo\r\rworld", writer.buffered());
}
test "encodeWriter empty" {
const testing = std.testing;
var buf: [64]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try encodeWriter(&writer, "", .{ .bracketed = true });
try testing.expectEqualStrings("\x1b[200~\x1b[201~", writer.buffered());
writer = .fixed(&buf);
try encodeWriter(&writer, "", .{ .bracketed = false });
try testing.expectEqualStrings("", writer.buffered());
}
test "encodeWriter chunks through a small writer buffer" {
const testing = std.testing;
const alloc = testing.allocator;
// A writer with a 4-byte staging buffer that drains into a list,
// so the data is copied and encoded in several chunks.
const Sink = struct {
list: std.ArrayList(u8) = .empty,
writer: std.Io.Writer,
fn drain(
w: *std.Io.Writer,
data: []const []const u8,
splat: usize,
) std.Io.Writer.Error!usize {
const self: *@This() = @alignCast(@fieldParentPtr("writer", w));
self.list.appendSlice(testing.allocator, w.buffered()) catch return error.WriteFailed;
w.end = 0;
var n: usize = 0;
for (data[0 .. data.len - 1]) |slice| {
self.list.appendSlice(testing.allocator, slice) catch return error.WriteFailed;
n += slice.len;
}
for (0..splat) |_| {
self.list.appendSlice(testing.allocator, data[data.len - 1]) catch return error.WriteFailed;
}
return n + splat * data[data.len - 1].len;
}
};
var staging: [4]u8 = undefined;
var sink: Sink = .{ .writer = .{
.buffer = &staging,
.vtable = &.{ .drain = Sink.drain },
} };
defer sink.list.deinit(alloc);
const data = "line one\nline\x1btwo\nline three\n";
try encodeWriter(&sink.writer, data, .{ .bracketed = true });
try sink.writer.flush();
try testing.expectEqualStrings(
"\x1b[200~line one\nline two\nline three\n\x1b[201~",
sink.list.items,
);
}
test "encodeWriter too small" {
const testing = std.testing;
var buf: [4]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try testing.expectError(
error.WriteFailed,
encodeWriter(&writer, "hello", .{ .bracketed = true }),
);
}
test max_frame_size {
const testing = std.testing;
const result = try encode(@as([]const u8, ""), .{ .bracketed = true });
try testing.expectEqual(max_frame_size, result[0].len + result[2].len);
}
test "encode bracketed" {
const testing = std.testing;
const result = try encode(

View File

@@ -161,7 +161,7 @@ const vtable: Io.VTable = if (!supported) std.Io.failing.vtable.* else .{
.progressParentFile = Io.failingProgressParentFile,
.random = Io.noRandom,
.randomSecure = Io.failingRandomSecure,
.randomSecure = randomSecure,
.now = Io.noNow,
.clockResolution = Io.failingClockResolution,
@@ -212,6 +212,35 @@ fn swapCancelProtection(
fn checkCancel(_: ?*anyopaque) Io.Cancelable!void {}
fn randomSecure(_: ?*anyopaque, buffer: []u8) Io.RandomSecureError!void {
if (buffer.len == 0) return;
// The same sources as `std.Io.Threaded.randomSecure` minus
// cancelation and the /dev/urandom fallback: arc4random_buf where
// libc provides it (all the BSDs and Darwin, glibc 2.36+), otherwise
// the getrandom syscall on Linux. Anything else has no entropy.
if (builtin.link_libc and @TypeOf(posix.system.arc4random_buf) != void) {
posix.system.arc4random_buf(buffer.ptr, buffer.len);
return;
}
if (builtin.os.tag == .linux) {
const linux = std.os.linux;
var i: usize = 0;
while (i < buffer.len) {
const rc = linux.getrandom(buffer[i..].ptr, buffer.len - i, 0);
switch (linux.errno(rc)) {
.SUCCESS => i += rc,
.INTR => continue,
else => return error.EntropyUnavailable,
}
}
return;
}
return error.EntropyUnavailable;
}
fn closeFd(fd: posix.fd_t) void {
// Never retry close on EINTR: POSIX leaves the fd state unspecified
// and Linux always closes it, so retrying risks closing an unrelated
@@ -965,6 +994,28 @@ test "Io.Mutex through TinyIo" {
test_io.vtable.futexWaitUncancelable(test_io.userdata, &word, 1);
}
test "randomSecure fills with fresh entropy" {
if (comptime !supported) return error.SkipZigTest;
const tio: TinyIo = .init;
const test_io = tio.io();
const testing = std.testing;
var a: [32]u8 = @splat(0);
var b: [32]u8 = @splat(0);
try test_io.randomSecure(&a);
try test_io.randomSecure(&b);
// Non-zero and non-repeating. A zero fill is what `random` does
// without a source, which would make every one-time password the
// same; identical draws would mean the same thing.
try testing.expect(!std.mem.allEqual(u8, &a, 0));
try testing.expect(!std.mem.allEqual(u8, &b, 0));
try testing.expect(!std.mem.eql(u8, &a, &b));
// Zero-length is a no-op.
try test_io.randomSecure(a[0..0]);
}
test "unused operations fail gracefully" {
if (comptime !supported) return error.SkipZigTest;
const tio: TinyIo = .init;

View File

@@ -95,6 +95,10 @@ pub const TerminalStream = terminal.TerminalStream;
pub const Stream = terminal.Stream;
pub const StreamAction = terminal.StreamAction;
pub const UnknownSequence = terminal.UnknownSequence;
pub const Paste = terminal.Paste;
pub const PasteSource = terminal.PasteSource;
pub const PasteError = terminal.PasteError;
pub const Cursor = Screen.Cursor;
pub const CursorStyle = Screen.CursorStyle;
pub const CursorStyleReq = terminal.CursorStyle;
@@ -129,8 +133,11 @@ pub const input = struct {
// Paste-related APIs
pub const PasteError = paste.Error;
pub const PasteOptions = paste.Options;
pub const max_paste_frame_size = paste.max_frame_size;
pub const isSafePaste = paste.isSafe;
pub const isSafePasteWith = paste.isSafeWith;
pub const encodePaste = paste.encode;
pub const encodePasteWriter = paste.encodeWriter;
// Key encoding
pub const Key = key.Key;
@@ -208,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

@@ -23,6 +23,13 @@ pub const DecodePngFn = *const fn (
*Image,
) callconv(lib.calling_conv) bool;
/// C: GhosttySysRandomSecureFn
pub const RandomSecureFn = *const fn (
?*anyopaque,
[*]u8,
usize,
) callconv(lib.calling_conv) bool;
/// C: GhosttySysLogLevel
pub const LogLevel = enum(c_int) {
@"error" = 0,
@@ -55,12 +62,14 @@ pub const Option = enum(c_int) {
userdata = 0,
decode_png = 1,
log = 2,
random_secure = 3,
pub fn InType(comptime self: Option) type {
return switch (self) {
.userdata => ?*const anyopaque,
.decode_png => ?DecodePngFn,
.log => ?LogFn,
.random_secure => ?RandomSecureFn,
};
}
};
@@ -71,6 +80,7 @@ const Global = struct {
userdata: ?*anyopaque = null,
decode_png: ?DecodePngFn = null,
log: ?LogFn = null,
random_secure: ?RandomSecureFn = null,
};
/// Global state for the C sys interface.
@@ -98,6 +108,12 @@ fn decodePngWrapper(
};
}
/// Zig-compatible wrapper that calls through to the stored C callback.
fn randomSecureWrapper(buffer: []u8) terminal_sys.RandomSecureError!void {
const func = global.random_secure orelse return error.EntropyUnavailable;
if (!func(global.userdata, buffer.ptr, buffer.len)) return error.EntropyUnavailable;
}
pub fn set(
option: Option,
value: ?*const anyopaque,
@@ -127,6 +143,10 @@ fn setTyped(
terminal_sys.decode_png = if (value != null) &decodePngWrapper else null;
},
.log => global.log = value,
.random_secure => {
global.random_secure = value;
terminal_sys.random_secure = if (value != null) &randomSecureWrapper else null;
},
}
return .success;
}

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_"),

View File

@@ -57,8 +57,10 @@ pub const max_write_aliases = write.max_write_aliases;
pub const Response = response.Response;
pub const ReadSuccess = response.ReadSuccess;
pub const PasteEvent = response.PasteEvent;
pub const read_chunk_size = response.read_chunk_size;
pub const max_read_mimes = response.max_read_mimes;
pub const max_listing_mimes = response.max_listing_mimes;
pub const targets_mime = response.targets_mime;
pub const Grants = grants.Grants;

View File

@@ -5,6 +5,7 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const clipboard_command = @import("clipboard_command.zig");
const sys = @import("../sys.zig");
const max_pw_len = clipboard_command.max_pw_len;
@@ -105,13 +106,31 @@ pub const Grants = struct {
/// The length of a one-time password generated for paste events.
pub const otp_len = 22;
/// Generate a one-time password for a paste event. The alphabet matches
/// kitty (alphanumeric without easily-confused characters), but the spec
/// doesn't demand this.
pub fn generateOtp(random: std.Random) [otp_len]u8 {
const alphabet = "23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ";
/// The one-time password alphabet. This matches kitty (alphanumeric
/// without easily-confused characters), but the spec doesn't demand
/// this.
pub const otp_alphabet = "23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ";
/// Generate a one-time password for a paste event.
///
/// The password is a secret: a program that learns it can read the
/// clipboard without a prompt. Entropy comes from `sys.random_secure`
/// if set, otherwise from the Io; see `sys.randomSecure`.
pub fn generateOtp(io: std.Io) std.Io.RandomSecureError![otp_len]u8 {
var result: [otp_len]u8 = undefined;
for (&result) |*c| c.* = alphabet[random.uintLessThan(usize, alphabet.len)];
var len: usize = 0;
while (len < result.len) {
var raw: [2 * otp_len]u8 = undefined;
try sys.randomSecure(io, &raw);
const limit = (std.math.maxInt(u8) + 1) / otp_alphabet.len * otp_alphabet.len;
for (raw) |byte| {
if (byte >= limit) continue;
result[len] = otp_alphabet[byte % otp_alphabet.len];
len += 1;
if (len == result.len) break;
}
}
return result;
}
@@ -182,3 +201,38 @@ test "grants: capacity evicts the oldest" {
const newest = try std.fmt.bufPrint(&buf, "pw{}", .{Grants.max_entries});
try testing.expect(grants.use(alloc, newest, .read));
}
test "generateOtp: length and alphabet with a real Io" {
const testing = std.testing;
const otp = try generateOtp(testing.io);
try testing.expectEqual(otp_len, otp.len);
for (otp) |c| try testing.expect(std.mem.indexOfScalar(u8, otp_alphabet, c) != null);
// Two passwords don't collide (a repeat would mean no entropy).
const other = try generateOtp(testing.io);
try testing.expect(!std.mem.eql(u8, &otp, &other));
}
test "generateOtp: no entropy is an error, never a weak password" {
const testing = std.testing;
try testing.expectError(error.EntropyUnavailable, generateOtp(std.Io.failing));
}
test "generateOtp: sys override supplies entropy without an Io source" {
const testing = std.testing;
const S = struct {
var counter: u8 = 0;
fn fill(buffer: []u8) sys.RandomSecureError!void {
for (buffer) |*b| {
b.* = counter;
counter +%= 1;
}
}
};
sys.random_secure = &S.fill;
defer sys.random_secure = null;
const otp = try generateOtp(std.Io.failing);
for (otp) |c| try testing.expect(std.mem.indexOfScalar(u8, otp_alphabet, c) != null);
}

View File

@@ -23,6 +23,9 @@ pub const max_read_mimes = 4;
/// The special MIME type that requests the list of available types.
pub const targets_mime = ".";
/// Maximum MIME types reported in a paste event's targets listing.
pub const max_listing_mimes = 16;
/// A single response packet.
pub const Response = struct {
op: Operation,
@@ -182,6 +185,36 @@ pub const ReadSuccess = struct {
}
};
/// An unsolicited Kitty paste event (mode 5522): a read response that
/// lists the clipboard's available MIME types and carries the one-time
/// password the program uses for its follow-up read.
pub const PasteEvent = struct {
/// True if the paste came from the primary selection, reported as
/// `loc=primary` on the OK packet.
primary: bool = false,
/// The one-time password, echoed in every packet.
pw: []const u8,
/// The MIME types available on the clipboard.
available: []const []const u8,
terminator: Terminator = .st,
pub fn encode(
self: *const PasteEvent,
writer: *std.Io.Writer,
) std.Io.Writer.Error!void {
try (ReadSuccess{
.primary = self.primary,
.pw = self.pw,
.list = true,
.available = self.available,
.terminator = self.terminator,
}).encode(writer);
}
};
test "response: basic status packet" {
const testing = std.testing;
@@ -383,3 +416,54 @@ test "read success: paste event carries pw in every packet" {
writer.buffered(),
);
}
test "paste event: pw in every packet, listing of every type" {
const testing = std.testing;
var buf: [512]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try (PasteEvent{
.pw = "otp",
.available = &.{ "text/plain", "image/png" },
}).encode(&writer);
// Payload is base64 of "text/plain image/png\n".
try testing.expectEqualStrings(
"\x1b]5522;type=read:status=OK:pw=b3Rw\x1b\\" ++
"\x1b]5522;type=read:status=DATA:mime=Lg==:pw=b3Rw;dGV4dC9wbGFpbiBpbWFnZS9wbmcK\x1b\\" ++
"\x1b]5522;type=read:status=DONE:pw=b3Rw\x1b\\",
writer.buffered(),
);
}
test "paste event: primary is reported only on the OK packet" {
const testing = std.testing;
var buf: [512]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try (PasteEvent{
.primary = true,
.pw = "otp",
.available = &.{"text/plain"},
.terminator = .bel,
}).encode(&writer);
try testing.expectEqualStrings(
"\x1b]5522;type=read:status=OK:loc=primary:pw=b3Rw\x07" ++
"\x1b]5522;type=read:status=DATA:mime=Lg==:pw=b3Rw;dGV4dC9wbGFpbgo=\x07" ++
"\x1b]5522;type=read:status=DONE:pw=b3Rw\x07",
writer.buffered(),
);
}
test "paste event: empty listing packet is still sent" {
const testing = std.testing;
var buf: [512]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try (PasteEvent{ .pw = "otp", .available = &.{} }).encode(&writer);
try testing.expectEqualStrings(
"\x1b]5522;type=read:status=OK:pw=b3Rw\x1b\\" ++
"\x1b]5522;type=read:status=DATA:mime=Lg==:pw=b3Rw\x1b\\" ++
"\x1b]5522;type=read:status=DONE:pw=b3Rw\x1b\\",
writer.buffered(),
);
}

View File

@@ -20,6 +20,7 @@ pub const kitty = @import("kitty.zig");
pub const modes = @import("modes.zig");
pub const page = @import("page.zig");
pub const parse_table = @import("parse_table.zig");
pub const paste = @import("paste.zig");
pub const search = @import("search.zig");
pub const snapshot = @import("snapshot/main.zig");
pub const sgr = @import("sgr.zig");
@@ -60,6 +61,9 @@ pub const TerminalStream = stream_terminal.Stream;
pub const Stream = stream.Stream;
pub const StreamAction = stream.Action;
pub const UnknownSequence = stream_terminal.Handler.UnknownSequence;
pub const Paste = paste.Request;
pub const PasteSource = paste.Source;
pub const PasteError = stream_terminal.Handler.PasteError;
pub const Cursor = Screen.Cursor;
pub const CursorStyle = Screen.CursorStyle;
pub const CursorStyleReq = ansi.CursorStyle;

View File

@@ -8,6 +8,7 @@
//! to ensure all our various types and logic remain in sync.
const std = @import("std");
const build_options = @import("terminal_options");
const testing = std.testing;
/// A struct that maintains the state of all the settable modes.
@@ -331,10 +332,12 @@ const entries: []const ModeEntry = &.{
// paste sends an unsolicited OSC 5522 targets listing with a
// one-time password instead of pasting the text.
// See https://sw.kovidgoyal.net/kitty/clipboard/
//
// Forcibly disabled for now since the functionality isn't exposed
// yet to libghostty or any GUI apps.
.{ .name = "kitty_paste_events", .value = 5522, .disabled = true },
.{
.name = "kitty_paste_events",
.value = 5522,
// Only libghostty-vt supports this currently
.disabled = build_options.artifact != .lib,
},
};
test {

185
src/terminal/paste.zig Normal file
View File

@@ -0,0 +1,185 @@
//! Pasting into a terminal.
//!
//! This is the single place that turns "the user pasted" into bytes for
//! the pty, applying the terminal's current state:
//!
//! * Mode 5522 (Kitty clipboard protocol paste events) set, a
//! user-initiated clipboard paste, and the embedder able to serve
//! the program's follow-up clipboard read: send a paste event
//! listing the clipboard's MIME types with a fresh one-time password
//! and record a one-time read grant for it. The data is not written.
//! * Otherwise: write the first text representation, with unsafe bytes
//! replaced (xterm behavior), framed per mode 2004 (bracketed paste)
//! or with newlines converted to carriage returns if not.
//!
//! The precedence (5522 event, else 2004 framing, else plain) and the
//! safety rule live only here so every embedder of the terminal shares
//! one implementation.
const std = @import("std");
const Allocator = std.mem.Allocator;
const lib = @import("lib.zig");
const clipboard = @import("clipboard.zig");
const kitty_clipboard = @import("kitty/clipboard.zig");
const input_paste = @import("../input/paste.zig");
const Terminal = @import("Terminal.zig");
/// Why a paste happened. Only clipboard pastes may become paste events.
///
/// C: GhosttyPasteSource
pub const Source = lib.Enum(lib.target, &.{
// The user pasted from a clipboard: keybind, menu, middle click.
"clipboard",
// Text inserted some other way: IME commit, drag and drop,
// scripted input. Never becomes a paste event, matching kitty.
// This is not a way to opt out of events; an embedder that
// doesn't want them doesn't serve clipboard reads.
"text",
});
/// A paste of clipboard contents into the terminal. What actually gets
/// written depends on terminal state; see `paste`.
pub const Request = struct {
/// The clipboard the contents came from. Reported to the program on
/// a paste event (`.primary` and `.selection` both as loc=primary,
/// the protocol knows only two); no effect on a text paste.
location: clipboard.Location = .standard,
/// Why this paste happened. Only a user-initiated clipboard paste
/// may become a paste event; text insertion always writes text.
source: Source = .clipboard,
/// The representations available, in the embedder's preferred
/// order. A text paste writes the first representation with a text
/// MIME type (clipboard.isTextMime) and ignores the rest. A paste
/// event reports every MIME type and never touches data, so
/// non-text entries may carry empty data. Borrowed for the call.
contents: []const clipboard.Content,
/// Write data that could inject commands (see `isSafe`). The usual
/// flow is to call with false, confirm with the user on
/// error.UnsafePaste, and call again with true.
allow_unsafe: bool = false,
};
/// What a caller supplies to `paste`: the terminal state the decision
/// depends on, the session state an event records into, and the sink.
pub const Context = struct {
/// The terminal whose modes decide the encoding.
terminal: *const Terminal,
/// Kitty clipboard session grants. A paste event records its
/// one-time password here so the program's follow-up read is
/// served without a prompt.
grants: *kitty_clipboard.Grants,
/// Secure entropy for one-time passwords. See generateOtp for why
/// there is no fallback when this has none.
io: std.Io,
/// Allocator for the grant. Must be the one `grants` is freed with.
alloc: Allocator,
/// True if the embedder serves clipboard reads, so a paste event's
/// follow-up read can be answered. Without that an event would be
/// refused and the user's paste would vanish, so `paste` falls
/// through to a text paste instead.
can_event: bool,
/// Receives the bytes for the pty. `paste` makes exactly one logical
/// write per call: the whole encoded text or the whole event. On
/// error the writer may hold a partial result that must be
/// discarded.
writer: *std.Io.Writer,
};
pub const Error = Allocator.Error || std.Io.RandomSecureError || std.Io.Writer.Error || error{
/// The data could inject commands and allow_unsafe was false.
/// Nothing was written.
UnsafePaste,
};
/// Paste into the terminal, applying the terminal's current state as
/// described in the module docs. Returns true if anything was written
/// to `ctx.writer`: the encoded text or a paste event. False means
/// there was nothing to paste (no non-empty text representation).
///
/// The safety rule for a text paste (`input.paste.isSafeWith`): a
/// bracketed paste is unsafe only if it contains the bracket terminator
/// (CSI 201~); an unbracketed paste is unsafe if it contains a newline
/// or the terminator. Embedders wanting a stricter rule check
/// `input.paste.isSafe` themselves before calling. A paste event never
/// puts the data on the input stream, so the rule doesn't apply to it.
///
/// On success, the caller delivers the writer's contents to the pty.
/// On error nothing should be delivered; in particular an event's grant
/// is only recorded once the event is fully encoded, so a failure never
/// leaves a grant for an event that was never sent.
pub fn paste(ctx: Context, req: Request) Error!bool {
// A paste event only works if the program's follow-up read can be
// served; without that, fall through to text.
if (req.source == .clipboard and
ctx.can_event and
ctx.terminal.modes.get(.kitty_paste_events))
{
try pasteKittyEvent(ctx, req);
return true;
}
// For non-Kitty paste events we can only accept text content.
const text: []const u8 = for (req.contents) |c| {
if (clipboard.isTextMime(c.mime)) break c.data;
} else return false;
if (text.len == 0) return false;
// Reject unsafe inputs
const opts: input_paste.Options = .fromTerminal(ctx.terminal);
if (!req.allow_unsafe and !input_paste.isSafeWith(text, opts)) {
return error.UnsafePaste;
}
// The data is copied exactly once, into the writer, where the
// encoder strips and converts it in place.
try input_paste.encodeWriter(ctx.writer, text, opts);
return true;
}
fn pasteKittyEvent(ctx: Context, req: Request) Error!void {
const otp = try kitty_clipboard.generateOtp(ctx.io);
// Every representation is listed, never read. The listing is
// bounded; a clipboard with more types than that is not a thing.
var mimes_buf: [kitty_clipboard.max_listing_mimes][]const u8 = undefined;
var mimes_len: usize = 0;
for (req.contents) |c| {
if (mimes_len == mimes_buf.len) break;
mimes_buf[mimes_len] = c.mime;
mimes_len += 1;
}
try (kitty_clipboard.PasteEvent{
// The protocol only distinguishes the clipboard from the
// primary selection, so both non-standard locations report as
// primary.
.primary = req.location != .standard,
.pw = &otp,
.available = mimes_buf[0..mimes_len],
}).encode(ctx.writer);
// Recorded last so a failed encode leaves no grant behind. The
// caller delivers the event after we return, so the grant is in
// place before the program can possibly use it.
try ctx.grants.grant(
ctx.alloc,
&otp,
.read,
true,
);
}
test {
// The behavior is tested end to end through the stream handler
// (stream_terminal.zig), which is the primary caller.
std.testing.refAllDecls(@This());
}

View File

@@ -17,6 +17,7 @@ const osc = @import("osc.zig");
const osc_color = @import("osc/parsers/color.zig");
const kitty_clipboard = @import("kitty/clipboard.zig");
const kitty_color = @import("kitty/color.zig");
const paste_pkg = @import("paste.zig");
const kitty_dnd = @import("kitty/dnd.zig");
const size_report = @import("size_report.zig");
const simd = @import("../simd/main.zig");
@@ -200,6 +201,11 @@ pub const Handler = struct {
/// session grant so later requests with the same password arrive
/// with `granted` set. Kitty itself serves a request for only the
/// targets listing (`list` with no `mimes`) without prompting.
///
/// Installing this also enables Kitty paste events (mode 5522):
/// `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 `paste`.
clipboard_read: ?*const fn (*Handler, clipboard.Read) void,
/// Called in response to an XTVERSION query. Returns the version
@@ -286,6 +292,56 @@ pub const Handler = struct {
write_pty(self, buf[0..writer.end :0]);
}
/// A paste request; see `paste`.
pub const Paste = paste_pkg.Request;
pub const PasteError = Allocator.Error || std.Io.RandomSecureError || error{
/// The data could inject commands and allow_unsafe was false.
/// Nothing was written.
UnsafePaste,
/// No write_pty effect is set, so nothing can be written.
NoWritePty,
};
/// Paste into the terminal, applying the terminal's current state
/// as necessary to owner mode 5522, bracketed paste, unsafe paste, etc.
/// Returns true if anything was written to the pty.
pub fn paste(self: *Handler, req: Paste) PasteError!bool {
if (self.effects.write_pty == null) return error.NoWritePty;
// One buffer for the whole result (frame + data + sentinel, or
// the event packets). Typical pastes stay on the stack.
const alloc = self.terminal.gpa();
var stack = std.heap.stackFallback(4096, alloc);
const stack_alloc = stack.get();
var aw: std.Io.Writer.Allocating = .init(stack_alloc);
defer aw.deinit();
const written_any = paste_pkg.paste(.{
.terminal = self.terminal,
.grants = &self.kitty_clipboard_grants,
.io = self.terminal.io(),
.alloc = alloc,
.can_event = self.effects.clipboard_read != null,
.writer = &aw.writer,
}, req) catch |err| return switch (err) {
// An allocating writer only fails to allocate.
error.WriteFailed => error.OutOfMemory,
error.OutOfMemory,
error.UnsafePaste,
error.EntropyUnavailable,
error.Canceled,
=> |e| e,
};
if (!written_any) return false;
const written = try aw.toOwnedSliceSentinel(0);
defer stack_alloc.free(written);
self.writePty(written);
return true;
}
pub fn vt(
self: *Handler,
comptime action: Action.Tag,
@@ -403,6 +459,10 @@ pub const Handler = struct {
.full_reset => {
self.terminal.fullReset();
// Full reset clears grants
self.kitty_clipboard_grants.deinit(self.terminal.gpa());
self.kitty_clipboard_grants = .{};
// Clear the progress bar
self.progressReport(.{ .state = .remove });
},
@@ -5394,3 +5454,453 @@ test "kitty dnd: effect reports registration, acceptance, and conclusion" {
try testing.expect(t.kitty_dnd == null);
try testing.expectEqualStrings("", S.mimes.items);
}
/// Capture state for the Handler.paste tests below: every pty write and
/// the clipboard reads the program makes afterwards.
const PasteCapture = struct {
var written: std.ArrayList(u8) = .empty;
var write_count: usize = 0;
var read_count: usize = 0;
var read_granted_count: usize = 0;
var last_read_granted: bool = false;
var last_read_name: [64]u8 = undefined;
var last_read_name_len: usize = 0;
fn reset() void {
written.clearRetainingCapacity();
write_count = 0;
read_count = 0;
read_granted_count = 0;
last_read_granted = false;
last_read_name_len = 0;
}
fn deinit() void {
written.deinit(testing.allocator);
written = .empty;
}
fn writePty(_: *Handler, data: [:0]const u8) void {
written.appendSlice(testing.allocator, data) catch @panic("OOM");
write_count += 1;
}
fn clipboardRead(_: *Handler, read: clipboard.Read) void {
read_count += 1;
last_read_granted = read.granted;
if (read.granted) read_granted_count += 1;
last_read_name_len = read.name.len;
@memcpy(last_read_name[0..read.name.len], read.name);
read.reply(.{ .success = .{ .contents = &.{.{
.mime = "text/plain",
.data = "Ghostty",
}} } });
}
fn readName() []const u8 {
return last_read_name[0..last_read_name_len];
}
};
test "paste: no write_pty effect is an error" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
var handler: Handler = .init(&t);
defer handler.deinit();
try testing.expectError(error.NoWritePty, handler.paste(.{
.contents = &.{.{ .mime = "text/plain", .data = "hello" }},
}));
}
test "paste: plain text converts newlines and strips unsafe bytes" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
defer handler.deinit();
handler.effects.write_pty = &S.writePty;
// Newlines are unsafe unbracketed; the embedder confirmed.
try testing.expect(try handler.paste(.{
.contents = &.{.{ .mime = "text/plain", .data = "hel\x1blo\nwor\x00ld" }},
.allow_unsafe = true,
}));
try testing.expectEqualStrings("hel lo\rwor ld", S.written.items);
try testing.expectEqual(@as(usize, 1), S.write_count);
// The first text representation is used; others are ignored.
S.reset();
try testing.expect(try handler.paste(.{
.contents = &.{
.{ .mime = "image/png", .data = "\x89PNG" },
.{ .mime = "UTF8_STRING", .data = "hi" },
.{ .mime = "text/plain", .data = "ignored" },
},
}));
try testing.expectEqualStrings("hi", S.written.items);
try testing.expectEqual(@as(usize, 1), S.write_count);
}
test "paste: unsafe text is refused unless allowed" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
defer handler.deinit();
handler.effects.write_pty = &S.writePty;
try testing.expectError(error.UnsafePaste, handler.paste(.{
.contents = &.{.{ .mime = "text/plain", .data = "rm -rf /\n" }},
}));
try testing.expectEqual(@as(usize, 0), S.write_count);
try testing.expect(try handler.paste(.{
.contents = &.{.{ .mime = "text/plain", .data = "rm -rf /\n" }},
.allow_unsafe = true,
}));
try testing.expectEqualStrings("rm -rf /\r", S.written.items);
}
test "paste: bracketed paste frames the text" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
defer handler.deinit();
handler.effects.write_pty = &S.writePty;
t.modes.set(.bracketed_paste, true);
// Newlines are safe inside the frame and are preserved.
try testing.expect(try handler.paste(.{
.contents = &.{.{ .mime = "text/plain", .data = "hello\nworld" }},
}));
try testing.expectEqualStrings("\x1b[200~hello\nworld\x1b[201~", S.written.items);
try testing.expectEqual(@as(usize, 1), S.write_count);
// The frame terminator is not.
S.reset();
try testing.expectError(error.UnsafePaste, handler.paste(.{
.contents = &.{.{ .mime = "text/plain", .data = "he\x1b[201~llo" }},
}));
try testing.expectEqual(@as(usize, 0), S.write_count);
// Allowed, the stripper still defuses it: ESC becomes a space.
try testing.expect(try handler.paste(.{
.contents = &.{.{ .mime = "text/plain", .data = "he\x1b[201~llo" }},
.allow_unsafe = true,
}));
try testing.expectEqualStrings("\x1b[200~he [201~llo\x1b[201~", S.written.items);
}
test "paste: no text representation writes nothing" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
defer handler.deinit();
handler.effects.write_pty = &S.writePty;
try testing.expect(!try handler.paste(.{
.contents = &.{.{ .mime = "image/png", .data = "\x89PNG" }},
}));
try testing.expect(!try handler.paste(.{
.contents = &.{},
}));
try testing.expect(!try handler.paste(.{
.contents = &.{.{ .mime = "text/plain", .data = "" }},
}));
try testing.expectEqual(@as(usize, 0), S.write_count);
}
test "paste: large text falls back to the heap in one write" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
defer handler.deinit();
handler.effects.write_pty = &S.writePty;
t.modes.set(.bracketed_paste, true);
const data = "x" ** 10_000;
try testing.expect(try handler.paste(.{
.contents = &.{.{ .mime = "text/plain", .data = data }},
}));
try testing.expectEqual(@as(usize, 1), S.write_count);
try testing.expectEqual(data.len + "\x1b[200~\x1b[201~".len, S.written.items.len);
try testing.expect(std.mem.startsWith(u8, S.written.items, "\x1b[200~xxx"));
try testing.expect(std.mem.endsWith(u8, S.written.items, "xxx\x1b[201~"));
}
test "paste: mode 5522 sends an event the program can read with" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_read = &S.clipboardRead;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
t.modes.set(.kitty_paste_events, true);
t.modes.set(.bracketed_paste, true);
// Every representation is listed, the data is never written, and
// the one-time password rides on every packet.
try testing.expect(try s.handler.paste(.{
.contents = &.{
.{ .mime = "text/plain", .data = "secret" },
.{ .mime = "image/png", .data = "" },
},
}));
try testing.expectEqual(@as(usize, 1), S.write_count);
try testing.expectEqual(@as(usize, 3), std.mem.count(u8, S.written.items, "\x1b]5522;"));
try testing.expect(std.mem.indexOf(u8, S.written.items, "secret") == null);
try testing.expect(std.mem.indexOf(u8, S.written.items, "\x1b[200~") == null);
// OK packet: parse the (base64) password out.
const ok_prefix = "\x1b]5522;type=read:status=OK:pw=";
try testing.expect(std.mem.startsWith(u8, S.written.items, ok_prefix));
const pw_end = std.mem.indexOfPos(u8, S.written.items, ok_prefix.len, "\x1b\\").?;
// Copied out since the capture buffer is reused below.
var pw_buf: [64]u8 = undefined;
const pw_b64 = pw_buf[0 .. pw_end - ok_prefix.len];
@memcpy(pw_b64, S.written.items[ok_prefix.len..pw_end]);
try testing.expectEqual(
std.base64.standard.Encoder.calcSize(kitty_clipboard.otp_len),
pw_b64.len,
);
// Listing packet: base64 of "text/plain image/png\n".
var expected_buf: [256]u8 = undefined;
const expected = try std.fmt.bufPrint(
&expected_buf,
"\x1b]5522;type=read:status=OK:pw={s}\x1b\\" ++
"\x1b]5522;type=read:status=DATA:mime=Lg==:pw={s};dGV4dC9wbGFpbiBpbWFnZS9wbmcK\x1b\\" ++
"\x1b]5522;type=read:status=DONE:pw={s}\x1b\\",
.{ pw_b64, pw_b64, pw_b64 },
);
try testing.expectEqualStrings(expected, S.written.items);
// The program reads with the password and the name "Paste event":
// the read arrives granted, exactly once.
var read_buf: [256]u8 = undefined;
const read = try std.fmt.bufPrint(
&read_buf,
"\x1b]5522;type=read:pw={s}:name=UGFzdGUgZXZlbnQ=;dGV4dC9wbGFpbg==\x1b\\",
.{pw_b64},
);
S.reset();
s.nextSlice(read);
try testing.expectEqual(@as(usize, 1), S.read_count);
try testing.expect(S.last_read_granted);
try testing.expectEqualStrings("Paste event", S.readName());
try testing.expectEqualStrings(
"\x1b]5522;type=read:status=OK\x1b\\" ++
"\x1b]5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==\x1b\\" ++
"\x1b]5522;type=read:status=DONE\x1b\\",
S.written.items,
);
// The password was one-time: a second read is not granted.
S.reset();
s.nextSlice(read);
try testing.expectEqual(@as(usize, 1), S.read_count);
try testing.expect(!S.last_read_granted);
try testing.expectEqual(@as(usize, 0), S.read_granted_count);
// Every event mints a fresh password.
S.reset();
try testing.expect(try s.handler.paste(.{
.contents = &.{.{ .mime = "text/plain", .data = "secret" }},
}));
try testing.expect(std.mem.indexOf(u8, S.written.items, pw_b64) == null);
}
test "paste: mode 5522 reports the selection as primary" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
defer handler.deinit();
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_read = &S.clipboardRead;
t.modes.set(.kitty_paste_events, true);
for ([_]clipboard.Location{ .primary, .selection }) |location| {
S.reset();
try testing.expect(try handler.paste(.{
.location = location,
.contents = &.{.{ .mime = "text/plain", .data = "x" }},
}));
try testing.expect(std.mem.startsWith(
u8,
S.written.items,
"\x1b]5522;type=read:status=OK:loc=primary:pw=",
));
// Only on the OK packet.
try testing.expectEqual(@as(usize, 1), std.mem.count(u8, S.written.items, "loc=primary"));
}
S.reset();
try testing.expect(try handler.paste(.{
.location = .standard,
.contents = &.{.{ .mime = "text/plain", .data = "x" }},
}));
try testing.expect(std.mem.indexOf(u8, S.written.items, "loc=") == null);
}
test "paste: mode 5522 without clipboard_read pastes text" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
defer handler.deinit();
handler.effects.write_pty = &S.writePty;
t.modes.set(.kitty_paste_events, true);
try testing.expect(try handler.paste(.{
.contents = &.{.{ .mime = "text/plain", .data = "hello" }},
}));
try testing.expectEqualStrings("hello", S.written.items);
try testing.expectEqual(@as(usize, 0), handler.kitty_clipboard_grants.entries.items.len);
}
test "paste: text source never becomes an event" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
defer handler.deinit();
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_read = &S.clipboardRead;
t.modes.set(.kitty_paste_events, true);
try testing.expect(try handler.paste(.{
.source = .text,
.contents = &.{.{ .mime = "text/plain", .data = "committed" }},
}));
try testing.expectEqualStrings("committed", S.written.items);
try testing.expectEqual(@as(usize, 0), handler.kitty_clipboard_grants.entries.items.len);
}
test "paste: mode 5522 without entropy fails and records no grant" {
var t: Terminal = try .init(std.Io.failing, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
defer handler.deinit();
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_read = &S.clipboardRead;
t.modes.set(.kitty_paste_events, true);
try testing.expectError(error.EntropyUnavailable, handler.paste(.{
.contents = &.{.{ .mime = "text/plain", .data = "secret" }},
}));
try testing.expectEqual(@as(usize, 0), S.write_count);
try testing.expectEqual(@as(usize, 0), handler.kitty_clipboard_grants.entries.items.len);
// Text pastes need no entropy and still work.
try testing.expect(try handler.paste(.{
.source = .text,
.contents = &.{.{ .mime = "text/plain", .data = "hello" }},
}));
try testing.expectEqualStrings("hello", S.written.items);
}
test "paste: mode 5522 is settable and reported in the lib build" {
if (comptime build_options.artifact != .lib) return error.SkipZigTest;
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// Recognized and reset by default, so programs can detect support.
s.nextSlice("\x1B[?5522$p");
try testing.expectEqualStrings("\x1B[?5522;2$y", S.written.items);
S.reset();
s.nextSlice("\x1B[?5522h");
try testing.expect(t.modes.get(.kitty_paste_events));
s.nextSlice("\x1B[?5522$p");
try testing.expectEqualStrings("\x1B[?5522;1$y", S.written.items);
s.nextSlice("\x1B[?5522l");
try testing.expect(!t.modes.get(.kitty_paste_events));
}
test "full reset drops kitty clipboard grants" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_read = &S.clipboardRead;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
try s.handler.kitty_clipboard_grants.grant(testing.allocator, "pw", .read, false);
try testing.expectEqual(@as(usize, 1), s.handler.kitty_clipboard_grants.entries.items.len);
s.nextSlice("\x1Bc");
try testing.expectEqual(@as(usize, 0), s.handler.kitty_clipboard_grants.entries.items.len);
// A read with the old password is no longer granted, and the
// handler keeps working (grants can be recorded again).
S.reset();
s.nextSlice("\x1b]5522;type=read:pw=cHc=:name=YXBw;dGV4dC9wbGFpbg==\x1b\\");
try testing.expectEqual(@as(usize, 1), S.read_count);
try testing.expect(!S.last_read_granted);
try s.handler.kitty_clipboard_grants.grant(testing.allocator, "pw", .read, false);
}

View File

@@ -52,3 +52,51 @@ fn decodePngWuffs(
.data = result.data,
};
}
/// Fill a buffer with cryptographically secure random bytes. If null,
/// the terminal's `std.Io` (`randomSecure`) is used. This is an override
/// for embedders whose Io has no entropy source (e.g. wasm32-freestanding,
/// where TinyIo degrades to `std.Io.failing`) or that want to control
/// the source; when set it is used on every target.
///
/// This is used for secrets, so it must be a real CSPRNG. An error
/// makes the operation that needed the entropy fail; nothing falls back
/// to weaker randomness.
pub var random_secure: ?RandomSecureFn = null;
pub const RandomSecureError = error{EntropyUnavailable};
pub const RandomSecureFn = *const fn ([]u8) RandomSecureError!void;
/// Fill `buffer` with secure random bytes from `random_secure` if set,
/// otherwise from `io`. Every use of secure entropy in the terminal
/// package goes through this so the override applies uniformly.
pub fn randomSecure(io: std.Io, buffer: []u8) std.Io.RandomSecureError!void {
if (random_secure) |func| return func(buffer);
return io.randomSecure(buffer);
}
test "randomSecure: override is preferred over the Io" {
const testing = std.testing;
const S = struct {
fn fill(buffer: []u8) RandomSecureError!void {
@memset(buffer, 0xAB);
}
fn fail(_: []u8) RandomSecureError!void {
return error.EntropyUnavailable;
}
};
// Without the override a failing Io fails.
var buf: [8]u8 = @splat(0);
try testing.expectError(error.EntropyUnavailable, randomSecure(std.Io.failing, &buf));
// With it, the Io is never consulted.
random_secure = &S.fill;
defer random_secure = null;
try randomSecure(std.Io.failing, &buf);
try testing.expect(std.mem.allEqual(u8, &buf, 0xAB));
// An override failure surfaces as the Io's error.
random_secure = &S.fail;
try testing.expectError(error.EntropyUnavailable, randomSecure(testing.io, &buf));
}