From a5bb22e235e6297b05f07b08ef1fecff7f2a8c5d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 22 Aug 2026 14:40:03 -0700 Subject: [PATCH 1/4] 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()); +} From 87603231658a0e0c6a8b4be0be684b7f08778255 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 22 Aug 2026 14:43:23 -0700 Subject: [PATCH 2/4] terminal: add stream handler paste operation and enable mode 5522 in libghostty --- src/lib_vt.zig | 4 + src/terminal/main.zig | 1 + src/terminal/modes.zig | 11 +- src/terminal/stream_terminal.zig | 510 +++++++++++++++++++++++++++++++ 4 files changed, 522 insertions(+), 4 deletions(-) diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 423cedea7..53762f775 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -98,6 +98,7 @@ pub const UnknownSequence = terminal.UnknownSequence; pub const Paste = terminal.Paste; pub const PasteSource = terminal.PasteSource; +pub const PasteError = terminal.PasteError; pub const Cursor = Screen.Cursor; pub const CursorStyle = Screen.CursorStyle; pub const CursorStyleReq = terminal.CursorStyle; @@ -132,8 +133,11 @@ pub const input = struct { // Paste-related APIs pub const PasteError = paste.Error; pub const PasteOptions = paste.Options; + pub const max_paste_frame_size = paste.max_frame_size; pub const isSafePaste = paste.isSafe; + pub const isSafePasteWith = paste.isSafeWith; pub const encodePaste = paste.encode; + pub const encodePasteWriter = paste.encodeWriter; // Key encoding pub const Key = key.Key; diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 0d09e1f77..5be82c8a6 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -63,6 +63,7 @@ pub const StreamAction = stream.Action; pub const UnknownSequence = stream_terminal.Handler.UnknownSequence; pub const Paste = paste.Request; pub const PasteSource = paste.Source; +pub const PasteError = stream_terminal.Handler.PasteError; pub const Cursor = Screen.Cursor; pub const CursorStyle = Screen.CursorStyle; pub const CursorStyleReq = ansi.CursorStyle; diff --git a/src/terminal/modes.zig b/src/terminal/modes.zig index baacd0089..b1330db66 100644 --- a/src/terminal/modes.zig +++ b/src/terminal/modes.zig @@ -8,6 +8,7 @@ //! to ensure all our various types and logic remain in sync. const std = @import("std"); +const build_options = @import("terminal_options"); const testing = std.testing; /// A struct that maintains the state of all the settable modes. @@ -331,10 +332,12 @@ const entries: []const ModeEntry = &.{ // paste sends an unsolicited OSC 5522 targets listing with a // one-time password instead of pasting the text. // See https://sw.kovidgoyal.net/kitty/clipboard/ - // - // Forcibly disabled for now since the functionality isn't exposed - // yet to libghostty or any GUI apps. - .{ .name = "kitty_paste_events", .value = 5522, .disabled = true }, + .{ + .name = "kitty_paste_events", + .value = 5522, + // Only libghostty-vt supports this currently + .disabled = build_options.artifact != .lib, + }, }; test { diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index bcf8690bc..4805b4eb1 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -17,6 +17,7 @@ const osc = @import("osc.zig"); const osc_color = @import("osc/parsers/color.zig"); const kitty_clipboard = @import("kitty/clipboard.zig"); const kitty_color = @import("kitty/color.zig"); +const paste_pkg = @import("paste.zig"); const kitty_dnd = @import("kitty/dnd.zig"); const size_report = @import("size_report.zig"); const simd = @import("../simd/main.zig"); @@ -200,6 +201,11 @@ pub const Handler = struct { /// session grant so later requests with the same password arrive /// with `granted` set. Kitty itself serves a request for only the /// targets listing (`list` with no `mimes`) without prompting. + /// + /// Installing this also enables Kitty paste events (mode 5522): + /// `paste` sends the program an event instead of the text, and + /// the program's follow-up read arrives here with `granted` set + /// since the user already pasted. See `paste`. clipboard_read: ?*const fn (*Handler, clipboard.Read) void, /// Called in response to an XTVERSION query. Returns the version @@ -286,6 +292,56 @@ pub const Handler = struct { write_pty(self, buf[0..writer.end :0]); } + /// A paste request; see `paste`. + pub const Paste = paste_pkg.Request; + + pub const PasteError = Allocator.Error || std.Io.RandomSecureError || error{ + /// The data could inject commands and allow_unsafe was false. + /// Nothing was written. + UnsafePaste, + + /// No write_pty effect is set, so nothing can be written. + NoWritePty, + }; + + /// Paste into the terminal, applying the terminal's current state + /// as necessary to owner mode 5522, bracketed paste, unsafe paste, etc. + /// Returns true if anything was written to the pty. + pub fn paste(self: *Handler, req: Paste) PasteError!bool { + if (self.effects.write_pty == null) return error.NoWritePty; + + // One buffer for the whole result (frame + data + sentinel, or + // the event packets). Typical pastes stay on the stack. + const alloc = self.terminal.gpa(); + var stack = std.heap.stackFallback(4096, alloc); + const stack_alloc = stack.get(); + var aw: std.Io.Writer.Allocating = .init(stack_alloc); + defer aw.deinit(); + + const written_any = paste_pkg.paste(.{ + .terminal = self.terminal, + .grants = &self.kitty_clipboard_grants, + .io = self.terminal.io(), + .alloc = alloc, + .can_event = self.effects.clipboard_read != null, + .writer = &aw.writer, + }, req) catch |err| return switch (err) { + // An allocating writer only fails to allocate. + error.WriteFailed => error.OutOfMemory, + error.OutOfMemory, + error.UnsafePaste, + error.EntropyUnavailable, + error.Canceled, + => |e| e, + }; + if (!written_any) return false; + + const written = try aw.toOwnedSliceSentinel(0); + defer stack_alloc.free(written); + self.writePty(written); + return true; + } + pub fn vt( self: *Handler, comptime action: Action.Tag, @@ -403,6 +459,10 @@ pub const Handler = struct { .full_reset => { self.terminal.fullReset(); + // Full reset clears grants + self.kitty_clipboard_grants.deinit(self.terminal.gpa()); + self.kitty_clipboard_grants = .{}; + // Clear the progress bar self.progressReport(.{ .state = .remove }); }, @@ -5394,3 +5454,453 @@ test "kitty dnd: effect reports registration, acceptance, and conclusion" { try testing.expect(t.kitty_dnd == null); try testing.expectEqualStrings("", S.mimes.items); } + +/// Capture state for the Handler.paste tests below: every pty write and +/// the clipboard reads the program makes afterwards. +const PasteCapture = struct { + var written: std.ArrayList(u8) = .empty; + var write_count: usize = 0; + var read_count: usize = 0; + var read_granted_count: usize = 0; + var last_read_granted: bool = false; + var last_read_name: [64]u8 = undefined; + var last_read_name_len: usize = 0; + + fn reset() void { + written.clearRetainingCapacity(); + write_count = 0; + read_count = 0; + read_granted_count = 0; + last_read_granted = false; + last_read_name_len = 0; + } + + fn deinit() void { + written.deinit(testing.allocator); + written = .empty; + } + + fn writePty(_: *Handler, data: [:0]const u8) void { + written.appendSlice(testing.allocator, data) catch @panic("OOM"); + write_count += 1; + } + + fn clipboardRead(_: *Handler, read: clipboard.Read) void { + read_count += 1; + last_read_granted = read.granted; + if (read.granted) read_granted_count += 1; + last_read_name_len = read.name.len; + @memcpy(last_read_name[0..read.name.len], read.name); + read.reply(.{ .success = .{ .contents = &.{.{ + .mime = "text/plain", + .data = "Ghostty", + }} } }); + } + + fn readName() []const u8 { + return last_read_name[0..last_read_name_len]; + } +}; + +test "paste: no write_pty effect is an error" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var handler: Handler = .init(&t); + defer handler.deinit(); + try testing.expectError(error.NoWritePty, handler.paste(.{ + .contents = &.{.{ .mime = "text/plain", .data = "hello" }}, + })); +} + +test "paste: plain text converts newlines and strips unsafe bytes" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + defer handler.deinit(); + handler.effects.write_pty = &S.writePty; + + // Newlines are unsafe unbracketed; the embedder confirmed. + try testing.expect(try handler.paste(.{ + .contents = &.{.{ .mime = "text/plain", .data = "hel\x1blo\nwor\x00ld" }}, + .allow_unsafe = true, + })); + try testing.expectEqualStrings("hel lo\rwor ld", S.written.items); + try testing.expectEqual(@as(usize, 1), S.write_count); + + // The first text representation is used; others are ignored. + S.reset(); + try testing.expect(try handler.paste(.{ + .contents = &.{ + .{ .mime = "image/png", .data = "\x89PNG" }, + .{ .mime = "UTF8_STRING", .data = "hi" }, + .{ .mime = "text/plain", .data = "ignored" }, + }, + })); + try testing.expectEqualStrings("hi", S.written.items); + try testing.expectEqual(@as(usize, 1), S.write_count); +} + +test "paste: unsafe text is refused unless allowed" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + defer handler.deinit(); + handler.effects.write_pty = &S.writePty; + + try testing.expectError(error.UnsafePaste, handler.paste(.{ + .contents = &.{.{ .mime = "text/plain", .data = "rm -rf /\n" }}, + })); + try testing.expectEqual(@as(usize, 0), S.write_count); + + try testing.expect(try handler.paste(.{ + .contents = &.{.{ .mime = "text/plain", .data = "rm -rf /\n" }}, + .allow_unsafe = true, + })); + try testing.expectEqualStrings("rm -rf /\r", S.written.items); +} + +test "paste: bracketed paste frames the text" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + defer handler.deinit(); + handler.effects.write_pty = &S.writePty; + t.modes.set(.bracketed_paste, true); + + // Newlines are safe inside the frame and are preserved. + try testing.expect(try handler.paste(.{ + .contents = &.{.{ .mime = "text/plain", .data = "hello\nworld" }}, + })); + try testing.expectEqualStrings("\x1b[200~hello\nworld\x1b[201~", S.written.items); + try testing.expectEqual(@as(usize, 1), S.write_count); + + // The frame terminator is not. + S.reset(); + try testing.expectError(error.UnsafePaste, handler.paste(.{ + .contents = &.{.{ .mime = "text/plain", .data = "he\x1b[201~llo" }}, + })); + try testing.expectEqual(@as(usize, 0), S.write_count); + + // Allowed, the stripper still defuses it: ESC becomes a space. + try testing.expect(try handler.paste(.{ + .contents = &.{.{ .mime = "text/plain", .data = "he\x1b[201~llo" }}, + .allow_unsafe = true, + })); + try testing.expectEqualStrings("\x1b[200~he [201~llo\x1b[201~", S.written.items); +} + +test "paste: no text representation writes nothing" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + defer handler.deinit(); + handler.effects.write_pty = &S.writePty; + + try testing.expect(!try handler.paste(.{ + .contents = &.{.{ .mime = "image/png", .data = "\x89PNG" }}, + })); + try testing.expect(!try handler.paste(.{ + .contents = &.{}, + })); + try testing.expect(!try handler.paste(.{ + .contents = &.{.{ .mime = "text/plain", .data = "" }}, + })); + try testing.expectEqual(@as(usize, 0), S.write_count); +} + +test "paste: large text falls back to the heap in one write" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + defer handler.deinit(); + handler.effects.write_pty = &S.writePty; + t.modes.set(.bracketed_paste, true); + + const data = "x" ** 10_000; + try testing.expect(try handler.paste(.{ + .contents = &.{.{ .mime = "text/plain", .data = data }}, + })); + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqual(data.len + "\x1b[200~\x1b[201~".len, S.written.items.len); + try testing.expect(std.mem.startsWith(u8, S.written.items, "\x1b[200~xxx")); + try testing.expect(std.mem.endsWith(u8, S.written.items, "xxx\x1b[201~")); +} + +test "paste: mode 5522 sends an event the program can read with" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_read = &S.clipboardRead; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + t.modes.set(.kitty_paste_events, true); + t.modes.set(.bracketed_paste, true); + + // Every representation is listed, the data is never written, and + // the one-time password rides on every packet. + try testing.expect(try s.handler.paste(.{ + .contents = &.{ + .{ .mime = "text/plain", .data = "secret" }, + .{ .mime = "image/png", .data = "" }, + }, + })); + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqual(@as(usize, 3), std.mem.count(u8, S.written.items, "\x1b]5522;")); + try testing.expect(std.mem.indexOf(u8, S.written.items, "secret") == null); + try testing.expect(std.mem.indexOf(u8, S.written.items, "\x1b[200~") == null); + + // OK packet: parse the (base64) password out. + const ok_prefix = "\x1b]5522;type=read:status=OK:pw="; + try testing.expect(std.mem.startsWith(u8, S.written.items, ok_prefix)); + const pw_end = std.mem.indexOfPos(u8, S.written.items, ok_prefix.len, "\x1b\\").?; + // Copied out since the capture buffer is reused below. + var pw_buf: [64]u8 = undefined; + const pw_b64 = pw_buf[0 .. pw_end - ok_prefix.len]; + @memcpy(pw_b64, S.written.items[ok_prefix.len..pw_end]); + try testing.expectEqual( + std.base64.standard.Encoder.calcSize(kitty_clipboard.otp_len), + pw_b64.len, + ); + + // Listing packet: base64 of "text/plain image/png\n". + var expected_buf: [256]u8 = undefined; + const expected = try std.fmt.bufPrint( + &expected_buf, + "\x1b]5522;type=read:status=OK:pw={s}\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=Lg==:pw={s};dGV4dC9wbGFpbiBpbWFnZS9wbmcK\x1b\\" ++ + "\x1b]5522;type=read:status=DONE:pw={s}\x1b\\", + .{ pw_b64, pw_b64, pw_b64 }, + ); + try testing.expectEqualStrings(expected, S.written.items); + + // The program reads with the password and the name "Paste event": + // the read arrives granted, exactly once. + var read_buf: [256]u8 = undefined; + const read = try std.fmt.bufPrint( + &read_buf, + "\x1b]5522;type=read:pw={s}:name=UGFzdGUgZXZlbnQ=;dGV4dC9wbGFpbg==\x1b\\", + .{pw_b64}, + ); + S.reset(); + s.nextSlice(read); + try testing.expectEqual(@as(usize, 1), S.read_count); + try testing.expect(S.last_read_granted); + try testing.expectEqualStrings("Paste event", S.readName()); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + S.written.items, + ); + + // The password was one-time: a second read is not granted. + S.reset(); + s.nextSlice(read); + try testing.expectEqual(@as(usize, 1), S.read_count); + try testing.expect(!S.last_read_granted); + try testing.expectEqual(@as(usize, 0), S.read_granted_count); + + // Every event mints a fresh password. + S.reset(); + try testing.expect(try s.handler.paste(.{ + .contents = &.{.{ .mime = "text/plain", .data = "secret" }}, + })); + try testing.expect(std.mem.indexOf(u8, S.written.items, pw_b64) == null); +} + +test "paste: mode 5522 reports the selection as primary" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + defer handler.deinit(); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_read = &S.clipboardRead; + t.modes.set(.kitty_paste_events, true); + + for ([_]clipboard.Location{ .primary, .selection }) |location| { + S.reset(); + try testing.expect(try handler.paste(.{ + .location = location, + .contents = &.{.{ .mime = "text/plain", .data = "x" }}, + })); + try testing.expect(std.mem.startsWith( + u8, + S.written.items, + "\x1b]5522;type=read:status=OK:loc=primary:pw=", + )); + // Only on the OK packet. + try testing.expectEqual(@as(usize, 1), std.mem.count(u8, S.written.items, "loc=primary")); + } + + S.reset(); + try testing.expect(try handler.paste(.{ + .location = .standard, + .contents = &.{.{ .mime = "text/plain", .data = "x" }}, + })); + try testing.expect(std.mem.indexOf(u8, S.written.items, "loc=") == null); +} + +test "paste: mode 5522 without clipboard_read pastes text" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + defer handler.deinit(); + handler.effects.write_pty = &S.writePty; + t.modes.set(.kitty_paste_events, true); + + try testing.expect(try handler.paste(.{ + .contents = &.{.{ .mime = "text/plain", .data = "hello" }}, + })); + try testing.expectEqualStrings("hello", S.written.items); + try testing.expectEqual(@as(usize, 0), handler.kitty_clipboard_grants.entries.items.len); +} + +test "paste: text source never becomes an event" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + defer handler.deinit(); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_read = &S.clipboardRead; + t.modes.set(.kitty_paste_events, true); + + try testing.expect(try handler.paste(.{ + .source = .text, + .contents = &.{.{ .mime = "text/plain", .data = "committed" }}, + })); + try testing.expectEqualStrings("committed", S.written.items); + try testing.expectEqual(@as(usize, 0), handler.kitty_clipboard_grants.entries.items.len); +} + +test "paste: mode 5522 without entropy fails and records no grant" { + var t: Terminal = try .init(std.Io.failing, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + defer handler.deinit(); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_read = &S.clipboardRead; + t.modes.set(.kitty_paste_events, true); + + try testing.expectError(error.EntropyUnavailable, handler.paste(.{ + .contents = &.{.{ .mime = "text/plain", .data = "secret" }}, + })); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expectEqual(@as(usize, 0), handler.kitty_clipboard_grants.entries.items.len); + + // Text pastes need no entropy and still work. + try testing.expect(try handler.paste(.{ + .source = .text, + .contents = &.{.{ .mime = "text/plain", .data = "hello" }}, + })); + try testing.expectEqualStrings("hello", S.written.items); +} + +test "paste: mode 5522 is settable and reported in the lib build" { + if (comptime build_options.artifact != .lib) return error.SkipZigTest; + + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // Recognized and reset by default, so programs can detect support. + s.nextSlice("\x1B[?5522$p"); + try testing.expectEqualStrings("\x1B[?5522;2$y", S.written.items); + + S.reset(); + s.nextSlice("\x1B[?5522h"); + try testing.expect(t.modes.get(.kitty_paste_events)); + s.nextSlice("\x1B[?5522$p"); + try testing.expectEqualStrings("\x1B[?5522;1$y", S.written.items); + + s.nextSlice("\x1B[?5522l"); + try testing.expect(!t.modes.get(.kitty_paste_events)); +} + +test "full reset drops kitty clipboard grants" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_read = &S.clipboardRead; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + try s.handler.kitty_clipboard_grants.grant(testing.allocator, "pw", .read, false); + try testing.expectEqual(@as(usize, 1), s.handler.kitty_clipboard_grants.entries.items.len); + + s.nextSlice("\x1Bc"); + try testing.expectEqual(@as(usize, 0), s.handler.kitty_clipboard_grants.entries.items.len); + + // A read with the old password is no longer granted, and the + // handler keeps working (grants can be recorded again). + S.reset(); + s.nextSlice("\x1b]5522;type=read:pw=cHc=:name=YXBw;dGV4dC9wbGFpbg==\x1b\\"); + try testing.expectEqual(@as(usize, 1), S.read_count); + try testing.expect(!S.last_read_granted); + try s.handler.kitty_clipboard_grants.grant(testing.allocator, "pw", .read, false); +} From dda8e6f3146fc3cd2bcff0049cfc8867b3e7b58a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 22 Aug 2026 14:54:04 -0700 Subject: [PATCH 3/4] sys: add secure random override option with a platform default --- include/ghostty/vt/sys.h | 33 +++++++++++++++ src/lib/TinyIo.zig | 53 ++++++++++++++++++++++++- src/terminal/c/sys.zig | 20 ++++++++++ src/terminal/kitty/clipboard_grants.zig | 24 ++++++++++- src/terminal/sys.zig | 48 ++++++++++++++++++++++ 5 files changed, 175 insertions(+), 3 deletions(-) diff --git a/include/ghostty/vt/sys.h b/include/ghostty/vt/sys.h index ae9059692..c5e5903b2 100644 --- a/include/ghostty/vt/sys.h +++ b/include/ghostty/vt/sys.h @@ -124,6 +124,24 @@ typedef bool (*GhosttySysDecodePngFn)( size_t data_len, GhosttySysImage* out); +/** + * Callback type for secure random bytes. + * + * Fills @p buf with @p len cryptographically secure random bytes. The + * library uses this for secrets, so it must be a real CSPRNG (getrandom, + * arc4random_buf, BCryptGenRandom, crypto.getRandomValues, ...); a + * predictable source is a security hole. + * + * @param userdata The userdata pointer set via GHOSTTY_SYS_OPT_USERDATA + * @param buf Buffer to fill + * @param len Number of bytes to fill + * @return true if the buffer was filled, false if no entropy is available + */ +typedef bool (*GhosttySysRandomSecureFn)( + void* userdata, + uint8_t* buf, + size_t len); + /** * System option identifiers for ghostty_sys_set(). */ @@ -165,6 +183,21 @@ typedef enum GHOSTTY_ENUM_TYPED { * Input type: GhosttySysLogFn (function pointer, or NULL) */ GHOSTTY_SYS_OPT_LOG = 2, + + /** + * Override the secure random source. + * + * By default the library draws secure random bytes from the + * platform (getrandom or arc4random_buf on POSIX, CNG on Windows). + * Targets without one, such as wasm32-freestanding, have no default + * and operations that need entropy fail with GHOSTTY_IO_ERROR until + * this is set. When set, + * it is used instead of the platform source on every target. When + * cleared (NULL value), the platform default is restored. + * + * Input type: GhosttySysRandomSecureFn (function pointer, or NULL) + */ + GHOSTTY_SYS_OPT_RANDOM_SECURE = 3, GHOSTTY_SYS_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySysOption; diff --git a/src/lib/TinyIo.zig b/src/lib/TinyIo.zig index f89278070..ddabe99fd 100644 --- a/src/lib/TinyIo.zig +++ b/src/lib/TinyIo.zig @@ -161,7 +161,7 @@ const vtable: Io.VTable = if (!supported) std.Io.failing.vtable.* else .{ .progressParentFile = Io.failingProgressParentFile, .random = Io.noRandom, - .randomSecure = Io.failingRandomSecure, + .randomSecure = randomSecure, .now = Io.noNow, .clockResolution = Io.failingClockResolution, @@ -212,6 +212,35 @@ fn swapCancelProtection( fn checkCancel(_: ?*anyopaque) Io.Cancelable!void {} +fn randomSecure(_: ?*anyopaque, buffer: []u8) Io.RandomSecureError!void { + if (buffer.len == 0) return; + + // The same sources as `std.Io.Threaded.randomSecure` minus + // cancelation and the /dev/urandom fallback: arc4random_buf where + // libc provides it (all the BSDs and Darwin, glibc 2.36+), otherwise + // the getrandom syscall on Linux. Anything else has no entropy. + if (builtin.link_libc and @TypeOf(posix.system.arc4random_buf) != void) { + posix.system.arc4random_buf(buffer.ptr, buffer.len); + return; + } + + if (builtin.os.tag == .linux) { + const linux = std.os.linux; + var i: usize = 0; + while (i < buffer.len) { + const rc = linux.getrandom(buffer[i..].ptr, buffer.len - i, 0); + switch (linux.errno(rc)) { + .SUCCESS => i += rc, + .INTR => continue, + else => return error.EntropyUnavailable, + } + } + return; + } + + return error.EntropyUnavailable; +} + fn closeFd(fd: posix.fd_t) void { // Never retry close on EINTR: POSIX leaves the fd state unspecified // and Linux always closes it, so retrying risks closing an unrelated @@ -965,6 +994,28 @@ test "Io.Mutex through TinyIo" { test_io.vtable.futexWaitUncancelable(test_io.userdata, &word, 1); } +test "randomSecure fills with fresh entropy" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var a: [32]u8 = @splat(0); + var b: [32]u8 = @splat(0); + try test_io.randomSecure(&a); + try test_io.randomSecure(&b); + + // Non-zero and non-repeating. A zero fill is what `random` does + // without a source, which would make every one-time password the + // same; identical draws would mean the same thing. + try testing.expect(!std.mem.allEqual(u8, &a, 0)); + try testing.expect(!std.mem.allEqual(u8, &b, 0)); + try testing.expect(!std.mem.eql(u8, &a, &b)); + + // Zero-length is a no-op. + try test_io.randomSecure(a[0..0]); +} + test "unused operations fail gracefully" { if (comptime !supported) return error.SkipZigTest; const tio: TinyIo = .init; diff --git a/src/terminal/c/sys.zig b/src/terminal/c/sys.zig index 319f17985..40e7e1c84 100644 --- a/src/terminal/c/sys.zig +++ b/src/terminal/c/sys.zig @@ -23,6 +23,13 @@ pub const DecodePngFn = *const fn ( *Image, ) callconv(lib.calling_conv) bool; +/// C: GhosttySysRandomSecureFn +pub const RandomSecureFn = *const fn ( + ?*anyopaque, + [*]u8, + usize, +) callconv(lib.calling_conv) bool; + /// C: GhosttySysLogLevel pub const LogLevel = enum(c_int) { @"error" = 0, @@ -55,12 +62,14 @@ pub const Option = enum(c_int) { userdata = 0, decode_png = 1, log = 2, + random_secure = 3, pub fn InType(comptime self: Option) type { return switch (self) { .userdata => ?*const anyopaque, .decode_png => ?DecodePngFn, .log => ?LogFn, + .random_secure => ?RandomSecureFn, }; } }; @@ -71,6 +80,7 @@ const Global = struct { userdata: ?*anyopaque = null, decode_png: ?DecodePngFn = null, log: ?LogFn = null, + random_secure: ?RandomSecureFn = null, }; /// Global state for the C sys interface. @@ -98,6 +108,12 @@ fn decodePngWrapper( }; } +/// Zig-compatible wrapper that calls through to the stored C callback. +fn randomSecureWrapper(buffer: []u8) terminal_sys.RandomSecureError!void { + const func = global.random_secure orelse return error.EntropyUnavailable; + if (!func(global.userdata, buffer.ptr, buffer.len)) return error.EntropyUnavailable; +} + pub fn set( option: Option, value: ?*const anyopaque, @@ -127,6 +143,10 @@ fn setTyped( terminal_sys.decode_png = if (value != null) &decodePngWrapper else null; }, .log => global.log = value, + .random_secure => { + global.random_secure = value; + terminal_sys.random_secure = if (value != null) &randomSecureWrapper else null; + }, } return .success; } diff --git a/src/terminal/kitty/clipboard_grants.zig b/src/terminal/kitty/clipboard_grants.zig index 217d5d781..59b9bef74 100644 --- a/src/terminal/kitty/clipboard_grants.zig +++ b/src/terminal/kitty/clipboard_grants.zig @@ -5,6 +5,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const clipboard_command = @import("clipboard_command.zig"); +const sys = @import("../sys.zig"); const max_pw_len = clipboard_command.max_pw_len; @@ -113,13 +114,14 @@ pub const otp_alphabet = "23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVW /// 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. +/// clipboard without a prompt. Entropy comes from `sys.random_secure` +/// if set, otherwise from the Io; see `sys.randomSecure`. pub fn generateOtp(io: std.Io) std.Io.RandomSecureError![otp_len]u8 { var result: [otp_len]u8 = undefined; var len: usize = 0; while (len < result.len) { var raw: [2 * otp_len]u8 = undefined; - try io.randomSecure(&raw); + try sys.randomSecure(io, &raw); const limit = (std.math.maxInt(u8) + 1) / otp_alphabet.len * otp_alphabet.len; for (raw) |byte| { if (byte >= limit) continue; @@ -216,3 +218,21 @@ test "generateOtp: no entropy is an error, never a weak password" { const testing = std.testing; try testing.expectError(error.EntropyUnavailable, generateOtp(std.Io.failing)); } + +test "generateOtp: sys override supplies entropy without an Io source" { + const testing = std.testing; + const S = struct { + var counter: u8 = 0; + fn fill(buffer: []u8) sys.RandomSecureError!void { + for (buffer) |*b| { + b.* = counter; + counter +%= 1; + } + } + }; + sys.random_secure = &S.fill; + defer sys.random_secure = null; + + const otp = try generateOtp(std.Io.failing); + for (otp) |c| try testing.expect(std.mem.indexOfScalar(u8, otp_alphabet, c) != null); +} diff --git a/src/terminal/sys.zig b/src/terminal/sys.zig index f0c64da50..3f6b5017d 100644 --- a/src/terminal/sys.zig +++ b/src/terminal/sys.zig @@ -52,3 +52,51 @@ fn decodePngWuffs( .data = result.data, }; } + +/// Fill a buffer with cryptographically secure random bytes. If null, +/// the terminal's `std.Io` (`randomSecure`) is used. This is an override +/// for embedders whose Io has no entropy source (e.g. wasm32-freestanding, +/// where TinyIo degrades to `std.Io.failing`) or that want to control +/// the source; when set it is used on every target. +/// +/// This is used for secrets, so it must be a real CSPRNG. An error +/// makes the operation that needed the entropy fail; nothing falls back +/// to weaker randomness. +pub var random_secure: ?RandomSecureFn = null; + +pub const RandomSecureError = error{EntropyUnavailable}; +pub const RandomSecureFn = *const fn ([]u8) RandomSecureError!void; + +/// Fill `buffer` with secure random bytes from `random_secure` if set, +/// otherwise from `io`. Every use of secure entropy in the terminal +/// package goes through this so the override applies uniformly. +pub fn randomSecure(io: std.Io, buffer: []u8) std.Io.RandomSecureError!void { + if (random_secure) |func| return func(buffer); + return io.randomSecure(buffer); +} + +test "randomSecure: override is preferred over the Io" { + const testing = std.testing; + const S = struct { + fn fill(buffer: []u8) RandomSecureError!void { + @memset(buffer, 0xAB); + } + fn fail(_: []u8) RandomSecureError!void { + return error.EntropyUnavailable; + } + }; + + // Without the override a failing Io fails. + var buf: [8]u8 = @splat(0); + try testing.expectError(error.EntropyUnavailable, randomSecure(std.Io.failing, &buf)); + + // With it, the Io is never consulted. + random_secure = &S.fill; + defer random_secure = null; + try randomSecure(std.Io.failing, &buf); + try testing.expect(std.mem.allEqual(u8, &buf, 0xAB)); + + // An override failure surfaces as the Io's error. + random_secure = &S.fail; + try testing.expectError(error.EntropyUnavailable, randomSecure(testing.io, &buf)); +} From 60a1ae2df755629dc7aa7d7aac38569ca46d43a5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 22 Aug 2026 15:00:22 -0700 Subject: [PATCH 4/4] libghostty: add ghostty_terminal_paste C API with paste events example --- example/c-vt-paste/README.md | 10 +- example/c-vt-paste/src/main.c | 213 ++++++++++++++++++++++-- include/ghostty/vt.h | 10 +- include/ghostty/vt/paste.h | 144 ++++++++++++++-- include/ghostty/vt/terminal.h | 5 + include/ghostty/vt/types.h | 6 + src/lib_vt.zig | 1 + src/terminal/c/main.zig | 1 + src/terminal/c/paste.zig | 298 ++++++++++++++++++++++++++++++++++ src/terminal/c/result.zig | 1 + src/terminal/c/types.zig | 4 + 11 files changed, 662 insertions(+), 31 deletions(-) diff --git a/example/c-vt-paste/README.md b/example/c-vt-paste/README.md index 377cd3c3b..4db5297a8 100644 --- a/example/c-vt-paste/README.md +++ b/example/c-vt-paste/README.md @@ -1,7 +1,11 @@ -# Example: `ghostty-vt` Paste Utilities +# Example: `ghostty-vt` Paste -This contains a simple example of how to use the `ghostty-vt` paste -utilities to check if paste data is safe and encode it for terminal input. +This contains a simple example of how to paste into a `ghostty-vt` +terminal with `ghostty_terminal_paste`: plain and bracketed (mode 2004) +text pastes, the unsafe-paste confirmation flow, and Kitty clipboard +protocol paste events (mode 5522) including the program's follow-up +clipboard read. It also shows the terminal-free building blocks for +checking paste safety and encoding paste data. This uses a `build.zig` and `Zig` to build the C program so that we can reuse a lot of our build logic and depend directly on our source diff --git a/example/c-vt-paste/src/main.c b/example/c-vt-paste/src/main.c index e6e4b3d61..64799f10b 100644 --- a/example/c-vt-paste/src/main.c +++ b/example/c-vt-paste/src/main.c @@ -1,7 +1,130 @@ +#include +#include +#include #include #include #include +#define GS(s) ((GhosttyString){.ptr = (const uint8_t*)(s), .len = sizeof(s) - 1}) + +// Print bytes destined for the pty with control characters made visible. +static void print_escaped(const uint8_t* data, size_t len) { + for (size_t i = 0; i < len; i++) { + switch (data[i]) { + case 0x1b: printf("ESC"); break; + case '\r': printf("\\r"); break; + case '\n': printf("\\n"); break; + default: putchar(data[i]); break; + } + } +} + +// The base64 password of the last paste event, captured from the OK +// packet so the example can play the program's side of the protocol. +static char event_pw[128]; + +// Everything the terminal writes to the running program: the pasted +// text, or the paste event packets when mode 5522 is enabled. +static void on_write_pty(GhosttyTerminal terminal, + void* userdata, + const uint8_t* data, + size_t len) { + (void)terminal; + (void)userdata; + printf(" -> pty (%zu bytes): ", len); + print_escaped(data, len); + printf("\n"); + + // A paste event's OK packet: OSC 5522 ; type=read:status=OK:pw= ST + const char* prefix = "\x1b]5522;type=read:status=OK:pw="; + size_t prefix_len = strlen(prefix); + if (len > prefix_len && memcmp(data, prefix, prefix_len) == 0) { + size_t end = prefix_len; + while (end < len && data[end] != 0x1b) end++; + size_t pw_len = end - prefix_len; + if (pw_len < sizeof(event_pw)) { + memcpy(event_pw, data + prefix_len, pw_len); + event_pw[pw_len] = 0; + } + } +} + +// Serves clipboard reads. After a paste event the program's read arrives +// with `granted` set, because it carries the event's one-time password, +// so the embedder skips its permission prompt. +static void on_clipboard_read(GhosttyTerminal terminal, + void* userdata, + const GhosttyClipboardRead* read) { + (void)terminal; + (void)userdata; + printf(" clipboard read: name=\""); + fwrite(read->name.ptr, 1, read->name.len, stdout); + printf("\" granted=%s\n", read->granted ? "yes (no prompt needed)" : "no"); + + const char* text = "hello from the clipboard"; + GhosttyClipboardContent content = { + .mime = GS("text/plain"), + .data = {.ptr = (const uint8_t*)text, .len = strlen(text)}, + }; + GhosttyClipboardReadReply reply = { + .size = sizeof(reply), + .result = GHOSTTY_CLIPBOARD_READ_RESULT_SUCCESS, + .contents = &content, + .contents_len = 1, + .available = NULL, + .available_len = 0, + .remember = false, + }; + read->reply(read, &reply); +} + +// A real embedder would show a dialog here. +static bool confirm_with_user(void) { + printf(" paste could inject commands; user confirmed\n"); + return true; +} + +//! [terminal-paste] +// Paste whatever the clipboard holds. The terminal applies its own +// state: bracketed paste framing (mode 2004) or a Kitty paste event +// (mode 5522) instead of the text. +static void paste_clipboard(GhosttyTerminal terminal, const char* text) { + GhosttyClipboardContent contents[] = { + // The first text representation is what a text paste writes. + {.mime = GS("text/plain"), + .data = {.ptr = (const uint8_t*)text, .len = strlen(text)}}, + // Listed on a paste event, never written, so no data is needed. + {.mime = GS("image/png"), .data = {.ptr = NULL, .len = 0}}, + }; + GhosttyPaste paste = { + .size = sizeof(paste), + .location = GHOSTTY_CLIPBOARD_LOCATION_STANDARD, + .source = GHOSTTY_PASTE_SOURCE_CLIPBOARD, + .contents = contents, + .contents_len = sizeof(contents) / sizeof(contents[0]), + .allow_unsafe = false, + }; + + bool written = false; + GhosttyResult result = ghostty_terminal_paste(terminal, &paste, &written); + if (result == GHOSTTY_REJECTED) { + // The text could inject commands (e.g. a newline outside of a + // bracketed paste). Nothing was written; ask, then retry. + if (!confirm_with_user()) return; + paste.allow_unsafe = true; + result = ghostty_terminal_paste(terminal, &paste, &written); + } + if (result != GHOSTTY_SUCCESS) { + fprintf(stderr, "paste failed: %d\n", (int)result); + return; + } + + // Whether the pty got the text or a paste event depends on the + // terminal's modes; either way it went through write_pty above. + printf(" %s\n", written ? "written" : "nothing to paste"); +} +//! [terminal-paste] + //! [paste-safety] void safety_example() { const char* safe_data = "hello world"; @@ -29,27 +152,89 @@ void encode_example() { if (result == GHOSTTY_SUCCESS) { printf("Encoded %zu bytes: ", written); - fwrite(buf, 1, written, stdout); + print_escaped((const uint8_t*)buf, written); printf("\n"); } } //! [paste-encode] +static void vt_write(GhosttyTerminal terminal, const char* seq) { + ghostty_terminal_vt_write(terminal, (const uint8_t*)seq, strlen(seq)); +} + int main() { + GhosttyTerminal terminal = NULL; + if (ghostty_terminal_new(NULL, &terminal, 80, 24) != GHOSTTY_SUCCESS) { + fprintf(stderr, "Failed to create terminal\n"); + return 1; + } + + // Pasted bytes and paste events go to write_pty. Serving clipboard + // reads is what lets the terminal send paste events at all: without + // this callback the program could never read the clipboard, so pastes + // stay text even when mode 5522 is enabled. + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_WRITE_PTY, + (const void*)on_write_pty); + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ, + (const void*)on_clipboard_read); + + printf("Plain paste:\n"); + paste_clipboard(terminal, "hello world"); + + printf("Paste with a newline (refused, then confirmed):\n"); + paste_clipboard(terminal, "echo hi\n"); + + // The program enables bracketed paste: newlines are safe inside the + // frame and are preserved. + printf("Bracketed paste (mode 2004):\n"); + vt_write(terminal, "\x1b[?2004h"); + paste_clipboard(terminal, "line one\nline two"); + + // The program enables paste events: the clipboard's MIME types are + // listed with a one-time password instead of writing the data. + printf("Paste event (mode 5522):\n"); + vt_write(terminal, "\x1b[?5522h"); + paste_clipboard(terminal, "hello world"); + + // Play the program's side: read the clipboard with the password from + // the event. The read arrives granted and the data is served through + // write_pty as base64 without any permission prompt. + if (event_pw[0] != 0) { + printf("Program reads with the event password:\n"); + char read_seq[256]; + snprintf(read_seq, sizeof(read_seq), + "\x1b]5522;type=read:pw=%s:name=UGFzdGUgZXZlbnQ=;dGV4dC9wbGFpbg==\x1b\\", + event_pw); + vt_write(terminal, read_seq); + } + + // Text inserted by other means (IME, drag and drop) is never an event. + printf("IME text with mode 5522 enabled:\n"); + { + const char* text = "committed"; + GhosttyClipboardContent content = { + .mime = GS("text/plain"), + .data = {.ptr = (const uint8_t*)text, .len = strlen(text)}, + }; + GhosttyPaste paste = { + .size = sizeof(paste), + .location = GHOSTTY_CLIPBOARD_LOCATION_STANDARD, + .source = GHOSTTY_PASTE_SOURCE_TEXT, + .contents = &content, + .contents_len = 1, + .allow_unsafe = true, + }; + bool written = false; + if (ghostty_terminal_paste(terminal, &paste, &written) == GHOSTTY_SUCCESS && + written) { + printf(" written\n"); + } + } + + ghostty_terminal_free(terminal); + + printf("\nTerminal-free building blocks:\n"); safety_example(); - - // Test unsafe paste data with bracketed paste end sequence - const char *unsafe_escape = "evil\x1b[201~code"; - if (!ghostty_paste_is_safe(unsafe_escape, strlen(unsafe_escape))) { - printf("Data with escape sequence is UNSAFE\n"); - } - - // Test empty data - const char *empty_data = ""; - if (ghostty_paste_is_safe(empty_data, 0)) { - printf("Empty data is safe\n"); - } - encode_example(); return 0; diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 7519a3f25..57f0a0e65 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -34,7 +34,7 @@ * - @ref snapshot "Terminal Snapshot" - Encode and incrementally restore terminal state * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences - * - @ref paste "Paste Utilities" - Validate paste data safety + * - @ref paste "Paste" - Paste into a terminal, validate and encode paste data * - @ref unicode "Unicode Utilities" - Codepoint properties for text layout * - @ref build_info "Build Info" - Query compile-time build configuration * - @ref allocator "Memory Management" - Memory management and custom allocators @@ -53,7 +53,7 @@ * - @ref c-vt/src/main.c - OSC parser example * - @ref c-vt-encode-key/src/main.c - Key encoding example * - @ref c-vt-encode-mouse/src/main.c - Mouse encoding example - * - @ref c-vt-paste/src/main.c - Paste safety check example + * - @ref c-vt-paste/src/main.c - Paste example * - @ref c-vt-sgr/src/main.c - SGR parser example * - @ref c-vt-formatter/src/main.c - Terminal formatter example * - @ref c-vt-grid-traverse/src/main.c - Grid traversal example using grid refs @@ -83,8 +83,10 @@ */ /** @example c-vt-paste/src/main.c - * This example demonstrates how to use the paste utilities to check if - * paste data is safe before sending it to the terminal. + * This example demonstrates how to paste into a terminal, including the + * unsafe-paste confirmation flow and Kitty clipboard protocol paste events + * (mode 5522), as well as the terminal-free paste safety and encoding + * utilities. */ /** @example c-vt-sgr/src/main.c diff --git a/include/ghostty/vt/paste.h b/include/ghostty/vt/paste.h index b3df5be4e..9c6774429 100644 --- a/include/ghostty/vt/paste.h +++ b/include/ghostty/vt/paste.h @@ -1,26 +1,57 @@ /** * @file paste.h * - * Paste utilities - validate and encode paste data for terminal input. + * Paste - paste into a terminal, and validate and encode paste data. */ #ifndef GHOSTTY_VT_PASTE_H #define GHOSTTY_VT_PASTE_H -/** @defgroup paste Paste Utilities +/** @defgroup paste Paste * - * Utilities for validating and encoding paste data for terminal input. + * Pasting into a terminal, plus the terminal-free utilities for + * validating and encoding paste data. * - * ## Basic Usage + * ## Pasting into a Terminal * - * Use ghostty_paste_is_safe() to check if paste data contains potentially - * dangerous sequences before sending it to the terminal. + * What a paste writes to the pty depends on the terminal's state, so + * the recommended way to paste is ghostty_terminal_paste(). The embedder + * hands over what the clipboard holds as MIME-typed contents (just + * `text/plain` for an ordinary paste) and where it came from, and the + * terminal decides how its current modes apply: * - * Use ghostty_paste_encode() to encode paste data for writing to the pty, + * - If Kitty clipboard protocol paste events (mode 5522, + * GHOSTTY_MODE_PASTE_EVENTS) are enabled, the paste was user-initiated + * (GHOSTTY_PASTE_SOURCE_CLIPBOARD), and a clipboard_read callback is + * installed, the terminal sends the program a paste event listing the + * clipboard's MIME types with a one-time password instead of the data. + * The program then reads what it wants through the clipboard_read + * callback, which arrives with `granted` set so no permission prompt + * is needed. + * - Otherwise the first text representation is written: unsafe control + * bytes are replaced with spaces, and it is wrapped in bracketed paste + * sequences if mode 2004 (GHOSTTY_MODE_BRACKETED_PASTE) is enabled, or + * has its newlines converted to carriage returns if not. + * + * Text that could inject commands (a newline when unbracketed, or the + * bracketed paste terminator when bracketed) is refused with + * GHOSTTY_REJECTED unless GhosttyPaste::allow_unsafe is set. The usual + * flow is to call once, confirm with the user on GHOSTTY_REJECTED, and + * call again with `allow_unsafe` set. + * + * Output is delivered through the write_pty callback + * (GHOSTTY_TERMINAL_OPT_WRITE_PTY) in a single call. + * + * @snippet c-vt-paste/src/main.c terminal-paste + * + * ## Building Blocks + * + * For embedders that encode without a terminal, ghostty_paste_is_safe() + * checks if paste data contains potentially dangerous sequences + * (conservatively, regardless of terminal state) and + * ghostty_paste_encode() encodes paste data for writing to the pty, * including bracketed paste wrapping and unsafe byte stripping. * - * ## Examples - * * ### Safety Check * * @snippet c-vt-paste/src/main.c paste-safety @@ -35,11 +66,99 @@ #include #include #include +#include #ifdef __cplusplus extern "C" { #endif +/** + * Why a paste happened. + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** The user pasted from a clipboard: keybind, menu, middle click. */ + GHOSTTY_PASTE_SOURCE_CLIPBOARD = 0, + + /** + * Text inserted some other way: IME commit, drag and drop, scripted + * input. Always written as text, never as a paste event, matching + * kitty. This is not a way to opt out of paste events; an embedder + * that doesn't want them doesn't install a clipboard_read callback. + */ + GHOSTTY_PASTE_SOURCE_TEXT = 1, + GHOSTTY_PASTE_SOURCE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyPasteSource; + +/** + * A paste of clipboard contents into the terminal. + * + * This is a sized struct; set `size` to `sizeof(GhosttyPaste)`. The + * contents array and the strings it points to are borrowed only for the + * duration of the ghostty_terminal_paste() call. + */ +typedef struct { + /** Size of this struct in bytes. */ + size_t size; + + /** + * The clipboard the contents came from. Reported to the program on a + * paste event (the selection and primary locations are both reported + * as the primary selection, the protocol knows only two); no effect + * on a text paste. + */ + GhosttyClipboardLocation location; + + /** Why this paste happened. */ + GhosttyPasteSource source; + + /** + * Borrowed array of the representations available, in preferred + * order. A text paste writes the first entry with a text MIME type + * such as "text/plain" and ignores the rest. A paste event lists every + * entry's MIME type and never reads data, so non-text entries may have + * empty data. May be NULL when contents_len is zero. + */ + const GhosttyClipboardContent* contents; + + /** Number of entries in contents. */ + size_t contents_len; + + /** + * Write text that could inject commands. Call with false, confirm + * with the user on GHOSTTY_REJECTED, and call again with true. + */ + bool allow_unsafe; +} GhosttyPaste; + +/** + * Paste into the terminal according to its current state: a Kitty + * clipboard protocol paste event if mode 5522 is enabled and a + * clipboard_read callback is installed, otherwise the text framed per + * mode 2004. See the group documentation for the full behavior. Output + * goes through the write_pty callback in a single call. The viewport is + * not scrolled; that is up to the embedder, as for key input. + * + * @param terminal The terminal handle + * @param paste The paste request, borrowed for the duration of the call + * @param[out] out_written On success, whether anything was written to + * the pty (the encoded text or a paste event). False means + * there was nothing to paste: no non-empty text + * representation. May be NULL. + * @return GHOSTTY_SUCCESS on success (see @p out_written); + * GHOSTTY_REJECTED if the text could inject commands and + * GhosttyPaste::allow_unsafe is false (nothing was written); + * GHOSTTY_INVALID_VALUE for a NULL terminal or paste, or when no + * write_pty callback is installed; GHOSTTY_OUT_OF_MEMORY; + * GHOSTTY_IO_ERROR if there is no secure entropy source to mint + * a paste event password (wasm32-freestanding without + * GHOSTTY_SYS_OPT_RANDOM_SECURE set), in which case nothing was + * written and no grant was recorded. + */ +GHOSTTY_API GhosttyResult ghostty_terminal_paste( + GhosttyTerminal terminal, + const GhosttyPaste* paste, + bool* out_written); + /** * Check if paste data is safe to paste into the terminal. * @@ -49,7 +168,9 @@ extern "C" { * to exit bracketed paste mode and inject commands * * This check is conservative and considers data unsafe regardless of - * current terminal state. + * current terminal state. ghostty_terminal_paste() applies the + * terminal-state-aware rule itself (newlines are safe inside a + * bracketed paste); use this to apply the stricter rule on top. * * @param data The paste data to check (must not be NULL) * @param len The length of the data in bytes @@ -74,6 +195,9 @@ GHOSTTY_API bool ghostty_paste_is_safe(const char* data, size_t len); * GHOSTTY_OUT_OF_SPACE and sets the required size in @p out_written. * The caller can then retry with a sufficiently sized buffer. * + * This is the encoder ghostty_terminal_paste() uses for a text paste; + * use it directly when there is no terminal to paste into. + * * @param data The paste data to encode (modified in place, may be NULL) * @param data_len The length of the input data in bytes * @param bracketed Whether bracketed paste mode is active diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index b23303605..4f8999a03 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -724,6 +724,11 @@ struct GhosttyClipboardRead { * serves a request for only the targets listing (`list` with no `mimes`) * without prompting. * + * Installing this callback also enables Kitty paste events (mode 5522): + * ghostty_terminal_paste() sends the program an event instead of the text, + * and the program's follow-up read arrives here with `granted` set since + * the user already pasted. See ghostty_terminal_paste(). + * * @param terminal The terminal handle * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA * @param read Borrowed clipboard read request diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index 87856ed01..2184ec95c 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -100,6 +100,12 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_IO_ERROR = -5, /** Operation failed because encoded input exceeded a configured limit */ GHOSTTY_LIMIT_EXCEEDED = -6, + /** + * Operation was rejected by a safety check (e.g. pasted text that could + * inject commands). Nothing was done. Confirm with the user and retry + * with the operation's allow flag set. + */ + GHOSTTY_REJECTED = -7, GHOSTTY_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyResult; diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 53762f775..325f0e138 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -215,6 +215,7 @@ comptime { @export(&c.focus_encode, .{ .name = "ghostty_focus_encode" }); @export(&c.paste_is_safe, .{ .name = "ghostty_paste_is_safe" }); @export(&c.paste_encode, .{ .name = "ghostty_paste_encode" }); + @export(&c.terminal_paste, .{ .name = "ghostty_terminal_paste" }); @export(&c.mouse_event_new, .{ .name = "ghostty_mouse_event_new" }); @export(&c.mouse_event_free, .{ .name = "ghostty_mouse_event_free" }); @export(&c.mouse_event_set_action, .{ .name = "ghostty_mouse_event_set_action" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index a7b72aa05..562fe40e6 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -160,6 +160,7 @@ pub const mouse_encoder_encode = mouse_encode.encode; pub const paste_is_safe = paste.is_safe; pub const paste_encode = paste.encode; +pub const terminal_paste = paste.terminal_paste; pub const alloc_alloc = allocator.alloc; pub const alloc_free = allocator.free; diff --git a/src/terminal/c/paste.zig b/src/terminal/c/paste.zig index bce6a5658..53d371f33 100644 --- a/src/terminal/c/paste.zig +++ b/src/terminal/c/paste.zig @@ -1,8 +1,84 @@ const std = @import("std"); const lib = @import("../lib.zig"); const paste = @import("../../input/paste.zig"); +const terminal_paste_pkg = @import("../paste.zig"); +const clipboard = @import("../clipboard.zig"); +const terminal_c = @import("terminal.zig"); +const Terminal = terminal_c.Terminal; +const ClipboardContent = terminal_c.ClipboardContent; +const ClipboardRead = terminal_c.ClipboardRead; +const ClipboardReadReply = terminal_c.ClipboardReadReply; const Result = @import("result.zig").Result; +/// Why a paste happened. +/// +/// C: GhosttyPasteSource +pub const Source = terminal_paste_pkg.Source; + +/// A paste of clipboard contents into the terminal. Sized struct. +/// +/// C: GhosttyPaste +pub const Request = extern struct { + size: usize = @sizeOf(Request), + location: clipboard.Location, + source: Source, + contents: ?[*]const ClipboardContent, + contents_len: usize, + allow_unsafe: bool, +}; + +pub fn terminal_paste( + terminal_: Terminal, + req_: ?*const Request, + out_written: ?*bool, +) callconv(lib.calling_conv) Result { + const wrapper = terminal_ orelse return .invalid_value; + const req = req_ orelse return .invalid_value; + + // Every field is required; a smaller size is a caller from a + // different ABI version than any this struct has had. + if (req.size < @sizeOf(Request)) return .invalid_value; + + // The handler always has a write_pty trampoline that no-ops without + // a C callback, so the "nothing can be written" check is ours. + if (wrapper.effects.write_pty == null) return .invalid_value; + + const c_contents: []const ClipboardContent = if (req.contents) |ptr| + ptr[0..req.contents_len] + else + &.{}; + + // A paste carries a handful of representations, so keep the common + // case allocation-free. + var sfa = std.heap.stackFallback(256, wrapper.terminal.gpa()); + const alloc = sfa.get(); + const contents = alloc.alloc( + clipboard.Content, + c_contents.len, + ) catch return .out_of_memory; + defer alloc.free(contents); + for (contents, c_contents) |*content, c_content| { + content.* = .{ + .mime = c_content.mime.ptr[0..c_content.mime.len], + .data = c_content.data.ptr[0..c_content.data.len], + }; + } + + const written = wrapper.stream.handler.paste(.{ + .location = req.location, + .source = req.source, + .contents = contents, + .allow_unsafe = req.allow_unsafe, + }) catch |err| return switch (err) { + error.UnsafePaste => .rejected, + error.NoWritePty => .invalid_value, + error.OutOfMemory => .out_of_memory, + error.EntropyUnavailable, error.Canceled => .io_error, + }; + if (out_written) |ptr| ptr.* = written; + return .success; +} + pub fn is_safe(data: ?[*]const u8, len: usize) callconv(lib.calling_conv) bool { const slice: []const u8 = if (data) |v| v[0..len] else &.{}; return paste.isSafe(slice); @@ -127,3 +203,225 @@ test "is_safe with null empty data" { const testing = std.testing; try testing.expect(is_safe(null, 0)); } + +/// Capture state for the terminal_paste tests: every pty write and the +/// clipboard reads that follow a paste event. +const TerminalPasteCapture = struct { + var written: [1024]u8 = undefined; + var written_len: usize = 0; + var write_count: usize = 0; + var read_count: usize = 0; + var last_read_granted: bool = false; + + fn reset() void { + written_len = 0; + write_count = 0; + read_count = 0; + last_read_granted = false; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void { + @memcpy(written[written_len..][0..len], ptr[0..len]); + written_len += len; + write_count += 1; + } + + fn clipboardRead(_: Terminal, _: ?*anyopaque, request: *const ClipboardRead) callconv(lib.calling_conv) void { + read_count += 1; + last_read_granted = request.granted; + const contents = [_]ClipboardContent{.{ + .mime = .init(@as([]const u8, "text/plain")), + .data = .init(@as([]const u8, "Ghostty")), + }}; + request.reply(request, &.{ + .size = @sizeOf(ClipboardReadReply), + .result = .success, + .contents = &contents, + .contents_len = contents.len, + .available = null, + .available_len = 0, + .remember = false, + }); + } + + fn writtenSlice() []const u8 { + return written[0..written_len]; + } + + /// A single text/plain request; the caller provides the content + /// storage since the request borrows it. + fn textRequest(content: *ClipboardContent, text: []const u8) Request { + content.* = .{ + .mime = .init(@as([]const u8, "text/plain")), + .data = .init(text), + }; + return .{ + .location = .standard, + .source = .clipboard, + .contents = content[0..1], + .contents_len = 1, + .allow_unsafe = false, + }; + } +}; + +test "terminal_paste null handling" { + const testing = std.testing; + const S = TerminalPasteCapture; + + var t: Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new(&lib.alloc.test_allocator, &t, 80, 24)); + defer terminal_c.free(t); + + var written: bool = true; + var content: ClipboardContent = undefined; + const req: Request = S.textRequest(&content, "hello"); + try testing.expectEqual(Result.invalid_value, terminal_paste(null, &req, &written)); + try testing.expectEqual(Result.invalid_value, terminal_paste(t, null, &written)); + try testing.expect(written); + + // A size smaller than the struct is rejected. + var small = req; + small.size = @sizeOf(usize); + try testing.expectEqual(Result.invalid_value, terminal_paste(t, &small, &written)); +} + +test "terminal_paste without write_pty is invalid" { + const testing = std.testing; + const S = TerminalPasteCapture; + S.reset(); + + var t: Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new(&lib.alloc.test_allocator, &t, 80, 24)); + defer terminal_c.free(t); + + var content: ClipboardContent = undefined; + const req: Request = S.textRequest(&content, "hello"); + try testing.expectEqual(Result.invalid_value, terminal_paste(t, &req, null)); +} + +test "terminal_paste text and unsafe" { + const testing = std.testing; + const S = TerminalPasteCapture; + S.reset(); + + var t: Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new(&lib.alloc.test_allocator, &t, 80, 24)); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.set(t, .write_pty, @ptrCast(&S.writePty))); + + // Plain text, NULL out_written pointer is fine. + var content: ClipboardContent = undefined; + const req: Request = S.textRequest(&content, "hel\x1blo"); + try testing.expectEqual(Result.success, terminal_paste(t, &req, null)); + try testing.expectEqualStrings("hel lo", S.writtenSlice()); + try testing.expectEqual(@as(usize, 1), S.write_count); + + // Unsafe is refused with nothing written, then allowed. + S.reset(); + var written: bool = false; + var unsafe_content: ClipboardContent = undefined; + var unsafe: Request = S.textRequest(&unsafe_content, "rm -rf /\n"); + try testing.expectEqual(Result.rejected, terminal_paste(t, &unsafe, &written)); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expect(!written); + + unsafe.allow_unsafe = true; + try testing.expectEqual(Result.success, terminal_paste(t, &unsafe, &written)); + try testing.expect(written); + try testing.expectEqualStrings("rm -rf /\r", S.writtenSlice()); + + // Bracketed paste mode frames the text through the real mode path. + S.reset(); + const decset = "\x1b[?2004h"; + terminal_c.vt_write(t, decset, decset.len); + try testing.expectEqual(Result.success, terminal_paste(t, &req, &written)); + try testing.expect(written); + try testing.expectEqualStrings("\x1b[200~hel lo\x1b[201~", S.writtenSlice()); + + // No text representation writes nothing. NULL contents with a zero + // length is an empty list. + S.reset(); + var empty_content: ClipboardContent = undefined; + var empty: Request = S.textRequest(&empty_content, ""); + empty.contents = null; + empty.contents_len = 0; + try testing.expectEqual(Result.success, terminal_paste(t, &empty, &written)); + try testing.expect(!written); + try testing.expectEqual(@as(usize, 0), S.write_count); +} + +test "terminal_paste event" { + const testing = std.testing; + const S = TerminalPasteCapture; + S.reset(); + + var t: Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new(&lib.alloc.test_allocator, &t, 80, 24)); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.set(t, .write_pty, @ptrCast(&S.writePty))); + t.?.terminal.modes.set(.kitty_paste_events, true); + + // Without a clipboard_read callback the paste stays text. + var written: bool = false; + const contents = [_]ClipboardContent{ + .{ + .mime = .init(@as([]const u8, "text/plain")), + .data = .init(@as([]const u8, "secret")), + }, + .{ + .mime = .init(@as([]const u8, "image/png")), + .data = .init(@as([]const u8, "")), + }, + }; + const req: Request = .{ + .location = .primary, + .source = .clipboard, + .contents = &contents, + .contents_len = contents.len, + .allow_unsafe = false, + }; + try testing.expectEqual(Result.success, terminal_paste(t, &req, &written)); + try testing.expect(written); + try testing.expectEqualStrings("secret", S.writtenSlice()); + + // With one, an event is sent listing every MIME type and the data + // is never written. + S.reset(); + try testing.expectEqual(Result.success, terminal_c.set(t, .clipboard_read, @ptrCast(&S.clipboardRead))); + try testing.expectEqual(Result.success, terminal_paste(t, &req, &written)); + try testing.expect(written); + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqual(@as(usize, 3), std.mem.count(u8, S.writtenSlice(), "\x1b]5522;")); + try testing.expect(std.mem.startsWith(u8, S.writtenSlice(), "\x1b]5522;type=read:status=OK:loc=primary:pw=")); + try testing.expect(std.mem.indexOf(u8, S.writtenSlice(), "secret") == null); + try testing.expect(std.mem.indexOf(u8, S.writtenSlice(), ";dGV4dC9wbGFpbiBpbWFnZS9wbmcK\x1b\\") != null); + + // The program's read with the event password is granted once. + const ok_prefix = "\x1b]5522;type=read:status=OK:loc=primary:pw="; + const pw_end = std.mem.indexOfPos(u8, S.writtenSlice(), ok_prefix.len, "\x1b\\").?; + var read_buf: [256]u8 = undefined; + const read = try std.fmt.bufPrint( + &read_buf, + "\x1b]5522;type=read:pw={s}:name=UGFzdGUgZXZlbnQ=;dGV4dC9wbGFpbg==\x1b\\", + .{S.writtenSlice()[ok_prefix.len..pw_end]}, + ); + S.reset(); + terminal_c.vt_write(t, read.ptr, read.len); + try testing.expectEqual(@as(usize, 1), S.read_count); + try testing.expect(S.last_read_granted); + try testing.expect(std.mem.indexOf(u8, S.writtenSlice(), ";R2hvc3R0eQ==\x1b\\") != null); + + S.reset(); + terminal_c.vt_write(t, read.ptr, read.len); + try testing.expectEqual(@as(usize, 1), S.read_count); + try testing.expect(!S.last_read_granted); + + // Text sources never become events. + S.reset(); + var ime = req; + ime.source = .text; + try testing.expectEqual(Result.success, terminal_paste(t, &ime, &written)); + try testing.expect(written); + try testing.expectEqualStrings("secret", S.writtenSlice()); +} diff --git a/src/terminal/c/result.zig b/src/terminal/c/result.zig index a127d4386..174e973ba 100644 --- a/src/terminal/c/result.zig +++ b/src/terminal/c/result.zig @@ -7,4 +7,5 @@ pub const Result = enum(c_int) { no_value = -4, io_error = -5, limit_exceeded = -6, + rejected = -7, }; diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index 0367a35b6..933edcdcf 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -18,6 +18,7 @@ const formatter_pkg = @import("../formatter.zig"); const modes_pkg = @import("../modes.zig"); const mouse_pkg = @import("../mouse.zig"); const page = @import("../page.zig"); +const paste_pkg = @import("../paste.zig"); const point = @import("../point.zig"); const Selection = @import("../Selection.zig"); const sgr = @import("../sgr.zig"); @@ -37,6 +38,7 @@ const kitty_graphics = @import("kitty_graphics.zig"); const mouse_encode = @import("mouse_encode.zig"); const mouse_event = @import("mouse_event.zig"); const osc = @import("osc.zig"); +const paste = @import("paste.zig"); const render = @import("render.zig"); const result = @import("result.zig"); const row = @import("row.zig"); @@ -192,6 +194,7 @@ const type_decls = [_]TypeDecl{ .initStruct("GhosttyKittyGraphicsPlacementRenderInfo", kitty_graphics.PlacementRenderInfo), .initStruct("GhosttyMouseEncoderSize", mouse_encode.Size), .initStruct("GhosttyMousePosition", mouse_event.Position), + .initStruct("GhosttyPaste", paste.Request), .initTaggedStruct("GhosttyPoint", point.Point.C, "tag", "value", .generated), .initStruct("GhosttyPointCoordinate", point.Coordinate), .initUnion("GhosttyPointValue", point.Point.CValue, point.Point.C), @@ -272,6 +275,7 @@ const type_decls = [_]TypeDecl{ "GHOSTTY_OSC_COMMAND_", "TYPE_MAX_VALUE", ), + .initEnum("GhosttyPasteSource", paste_pkg.Source, "GHOSTTY_PASTE_SOURCE_"), .initEnum("GhosttyPointTag", point.Tag, "GHOSTTY_POINT_TAG_"), .initEnum("GhosttyRenderStateCursorVisualStyle", render.CursorVisualStyle, "GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_"), .initEnum("GhosttyRenderStateData", render.Data, "GHOSTTY_RENDER_STATE_DATA_"),