libghostty: paste reads clipboard contents on demand, streams to pty (#13983)

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.
This commit is contained in:
Mitchell Hashimoto
2026-08-23 20:56:16 -07:00
committed by GitHub
12 changed files with 819 additions and 307 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -10,6 +10,7 @@
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <ghostty/vt/types.h>
/** @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

View File

@@ -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 <stdbool.h>
#include <stddef.h>
#include <ghostty/vt/types.h>
#include <ghostty/vt/io.h>
#include <ghostty/vt/terminal.h>
#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,

View File

@@ -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));
}

View File

@@ -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=";

View File

@@ -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);

View File

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

View File

@@ -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

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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);
}