diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 3d70361e8..61acc8237 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -245,6 +245,7 @@ nolinmcfarland nouritsu nwehg ocean6954 +ollioddi oshdubh otomn paaloeye @@ -277,6 +278,7 @@ rgehan rhodes-b rightaditya rjwittams +rkoten rmengelbrecht rmunn rockorager @@ -313,6 +315,7 @@ tothedarktowercame trag1c tristan957 tsacha +tuananh turbolent tweedbeetle uhojin @@ -320,6 +323,7 @@ unphased unsaltedscholar uzaaft vancluever +vasilmytsyk vaughanandrews -vectorpeak Stupid bot cosplaying as a maintainer vegerot diff --git a/example/c-vt-paste/README.md b/example/c-vt-paste/README.md index 377cd3c3b..1e1e6fa14 100644 --- a/example/c-vt-paste/README.md +++ b/example/c-vt-paste/README.md @@ -1,7 +1,13 @@ -# 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. The clipboard's data is produced on demand through a +read callback, so only what is actually pasted is ever read, and the +result streams to the pty in chunks. 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..c83020eed 100644 --- a/example/c-vt-paste/src/main.c +++ b/example/c-vt-paste/src/main.c @@ -1,7 +1,160 @@ +#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] +// What the clipboard holds. A real embedder would keep a handle to the +// pasteboard or its items here; the data is only produced on demand. +typedef struct { + const char* text; +} clipboard_t; + +// Produces the data of one representation when the terminal needs it. +// Only the text is ever read: the image is listed on a paste event +// but never requested, so a large image costs nothing to paste. +// Nothing written to the writer is retained, so the data can be +// streamed from anywhere in pieces of any size. +static bool read_clipboard(void* userdata, GhosttyString mime, GhosttyWriter writer) { + clipboard_t* clipboard = userdata; + if (mime.len == strlen("text/plain") && + memcmp(mime.ptr, "text/plain", mime.len) == 0) { + // Stream the text in small pieces just to show that it works. + const uint8_t* data = (const uint8_t*)clipboard->text; + size_t len = strlen(clipboard->text); + for (size_t offset = 0; offset < len; offset += 4) { + size_t n = len - offset < 4 ? len - offset : 4; + if (!writer.write(writer.userdata, data + offset, n)) return false; + } + return true; + } + printf(" image read requested, which never happens\n"); + return false; +} + +// 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) { + clipboard_t clipboard = {.text = text}; + GhosttyString mimes[] = { + // The first text representation is what a text paste writes. + GS("text/plain"), + // Listed on a paste event, never read. + GS("image/png"), + }; + GhosttyPaste paste = { + .size = sizeof(paste), + .location = GHOSTTY_CLIPBOARD_LOCATION_STANDARD, + .source = GHOSTTY_PASTE_SOURCE_CLIPBOARD, + .mimes = mimes, + .mimes_len = sizeof(mimes) / sizeof(mimes[0]), + .reader = {.read = read_clipboard, .userdata = &clipboard}, + .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, in + // chunks as the text was read. + printf(" %s\n", written ? "written" : "nothing to paste"); +} +//! [terminal-paste] + //! [paste-safety] void safety_example() { const char* safe_data = "hello world"; @@ -29,27 +182,87 @@ 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"); + { + clipboard_t clipboard = {.text = "committed"}; + GhosttyString mime = GS("text/plain"); + GhosttyPaste paste = { + .size = sizeof(paste), + .location = GHOSTTY_CLIPBOARD_LOCATION_STANDARD, + .source = GHOSTTY_PASTE_SOURCE_TEXT, + .mimes = &mime, + .mimes_len = 1, + .reader = {.read = read_clipboard, .userdata = &clipboard}, + .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/io.h b/include/ghostty/vt/io.h index 2d8e67029..ad2c57ad6 100644 --- a/include/ghostty/vt/io.h +++ b/include/ghostty/vt/io.h @@ -10,6 +10,7 @@ #include #include #include +#include /** @defgroup io I/O * @@ -98,6 +99,49 @@ typedef struct { void* userdata; } GhosttyWriter; +/** + * Read one MIME-typed representation of some content, streaming its + * bytes to a writer. + * + * The library calls this with the MIME type of the representation it + * needs. The callback writes all of that representation's data to + * @p writer, in as many calls to `writer.write(writer.userdata, data, + * len)` as is convenient (one call with everything or many small + * pieces both work), and returns true. Nothing written is retained + * beyond each write call, so the data may be borrowed from anywhere: + * a pasteboard item, a file being read, a stream. + * + * Returning false reports that the data could not be read. If the + * writer refuses a write (returns false), stop and return false + * without writing more. + * + * All pointer arguments, the mime, and the writer are borrowed and + * valid only for the duration of the callback. The callback is + * invoked synchronously on the calling thread. The API receiving the + * GhosttyMimeReader defines which MIME types are requested, how many + * times, and any consistency requirements across repeated reads. + * + * @param userdata Opaque userdata from GhosttyMimeReader + * @param mime The MIME type of the representation to read + * @param writer Where to write the data; valid only during this call + * @return true once all the data was written, false if it could not + * be read or the writer refused a write + */ +typedef bool (*GhosttyMimeReaderFn)( + void* userdata, + GhosttyString mime, + GhosttyWriter writer); + +/** + * A MIME-typed content source callback and its opaque context. + * + * The struct is passed by value. @p read must be non-NULL. + */ +typedef struct { + GhosttyMimeReaderFn read; + void* userdata; +} GhosttyMimeReader; + #ifdef __cplusplus } #endif diff --git a/include/ghostty/vt/paste.h b/include/ghostty/vt/paste.h index b3df5be4e..4ba21a9ad 100644 --- a/include/ghostty/vt/paste.h +++ b/include/ghostty/vt/paste.h @@ -1,26 +1,67 @@ /** * @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 the MIME types the clipboard holds (just `text/plain` for + * an ordinary paste), a GhosttyMimeReader that produces the data of + * any one of them, and where the paste 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. No data is read for the event. + * - 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. + * + * The data is pulled through GhosttyPaste::reader only when a + * representation is actually pasted (so a clipboard holding a large + * image next to some text costs nothing), and the encoded bytes + * stream to the write_pty callback (GHOSTTY_TERMINAL_OPT_WRITE_PTY) + * in chunks as they are produced, never in one piece. The callback + * may be invoked several times for a single paste; the pieces must be + * written to the pty in order. + * + * Text that could inject commands (a newline when unbracketed, or the + * bracketed paste terminator when bracketed) is refused with + * GHOSTTY_REJECTED and nothing written 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. Each call + * reads the text at most once and buffers it whole while the rule is + * applied, so the source needs no stability across reads (the + * confirmed retry simply pastes whatever the source holds then) and a + * refused or failed paste writes nothing at all. + * + * @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 +76,119 @@ #include #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 + * MIME type array and the strings it points to are borrowed only for + * the duration of the ghostty_terminal_paste() call, as is everything + * the reader produces. + */ +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 MIME types of the representations available, + * in preferred order. A text paste reads and writes the first entry + * with a text MIME type such as "text/plain" and ignores the rest. A + * paste event lists every entry and reads none. May be NULL when + * mimes_len is zero, which is nothing to paste. + */ + const GhosttyString* mimes; + + /** Number of entries in mimes. */ + size_t mimes_len; + + /** + * Produces the data of a representation on demand. Required when + * mimes_len is nonzero. + * + * Called at most once per ghostty_terminal_paste() call: for the + * text representation being pasted, never for anything else and + * never for a paste event. The MIME type requested is always an + * entry of `mimes`, passed through exactly as given there (the same + * pointer and length), so the callback may identify the + * representation by pointer or by content. A false return fails the + * paste with GHOSTTY_IO_ERROR. + */ + GhosttyMimeReader reader; + + /** + * 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 + * streams through the write_pty callback in chunks. The viewport is not + * scrolled; that is up to the embedder, as for key input. + * + * A paste event records a session grant for its one-time password only + * once the event is written; a failed call never leaves a grant for an + * event that was never sent. + * + * @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, MIME + * types without a reader, or when no write_pty callback is + * installed; GHOSTTY_OUT_OF_MEMORY; GHOSTTY_IO_ERROR if the + * reader failed or there is no secure entropy source to mint a + * paste event password (wasm32-freestanding without + * GHOSTTY_SYS_OPT_RANDOM_SECURE set). Errors write nothing. + */ +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 +198,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 +225,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/sys.h b/include/ghostty/vt/sys.h index ae9059692..c5e5903b2 100644 --- a/include/ghostty/vt/sys.h +++ b/include/ghostty/vt/sys.h @@ -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; 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/po/bg.po b/po/bg.po index 2069f5235..a8fdf55d4 100644 --- a/po/bg.po +++ b/po/bg.po @@ -9,7 +9,7 @@ msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2026-08-10 10:06-0500\n" -"PO-Revision-Date: 2026-02-09 22:07+0200\n" +"PO-Revision-Date: 2026-08-13 13:40+0300\n" "Last-Translator: reo101 \n" "Language-Team: Bulgarian \n" "Language: bg\n" @@ -175,7 +175,7 @@ msgstr "Раздели надясно" #: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" -msgstr "" +msgstr "Затвори разделянето" #: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" @@ -184,7 +184,7 @@ msgstr "Раздел" #: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 #: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" -msgstr "Смени името на таба…" +msgstr "Промени заглавието на раздела…" #: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 #: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 @@ -203,7 +203,7 @@ msgstr "Прозорец" #: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 msgid "Change Window Title…" -msgstr "" +msgstr "Промени заглавието на прозореца…" #: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 #: src/input/command.zig:421 @@ -221,11 +221,11 @@ msgstr "Конфигурация" #: src/apprt/gtk/ui/1.2/surface.blp:427 src/apprt/gtk/ui/1.5/window.blp:306 msgid "Open Configuration in OS Editor" -msgstr "" +msgstr "Отвори конфигурацията в редактора на операционната система" #: src/apprt/gtk/ui/1.2/surface.blp:433 src/apprt/gtk/ui/1.5/window.blp:312 msgid "Open Configuration in New Window" -msgstr "" +msgstr "Отвори конфигурацията в нов прозорец" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -269,27 +269,27 @@ msgstr "Изпълни команда…" #: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." -msgstr "" +msgstr "Клавишната комбинация е отменена от системата." #: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." -msgstr "" +msgstr "Клавишната комбинация е отказана от системата." #: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" -msgstr "" +msgstr "Глобалната клавишна комбинация не е достъпна" #: src/apprt/gtk/class/application.zig:2259 msgid "Export Terminal IO Events" -msgstr "" +msgstr "Експортиране на входно-изходните събития на терминала" #: src/apprt/gtk/class/application.zig:2262 msgid "Export" -msgstr "" +msgstr "Експортирай" #: src/apprt/gtk/class/application.zig:2783 msgid "Editing configuration file" -msgstr "" +msgstr "Редактиране на конфигурационния файл" #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" @@ -371,11 +371,11 @@ msgstr "Промяна на заглавието на терминала" #: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" -msgstr "Смени името на таба" +msgstr "Промяна на заглавието на раздела" #: src/apprt/gtk/class/title_dialog.zig:229 msgid "Change Window Title" -msgstr "" +msgstr "Промяна на заглавието на прозореца" #: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" @@ -395,710 +395,768 @@ msgstr "Разработчици на Ghostty" #: src/input/command.zig:149 msgid "Reset Terminal" -msgstr "" +msgstr "Нулиране на терминала" #: src/input/command.zig:150 msgid "Reset the terminal to a clean state." -msgstr "" +msgstr "Връщане на терминала в начално състояние." #: src/input/command.zig:155 msgid "Copy to Clipboard" -msgstr "" +msgstr "Копиране в клипборда" #: src/input/command.zig:156 msgid "" "Copy the selected text to the clipboard in both plain and styled formats." msgstr "" +"Копиране на избрания текст в клипборда както в обикновен, така и във " +"форматиран вид." #: src/input/command.zig:159 msgid "Copy Selection as Plain Text to Clipboard" -msgstr "" +msgstr "Копиране на избраното като обикновен текст в клипборда" #: src/input/command.zig:160 msgid "Copy the selected text as plain text to the clipboard." -msgstr "" +msgstr "Копиране на избрания текст като обикновен текст в клипборда." #: src/input/command.zig:163 msgid "Copy Selection as ANSI Sequences to Clipboard" -msgstr "" +msgstr "Копиране на избраното с ANSI форматиране в клипборда" #: src/input/command.zig:164 msgid "Copy the selected text as ANSI escape sequences to the clipboard." -msgstr "" +msgstr "Копиране на избрания текст с ANSI форматиране в клипборда." #: src/input/command.zig:167 msgid "Copy Selection as HTML to Clipboard" -msgstr "" +msgstr "Копиране на избраното като HTML в клипборда" #: src/input/command.zig:168 msgid "Copy the selected text as HTML to the clipboard." -msgstr "" +msgstr "Копиране на избрания текст като HTML в клипборда." #: src/input/command.zig:173 msgid "Copy URL to Clipboard" -msgstr "" +msgstr "Копиране на URL адреса в клипборда" #: src/input/command.zig:174 msgid "Copy the URL under the cursor to the clipboard." -msgstr "" +msgstr "Копиране на URL адреса под курсора в клипборда." #: src/input/command.zig:179 msgid "Copy Terminal Title to Clipboard" -msgstr "" +msgstr "Копиране на заглавието на терминала в клипборда" #: src/input/command.zig:180 msgid "" "Copy the terminal title to the clipboard. If the terminal title is not set " "this has no effect." msgstr "" +"Копиране на заглавието на терминала в клипборда. Ако няма зададено заглавие, " +"действието няма ефект." #: src/input/command.zig:185 msgid "Paste from Clipboard" -msgstr "" +msgstr "Поставяне от клипборда" #: src/input/command.zig:186 msgid "Paste the contents of the main clipboard." -msgstr "" +msgstr "Поставяне на съдържанието на основния клипборд." #: src/input/command.zig:191 msgid "Paste from Selection" -msgstr "" +msgstr "Поставяне от избраното" #: src/input/command.zig:192 msgid "Paste the contents of the selection clipboard." -msgstr "" +msgstr "Поставяне на съдържанието на клипборда за избраното." #: src/input/command.zig:197 msgid "Start Search" -msgstr "" +msgstr "Начало на търсенето" #: src/input/command.zig:198 msgid "Start a search if one isn't already active." -msgstr "" +msgstr "Започване на търсене, ако в момента няма активно такова." #: src/input/command.zig:203 msgid "Search Selection" -msgstr "" +msgstr "Търсене на избраното" #: src/input/command.zig:204 msgid "Start a search for the current text selection." -msgstr "" +msgstr "Започване на търсене на текущо избрания текст." #: src/input/command.zig:209 msgid "End Search" -msgstr "" +msgstr "Край на търсенето" #: src/input/command.zig:210 msgid "End the current search if any and hide any GUI elements." msgstr "" +"Прекратяване на текущото търсене, ако има такова, и скриване на всички " +"елементи на графичния интерфейс." #: src/input/command.zig:215 msgid "Next Search Result" -msgstr "" +msgstr "Следващ резултат от търсенето" #: src/input/command.zig:216 msgid "Navigate to the next search result, if any." -msgstr "" +msgstr "Преминаване към следващия резултат от търсенето, ако има такъв." #: src/input/command.zig:219 msgid "Previous Search Result" -msgstr "" +msgstr "Предишен резултат от търсенето" #: src/input/command.zig:220 msgid "Navigate to the previous search result, if any." -msgstr "" +msgstr "Преминаване към предишния резултат от търсенето, ако има такъв." #: src/input/command.zig:225 msgid "Increase Font Size" -msgstr "" +msgstr "Увеличаване на размера на шрифта" #: src/input/command.zig:226 msgid "Increase the font size by 1 point." -msgstr "" +msgstr "Увеличаване на размера на шрифта с 1 пункт." #: src/input/command.zig:231 msgid "Decrease Font Size" -msgstr "" +msgstr "Намаляване на размера на шрифта" #: src/input/command.zig:232 msgid "Decrease the font size by 1 point." -msgstr "" +msgstr "Намаляване на размера на шрифта с 1 пункт." #: src/input/command.zig:237 msgid "Reset Font Size" -msgstr "" +msgstr "Възстановяване на размера на шрифта" #: src/input/command.zig:238 msgid "Reset the font size to the default." -msgstr "" +msgstr "Възстановяване на размера на шрифта до стойността по подразбиране." #: src/input/command.zig:243 msgid "Clear Screen" -msgstr "" +msgstr "Изчистване на екрана" #: src/input/command.zig:244 msgid "Clear the screen and scrollback." -msgstr "" +msgstr "Изчистване на екрана и буфера за превъртане назад." #: src/input/command.zig:249 msgid "Select All" -msgstr "" +msgstr "Избиране на всичко" #: src/input/command.zig:250 msgid "Select all text on the screen." -msgstr "" +msgstr "Избиране на целия текст на екрана." #: src/input/command.zig:255 msgid "Scroll to Top" -msgstr "" +msgstr "Превъртане до началото" #: src/input/command.zig:256 msgid "Scroll to the top of the screen." -msgstr "" +msgstr "Превъртане до началото на екрана." #: src/input/command.zig:261 msgid "Scroll to Bottom" -msgstr "" +msgstr "Превъртане до края" #: src/input/command.zig:262 msgid "Scroll to the bottom of the screen." -msgstr "" +msgstr "Превъртане до края на екрана." #: src/input/command.zig:267 msgid "Scroll to Selection" -msgstr "" +msgstr "Превъртане до избраното" #: src/input/command.zig:268 msgid "Scroll to the selected text." -msgstr "" +msgstr "Превъртане до избрания текст." #: src/input/command.zig:273 msgid "Scroll Page Up" -msgstr "" +msgstr "Превъртане с една страница нагоре" #: src/input/command.zig:274 msgid "Scroll the screen up by a page." -msgstr "" +msgstr "Превъртане на екрана с една страница нагоре." #: src/input/command.zig:279 msgid "Scroll Page Down" -msgstr "" +msgstr "Превъртане с една страница надолу" #: src/input/command.zig:280 msgid "Scroll the screen down by a page." -msgstr "" +msgstr "Превъртане на екрана с една страница надолу." #: src/input/command.zig:286 msgid "Copy Screen to Temporary File and Copy Path" -msgstr "" +msgstr "Записване на екрана във временен файл и копиране на пътя" #: src/input/command.zig:287 msgid "" "Copy the screen contents to a temporary file and copy the path to the " "clipboard." msgstr "" +"Съдържанието на екрана се записва във временен файл, а пътят до файла се " +"копира в клипборда." #: src/input/command.zig:291 msgid "Copy Screen to Temporary File and Paste Path" -msgstr "" +msgstr "Записване на екрана във временен файл и поставяне на пътя" #: src/input/command.zig:292 msgid "" "Copy the screen contents to a temporary file and paste the path to the file." msgstr "" +"Съдържанието на екрана се записва във временен файл и се поставя пътят до " +"файла." #: src/input/command.zig:296 msgid "Copy Screen to Temporary File and Open" -msgstr "" +msgstr "Записване на екрана във временен файл и отваряне на файла" #: src/input/command.zig:297 msgid "Copy the screen contents to a temporary file and open it." msgstr "" +"Съдържанието на екрана се записва във временен файл, който след това се " +"отваря." #: src/input/command.zig:305 msgid "Copy Screen as HTML to Temporary File and Copy Path" -msgstr "" +msgstr "Записване на екрана като HTML във временен файл и копиране на пътя" #: src/input/command.zig:306 msgid "" "Copy the screen contents as HTML to a temporary file and copy the path to " "the clipboard." msgstr "" +"Съдържанието на екрана се записва във временен файл в HTML формат, а пътят " +"до файла се копира в клипборда." #: src/input/command.zig:313 msgid "Copy Screen as HTML to Temporary File and Paste Path" -msgstr "" +msgstr "Записване на екрана като HTML във временен файл и поставяне на пътя" #: src/input/command.zig:314 msgid "" "Copy the screen contents as HTML to a temporary file and paste the path to " "the file." msgstr "" +"Съдържанието на екрана се записва във временен файл в HTML формат и се " +"поставя пътят до файла." #: src/input/command.zig:321 msgid "Copy Screen as HTML to Temporary File and Open" -msgstr "" +msgstr "Записване на екрана като HTML във временен файл и отваряне на файла" #: src/input/command.zig:322 msgid "Copy the screen contents as HTML to a temporary file and open it." msgstr "" +"Съдържанието на екрана се записва във временен файл в HTML формат, който " +"след това се отваря." #: src/input/command.zig:330 msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" msgstr "" +"Записване на екрана с ANSI форматиране във временен файл и копиране на пътя" #: src/input/command.zig:331 msgid "" "Copy the screen contents as ANSI escape sequences to a temporary file and " "copy the path to the clipboard." msgstr "" +"Съдържанието на екрана се записва във временен файл с ANSI форматиране, а " +"пътят до файла се копира в клипборда." #: src/input/command.zig:338 msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" msgstr "" +"Записване на екрана с ANSI форматиране във временен файл и поставяне на пътя" #: src/input/command.zig:339 msgid "" "Copy the screen contents as ANSI escape sequences to a temporary file and " "paste the path to the file." msgstr "" +"Съдържанието на екрана се записва във временен файл с ANSI форматиране и се " +"поставя пътят до файла." #: src/input/command.zig:346 msgid "Copy Screen as ANSI Sequences to Temporary File and Open" msgstr "" +"Записване на екрана с ANSI форматиране във временен файл и отваряне на файла" #: src/input/command.zig:347 msgid "" "Copy the screen contents as ANSI escape sequences to a temporary file and " "open it." msgstr "" +"Съдържанието на екрана се записва във временен файл с ANSI форматиране, " +"който след това се отваря." #: src/input/command.zig:354 msgid "Copy Selection to Temporary File and Copy Path" -msgstr "" +msgstr "Записване на избраното във временен файл и копиране на пътя" #: src/input/command.zig:355 msgid "" "Copy the selection contents to a temporary file and copy the path to the " "clipboard." msgstr "" +"Избраното съдържание се записва във временен файл, а пътят до файла се " +"копира в клипборда." #: src/input/command.zig:359 msgid "Copy Selection to Temporary File and Paste Path" -msgstr "" +msgstr "Записване на избраното във временен файл и поставяне на пътя" #: src/input/command.zig:360 msgid "" "Copy the selection contents to a temporary file and paste the path to the " "file." msgstr "" +"Избраното съдържание се записва във временен файл и се поставя пътят до " +"файла." #: src/input/command.zig:364 msgid "Copy Selection to Temporary File and Open" -msgstr "" +msgstr "Записване на избраното във временен файл и отваряне на файла" #: src/input/command.zig:365 msgid "Copy the selection contents to a temporary file and open it." msgstr "" +"Избраното съдържание се записва във временен файл, който след това се отваря." #: src/input/command.zig:373 msgid "Copy Selection as HTML to Temporary File and Copy Path" -msgstr "" +msgstr "Записване на избраното като HTML във временен файл и копиране на пътя" #: src/input/command.zig:374 msgid "" "Copy the selection contents as HTML to a temporary file and copy the path to " "the clipboard." msgstr "" +"Избраното съдържание се записва във временен файл в HTML формат, а пътят до " +"файла се копира в клипборда." #: src/input/command.zig:381 msgid "Copy Selection as HTML to Temporary File and Paste Path" -msgstr "" +msgstr "Записване на избраното като HTML във временен файл и поставяне на пътя" #: src/input/command.zig:382 msgid "" "Copy the selection contents as HTML to a temporary file and paste the path " "to the file." msgstr "" +"Избраното съдържание се записва във временен файл в HTML формат и се поставя " +"пътят до файла." #: src/input/command.zig:389 msgid "Copy Selection as HTML to Temporary File and Open" -msgstr "" +msgstr "Записване на избраното като HTML във временен файл и отваряне на файла" #: src/input/command.zig:390 msgid "Copy the selection contents as HTML to a temporary file and open it." msgstr "" +"Избраното съдържание се записва във временен файл в HTML формат, който след " +"това се отваря." #: src/input/command.zig:398 msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" msgstr "" +"Записване на избраното с ANSI форматиране във временен файл и копиране на " +"пътя" #: src/input/command.zig:399 msgid "" "Copy the selection contents as ANSI escape sequences to a temporary file and " "copy the path to the clipboard." msgstr "" +"Избраното съдържание се записва във временен файл с ANSI форматиране, а " +"пътят до файла се копира в клипборда." #: src/input/command.zig:406 msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" msgstr "" +"Записване на избраното с ANSI форматиране във временен файл и поставяне на " +"пътя" #: src/input/command.zig:407 msgid "" "Copy the selection contents as ANSI escape sequences to a temporary file and " "paste the path to the file." msgstr "" +"Избраното съдържание се записва във временен файл с ANSI форматиране и се " +"поставя пътят до файла." #: src/input/command.zig:414 msgid "Copy Selection as ANSI Sequences to Temporary File and Open" msgstr "" +"Записване на избраното с ANSI форматиране във временен файл и отваряне на " +"файла" #: src/input/command.zig:415 msgid "" "Copy the selection contents as ANSI escape sequences to a temporary file and " "open it." msgstr "" +"Избраното съдържание се записва във временен файл с ANSI форматиране, който " +"след това се отваря." #: src/input/command.zig:422 msgid "Open a new window." -msgstr "" +msgstr "Отваряне на нов прозорец." #: src/input/command.zig:428 msgid "Open a new tab." -msgstr "" +msgstr "Отваряне на нов раздел." #: src/input/command.zig:434 msgid "Move Tab Left" -msgstr "" +msgstr "Преместване на раздела наляво" #: src/input/command.zig:435 msgid "Move the current tab to the left." -msgstr "" +msgstr "Преместване на текущия раздел наляво." #: src/input/command.zig:439 msgid "Move Tab Right" -msgstr "" +msgstr "Преместване на раздела надясно" #: src/input/command.zig:440 msgid "Move the current tab to the right." -msgstr "" +msgstr "Преместване на текущия раздел надясно." #: src/input/command.zig:446 msgid "Move Tab to New Window" -msgstr "" +msgstr "Преместване на раздела в нов прозорец" #: src/input/command.zig:447 msgid "Move the current tab to a new window." -msgstr "" +msgstr "Преместване на текущия раздел в нов прозорец." #: src/input/command.zig:452 msgid "Toggle Tab Overview" -msgstr "" +msgstr "Превключване на прегледа на разделите" #: src/input/command.zig:453 msgid "Toggle the tab overview." -msgstr "" +msgstr "Превключване на прегледа на разделите." #: src/input/command.zig:458 msgid "Change Terminal Title…" -msgstr "" +msgstr "Промяна на заглавието на терминала…" #: src/input/command.zig:459 msgid "Prompt for a new title for the current terminal." -msgstr "" +msgstr "Показване на подкана за ново заглавие на текущия терминал." #: src/input/command.zig:465 msgid "Prompt for a new title for the current tab." -msgstr "" +msgstr "Показване на подкана за ново заглавие на текущия раздел." #: src/input/command.zig:478 msgid "Split the terminal to the left." -msgstr "" +msgstr "Разделяне на терминала наляво." #: src/input/command.zig:483 msgid "Split the terminal to the right." -msgstr "" +msgstr "Разделяне на терминала надясно." #: src/input/command.zig:488 msgid "Split the terminal up." -msgstr "" +msgstr "Разделяне на терминала нагоре." #: src/input/command.zig:493 msgid "Split the terminal down." -msgstr "" +msgstr "Разделяне на терминала надолу." #: src/input/command.zig:500 msgid "Focus Split: Previous" -msgstr "" +msgstr "Фокусиране на предишното разделяне" #: src/input/command.zig:501 msgid "Focus the previous split, if any." -msgstr "" +msgstr "Фокусиране на предишното разделяне, ако има такова." #: src/input/command.zig:505 msgid "Focus Split: Next" -msgstr "" +msgstr "Фокусиране на следващото разделяне" #: src/input/command.zig:506 msgid "Focus the next split, if any." -msgstr "" +msgstr "Фокусиране на следващото разделяне, ако има такова." #: src/input/command.zig:510 msgid "Focus Split: Left" -msgstr "" +msgstr "Фокусиране на разделянето отляво" #: src/input/command.zig:511 msgid "Focus the split to the left, if it exists." -msgstr "" +msgstr "Фокусиране на разделянето отляво, ако съществува." #: src/input/command.zig:515 msgid "Focus Split: Right" -msgstr "" +msgstr "Фокусиране на разделянето отдясно" #: src/input/command.zig:516 msgid "Focus the split to the right, if it exists." -msgstr "" +msgstr "Фокусиране на разделянето отдясно, ако съществува." #: src/input/command.zig:520 msgid "Focus Split: Up" -msgstr "" +msgstr "Фокусиране на разделянето отгоре" #: src/input/command.zig:521 msgid "Focus the split above, if it exists." -msgstr "" +msgstr "Фокусиране на разделянето отгоре, ако съществува." #: src/input/command.zig:525 msgid "Focus Split: Down" -msgstr "" +msgstr "Фокусиране на разделянето отдолу" #: src/input/command.zig:526 msgid "Focus the split below, if it exists." -msgstr "" +msgstr "Фокусиране на разделянето отдолу, ако съществува." #: src/input/command.zig:533 msgid "Focus Window: Previous" -msgstr "" +msgstr "Фокусиране на предишния прозорец" #: src/input/command.zig:534 msgid "Focus the previous window, if any." -msgstr "" +msgstr "Фокусиране на предишния прозорец, ако има такъв." #: src/input/command.zig:538 msgid "Focus Window: Next" -msgstr "" +msgstr "Фокусиране на следващия прозорец" #: src/input/command.zig:539 msgid "Focus the next window, if any." -msgstr "" +msgstr "Фокусиране на следващия прозорец, ако има такъв." #: src/input/command.zig:545 msgid "Toggle Split Zoom" -msgstr "" +msgstr "Превключване на увеличението на разделянето" #: src/input/command.zig:546 msgid "Toggle the zoom state of the current split." -msgstr "" +msgstr "Превключване на увеличението на текущото разделяне." #: src/input/command.zig:551 msgid "Toggle Read-Only Mode" -msgstr "" +msgstr "Превключване на режима само за четене" #: src/input/command.zig:552 msgid "Toggle read-only mode for the current surface." -msgstr "" +msgstr "Превключване на режима само за четене за текущия терминал." #: src/input/command.zig:557 msgid "Equalize Splits" -msgstr "" +msgstr "Изравняване на разделянията" #: src/input/command.zig:558 msgid "Equalize the size of all splits." -msgstr "" +msgstr "Изравняване на размера на всички разделяния." #: src/input/command.zig:563 msgid "Reset Window Size" -msgstr "" +msgstr "Възстановяване на размера на прозореца" #: src/input/command.zig:564 msgid "Reset the window size to the default." -msgstr "" +msgstr "Възстановяване на размера на прозореца до стойността по подразбиране." #: src/input/command.zig:569 msgid "Toggle Inspector" -msgstr "" +msgstr "Превключване на инспектора" #: src/input/command.zig:570 msgid "Toggle the inspector." -msgstr "" +msgstr "Превключване на инспектора." #: src/input/command.zig:575 msgid "Show the GTK Inspector" -msgstr "" +msgstr "Показване на GTK инспектора" #: src/input/command.zig:576 msgid "Show the GTK inspector." -msgstr "" +msgstr "Показване на GTK инспектора." #: src/input/command.zig:581 msgid "Show On-Screen Keyboard" -msgstr "" +msgstr "Показване на екранната клавиатура" #: src/input/command.zig:582 msgid "Show the on-screen keyboard if present." -msgstr "" +msgstr "Показване на екранната клавиатура, ако е налична." #: src/input/command.zig:588 msgid "Open Config Using OS editor" -msgstr "" +msgstr "Отваряне на конфигурацията с редактора на операционната система" #: src/input/command.zig:589 msgid "Open the config file with the OS's default editor." msgstr "" +"Отваряне на конфигурационния файл с редактора по подразбиране на " +"операционната система." #: src/input/command.zig:593 msgid "Open Config in New Terminal Window" -msgstr "" +msgstr "Отваряне на конфигурацията в нов терминален прозорец" #: src/input/command.zig:594 msgid "Open the config file in a new window using $EDITOR or $VISUAL." msgstr "" +"Отваряне на конфигурационния файл в нов прозорец чрез $EDITOR или $VISUAL." #: src/input/command.zig:600 msgid "Reload Config" -msgstr "" +msgstr "Презареждане на конфигурацията" #: src/input/command.zig:601 msgid "Reload the config file." -msgstr "" +msgstr "Презареждане на конфигурационния файл." #: src/input/command.zig:606 msgid "Close Terminal" -msgstr "" +msgstr "Затваряне на терминала" #: src/input/command.zig:607 msgid "Close the current terminal." -msgstr "" +msgstr "Затваряне на текущия терминал." #: src/input/command.zig:614 msgid "Close the current tab." -msgstr "" +msgstr "Затваряне на текущия раздел." #: src/input/command.zig:618 msgid "Close Other Tabs" -msgstr "" +msgstr "Затваряне на другите раздели" #: src/input/command.zig:619 msgid "Close all tabs in this window except the current one." -msgstr "" +msgstr "Затваряне на всички раздели в този прозорец освен текущия." #: src/input/command.zig:623 msgid "Close Tabs to the Right" -msgstr "" +msgstr "Затваряне на разделите отдясно" #: src/input/command.zig:624 msgid "Close all tabs to the right of the current one." -msgstr "" +msgstr "Затваряне на всички раздели вдясно от текущия." #: src/input/command.zig:631 msgid "Close the current window." -msgstr "" +msgstr "Затваряне на текущия прозорец." #: src/input/command.zig:636 msgid "Close All Windows" -msgstr "" +msgstr "Затваряне на всички прозорци" #: src/input/command.zig:637 msgid "Close all windows." -msgstr "" +msgstr "Затваряне на всички прозорци." #: src/input/command.zig:642 msgid "Toggle Maximize" -msgstr "" +msgstr "Превключване на максимизирането" #: src/input/command.zig:643 msgid "Toggle the maximized state of the current window." -msgstr "" +msgstr "Превключване на максимизираното състояние на текущия прозорец." #: src/input/command.zig:648 msgid "Toggle Fullscreen" -msgstr "" +msgstr "Превключване на цял екран" #: src/input/command.zig:649 msgid "Toggle the fullscreen state of the current window." -msgstr "" +msgstr "Превключване на състоянието на цял екран на текущия прозорец." #: src/input/command.zig:654 msgid "Toggle Window Decorations" -msgstr "" +msgstr "Превключване на декорациите на прозореца" #: src/input/command.zig:655 msgid "Toggle the window decorations." -msgstr "" +msgstr "Показване или скриване на декорациите на прозореца." #: src/input/command.zig:660 msgid "Toggle Float on Top" -msgstr "" +msgstr "Превключване на задържането на прозореца най-отгоре" #: src/input/command.zig:661 msgid "Toggle the float on top state of the current window." msgstr "" +"Превключване дали текущият прозорец да остава над всички останали прозорци." #: src/input/command.zig:666 msgid "Toggle Secure Input" -msgstr "" +msgstr "Превключване на защитеното въвеждане" #: src/input/command.zig:667 msgid "Toggle secure input mode." -msgstr "" +msgstr "Превключване на режима за защитено въвеждане." #: src/input/command.zig:672 msgid "Toggle Mouse Reporting" -msgstr "" +msgstr "Превключване на предаването на събития от мишката" #: src/input/command.zig:673 msgid "Toggle whether mouse events are reported to terminal applications." msgstr "" +"Превключване дали събитията от мишката да се предават на приложенията в " +"терминала." #: src/input/command.zig:678 msgid "Toggle Background Opacity" -msgstr "" +msgstr "Превключване на прозрачността на фона" #: src/input/command.zig:679 msgid "Toggle the background opacity of a window that started transparent." msgstr "" +"Превключване между прозрачен и непрозрачен фон за прозорец, стартиран с " +"прозрачен фон." #: src/input/command.zig:684 msgid "Check for Updates" -msgstr "" +msgstr "Проверка за обновления" #: src/input/command.zig:685 msgid "Check for updates to the application." -msgstr "" +msgstr "Проверка за обновления на приложението." #: src/input/command.zig:690 msgid "Undo" -msgstr "" +msgstr "Отмяна" #: src/input/command.zig:691 msgid "Undo the last action." -msgstr "" +msgstr "Отмяна на последното действие." #: src/input/command.zig:696 msgid "Redo" -msgstr "" +msgstr "Повтаряне" #: src/input/command.zig:697 msgid "Redo the last undone action." -msgstr "" +msgstr "Повтаряне на последното отменено действие." #: src/input/command.zig:703 msgid "Quit the application." -msgstr "" +msgstr "Излизане от приложението." #: src/input/command.zig:708 msgid "Ghostty" -msgstr "" +msgstr "Ghostty" #: src/input/command.zig:709 msgid "Put a little Ghostty in your terminal." -msgstr "" +msgstr "Добавете малко Ghostty в терминала си." diff --git a/src/input/paste.zig b/src/input/paste.zig index 16b6266b6..60a4fdde3 100644 --- a/src/input/paste.zig +++ b/src/input/paste.zig @@ -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( diff --git a/src/lib/TinyIo.zig b/src/lib/TinyIo.zig index f89278070..ddabe99fd 100644 --- a/src/lib/TinyIo.zig +++ b/src/lib/TinyIo.zig @@ -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; diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 414f6995b..325f0e138 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -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" }); diff --git a/src/terminal/c/io.zig b/src/terminal/c/io.zig index 30a431b6f..76e7451dc 100644 --- a/src/terminal/c/io.zig +++ b/src/terminal/c/io.zig @@ -44,6 +44,23 @@ pub const Writer = extern struct { } }; +/// C: GhosttyMimeReaderFn +pub const MimeReaderFn = *const fn ( + userdata: ?*anyopaque, + mime: lib.String, + writer: Writer, +) callconv(lib.calling_conv) bool; + +/// C: GhosttyMimeReader +pub const MimeReader = extern struct { + read: ?MimeReaderFn = null, + userdata: ?*anyopaque = null, + + pub fn valid(self: MimeReader) bool { + return self.read != null; + } +}; + /// Adapts a `GhosttyReader` to `std.Io.Reader`. /// /// The adapter must have a stable address while `interface` is in use. Its @@ -418,6 +435,8 @@ test "C reader and writer layouts keep callback first" { try std.testing.expectEqual(@sizeOf(?ReaderFn) + @sizeOf(?*anyopaque), @sizeOf(Reader)); try std.testing.expectEqual(@as(usize, 0), @offsetOf(Writer, "write")); + try std.testing.expectEqual(@as(usize, 0), @offsetOf(MimeReader, "read")); + try std.testing.expectEqual(@sizeOf(?MimeReaderFn), @offsetOf(MimeReader, "userdata")); try std.testing.expectEqual(@sizeOf(?WriterFn), @offsetOf(Writer, "userdata")); try std.testing.expectEqual(@sizeOf(?WriterFn) + @sizeOf(?*anyopaque), @sizeOf(Writer)); } 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..8dfa649cd 100644 --- a/src/terminal/c/paste.zig +++ b/src/terminal/c/paste.zig @@ -1,8 +1,130 @@ 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 io = @import("io.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. The flat C form of the Zig tagged union +/// (terminal.paste.Source); the location rides alongside in +/// GhosttyPaste and only applies to a clipboard paste. +/// +/// C: GhosttyPasteSource +pub const Source = lib.Enum(lib.target, &.{ + "clipboard", + "text", +}); + +/// 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, + mimes: ?[*]const lib.String, + mimes_len: usize, + reader: io.MimeReader, + 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_mimes: []const lib.String = if (req.mimes) |ptr| + ptr[0..req.mimes_len] + else + &.{}; + + // Only a paste with something to read needs a reader. + if (c_mimes.len > 0 and !req.reader.valid()) return .invalid_value; + + // 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 mimes = alloc.alloc([]const u8, c_mimes.len) catch return .out_of_memory; + defer alloc.free(mimes); + for (mimes, c_mimes) |*mime, c_mime| mime.* = c_mime.ptr[0..c_mime.len]; + + const written = wrapper.stream.handler.paste(.{ + .source = switch (req.source) { + .clipboard => .{ .clipboard = req.location }, + .text => .text, + }, + .contents = .{ .reader = .{ + .mimes = mimes, + .read = .{ .ctx = @constCast(req), .read_fn = &readTrampoline }, + } }, + .allow_unsafe = req.allow_unsafe, + }) catch |err| return switch (err) { + error.UnsafePaste => .rejected, + error.NoWritePty => .invalid_value, + error.OutOfMemory => .out_of_memory, + error.ReadFailed, + error.EntropyUnavailable, + error.Canceled, + => .io_error, + }; + if (out_written) |ptr| ptr.* = written; + return .success; +} + +/// The sink handed to the C read callback: a GhosttyWriter over the +/// Zig sink, remembering whether the sink itself failed so that can be +/// told apart from the callback failing to read. +const Sink = struct { + writer: *std.Io.Writer, + write_failed: bool = false, + + fn write( + userdata: ?*anyopaque, + data: [*]const u8, + len: usize, + ) callconv(lib.calling_conv) bool { + const self: *Sink = @ptrCast(@alignCast(userdata.?)); + self.writer.writeAll(data[0..len]) catch { + self.write_failed = true; + return false; + }; + return true; + } +}; + +fn readTrampoline( + ctx: ?*anyopaque, + mime: []const u8, + writer: *std.Io.Writer, +) clipboard.MimeReader.Error!void { + const req: *const Request = @ptrCast(@alignCast(ctx.?)); + var sink: Sink = .{ .writer = writer }; + if (!req.reader.read.?(req.reader.userdata, .init(mime), .{ + .write = &Sink.write, + .userdata = &sink, + })) { + return if (sink.write_failed) error.WriteFailed else error.ReadFailed; + } +} + 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 +249,273 @@ 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]; + } + + /// The representations a test paste serves: the MIME list for the + /// request and the data the read callback streams, in pieces. + const Contents = struct { + mimes: [2]lib.String = undefined, + data: [2][]const u8 = undefined, + len: usize = 0, + reads: [2]usize = @splat(0), + fail: bool = false, + + fn init(entries: []const struct { []const u8, []const u8 }) Contents { + var self: Contents = .{}; + for (entries) |entry| { + self.mimes[self.len] = .init(entry[0]); + self.data[self.len] = entry[1]; + self.len += 1; + } + return self; + } + + fn request(self: *Contents) Request { + return .{ + .location = .standard, + .source = .clipboard, + .mimes = &self.mimes, + .mimes_len = self.len, + .reader = .{ .read = &read, .userdata = self }, + .allow_unsafe = false, + }; + } + + fn read(userdata: ?*anyopaque, mime: lib.String, writer: io.Writer) callconv(lib.calling_conv) bool { + const self: *Contents = @ptrCast(@alignCast(userdata.?)); + // The mime is the exact string from the request's list, so + // identifying it by pointer works. + const index: usize = for (self.mimes[0..self.len], 0..) |m, i| { + if (m.ptr == mime.ptr and m.len == mime.len) break i; + } else return false; + self.reads[index] += 1; + if (self.fail) return false; + const data = self.data[index]; + var offset: usize = 0; + while (offset < data.len) { + const n = @min(3, data.len - offset); + if (!writer.write.?(writer.userdata, data[offset..].ptr, n)) return false; + offset += n; + } + return true; + } + }; +}; + +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); + try testing.expectEqual(Result.success, terminal_c.set(t, .write_pty, @ptrCast(&S.writePty))); + + var written: bool = true; + var contents: S.Contents = .init(&.{.{ "text/plain", "hello" }}); + const req = contents.request(); + 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)); + + // MIME types without a reader are rejected; none at all is fine. + var unreadable = req; + unreadable.reader.read = null; + try testing.expectEqual(Result.invalid_value, terminal_paste(t, &unreadable, &written)); + unreadable.mimes = null; + unreadable.mimes_len = 0; + try testing.expectEqual(Result.success, terminal_paste(t, &unreadable, &written)); + try testing.expect(!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 contents: S.Contents = .init(&.{.{ "text/plain", "hello" }}); + const req = contents.request(); + 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. The text is read + // once, the image never. + var contents: S.Contents = .init(&.{ + .{ "image/png", "\x89PNG" }, + .{ "text/plain", "hel\x1blo" }, + }); + const req = contents.request(); + 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); + try testing.expectEqual(@as(usize, 0), contents.reads[0]); + try testing.expectEqual(@as(usize, 1), contents.reads[1]); + + // Unsafe is refused with nothing written, then allowed. + S.reset(); + var written: bool = false; + var unsafe_contents: S.Contents = .init(&.{.{ "text/plain", "rm -rf /\n" }}); + var unsafe = unsafe_contents.request(); + try testing.expectEqual(Result.rejected, terminal_paste(t, &unsafe, &written)); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expect(!written); + try testing.expectEqual(@as(usize, 1), unsafe_contents.reads[0]); + + 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()); + try testing.expectEqual(@as(usize, 2), unsafe_contents.reads[0]); + + // 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 and reads nothing. + S.reset(); + var image: S.Contents = .init(&.{.{ "image/png", "\x89PNG" }}); + const image_req = image.request(); + try testing.expectEqual(Result.success, terminal_paste(t, &image_req, &written)); + try testing.expect(!written); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expectEqual(@as(usize, 0), image.reads[0]); + + // A failing reader is an I/O error. + S.reset(); + contents.fail = true; + try testing.expectEqual(Result.io_error, terminal_paste(t, &req, &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; + var contents: S.Contents = .init(&.{ + .{ "text/plain", "secret" }, + .{ "image/png", "\x89PNG" }, + }); + var req = contents.request(); + req.location = .primary; + 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 no data + // is read, let alone written. + S.reset(); + contents.reads = @splat(0); + 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); + try testing.expectEqual(@as(usize, 0), contents.reads[0]); + try testing.expectEqual(@as(usize, 0), contents.reads[1]); + + // 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/sys.zig b/src/terminal/c/sys.zig index 319f17985..40e7e1c84 100644 --- a/src/terminal/c/sys.zig +++ b/src/terminal/c/sys.zig @@ -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; } diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 6c35d6e06..34f605552 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -353,7 +353,7 @@ const Effects = struct { }; }; - fn writePtyTrampoline(handler: *Handler, data: [:0]const u8) void { + fn writePtyTrampoline(handler: *Handler, data: []const u8) void { const wrapper = TerminalWrapper.fromHandler(handler); const func = wrapper.effects.write_pty orelse return; func(@ptrCast(wrapper), wrapper.effects.userdata, data.ptr, data.len); diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index 0367a35b6..b7998bb6c 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -37,6 +37,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"); @@ -190,8 +191,10 @@ const type_decls = [_]TypeDecl{ .initStruct("GhosttyFormatterTerminalOptions", formatter.TerminalOptions), .initStruct("GhosttyGridRef", grid_ref.CGridRef), .initStruct("GhosttyKittyGraphicsPlacementRenderInfo", kitty_graphics.PlacementRenderInfo), + .initStruct("GhosttyMimeReader", io.MimeReader), .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.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_"), diff --git a/src/terminal/clipboard.zig b/src/terminal/clipboard.zig index 836df373e..1c2a26be3 100644 --- a/src/terminal/clipboard.zig +++ b/src/terminal/clipboard.zig @@ -32,6 +32,43 @@ pub const Content = struct { data: []const u8, }; +/// Requests content of a specific mime-type. For now this is used for +/// on-demand clipboard access since content can be large (particularly +/// non-text content), but it is generic so that this could handle other +/// mime-typed sources in the future like maybe drag-and-drop. +/// +/// C: GhosttyMimeReader +pub const MimeReader = struct { + /// Passed through to `read_fn`. + ctx: ?*anyopaque = null, + + /// Write all the data of the representation named by `mime` to + /// `sink`, in as many writes as is convenient. The mime and sink + /// are borrowed, only valid for the duration of the call, and + /// nothing written to the sink is retained, so the data may be + /// borrowed from anywhere. Return error.ReadFailed if the data + /// can't be produced and propagate error.WriteFailed from the + /// sink. + read_fn: *const fn ( + ctx: ?*anyopaque, + mime: []const u8, + sink: *std.Io.Writer, + ) Error!void, + + pub const Error = error{ + /// The data could not be read. + ReadFailed, + } || std.Io.Writer.Error; + + pub fn read( + self: MimeReader, + mime: []const u8, + sink: *std.Io.Writer, + ) Error!void { + return self.read_fn(self.ctx, mime, sink); + } +}; + /// One atomic clipboard write. /// /// Contents are borrowed and only valid for the duration of a clipboard write diff --git a/src/terminal/kitty/clipboard.zig b/src/terminal/kitty/clipboard.zig index 18271c704..261cb7489 100644 --- a/src/terminal/kitty/clipboard.zig +++ b/src/terminal/kitty/clipboard.zig @@ -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; diff --git a/src/terminal/kitty/clipboard_grants.zig b/src/terminal/kitty/clipboard_grants.zig index 599e27e98..59b9bef74 100644 --- a/src/terminal/kitty/clipboard_grants.zig +++ b/src/terminal/kitty/clipboard_grants.zig @@ -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); +} diff --git a/src/terminal/kitty/clipboard_response.zig b/src/terminal/kitty/clipboard_response.zig index c7f01c165..50bbbc677 100644 --- a/src/terminal/kitty/clipboard_response.zig +++ b/src/terminal/kitty/clipboard_response.zig @@ -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(), + ); +} diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 55438dde4..5d7c73871 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -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,11 @@ 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 PasteContents = paste.Contents; +pub const PasteSource = paste.Source; +pub const MimeReader = clipboard.MimeReader; +pub const PasteError = stream_terminal.Handler.PasteError; pub const Cursor = Screen.Cursor; pub const CursorStyle = Screen.CursorStyle; pub const CursorStyleReq = ansi.CursorStyle; diff --git a/src/terminal/modes.zig b/src/terminal/modes.zig index baacd0089..b1330db66 100644 --- a/src/terminal/modes.zig +++ b/src/terminal/modes.zig @@ -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 { diff --git a/src/terminal/paste.zig b/src/terminal/paste.zig new file mode 100644 index 000000000..05e088ee2 --- /dev/null +++ b/src/terminal/paste.zig @@ -0,0 +1,247 @@ +//! 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. No data is read. +//! * 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 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, which is why only they carry a location: it's meaningless +/// for text insertion. +/// +/// C: GhosttyPasteSource, flattened next to the location since C has +/// no tagged unions. +pub const Source = union(enum) { + /// The user pasted from a clipboard: keybind, menu, middle click. + /// The payload is the clipboard the contents came from. + clipboard: clipboard.Location, + + /// Text inserted some other way: IME commit, drag and drop, + /// scripted input. + text, +}; + +/// A paste of clipboard contents into the terminal. What actually gets +/// written depends on terminal state; see `paste`. +pub const Request = struct { + /// Why this paste happened, and for a clipboard paste, from which + /// clipboard. Only a user-initiated clipboard paste may become a + /// paste event; text insertion always writes text. + source: Source = .{ .clipboard = .standard }, + + /// The representations available. Borrowed only during the + /// duration of the paste function call. + contents: Contents, + + /// Write data that could inject commands (see `paste`). The usual + /// flow is to call with false, confirm with the user on + /// error.UnsafePaste, and call again with true. + allow_unsafe: bool = false, +}; + +/// The representations available for a paste, in the embedder's +/// preferred order. +pub const Contents = union(enum) { + /// Every representation already in memory. For text the embedder + /// holds anyway (an IME commit, dropped text) or small clipboards. + memory: []const clipboard.Content, + + /// Representations read on demand, so nothing is loaded that isn't + /// pasted. This is the form for a real clipboard, whose non-text + /// items may be huge. + reader: Reader, + + /// The on-demand form: the MIME types available plus the reader + /// that produces the data of any one of them. + pub const Reader = struct { + /// The MIME types available, in preferred order. + mimes: []const []const u8, + + /// Produces the data of any entry of `mimes`, which is passed + /// through to it as is. A paste reads at most once: the text + /// representation being pasted, never anything else and never + /// anything for a paste event. There is no requirement across + /// paste calls, so a source that changes between an unsafe + /// refusal and the embedder's confirmed retry simply pastes + /// its current contents. + read: clipboard.MimeReader, + }; + + /// The number of representations. + pub fn len(self: Contents) usize { + return switch (self) { + .memory => |v| v.len, + .reader => |v| v.mimes.len, + }; + } + + /// The MIME type of representation `index`. + pub fn mime(self: Contents, index: usize) []const u8 { + return switch (self) { + .memory => |v| v[index].mime, + .reader => |v| v.mimes[index], + }; + } +}; + +/// 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, + + /// Allocator for transient state: the buffered read and a paste + /// event's grant. Must be the allocator `kitty_clipboard.grants` + /// is freed with. + alloc: Allocator, + + /// Receives the encoded text in chunks, or the event. Must be + /// buffered (the encoder works in its buffer) and is not flushed + /// by `paste`; the caller flushes once it returns. + writer: *std.Io.Writer, + + /// Kitty clipboard protocol session state, or null if the embedder + /// does not serve Kitty clipboard reads (`clipboard.Read`). A + /// paste event is that protocol: it is only useful if the + /// program's follow-up read can be answered, since otherwise the + /// read would be refused and the user's paste would vanish. With + /// null, `paste` always writes text. + kitty_clipboard: ?KittyClipboard, + + pub const KittyClipboard = struct { + /// 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, + }; +}; + +pub const Error = Allocator.Error || + std.Io.RandomSecureError || + clipboard.MimeReader.Error || + error{ + /// The data could inject commands and allow_unsafe was false. + 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. +/// +/// The contents are read at most once per call and buffered whole, so +/// the source needs no stability across reads. Nothing reaches the +/// writer until the read completed and the text passed the rule: +/// every error writes nothing, and a failed event records no grant. +pub fn paste(ctx: Context, req: Request) Error!bool { + // If the source is a clipboard and mode 5522 is enabled and + // the caller can handle kitty events, then do a kitty event. + if (req.source == .clipboard and + ctx.terminal.modes.get(.kitty_paste_events)) + { + if (ctx.kitty_clipboard) |kitty| { + try pasteKittyEvent(ctx, kitty, req); + return true; + } + } + + // For non-Kitty paste events we can only accept text content. + const index: usize = for (0..req.contents.len()) |i| { + if (clipboard.isTextMime(req.contents.mime(i))) break i; + } else return false; + + const opts: input_paste.Options = .fromTerminal(ctx.terminal); + + // In-memory contents are used directly to avoid a double copy but + // reader-based contents are read into memory so we can do the unsafe + // scan. + var aw: std.Io.Writer.Allocating = .init(ctx.alloc); + defer aw.deinit(); + const text: []const u8 = switch (req.contents) { + .memory => |v| v[index].data, + .reader => |v| text: { + v.read.read(v.mimes[index], &aw.writer) catch |err| switch (err) { + // An allocating writer only fails to allocate. + error.WriteFailed => return error.OutOfMemory, + error.ReadFailed => |e| return e, + }; + break :text aw.writer.buffered(); + }, + }; + if (text.len == 0) return false; + if (!req.allow_unsafe and !input_paste.isSafeWith( + text, + opts, + )) return error.UnsafePaste; + + // The text is copied exactly once per chunk, 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, + kitty: Context.KittyClipboard, + req: Request, +) Error!void { + const otp = try kitty_clipboard.generateOtp(kitty.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; + const mimes_len = @min(req.contents.len(), mimes_buf.len); + for (mimes_buf[0..mimes_len], 0..) |*mime, i| mime.* = req.contents.mime(i); + + // The grant is recorded before the event can reach the program, + // and revoked if the event fails to be written so a failure never + // leaves a grant for an event that was never sent. Using a + // one-time grant consumes it, which is the revocation. + try kitty.grants.grant(ctx.alloc, &otp, .read, true); + errdefer _ = kitty.grants.use(ctx.alloc, &otp, .read); + + try (kitty_clipboard.PasteEvent{ + // The protocol only distinguishes the clipboard from the + // primary selection, so both non-standard locations report as + // primary. Only a clipboard paste gets here; see `paste`. + .primary = req.source.clipboard != .standard, + .pw = &otp, + .available = mimes_buf[0..mimes_len], + }).encode(ctx.writer); +} + +test { + // The behavior is tested end to end through the stream handler + // (stream_terminal.zig), which is the primary caller. + std.testing.refAllDecls(@This()); +} diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index bcf8690bc..59dc25e56 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -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"); @@ -107,7 +108,7 @@ pub const Handler = struct { /// e.g. in response to a DECRQM query. The data is only valid /// during the lifetime of the call so callers must copy it /// if it needs to be stored or used after the call returns. - write_pty: ?*const fn (*Handler, [:0]const u8) void, + write_pty: ?*const fn (*Handler, []const u8) void, /// Called when the bell is rung (BEL). bell: ?*const fn (*Handler) void, @@ -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,63 @@ 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, + + /// The contents reader failed. Nothing was written. + ReadFailed, + + /// No write_pty effect is set, so nothing can be written. + NoWritePty, + }; + + /// The size of the chunks a paste streams to write_pty in. + pub const paste_chunk_size = 4096; + + /// 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. + /// + /// The output streams to write_pty in chunks of `paste_chunk_size`. + /// The contents are read at most once and only the pasted text + /// representation is ever read, buffered whole while it is checked + /// and encoded; see `terminal.paste`. + pub fn paste(self: *Handler, req: Paste) PasteError!bool { + if (self.effects.write_pty == null) return error.NoWritePty; + + var buf: [paste_chunk_size]u8 = undefined; + var pty: PtyWriter = .init(self, &buf); + // Delivered on error too: a partial paste has its frame closed + // and the program must see that. + defer pty.writer.flush() catch unreachable; + + return paste_pkg.paste(.{ + .terminal = self.terminal, + .alloc = self.terminal.gpa(), + // Paste events need the program's follow-up Kitty + // clipboard read served. + .kitty_clipboard = if (self.effects.clipboard_read != null) .{ + .grants = &self.kitty_clipboard_grants, + .io = self.terminal.io(), + } else null, + .writer = &pty.writer, + }, req) catch |err| switch (err) { + // The pty writer never fails. + error.WriteFailed => unreachable, + error.ReadFailed, + error.OutOfMemory, + error.UnsafePaste, + error.EntropyUnavailable, + error.Canceled, + => |e| e, + }; + } + pub fn vt( self: *Handler, comptime action: Action.Tag, @@ -403,6 +466,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 }); }, @@ -469,7 +536,7 @@ pub const Handler = struct { } } - inline fn writePty(self: *Handler, data: [:0]const u8) void { + inline fn writePty(self: *Handler, data: []const u8) void { const func = self.effects.write_pty orelse return; func(self, data); } @@ -1685,6 +1752,51 @@ pub const Handler = struct { } }; +/// A writer that delivers everything through the write_pty effect: +/// the buffer as it fills, and data that doesn't fit it directly. +/// Never fails, since the effect can't. +const PtyWriter = struct { + handler: *Handler, + writer: std.Io.Writer, + + fn init(handler: *Handler, buffer: []u8) PtyWriter { + return .{ + .handler = handler, + .writer = .{ + .vtable = &.{ .drain = drain }, + .buffer = buffer, + }, + }; + } + + fn drain( + w: *std.Io.Writer, + data: []const []const u8, + splat: usize, + ) std.Io.Writer.Error!usize { + const self: *PtyWriter = @alignCast(@fieldParentPtr("writer", w)); + + // Buffered bytes go first to keep the order. + if (w.end > 0) { + self.handler.writePty(w.buffer[0..w.end]); + w.end = 0; + } + + var consumed: usize = 0; + for (data[0 .. data.len - 1]) |slice| { + if (slice.len > 0) self.handler.writePty(slice); + consumed += slice.len; + } + + const pattern = data[data.len - 1]; + for (0..splat) |_| { + if (pattern.len > 0) self.handler.writePty(pattern); + consumed += pattern.len; + } + return consumed; + } +}; + test "resize clears synchronized output on unchanged cell dimensions" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); @@ -1757,7 +1869,7 @@ test "resize reports mode 2048 geometry" { var response: [128]u8 = undefined; var response_len: usize = 0; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { @memcpy(response[0..data.len], data); response_len = data.len; } @@ -1786,7 +1898,7 @@ test "resize suppresses mode 2048 reports" { const S = struct { var calls: usize = 0; - fn writePty(_: *Handler, _: [:0]const u8) void { + fn writePty(_: *Handler, _: []const u8) void { calls += 1; } }; @@ -1842,7 +1954,7 @@ test "resize failure preserves terminal state and does not write" { const S = struct { var called: bool = false; - fn writePty(_: *Handler, _: [:0]const u8) void { + fn writePty(_: *Handler, _: []const u8) void { called = true; } }; @@ -1884,7 +1996,7 @@ test "resize effects do not change canonical terminal state" { defer readonly.deinit(testing.allocator); const S = struct { - fn writePty(_: *Handler, _: [:0]const u8) void {} + fn writePty(_: *Handler, _: []const u8) void {} }; var authoritative_handler: Handler = .init(&authoritative); authoritative_handler.effects.write_pty = &S.writePty; @@ -2156,7 +2268,7 @@ test "DECRQSS responses" { calls = 0; } - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { @memcpy(response[0..data.len], data); response_len = data.len; calls += 1; @@ -2228,7 +2340,7 @@ test "XTGETTCAP responses" { calls = 0; } - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { @memcpy(response[0..data.len], data); response_len = data.len; calls += 1; @@ -2310,7 +2422,7 @@ test "XTGETTCAP TN responses" { calls = 0; } - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { @memcpy(response[0..data.len], data); response_len = data.len; calls += 1; @@ -2440,7 +2552,7 @@ test "glyph protocol APC with write_pty callback" { const S = struct { var last_response: ?[:0]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (last_response) |old| testing.allocator.free(old); last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM"); } @@ -2619,7 +2731,7 @@ test "OSC color query responses" { last_response = null; } - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { reset(); last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM"); } @@ -2770,7 +2882,7 @@ test "kitty color protocol query responses" { last_response = null; } - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { reset(); last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM"); } @@ -3245,7 +3357,7 @@ test "clipboard_read effect callback" { }} } }; var reply_twice: bool = false; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { written.appendSlice(testing.allocator, data) catch @panic("OOM"); } @@ -3420,7 +3532,7 @@ const KittyClipboardCapture = struct { last_read_can_remember = false; } - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { @memcpy(responses[responses_len..][0..data.len], data); responses_len += data.len; } @@ -4048,7 +4160,7 @@ test "request mode DECRQM with write_pty callback" { { const S = struct { var last_response: ?[:0]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (last_response) |old| testing.allocator.free(old); last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM"); } @@ -4172,7 +4284,7 @@ test "kitty_keyboard_query" { const S = struct { var written: ?[]const u8 = null; var written_buf: [64]u8 = undefined; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { std.debug.assert(data.len <= written_buf.len); @memcpy(written_buf[0..data.len], data); written = written_buf[0..data.len]; @@ -4204,7 +4316,7 @@ test "xtversion default" { const S = struct { var written: ?[]const u8 = null; var written_buf: [64]u8 = undefined; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { std.debug.assert(data.len <= written_buf.len); @memcpy(written_buf[0..data.len], data); written = written_buf[0..data.len]; @@ -4230,7 +4342,7 @@ test "xtversion with effect" { const S = struct { var written: ?[]const u8 = null; var written_buf: [64]u8 = undefined; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { std.debug.assert(data.len <= written_buf.len); @memcpy(written_buf[0..data.len], data); written = written_buf[0..data.len]; @@ -4259,7 +4371,7 @@ test "xtversion with empty string effect" { const S = struct { var written: ?[]const u8 = null; var written_buf: [64]u8 = undefined; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { std.debug.assert(data.len <= written_buf.len); @memcpy(written_buf[0..data.len], data); written = written_buf[0..data.len]; @@ -4288,7 +4400,7 @@ test "size report csi_14_t with effect" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } fn getSize(_: *Handler) ?size_report.Size { @@ -4319,7 +4431,7 @@ test "mode 2048 enable reports current geometry and disable is silent" { var response_len: usize = 0; var calls: usize = 0; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { @memcpy(response[0..data.len], data); response_len = data.len; calls += 1; @@ -4354,7 +4466,7 @@ test "mode 2048 enable tolerates missing effects" { const S = struct { var calls: usize = 0; - fn writePty(_: *Handler, _: [:0]const u8) void { + fn writePty(_: *Handler, _: []const u8) void { calls += 1; } @@ -4407,7 +4519,7 @@ test "size report csi_16_t with effect" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } fn getSize(_: *Handler) ?size_report.Size { @@ -4435,7 +4547,7 @@ test "size report csi_18_t with effect" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } fn getSize(_: *Handler) ?size_report.Size { @@ -4463,7 +4575,7 @@ test "size report no effect callback" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } }; @@ -4486,7 +4598,7 @@ test "size report csi_21_t title disabled by default" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } }; @@ -4512,7 +4624,7 @@ test "size report csi_21_t title enabled" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } }; @@ -4540,7 +4652,7 @@ test "enquiry no effect" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } }; @@ -4563,7 +4675,7 @@ test "enquiry with effect" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } fn enquiry(_: *Handler) []const u8 { @@ -4590,7 +4702,7 @@ test "enquiry with empty response" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } fn enquiry(_: *Handler) []const u8 { @@ -4617,7 +4729,7 @@ test "device status: operating status" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (written) |old| testing.allocator.free(old); written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } @@ -4642,7 +4754,7 @@ test "device status: cursor position" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (written) |old| testing.allocator.free(old); written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } @@ -4672,7 +4784,7 @@ test "device status: cursor position with origin mode" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (written) |old| testing.allocator.free(old); written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } @@ -4704,7 +4816,7 @@ test "device status: color scheme dark" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (written) |old| testing.allocator.free(old); written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } @@ -4733,7 +4845,7 @@ test "device status: color scheme light" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (written) |old| testing.allocator.free(old); written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } @@ -4762,7 +4874,7 @@ test "device status: color scheme without callback" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (written) |old| testing.allocator.free(old); written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } @@ -4789,7 +4901,7 @@ test "visibility reports" { var written: ?[]const u8 = null; var count: usize = 0; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (written) |old| testing.allocator.free(old); written = testing.allocator.dupe(u8, data) catch @panic("OOM"); count += 1; @@ -4858,7 +4970,7 @@ test "device attributes: primary DA" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (written) |old| testing.allocator.free(old); written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } @@ -4886,7 +4998,7 @@ test "device attributes: secondary DA" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (written) |old| testing.allocator.free(old); written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } @@ -4914,7 +5026,7 @@ test "device attributes: tertiary DA" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (written) |old| testing.allocator.free(old); written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } @@ -4961,7 +5073,7 @@ test "device attributes: custom response" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (written) |old| testing.allocator.free(old); written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } @@ -5003,7 +5115,7 @@ test "kitty graphics APC response" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (written) |old| testing.allocator.free(old); written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } @@ -5060,7 +5172,7 @@ test "continuation reconstructs standard stream without duplicate effects" { title_count += 1; } - fn writePty(_: *Handler, _: [:0]const u8) void { + fn writePty(_: *Handler, _: []const u8) void { write_count += 1; } @@ -5197,7 +5309,7 @@ test "continuation reconstructs standard stream without duplicate effects" { test "kitty dnd: query response" { const S = struct { var pty: std.ArrayListUnmanaged(u8) = .empty; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { pty.appendSlice(testing.allocator, data) catch unreachable; } }; @@ -5219,7 +5331,7 @@ test "kitty dnd: query response" { test "kitty dnd: register, drop, and serve data" { const S = struct { var pty: std.ArrayListUnmanaged(u8) = .empty; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { pty.appendSlice(testing.allocator, data) catch unreachable; } }; @@ -5295,7 +5407,7 @@ test "kitty dnd: state updates work without write_pty effect" { test "kitty dnd: registration survives terminal reset" { const S = struct { var pty: std.ArrayListUnmanaged(u8) = .empty; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { pty.appendSlice(testing.allocator, data) catch unreachable; } }; @@ -5394,3 +5506,595 @@ 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: []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 = .{ .memory = &.{.{ .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 = .{ .memory = &.{.{ .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 = .{ .memory = &.{ + .{ .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 = .{ .memory = &.{.{ .mime = "text/plain", .data = "rm -rf /\n" }} }, + })); + try testing.expectEqual(@as(usize, 0), S.write_count); + + try testing.expect(try handler.paste(.{ + .contents = .{ .memory = &.{.{ .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 = .{ .memory = &.{.{ .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 = .{ .memory = &.{.{ .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 = .{ .memory = &.{.{ .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 = .{ .memory = &.{.{ .mime = "image/png", .data = "\x89PNG" }} }, + })); + try testing.expect(!try handler.paste(.{ + .contents = .{ .memory = &.{} }, + })); + try testing.expect(!try handler.paste(.{ + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "" }} }, + })); + try testing.expectEqual(@as(usize, 0), S.write_count); +} + +test "paste: large text streams to the pty in chunks" { + 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); + + // Two full chunks and a partial one with the frame, never the + // whole thing at once. + const data = "x" ** 10_000; + try testing.expect(try handler.paste(.{ + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = data }} }, + })); + const total = data.len + "\x1b[200~\x1b[201~".len; + try testing.expectEqual( + @as(usize, (total + Handler.paste_chunk_size - 1) / Handler.paste_chunk_size), + S.write_count, + ); + try testing.expectEqual(total, 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~")); +} + +/// A paste contents reader for the tests: serves fixed data per MIME +/// type in pieces, counting the reads of each representation. +const PasteReader = struct { + mimes: []const []const u8, + data: []const []const u8, + piece: usize = 3, + reads: [4]usize = @splat(0), + /// Fail after this many bytes of a read. + fail_after: ?usize = null, + + fn contents(self: *PasteReader) paste_pkg.Contents { + return .{ .reader = .{ + .mimes = self.mimes, + .read = .{ .ctx = self, .read_fn = &read }, + } }; + } + + fn read(ctx: ?*anyopaque, mime: []const u8, sink: *std.Io.Writer) clipboard.MimeReader.Error!void { + const self: *PasteReader = @ptrCast(@alignCast(ctx.?)); + const index: usize = for (self.mimes, 0..) |m, i| { + if (std.mem.eql(u8, m, mime)) break i; + } else return error.ReadFailed; + self.reads[index] += 1; + const data = self.data[index]; + var offset: usize = 0; + while (offset < data.len) { + if (self.fail_after) |limit| if (offset >= limit) return error.ReadFailed; + const n = @min(self.piece, data.len - offset); + try sink.writeAll(data[offset..][0..n]); + offset += n; + } + } +}; + +test "paste: reader contents are read on demand" { + 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; + + // Unsafe text is refused from one read with nothing written; the + // image is never read. + var reader: PasteReader = .{ + .mimes = &.{ "image/png", "text/plain" }, + .data = &.{ "\x89PNG", "echo hi\nrm -rf /\n" }, + }; + try testing.expectError(error.UnsafePaste, handler.paste(.{ + .contents = reader.contents(), + })); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expectEqual(@as(usize, 0), reader.reads[0]); + try testing.expectEqual(@as(usize, 1), reader.reads[1]); + + // Allowed, the text is read once, encoded. + reader.reads = @splat(0); + try testing.expect(try handler.paste(.{ + .contents = reader.contents(), + .allow_unsafe = true, + })); + try testing.expectEqualStrings("echo hi\rrm -rf /\r", S.written.items); + try testing.expectEqual(@as(usize, 0), reader.reads[0]); + try testing.expectEqual(@as(usize, 1), reader.reads[1]); + + // Safe text is read once too: buffered for the check, then written. + S.reset(); + reader = .{ + .mimes = &.{"text/plain"}, + .data = &.{"hello world"}, + }; + try testing.expect(try handler.paste(.{ .contents = reader.contents() })); + try testing.expectEqualStrings("hello world", S.written.items); + try testing.expectEqual(@as(usize, 1), reader.reads[0]); + + // Empty text is nothing to paste, found on the one read. + S.reset(); + reader = .{ + .mimes = &.{"text/plain"}, + .data = &.{""}, + }; + try testing.expect(!try handler.paste(.{ .contents = reader.contents() })); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expectEqual(@as(usize, 1), reader.reads[0]); + + // A paste event lists the types and reads nothing at all. + S.reset(); + t.modes.set(.kitty_paste_events, true); + reader = .{ + .mimes = &.{ "text/plain", "image/png" }, + .data = &.{ "secret", "\x89PNG" }, + }; + try testing.expect(try handler.paste(.{ .contents = reader.contents() })); + try testing.expect(std.mem.indexOf(u8, S.written.items, ";dGV4dC9wbGFpbiBpbWFnZS9wbmcK\x1b\\") != null); + try testing.expect(std.mem.indexOf(u8, S.written.items, "secret") == null); + try testing.expectEqual(@as(usize, 0), reader.reads[0]); + try testing.expectEqual(@as(usize, 0), reader.reads[1]); +} + +test "paste: reader failure 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; + t.modes.set(.bracketed_paste, true); + + // The read is buffered whole before anything is written, so a + // mid-read failure discards the buffer, checked or not. + var reader: PasteReader = .{ + .mimes = &.{"text/plain"}, + .data = &.{"hello world"}, + .fail_after = 6, + }; + try testing.expectError(error.ReadFailed, handler.paste(.{ + .contents = reader.contents(), + })); + try testing.expectEqual(@as(usize, 0), S.write_count); + + try testing.expectError(error.ReadFailed, handler.paste(.{ + .contents = reader.contents(), + .allow_unsafe = true, + })); + try testing.expectEqual(@as(usize, 0), S.write_count); +} + +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 = .{ .memory = &.{ + .{ .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 = .{ .memory = &.{.{ .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(.{ + .source = .{ .clipboard = location }, + .contents = .{ .memory = &.{.{ .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(.{ + .source = .{ .clipboard = .standard }, + .contents = .{ .memory = &.{.{ .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 = .{ .memory = &.{.{ .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 = .{ .memory = &.{.{ .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 = .{ .memory = &.{.{ .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 = .{ .memory = &.{.{ .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); +} diff --git a/src/terminal/sys.zig b/src/terminal/sys.zig index f0c64da50..3f6b5017d 100644 --- a/src/terminal/sys.zig +++ b/src/terminal/sys.zig @@ -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)); +}