From a5bb22e235e6297b05f07b08ef1fecff7f2a8c5d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 22 Aug 2026 14:40:03 -0700 Subject: [PATCH] terminal: add shared paste core with Kitty clipboard paste events --- src/input/paste.zig | 168 +++++++++++++++++++- src/lib_vt.zig | 3 + src/terminal/kitty/clipboard.zig | 2 + src/terminal/kitty/clipboard_grants.zig | 46 +++++- src/terminal/kitty/clipboard_response.zig | 84 ++++++++++ src/terminal/main.zig | 3 + src/terminal/paste.zig | 185 ++++++++++++++++++++++ 7 files changed, 483 insertions(+), 8 deletions(-) create mode 100644 src/terminal/paste.zig diff --git a/src/input/paste.zig b/src/input/paste.zig index 16b6266b6..60a4fdde3 100644 --- a/src/input/paste.zig +++ b/src/input/paste.zig @@ -1,6 +1,14 @@ const std = @import("std"); const Terminal = @import("../terminal/Terminal.zig"); +/// The bracketed paste (mode 2004) frame written around the data. +pub const bracketed_prefix = "\x1b[200~"; +pub const bracketed_suffix = "\x1b[201~"; + +/// The maximum number of bytes `encode` adds around the data, so callers +/// can size a buffer for the full encoded result. +pub const max_frame_size = bracketed_prefix.len + bracketed_suffix.len; + pub const Options = struct { /// True if bracketed paste mode is on. bracketed: bool, @@ -93,8 +101,8 @@ pub fn encode( // Bracketed paste mode (mode 2004) wraps pasted data in // fenceposts so that the terminal can ignore things like newlines. if (opts.bracketed) { - result[0] = "\x1b[200~"; - result[2] = "\x1b[201~"; + result[0] = bracketed_prefix; + result[2] = bracketed_suffix; return result; } @@ -116,6 +124,42 @@ pub const Error = error{ MutableRequired, }; +/// Encode the given data for pasting directly into a writer. This is +/// the same transformation as `encode` (unsafe bytes replaced, bracketed +/// frame or newline conversion per `opts`) but the data is copied +/// exactly once: into the writer's buffer, where it is modified in place. +/// This is the form to use when the data is const and the result is +/// being assembled into a single buffer anyway. +/// +/// The data is copied in chunks sized to the writer's buffer, so any +/// writer works; a writer with less total capacity than the writer +/// needs to hold at once reports `error.WriteFailed` as usual. +/// +/// WARNING: The input data is not checked for safety. See `isSafe` +/// and `isSafeWith` to check if the data is safe to paste. +pub fn encodeWriter( + writer: *std.Io.Writer, + data: []const u8, + opts: Options, +) std.Io.Writer.Error!void { + if (opts.bracketed) try writer.writeAll(bracketed_prefix); + + // The byte transformations are position-independent, so the data + // can be copied and encoded chunk by chunk. The frame returned by + // encode is ignored since it's written around the whole data here. + var remaining = data; + while (remaining.len > 0) { + const dest = try writer.writableSliceGreedy(1); + const n = @min(dest.len, remaining.len); + @memcpy(dest[0..n], remaining[0..n]); + _ = encode(dest[0..n], opts); + writer.advance(n); + remaining = remaining[n..]; + } + + if (opts.bracketed) try writer.writeAll(bracketed_suffix); +} + /// Returns true if the data looks safe to paste. Data is considered /// unsafe if it contains any of the following: /// @@ -133,6 +177,22 @@ pub fn isSafe(data: []const u8) bool { std.mem.indexOf(u8, data, "\x1b[201~") == null; } +/// Returns true if the data looks safe to paste given how it will be +/// encoded. This is the terminal-state-aware counterpart of `isSafe`: +/// +/// - Bracketed (mode 2004 on): the program receives the data as one +/// framed unit, so newlines are fine. The data is unsafe only if it +/// contains the end of the frame (`\x1b[201~`), which would let the +/// rest of the data escape the frame and inject commands. +/// - Unbracketed: the same rule as `isSafe`. +/// +/// Callers wanting the conservative rule regardless of terminal state +/// should use `isSafe` instead. +pub fn isSafeWith(data: []const u8, opts: Options) bool { + if (opts.bracketed) return std.mem.indexOf(u8, data, bracketed_suffix) == null; + return isSafe(data); +} + test isSafe { const testing = std.testing; try testing.expect(isSafe("hello")); @@ -141,6 +201,110 @@ test isSafe { try testing.expect(!isSafe("he\x1b[201~llo")); } +test isSafeWith { + const testing = std.testing; + + // Bracketed: newlines are fine, the frame terminator is not. + try testing.expect(isSafeWith("hello", .{ .bracketed = true })); + try testing.expect(isSafeWith("hello\nworld", .{ .bracketed = true })); + try testing.expect(!isSafeWith("he\x1b[201~llo", .{ .bracketed = true })); + try testing.expect(!isSafeWith("hello\n\x1b[201~", .{ .bracketed = true })); + + // Unbracketed: the conservative rule. + try testing.expect(isSafeWith("hello", .{ .bracketed = false })); + try testing.expect(!isSafeWith("hello\nworld", .{ .bracketed = false })); + try testing.expect(!isSafeWith("he\x1b[201~llo", .{ .bracketed = false })); +} + +test "encodeWriter bracketed" { + const testing = std.testing; + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encodeWriter(&writer, "hel\x1blo\nworld", .{ .bracketed = true }); + try testing.expectEqualStrings("\x1b[200~hel lo\nworld\x1b[201~", writer.buffered()); +} + +test "encodeWriter unbracketed" { + const testing = std.testing; + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encodeWriter(&writer, "hel\x00lo\r\nworld", .{ .bracketed = false }); + try testing.expectEqualStrings("hel lo\r\rworld", writer.buffered()); +} + +test "encodeWriter empty" { + const testing = std.testing; + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encodeWriter(&writer, "", .{ .bracketed = true }); + try testing.expectEqualStrings("\x1b[200~\x1b[201~", writer.buffered()); + writer = .fixed(&buf); + try encodeWriter(&writer, "", .{ .bracketed = false }); + try testing.expectEqualStrings("", writer.buffered()); +} + +test "encodeWriter chunks through a small writer buffer" { + const testing = std.testing; + const alloc = testing.allocator; + + // A writer with a 4-byte staging buffer that drains into a list, + // so the data is copied and encoded in several chunks. + const Sink = struct { + list: std.ArrayList(u8) = .empty, + writer: std.Io.Writer, + + fn drain( + w: *std.Io.Writer, + data: []const []const u8, + splat: usize, + ) std.Io.Writer.Error!usize { + const self: *@This() = @alignCast(@fieldParentPtr("writer", w)); + self.list.appendSlice(testing.allocator, w.buffered()) catch return error.WriteFailed; + w.end = 0; + var n: usize = 0; + for (data[0 .. data.len - 1]) |slice| { + self.list.appendSlice(testing.allocator, slice) catch return error.WriteFailed; + n += slice.len; + } + for (0..splat) |_| { + self.list.appendSlice(testing.allocator, data[data.len - 1]) catch return error.WriteFailed; + } + return n + splat * data[data.len - 1].len; + } + }; + + var staging: [4]u8 = undefined; + var sink: Sink = .{ .writer = .{ + .buffer = &staging, + .vtable = &.{ .drain = Sink.drain }, + } }; + defer sink.list.deinit(alloc); + + const data = "line one\nline\x1btwo\nline three\n"; + try encodeWriter(&sink.writer, data, .{ .bracketed = true }); + try sink.writer.flush(); + try testing.expectEqualStrings( + "\x1b[200~line one\nline two\nline three\n\x1b[201~", + sink.list.items, + ); +} + +test "encodeWriter too small" { + const testing = std.testing; + var buf: [4]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try testing.expectError( + error.WriteFailed, + encodeWriter(&writer, "hello", .{ .bracketed = true }), + ); +} + +test max_frame_size { + const testing = std.testing; + const result = try encode(@as([]const u8, ""), .{ .bracketed = true }); + try testing.expectEqual(max_frame_size, result[0].len + result[2].len); +} + test "encode bracketed" { const testing = std.testing; const result = try encode( diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 414f6995b..423cedea7 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -95,6 +95,9 @@ pub const TerminalStream = terminal.TerminalStream; pub const Stream = terminal.Stream; pub const StreamAction = terminal.StreamAction; pub const UnknownSequence = terminal.UnknownSequence; + +pub const Paste = terminal.Paste; +pub const PasteSource = terminal.PasteSource; pub const Cursor = Screen.Cursor; pub const CursorStyle = Screen.CursorStyle; pub const CursorStyleReq = terminal.CursorStyle; diff --git a/src/terminal/kitty/clipboard.zig b/src/terminal/kitty/clipboard.zig index 18271c704..261cb7489 100644 --- a/src/terminal/kitty/clipboard.zig +++ b/src/terminal/kitty/clipboard.zig @@ -57,8 +57,10 @@ pub const max_write_aliases = write.max_write_aliases; pub const Response = response.Response; pub const ReadSuccess = response.ReadSuccess; +pub const PasteEvent = response.PasteEvent; pub const read_chunk_size = response.read_chunk_size; pub const max_read_mimes = response.max_read_mimes; +pub const max_listing_mimes = response.max_listing_mimes; pub const targets_mime = response.targets_mime; pub const Grants = grants.Grants; diff --git a/src/terminal/kitty/clipboard_grants.zig b/src/terminal/kitty/clipboard_grants.zig index 599e27e98..217d5d781 100644 --- a/src/terminal/kitty/clipboard_grants.zig +++ b/src/terminal/kitty/clipboard_grants.zig @@ -105,13 +105,30 @@ pub const Grants = struct { /// The length of a one-time password generated for paste events. pub const otp_len = 22; -/// Generate a one-time password for a paste event. The alphabet matches -/// kitty (alphanumeric without easily-confused characters), but the spec -/// doesn't demand this. -pub fn generateOtp(random: std.Random) [otp_len]u8 { - const alphabet = "23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"; +/// The one-time password alphabet. This matches kitty (alphanumeric +/// without easily-confused characters), but the spec doesn't demand +/// this. +pub const otp_alphabet = "23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"; + +/// Generate a one-time password for a paste event. +/// +/// The password is a secret: a program that learns it can read the +/// clipboard without a prompt. +pub fn generateOtp(io: std.Io) std.Io.RandomSecureError![otp_len]u8 { var result: [otp_len]u8 = undefined; - for (&result) |*c| c.* = alphabet[random.uintLessThan(usize, alphabet.len)]; + var len: usize = 0; + while (len < result.len) { + var raw: [2 * otp_len]u8 = undefined; + try io.randomSecure(&raw); + const limit = (std.math.maxInt(u8) + 1) / otp_alphabet.len * otp_alphabet.len; + for (raw) |byte| { + if (byte >= limit) continue; + result[len] = otp_alphabet[byte % otp_alphabet.len]; + len += 1; + if (len == result.len) break; + } + } + return result; } @@ -182,3 +199,20 @@ test "grants: capacity evicts the oldest" { const newest = try std.fmt.bufPrint(&buf, "pw{}", .{Grants.max_entries}); try testing.expect(grants.use(alloc, newest, .read)); } + +test "generateOtp: length and alphabet with a real Io" { + const testing = std.testing; + + const otp = try generateOtp(testing.io); + try testing.expectEqual(otp_len, otp.len); + for (otp) |c| try testing.expect(std.mem.indexOfScalar(u8, otp_alphabet, c) != null); + + // Two passwords don't collide (a repeat would mean no entropy). + const other = try generateOtp(testing.io); + try testing.expect(!std.mem.eql(u8, &otp, &other)); +} + +test "generateOtp: no entropy is an error, never a weak password" { + const testing = std.testing; + try testing.expectError(error.EntropyUnavailable, generateOtp(std.Io.failing)); +} diff --git a/src/terminal/kitty/clipboard_response.zig b/src/terminal/kitty/clipboard_response.zig index c7f01c165..50bbbc677 100644 --- a/src/terminal/kitty/clipboard_response.zig +++ b/src/terminal/kitty/clipboard_response.zig @@ -23,6 +23,9 @@ pub const max_read_mimes = 4; /// The special MIME type that requests the list of available types. pub const targets_mime = "."; +/// Maximum MIME types reported in a paste event's targets listing. +pub const max_listing_mimes = 16; + /// A single response packet. pub const Response = struct { op: Operation, @@ -182,6 +185,36 @@ pub const ReadSuccess = struct { } }; +/// An unsolicited Kitty paste event (mode 5522): a read response that +/// lists the clipboard's available MIME types and carries the one-time +/// password the program uses for its follow-up read. +pub const PasteEvent = struct { + /// True if the paste came from the primary selection, reported as + /// `loc=primary` on the OK packet. + primary: bool = false, + + /// The one-time password, echoed in every packet. + pw: []const u8, + + /// The MIME types available on the clipboard. + available: []const []const u8, + + terminator: Terminator = .st, + + pub fn encode( + self: *const PasteEvent, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + try (ReadSuccess{ + .primary = self.primary, + .pw = self.pw, + .list = true, + .available = self.available, + .terminator = self.terminator, + }).encode(writer); + } +}; + test "response: basic status packet" { const testing = std.testing; @@ -383,3 +416,54 @@ test "read success: paste event carries pw in every packet" { writer.buffered(), ); } + +test "paste event: pw in every packet, listing of every type" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (PasteEvent{ + .pw = "otp", + .available = &.{ "text/plain", "image/png" }, + }).encode(&writer); + // Payload is base64 of "text/plain image/png\n". + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK:pw=b3Rw\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=Lg==:pw=b3Rw;dGV4dC9wbGFpbiBpbWFnZS9wbmcK\x1b\\" ++ + "\x1b]5522;type=read:status=DONE:pw=b3Rw\x1b\\", + writer.buffered(), + ); +} + +test "paste event: primary is reported only on the OK packet" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (PasteEvent{ + .primary = true, + .pw = "otp", + .available = &.{"text/plain"}, + .terminator = .bel, + }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK:loc=primary:pw=b3Rw\x07" ++ + "\x1b]5522;type=read:status=DATA:mime=Lg==:pw=b3Rw;dGV4dC9wbGFpbgo=\x07" ++ + "\x1b]5522;type=read:status=DONE:pw=b3Rw\x07", + writer.buffered(), + ); +} + +test "paste event: empty listing packet is still sent" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (PasteEvent{ .pw = "otp", .available = &.{} }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK:pw=b3Rw\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=Lg==:pw=b3Rw\x1b\\" ++ + "\x1b]5522;type=read:status=DONE:pw=b3Rw\x1b\\", + writer.buffered(), + ); +} diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 55438dde4..0d09e1f77 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -20,6 +20,7 @@ pub const kitty = @import("kitty.zig"); pub const modes = @import("modes.zig"); pub const page = @import("page.zig"); pub const parse_table = @import("parse_table.zig"); +pub const paste = @import("paste.zig"); pub const search = @import("search.zig"); pub const snapshot = @import("snapshot/main.zig"); pub const sgr = @import("sgr.zig"); @@ -60,6 +61,8 @@ pub const TerminalStream = stream_terminal.Stream; pub const Stream = stream.Stream; pub const StreamAction = stream.Action; pub const UnknownSequence = stream_terminal.Handler.UnknownSequence; +pub const Paste = paste.Request; +pub const PasteSource = paste.Source; pub const Cursor = Screen.Cursor; pub const CursorStyle = Screen.CursorStyle; pub const CursorStyleReq = ansi.CursorStyle; diff --git a/src/terminal/paste.zig b/src/terminal/paste.zig new file mode 100644 index 000000000..3989cf537 --- /dev/null +++ b/src/terminal/paste.zig @@ -0,0 +1,185 @@ +//! Pasting into a terminal. +//! +//! This is the single place that turns "the user pasted" into bytes for +//! the pty, applying the terminal's current state: +//! +//! * Mode 5522 (Kitty clipboard protocol paste events) set, a +//! user-initiated clipboard paste, and the embedder able to serve +//! the program's follow-up clipboard read: send a paste event +//! listing the clipboard's MIME types with a fresh one-time password +//! and record a one-time read grant for it. The data is not written. +//! * Otherwise: write the first text representation, with unsafe bytes +//! replaced (xterm behavior), framed per mode 2004 (bracketed paste) +//! or with newlines converted to carriage returns if not. +//! +//! The precedence (5522 event, else 2004 framing, else plain) and the +//! safety rule live only here so every embedder of the terminal shares +//! one implementation. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const lib = @import("lib.zig"); +const clipboard = @import("clipboard.zig"); +const kitty_clipboard = @import("kitty/clipboard.zig"); +const input_paste = @import("../input/paste.zig"); +const Terminal = @import("Terminal.zig"); + +/// Why a paste happened. Only clipboard pastes may become paste events. +/// +/// C: GhosttyPasteSource +pub const Source = lib.Enum(lib.target, &.{ + // The user pasted from a clipboard: keybind, menu, middle click. + "clipboard", + + // Text inserted some other way: IME commit, drag and drop, + // scripted input. Never becomes a paste event, matching kitty. + // This is not a way to opt out of events; an embedder that + // doesn't want them doesn't serve clipboard reads. + "text", +}); + +/// A paste of clipboard contents into the terminal. What actually gets +/// written depends on terminal state; see `paste`. +pub const Request = struct { + /// The clipboard the contents came from. Reported to the program on + /// a paste event (`.primary` and `.selection` both as loc=primary, + /// the protocol knows only two); no effect on a text paste. + location: clipboard.Location = .standard, + + /// Why this paste happened. Only a user-initiated clipboard paste + /// may become a paste event; text insertion always writes text. + source: Source = .clipboard, + + /// The representations available, in the embedder's preferred + /// order. A text paste writes the first representation with a text + /// MIME type (clipboard.isTextMime) and ignores the rest. A paste + /// event reports every MIME type and never touches data, so + /// non-text entries may carry empty data. Borrowed for the call. + contents: []const clipboard.Content, + + /// Write data that could inject commands (see `isSafe`). The usual + /// flow is to call with false, confirm with the user on + /// error.UnsafePaste, and call again with true. + allow_unsafe: bool = false, +}; + +/// What a caller supplies to `paste`: the terminal state the decision +/// depends on, the session state an event records into, and the sink. +pub const Context = struct { + /// The terminal whose modes decide the encoding. + terminal: *const Terminal, + + /// Kitty clipboard session grants. A paste event records its + /// one-time password here so the program's follow-up read is + /// served without a prompt. + grants: *kitty_clipboard.Grants, + + /// Secure entropy for one-time passwords. See generateOtp for why + /// there is no fallback when this has none. + io: std.Io, + + /// Allocator for the grant. Must be the one `grants` is freed with. + alloc: Allocator, + + /// True if the embedder serves clipboard reads, so a paste event's + /// follow-up read can be answered. Without that an event would be + /// refused and the user's paste would vanish, so `paste` falls + /// through to a text paste instead. + can_event: bool, + + /// Receives the bytes for the pty. `paste` makes exactly one logical + /// write per call: the whole encoded text or the whole event. On + /// error the writer may hold a partial result that must be + /// discarded. + writer: *std.Io.Writer, +}; + +pub const Error = Allocator.Error || std.Io.RandomSecureError || std.Io.Writer.Error || error{ + /// The data could inject commands and allow_unsafe was false. + /// Nothing was written. + UnsafePaste, +}; + +/// Paste into the terminal, applying the terminal's current state as +/// described in the module docs. Returns true if anything was written +/// to `ctx.writer`: the encoded text or a paste event. False means +/// there was nothing to paste (no non-empty text representation). +/// +/// The safety rule for a text paste (`input.paste.isSafeWith`): a +/// bracketed paste is unsafe only if it contains the bracket terminator +/// (CSI 201~); an unbracketed paste is unsafe if it contains a newline +/// or the terminator. Embedders wanting a stricter rule check +/// `input.paste.isSafe` themselves before calling. A paste event never +/// puts the data on the input stream, so the rule doesn't apply to it. +/// +/// On success, the caller delivers the writer's contents to the pty. +/// On error nothing should be delivered; in particular an event's grant +/// is only recorded once the event is fully encoded, so a failure never +/// leaves a grant for an event that was never sent. +pub fn paste(ctx: Context, req: Request) Error!bool { + // A paste event only works if the program's follow-up read can be + // served; without that, fall through to text. + if (req.source == .clipboard and + ctx.can_event and + ctx.terminal.modes.get(.kitty_paste_events)) + { + try pasteKittyEvent(ctx, req); + return true; + } + + // For non-Kitty paste events we can only accept text content. + const text: []const u8 = for (req.contents) |c| { + if (clipboard.isTextMime(c.mime)) break c.data; + } else return false; + if (text.len == 0) return false; + + // Reject unsafe inputs + const opts: input_paste.Options = .fromTerminal(ctx.terminal); + if (!req.allow_unsafe and !input_paste.isSafeWith(text, opts)) { + return error.UnsafePaste; + } + + // The data is copied exactly once, into the writer, where the + // encoder strips and converts it in place. + try input_paste.encodeWriter(ctx.writer, text, opts); + return true; +} + +fn pasteKittyEvent(ctx: Context, req: Request) Error!void { + const otp = try kitty_clipboard.generateOtp(ctx.io); + + // Every representation is listed, never read. The listing is + // bounded; a clipboard with more types than that is not a thing. + var mimes_buf: [kitty_clipboard.max_listing_mimes][]const u8 = undefined; + var mimes_len: usize = 0; + for (req.contents) |c| { + if (mimes_len == mimes_buf.len) break; + mimes_buf[mimes_len] = c.mime; + mimes_len += 1; + } + + try (kitty_clipboard.PasteEvent{ + // The protocol only distinguishes the clipboard from the + // primary selection, so both non-standard locations report as + // primary. + .primary = req.location != .standard, + .pw = &otp, + .available = mimes_buf[0..mimes_len], + }).encode(ctx.writer); + + // Recorded last so a failed encode leaves no grant behind. The + // caller delivers the event after we return, so the grant is in + // place before the program can possibly use it. + try ctx.grants.grant( + ctx.alloc, + &otp, + .read, + true, + ); +} + +test { + // The behavior is tested end to end through the stream handler + // (stream_terminal.zig), which is the primary caller. + std.testing.refAllDecls(@This()); +}