From da27e6c9082705f62a9ebbeae1753e17d2a88088 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 23 Aug 2026 20:30:00 -0700 Subject: [PATCH] libghostty: paste reads clipboard contents on demand, streams to pty Follow up to #13978 `ghostty_terminal_paste` no longer takes the clipboard's data up front. The request now carries only the list of available MIME types plus a a callback that writes one representation's bytes into a `GhosttyWriter`. Previously an embedder had to load every representation for every MIME type into memory before pasting. For a clipboard holding a large image or video next to some text that could be hundreds of megabytes that were never used. I also took care to make sure that the data is only read once, to avoid any time-of-check/time-of-use (TOCTOU) issues. There is only one case where data might be fully buffered in memory now: unsafe text data that needs to be checked. This is true for how Ghostty GUI works today too. --- example/c-vt-paste/README.md | 6 +- example/c-vt-paste/src/main.c | 58 +++-- include/ghostty/vt/io.h | 44 ++++ include/ghostty/vt/paste.h | 88 ++++--- src/terminal/c/io.zig | 19 ++ src/terminal/c/paste.zig | 236 +++++++++++++------ src/terminal/c/terminal.zig | 2 +- src/terminal/c/types.zig | 4 +- src/terminal/clipboard.zig | 37 +++ src/terminal/main.zig | 2 + src/terminal/paste.zig | 244 +++++++++++-------- src/terminal/stream_terminal.zig | 386 +++++++++++++++++++++++-------- 12 files changed, 819 insertions(+), 307 deletions(-) diff --git a/example/c-vt-paste/README.md b/example/c-vt-paste/README.md index 4db5297a8..1e1e6fa14 100644 --- a/example/c-vt-paste/README.md +++ b/example/c-vt-paste/README.md @@ -4,8 +4,10 @@ This contains a simple example of how to paste into a `ghostty-vt` terminal with `ghostty_terminal_paste`: plain and bracketed (mode 2004) text pastes, the unsafe-paste confirmation flow, and Kitty clipboard protocol paste events (mode 5522) including the program's follow-up -clipboard read. It also shows the terminal-free building blocks for -checking paste safety and encoding paste data. +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 64799f10b..c83020eed 100644 --- a/example/c-vt-paste/src/main.c +++ b/example/c-vt-paste/src/main.c @@ -85,23 +85,52 @@ static bool confirm_with_user(void) { } //! [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) { - GhosttyClipboardContent contents[] = { + clipboard_t clipboard = {.text = text}; + GhosttyString mimes[] = { // The first text representation is what a text paste writes. - {.mime = GS("text/plain"), - .data = {.ptr = (const uint8_t*)text, .len = strlen(text)}}, - // Listed on a paste event, never written, so no data is needed. - {.mime = GS("image/png"), .data = {.ptr = NULL, .len = 0}}, + 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, - .contents = contents, - .contents_len = sizeof(contents) / sizeof(contents[0]), + .mimes = mimes, + .mimes_len = sizeof(mimes) / sizeof(mimes[0]), + .reader = {.read = read_clipboard, .userdata = &clipboard}, .allow_unsafe = false, }; @@ -120,7 +149,8 @@ static void paste_clipboard(GhosttyTerminal terminal, const char* text) { } // Whether the pty got the text or a paste event depends on the - // terminal's modes; either way it went through write_pty above. + // 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] @@ -211,17 +241,15 @@ int main() { // Text inserted by other means (IME, drag and drop) is never an event. printf("IME text with mode 5522 enabled:\n"); { - const char* text = "committed"; - GhosttyClipboardContent content = { - .mime = GS("text/plain"), - .data = {.ptr = (const uint8_t*)text, .len = strlen(text)}, - }; + 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, - .contents = &content, - .contents_len = 1, + .mimes = &mime, + .mimes_len = 1, + .reader = {.read = read_clipboard, .userdata = &clipboard}, .allow_unsafe = true, }; bool written = false; 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 9c6774429..4ba21a9ad 100644 --- a/include/ghostty/vt/paste.h +++ b/include/ghostty/vt/paste.h @@ -16,9 +16,10 @@ * * What a paste writes to the pty depends on the terminal's state, so * the recommended way to paste is ghostty_terminal_paste(). The embedder - * hands over what the clipboard holds as MIME-typed contents (just - * `text/plain` for an ordinary paste) and where it came from, and the - * terminal decides how its current modes apply: + * 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: * * - If Kitty clipboard protocol paste events (mode 5522, * GHOSTTY_MODE_PASTE_EVENTS) are enabled, the paste was user-initiated @@ -27,20 +28,29 @@ * 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. + * 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 unless GhosttyPaste::allow_unsafe is set. The usual - * flow is to call once, confirm with the user on GHOSTTY_REJECTED, and - * call again with `allow_unsafe` set. - * - * Output is delivered through the write_pty callback - * (GHOSTTY_TERMINAL_OPT_WRITE_PTY) in a single call. + * 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 * @@ -66,6 +76,7 @@ #include #include #include +#include #include #ifdef __cplusplus @@ -73,7 +84,7 @@ extern "C" { #endif /** - * Why a paste happened. + * Why a paste happened. */ typedef enum GHOSTTY_ENUM_TYPED { /** The user pasted from a clipboard: keybind, menu, middle click. */ @@ -93,8 +104,9 @@ typedef enum GHOSTTY_ENUM_TYPED { * A paste of clipboard contents into the terminal. * * This is a sized struct; set `size` to `sizeof(GhosttyPaste)`. The - * contents array and the strings it points to are borrowed only for the - * duration of the ghostty_terminal_paste() call. + * 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. */ @@ -112,16 +124,30 @@ typedef struct { GhosttyPasteSource source; /** - * Borrowed array of the representations available, in preferred - * order. A text paste writes the first entry with a text MIME type - * such as "text/plain" and ignores the rest. A paste event lists every - * entry's MIME type and never reads data, so non-text entries may have - * empty data. May be NULL when contents_len is zero. + * 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 GhosttyClipboardContent* contents; + const GhosttyString* mimes; - /** Number of entries in contents. */ - size_t contents_len; + /** 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 @@ -135,8 +161,12 @@ typedef struct { * clipboard protocol paste event if mode 5522 is enabled and a * clipboard_read callback is installed, otherwise the text framed per * mode 2004. See the group documentation for the full behavior. Output - * goes through the write_pty callback in a single call. The viewport is - * not scrolled; that is up to the embedder, as for key input. + * 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 @@ -147,12 +177,12 @@ typedef struct { * @return GHOSTTY_SUCCESS on success (see @p out_written); * GHOSTTY_REJECTED if the text could inject commands and * GhosttyPaste::allow_unsafe is false (nothing was written); - * GHOSTTY_INVALID_VALUE for a NULL terminal or paste, or when no - * write_pty callback is installed; GHOSTTY_OUT_OF_MEMORY; - * GHOSTTY_IO_ERROR if there is no secure entropy source to mint - * a paste event password (wasm32-freestanding without - * GHOSTTY_SYS_OPT_RANDOM_SECURE set), in which case nothing was - * written and no grant was recorded. + * GHOSTTY_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, 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/paste.zig b/src/terminal/c/paste.zig index 53d371f33..8dfa649cd 100644 --- a/src/terminal/c/paste.zig +++ b/src/terminal/c/paste.zig @@ -3,6 +3,7 @@ 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; @@ -10,10 +11,15 @@ const ClipboardRead = terminal_c.ClipboardRead; const ClipboardReadReply = terminal_c.ClipboardReadReply; const Result = @import("result.zig").Result; -/// Why a paste happened. +/// 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 = terminal_paste_pkg.Source; +pub const Source = lib.Enum(lib.target, &.{ + "clipboard", + "text", +}); /// A paste of clipboard contents into the terminal. Sized struct. /// @@ -22,8 +28,9 @@ pub const Request = extern struct { size: usize = @sizeOf(Request), location: clipboard.Location, source: Source, - contents: ?[*]const ClipboardContent, - contents_len: usize, + mimes: ?[*]const lib.String, + mimes_len: usize, + reader: io.MimeReader, allow_unsafe: bool, }; @@ -43,42 +50,81 @@ pub fn terminal_paste( // a C callback, so the "nothing can be written" check is ours. if (wrapper.effects.write_pty == null) return .invalid_value; - const c_contents: []const ClipboardContent = if (req.contents) |ptr| - ptr[0..req.contents_len] + 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 contents = alloc.alloc( - clipboard.Content, - c_contents.len, - ) catch return .out_of_memory; - defer alloc.free(contents); - for (contents, c_contents) |*content, c_content| { - content.* = .{ - .mime = c_content.mime.ptr[0..c_content.mime.len], - .data = c_content.data.ptr[0..c_content.data.len], - }; - } + const 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(.{ - .location = req.location, - .source = req.source, - .contents = contents, + .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.EntropyUnavailable, error.Canceled => .io_error, + 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); @@ -248,21 +294,55 @@ const TerminalPasteCapture = struct { return written[0..written_len]; } - /// A single text/plain request; the caller provides the content - /// storage since the request borrows it. - fn textRequest(content: *ClipboardContent, text: []const u8) Request { - content.* = .{ - .mime = .init(@as([]const u8, "text/plain")), - .data = .init(text), - }; - return .{ - .location = .standard, - .source = .clipboard, - .contents = content[0..1], - .contents_len = 1, - .allow_unsafe = false, - }; - } + /// 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" { @@ -272,10 +352,11 @@ test "terminal_paste null handling" { 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 content: ClipboardContent = undefined; - const req: Request = S.textRequest(&content, "hello"); + 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); @@ -284,6 +365,15 @@ test "terminal_paste null handling" { 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" { @@ -295,8 +385,8 @@ test "terminal_paste without write_pty is invalid" { try testing.expectEqual(Result.success, terminal_c.new(&lib.alloc.test_allocator, &t, 80, 24)); defer terminal_c.free(t); - var content: ClipboardContent = undefined; - const req: Request = S.textRequest(&content, "hello"); + var contents: S.Contents = .init(&.{.{ "text/plain", "hello" }}); + const req = contents.request(); try testing.expectEqual(Result.invalid_value, terminal_paste(t, &req, null)); } @@ -310,26 +400,34 @@ test "terminal_paste text and unsafe" { defer terminal_c.free(t); try testing.expectEqual(Result.success, terminal_c.set(t, .write_pty, @ptrCast(&S.writePty))); - // Plain text, NULL out_written pointer is fine. - var content: ClipboardContent = undefined; - const req: Request = S.textRequest(&content, "hel\x1blo"); + // 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_content: ClipboardContent = undefined; - var unsafe: Request = S.textRequest(&unsafe_content, "rm -rf /\n"); + 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(); @@ -339,16 +437,20 @@ test "terminal_paste text and unsafe" { try testing.expect(written); try testing.expectEqualStrings("\x1b[200~hel lo\x1b[201~", S.writtenSlice()); - // No text representation writes nothing. NULL contents with a zero - // length is an empty list. + // No text representation writes nothing and reads nothing. S.reset(); - var empty_content: ClipboardContent = undefined; - var empty: Request = S.textRequest(&empty_content, ""); - empty.contents = null; - empty.contents_len = 0; - try testing.expectEqual(Result.success, terminal_paste(t, &empty, &written)); + 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" { @@ -364,30 +466,20 @@ test "terminal_paste event" { // Without a clipboard_read callback the paste stays text. var written: bool = false; - const contents = [_]ClipboardContent{ - .{ - .mime = .init(@as([]const u8, "text/plain")), - .data = .init(@as([]const u8, "secret")), - }, - .{ - .mime = .init(@as([]const u8, "image/png")), - .data = .init(@as([]const u8, "")), - }, - }; - const req: Request = .{ - .location = .primary, - .source = .clipboard, - .contents = &contents, - .contents_len = contents.len, - .allow_unsafe = false, - }; + 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 the data - // is never written. + // 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); @@ -396,6 +488,8 @@ test "terminal_paste event" { 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="; 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 933edcdcf..b7998bb6c 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -18,7 +18,6 @@ const formatter_pkg = @import("../formatter.zig"); const modes_pkg = @import("../modes.zig"); const mouse_pkg = @import("../mouse.zig"); const page = @import("../page.zig"); -const paste_pkg = @import("../paste.zig"); const point = @import("../point.zig"); const Selection = @import("../Selection.zig"); const sgr = @import("../sgr.zig"); @@ -192,6 +191,7 @@ 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), @@ -275,7 +275,7 @@ const type_decls = [_]TypeDecl{ "GHOSTTY_OSC_COMMAND_", "TYPE_MAX_VALUE", ), - .initEnum("GhosttyPasteSource", paste_pkg.Source, "GHOSTTY_PASTE_SOURCE_"), + .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/main.zig b/src/terminal/main.zig index 5be82c8a6..5d7c73871 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -62,7 +62,9 @@ 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; diff --git a/src/terminal/paste.zig b/src/terminal/paste.zig index 3989cf537..05e088ee2 100644 --- a/src/terminal/paste.zig +++ b/src/terminal/paste.zig @@ -7,7 +7,7 @@ //! user-initiated clipboard paste, and the embedder able to serve //! the program's follow-up clipboard read: send a paste event //! listing the clipboard's MIME types with a fresh one-time password -//! and record a one-time read grant for it. The data is not written. +//! 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. @@ -18,87 +18,134 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const lib = @import("lib.zig"); + const clipboard = @import("clipboard.zig"); const kitty_clipboard = @import("kitty/clipboard.zig"); const input_paste = @import("../input/paste.zig"); const Terminal = @import("Terminal.zig"); -/// Why a paste happened. Only clipboard pastes may become paste events. +/// 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 -pub const Source = lib.Enum(lib.target, &.{ - // The user pasted from a clipboard: keybind, menu, middle click. - "clipboard", +/// 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. Never becomes a paste event, matching kitty. - // This is not a way to opt out of events; an embedder that - // doesn't want them doesn't serve clipboard reads. - "text", -}); + /// 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 { - /// The clipboard the contents came from. Reported to the program on - /// a paste event (`.primary` and `.selection` both as loc=primary, - /// the protocol knows only two); no effect on a text paste. - location: clipboard.Location = .standard, + /// Why this paste happened, 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 }, - /// Why this paste happened. Only a user-initiated clipboard paste - /// may become a paste event; text insertion always writes text. - source: Source = .clipboard, + /// The representations available. Borrowed only during the + /// duration of the paste function call. + contents: Contents, - /// The representations available, in the embedder's preferred - /// order. A text paste writes the first representation with a text - /// MIME type (clipboard.isTextMime) and ignores the rest. A paste - /// event reports every MIME type and never touches data, so - /// non-text entries may carry empty data. Borrowed for the call. - contents: []const clipboard.Content, - - /// Write data that could inject commands (see `isSafe`). The usual + /// 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, - /// Kitty clipboard session grants. A paste event records its - /// one-time password here so the program's follow-up read is - /// served without a prompt. - grants: *kitty_clipboard.Grants, - - /// Secure entropy for one-time passwords. See generateOtp for why - /// there is no fallback when this has none. - io: std.Io, - - /// Allocator for the grant. Must be the one `grants` is freed with. + /// 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, - /// True if the embedder serves clipboard reads, so a paste event's - /// follow-up read can be answered. Without that an event would be - /// refused and the user's paste would vanish, so `paste` falls - /// through to a text paste instead. - can_event: bool, - - /// Receives the bytes for the pty. `paste` makes exactly one logical - /// write per call: the whole encoded text or the whole event. On - /// error the writer may hold a partial result that must be - /// discarded. + /// 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 || std.Io.Writer.Error || error{ - /// The data could inject commands and allow_unsafe was false. - /// Nothing was written. - UnsafePaste, -}; +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 @@ -112,70 +159,85 @@ pub const Error = Allocator.Error || std.Io.RandomSecureError || std.Io.Writer.E /// `input.paste.isSafe` themselves before calling. A paste event never /// puts the data on the input stream, so the rule doesn't apply to it. /// -/// On success, the caller delivers the writer's contents to the pty. -/// On error nothing should be delivered; in particular an event's grant -/// is only recorded once the event is fully encoded, so a failure never -/// leaves a grant for an event that was never sent. +/// 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 { - // A paste event only works if the program's follow-up read can be - // served; without that, fall through to text. + // 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.can_event and ctx.terminal.modes.get(.kitty_paste_events)) { - try pasteKittyEvent(ctx, req); - return true; + if (ctx.kitty_clipboard) |kitty| { + try pasteKittyEvent(ctx, kitty, req); + return true; + } } // For non-Kitty paste events we can only accept text content. - const text: []const u8 = for (req.contents) |c| { - if (clipboard.isTextMime(c.mime)) break c.data; + const index: usize = for (0..req.contents.len()) |i| { + if (clipboard.isTextMime(req.contents.mime(i))) break i; } else return false; - if (text.len == 0) return false; - // Reject unsafe inputs const opts: input_paste.Options = .fromTerminal(ctx.terminal); - if (!req.allow_unsafe and !input_paste.isSafeWith(text, opts)) { - return error.UnsafePaste; - } - // The data is copied exactly once, into the writer, where the - // encoder strips and converts it in place. + // 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, req: Request) Error!void { - const otp = try kitty_clipboard.generateOtp(ctx.io); +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; - var mimes_len: usize = 0; - for (req.contents) |c| { - if (mimes_len == mimes_buf.len) break; - mimes_buf[mimes_len] = c.mime; - mimes_len += 1; - } + 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. - .primary = req.location != .standard, + // 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); - - // Recorded last so a failed encode leaves no grant behind. The - // caller delivers the event after we return, so the grant is in - // place before the program can possibly use it. - try ctx.grants.grant( - ctx.alloc, - &otp, - .read, - true, - ); } test { diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 4805b4eb1..59dc25e56 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -108,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, @@ -300,46 +300,53 @@ pub const Handler = struct { /// 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; - // One buffer for the whole result (frame + data + sentinel, or - // the event packets). Typical pastes stay on the stack. - const alloc = self.terminal.gpa(); - var stack = std.heap.stackFallback(4096, alloc); - const stack_alloc = stack.get(); - var aw: std.Io.Writer.Allocating = .init(stack_alloc); - defer aw.deinit(); + 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; - const written_any = paste_pkg.paste(.{ + return paste_pkg.paste(.{ .terminal = self.terminal, - .grants = &self.kitty_clipboard_grants, - .io = self.terminal.io(), - .alloc = alloc, - .can_event = self.effects.clipboard_read != null, - .writer = &aw.writer, - }, req) catch |err| return switch (err) { - // An allocating writer only fails to allocate. - error.WriteFailed => error.OutOfMemory, + .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, }; - if (!written_any) return false; - - const written = try aw.toOwnedSliceSentinel(0); - defer stack_alloc.free(written); - self.writePty(written); - return true; } pub fn vt( @@ -529,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); } @@ -1745,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); @@ -1817,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; } @@ -1846,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; } }; @@ -1902,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; } }; @@ -1944,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; @@ -2216,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; @@ -2288,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; @@ -2370,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; @@ -2500,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"); } @@ -2679,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"); } @@ -2830,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"); } @@ -3305,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"); } @@ -3480,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; } @@ -4108,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"); } @@ -4232,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]; @@ -4264,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]; @@ -4290,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]; @@ -4319,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]; @@ -4348,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 { @@ -4379,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; @@ -4414,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; } @@ -4467,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 { @@ -4495,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 { @@ -4523,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"); } }; @@ -4546,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"); } }; @@ -4572,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"); } }; @@ -4600,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"); } }; @@ -4623,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 { @@ -4650,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 { @@ -4677,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"); } @@ -4702,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"); } @@ -4732,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"); } @@ -4764,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"); } @@ -4793,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"); } @@ -4822,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"); } @@ -4849,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; @@ -4918,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"); } @@ -4946,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"); } @@ -4974,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"); } @@ -5021,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"); } @@ -5063,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"); } @@ -5120,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; } @@ -5257,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; } }; @@ -5279,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; } }; @@ -5355,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; } }; @@ -5480,7 +5532,7 @@ const PasteCapture = struct { written = .empty; } - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { written.appendSlice(testing.allocator, data) catch @panic("OOM"); write_count += 1; } @@ -5509,7 +5561,7 @@ test "paste: no write_pty effect is an error" { var handler: Handler = .init(&t); defer handler.deinit(); try testing.expectError(error.NoWritePty, handler.paste(.{ - .contents = &.{.{ .mime = "text/plain", .data = "hello" }}, + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "hello" }} }, })); } @@ -5527,7 +5579,7 @@ test "paste: plain text converts newlines and strips unsafe bytes" { // Newlines are unsafe unbracketed; the embedder confirmed. try testing.expect(try handler.paste(.{ - .contents = &.{.{ .mime = "text/plain", .data = "hel\x1blo\nwor\x00ld" }}, + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "hel\x1blo\nwor\x00ld" }} }, .allow_unsafe = true, })); try testing.expectEqualStrings("hel lo\rwor ld", S.written.items); @@ -5536,11 +5588,11 @@ test "paste: plain text converts newlines and strips unsafe bytes" { // The first text representation is used; others are ignored. S.reset(); try testing.expect(try handler.paste(.{ - .contents = &.{ + .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); @@ -5559,12 +5611,12 @@ test "paste: unsafe text is refused unless allowed" { handler.effects.write_pty = &S.writePty; try testing.expectError(error.UnsafePaste, handler.paste(.{ - .contents = &.{.{ .mime = "text/plain", .data = "rm -rf /\n" }}, + .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 = &.{.{ .mime = "text/plain", .data = "rm -rf /\n" }}, + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "rm -rf /\n" }} }, .allow_unsafe = true, })); try testing.expectEqualStrings("rm -rf /\r", S.written.items); @@ -5585,7 +5637,7 @@ test "paste: bracketed paste frames the text" { // Newlines are safe inside the frame and are preserved. try testing.expect(try handler.paste(.{ - .contents = &.{.{ .mime = "text/plain", .data = "hello\nworld" }}, + .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); @@ -5593,13 +5645,13 @@ test "paste: bracketed paste frames the text" { // The frame terminator is not. S.reset(); try testing.expectError(error.UnsafePaste, handler.paste(.{ - .contents = &.{.{ .mime = "text/plain", .data = "he\x1b[201~llo" }}, + .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 = &.{.{ .mime = "text/plain", .data = "he\x1b[201~llo" }}, + .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); @@ -5618,18 +5670,18 @@ test "paste: no text representation writes nothing" { handler.effects.write_pty = &S.writePty; try testing.expect(!try handler.paste(.{ - .contents = &.{.{ .mime = "image/png", .data = "\x89PNG" }}, + .contents = .{ .memory = &.{.{ .mime = "image/png", .data = "\x89PNG" }} }, })); try testing.expect(!try handler.paste(.{ - .contents = &.{}, + .contents = .{ .memory = &.{} }, })); try testing.expect(!try handler.paste(.{ - .contents = &.{.{ .mime = "text/plain", .data = "" }}, + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "" }} }, })); try testing.expectEqual(@as(usize, 0), S.write_count); } -test "paste: large text falls back to the heap in one write" { +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); @@ -5642,16 +5694,158 @@ test "paste: large text falls back to the heap in one write" { 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 = &.{.{ .mime = "text/plain", .data = data }}, + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = data }} }, })); - try testing.expectEqual(@as(usize, 1), S.write_count); - try testing.expectEqual(data.len + "\x1b[200~\x1b[201~".len, S.written.items.len); + 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); @@ -5671,10 +5865,10 @@ test "paste: mode 5522 sends an event the program can read with" { // 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 = &.{ + .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;")); @@ -5735,7 +5929,7 @@ test "paste: mode 5522 sends an event the program can read with" { // Every event mints a fresh password. S.reset(); try testing.expect(try s.handler.paste(.{ - .contents = &.{.{ .mime = "text/plain", .data = "secret" }}, + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "secret" }} }, })); try testing.expect(std.mem.indexOf(u8, S.written.items, pw_b64) == null); } @@ -5757,8 +5951,8 @@ test "paste: mode 5522 reports the selection as primary" { for ([_]clipboard.Location{ .primary, .selection }) |location| { S.reset(); try testing.expect(try handler.paste(.{ - .location = location, - .contents = &.{.{ .mime = "text/plain", .data = "x" }}, + .source = .{ .clipboard = location }, + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "x" }} }, })); try testing.expect(std.mem.startsWith( u8, @@ -5771,8 +5965,8 @@ test "paste: mode 5522 reports the selection as primary" { S.reset(); try testing.expect(try handler.paste(.{ - .location = .standard, - .contents = &.{.{ .mime = "text/plain", .data = "x" }}, + .source = .{ .clipboard = .standard }, + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "x" }} }, })); try testing.expect(std.mem.indexOf(u8, S.written.items, "loc=") == null); } @@ -5791,7 +5985,7 @@ test "paste: mode 5522 without clipboard_read pastes text" { t.modes.set(.kitty_paste_events, true); try testing.expect(try handler.paste(.{ - .contents = &.{.{ .mime = "text/plain", .data = "hello" }}, + .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); @@ -5813,7 +6007,7 @@ test "paste: text source never becomes an event" { try testing.expect(try handler.paste(.{ .source = .text, - .contents = &.{.{ .mime = "text/plain", .data = "committed" }}, + .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); @@ -5834,7 +6028,7 @@ test "paste: mode 5522 without entropy fails and records no grant" { t.modes.set(.kitty_paste_events, true); try testing.expectError(error.EntropyUnavailable, handler.paste(.{ - .contents = &.{.{ .mime = "text/plain", .data = "secret" }}, + .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); @@ -5842,7 +6036,7 @@ test "paste: mode 5522 without entropy fails and records no grant" { // Text pastes need no entropy and still work. try testing.expect(try handler.paste(.{ .source = .text, - .contents = &.{.{ .mime = "text/plain", .data = "hello" }}, + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "hello" }} }, })); try testing.expectEqualStrings("hello", S.written.items); }