terminal: add shared paste core with Kitty clipboard paste events

This commit is contained in:
Mitchell Hashimoto
2026-08-22 14:40:03 -07:00
parent da5ddcb085
commit a5bb22e235
7 changed files with 483 additions and 8 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

185
src/terminal/paste.zig Normal file
View File

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