libghostty: implement Kitty clipboard protocol reads via clipboard_read effect

This commit is contained in:
Mitchell Hashimoto
2026-08-22 07:02:09 -07:00
parent 6959fd46c6
commit 4f49dc2b8b
5 changed files with 568 additions and 58 deletions

View File

@@ -584,8 +584,9 @@ typedef enum {
* duration of the reply call and may be freed as soon as it returns.
*
* Any result other than GHOSTTY_CLIPBOARD_READ_RESULT_SUCCESS answers the
* program with an empty clipboard; the other fields are ignored in that
* case. On success, `contents` should carry one representation per
* program with an empty clipboard (OSC 52) or the matching protocol status
* (OSC 5522: EPERM, ENOSYS, EBUSY, EIO); the other fields are ignored in
* that case. On success, `contents` should carry one representation per
* requested MIME type (GhosttyClipboardRead::mimes) that the clipboard
* has; unrequested representations are ignored. Protocols that carry a
* single text value (OSC 52) use the first entry with a text MIME type
@@ -649,7 +650,7 @@ typedef void (*GhosttyClipboardReadReplyFn)(
* GhosttyClipboardReadReply. This must happen before the callback returns;
* the request is invalid afterwards. Calling `reply` more than once is
* ignored. Returning without replying answers the program with an empty
* clipboard.
* clipboard (OSC 52) or EPERM (OSC 5522).
*
* @ingroup terminal
*/
@@ -707,15 +708,22 @@ struct GhosttyClipboardRead {
* Callback function type for clipboard_read.
*
* Called synchronously when the running program requests clipboard contents
* via OSC 52 with a "?" payload. Answering lets the program read the user's
* clipboard, so the embedder is expected to mediate consent. Because the
* read is synchronous, an embedder that needs to ask the user must block
* (for example by running a modal prompt) until it has an answer; the VT
* stream waits until the callback returns.
* via OSC 52 with a "?" payload or a Kitty clipboard (OSC 5522) read.
* Answering lets the program read the user's clipboard, so the embedder is
* expected to mediate consent. Because the read is synchronous, an embedder
* that needs to ask the user must block (for example by running a modal
* prompt) until it has an answer; the VT stream waits until the callback
* returns.
*
* Answer by calling `read->reply(read, &reply)` before returning. See
* GhosttyClipboardRead for the full contract.
*
* OSC 5522 requests carry the program's MIME list, name, and password grant
* state; a reply that sets `remember` records a 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.
*
* @param terminal The terminal handle
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
* @param read Borrowed clipboard read request
@@ -1424,9 +1432,10 @@ typedef enum GHOSTTY_ENUM_TYPED {
/**
* Callback invoked when the running program requests clipboard contents
* via OSC 52 with a "?" payload. The read is synchronous and must be
* answered before the callback returns. Set to NULL to ignore clipboard
* read requests (the default).
* via OSC 52 with a "?" payload or a Kitty clipboard (OSC 5522) read. The
* read is synchronous and must be answered before the callback returns.
* Set to NULL (the default) to ignore OSC 52 read requests and refuse
* OSC 5522 reads with EPERM.
*
* Input type: GhosttyTerminalClipboardReadFn
*/

View File

@@ -241,10 +241,11 @@ pub const ModeConfig = extern struct {
/// C callback state for terminal effects. Most trampolines are always
/// installed on the stream handler; they check these fields and no-op when
/// the corresponding callback is null. The unknown-sequence and
/// clipboard-write trampolines are installed dynamically to preserve
/// their null fast paths (for clipboard_write, a null Zig-level effect
/// makes Kitty clipboard writes fail up front instead of spooling a
/// transaction that can never commit).
/// clipboard trampolines are installed dynamically to preserve their
/// null fast paths (for clipboard_write, a null Zig-level effect makes
/// Kitty clipboard writes fail up front instead of spooling a
/// transaction that can never commit; for clipboard_read it keeps
/// reads denied).
const Effects = struct {
userdata: ?*anyopaque = null,
write_pty: ?WritePtyFn = null,
@@ -4845,7 +4846,7 @@ test "kitty clipboard write via C effects" {
S.responses[0..S.responses_len],
);
// Reads are always denied.
// Without a read callback reads are denied.
S.responses_len = 0;
const read = "\x1B]5522;type=read:id=r1;dGV4dC9wbGFpbg==\x1B\\";
vt_write(t, read, read.len);
@@ -4855,6 +4856,70 @@ test "kitty clipboard write via C effects" {
S.responses[0..S.responses_len],
);
// With a read callback the request is served through it.
const R = struct {
var count: usize = 0;
var last_mimes_len: usize = 0;
var last_mime_is_text: bool = false;
var last_list: bool = true;
var last_name_len: usize = 0;
var last_granted: bool = true;
var last_can_remember: bool = true;
fn clipboardRead(
_: Terminal,
_: ?*anyopaque,
request: *const ClipboardRead,
) callconv(lib.calling_conv) void {
count += 1;
last_mimes_len = request.mimes_len;
last_mime_is_text = request.mimes_len > 0 and std.mem.eql(
u8,
request.mimes.?[0].ptr[0..request.mimes.?[0].len],
"text/plain",
);
last_list = request.list;
last_name_len = request.name.len;
last_granted = request.granted;
last_can_remember = request.can_remember;
const mime: []const u8 = "text/plain";
const data: []const u8 = "hello";
const contents = [_]ClipboardContent{.{
.mime = .init(mime),
.data = .init(data),
}};
request.reply(request, &.{
.size = @sizeOf(ClipboardReadReply),
.result = .success,
.contents = &contents,
.contents_len = contents.len,
.available = null,
.available_len = 0,
.remember = false,
});
}
};
try testing.expectEqual(Result.success, set(t, .clipboard_read, @ptrCast(&R.clipboardRead)));
S.responses_len = 0;
// name="app" without a password: forwarded for prompts, not
// rememberable.
const read2 = "\x1B]5522;type=read:id=r2:name=YXBw;dGV4dC9wbGFpbg==\x1B\\";
vt_write(t, read2, read2.len);
try testing.expectEqual(@as(usize, 1), R.count);
try testing.expectEqual(@as(usize, 1), R.last_mimes_len);
try testing.expect(R.last_mime_is_text);
try testing.expect(!R.last_list);
try testing.expectEqual(@as(usize, 3), R.last_name_len);
try testing.expect(!R.last_granted);
try testing.expect(!R.last_can_remember);
try testing.expectEqualStrings(
"\x1B]5522;type=read:status=OK:id=r2\x1B\\" ++
"\x1B]5522;type=read:status=DATA:id=r2:mime=dGV4dC9wbGFpbg==;aGVsbG8=\x1B\\" ++
"\x1B]5522;type=read:status=DONE:id=r2\x1B\\",
S.responses[0..S.responses_len],
);
// Without a clipboard callback the transaction fails up front.
try testing.expectEqual(Result.success, set(t, .clipboard_write, null));
S.responses_len = 0;

View File

@@ -28,8 +28,8 @@ pub const max_pw_len = 128;
/// types are tiny; anything longer drops the packet.
pub const max_mime_len = 256;
/// Maximum decoded name length we bother validating. Longer names are
/// treated as present without validation; only their presence matters.
/// Maximum decoded name length. Kitty has no limit but names are shown
/// in permission prompts so anything longer drops the packet.
pub const max_name_len = 256;
/// The decoded, validated metadata of one OSC 5522 sequence.
@@ -62,9 +62,10 @@ pub const Metadata = struct {
/// treat the request as though it had no password."
pw: []const u8 = "",
/// True if a non-empty (valid) name was given. We don't retain the
/// name contents; it exists to opt into password grants.
has_name: bool = false,
/// Decoded human friendly name of the requesting program, shown in
/// permission prompts. Empty means absent. Its presence opts into
/// password grants.
name: []const u8 = "",
/// Parse the metadata field. The raw value is expected to be exactly
/// the metadata (prefix and payload and separators stripped out).
@@ -124,21 +125,13 @@ pub const Metadata = struct {
error.Invalid => return null,
};
} else if (std.mem.eql(u8, key, "name")) {
// We only need to know whether a (non-empty) name was
// given; the contents are decoded for validation only.
result.has_name = has_name: {
const name = decodeValue(
alloc,
value,
max_name_len,
) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
// Over-long names are accepted as present but
// not validated further.
error.Overflow => break :has_name true,
error.Invalid => return null,
};
break :has_name name.len > 0;
result.name = decodeValue(
alloc,
value,
max_name_len,
) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.Overflow, error.Invalid => return null,
};
}
// Unknown keys are ignored.
@@ -351,7 +344,21 @@ test "metadata: pw and name" {
// pw="secret", name="app"
const meta = (try Metadata.parse(arena.allocator(), "type=read:pw=c2VjcmV0:name=YXBw")).?;
try testing.expectEqualStrings("secret", meta.pw);
try testing.expect(meta.has_name);
try testing.expectEqualStrings("app", meta.name);
}
test "metadata: over-long name dropped" {
const testing = std.testing;
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const Encoder = std.base64.standard.Encoder;
const long = "n" ** (max_name_len + 1);
var buf: [Encoder.calcSize(long.len)]u8 = undefined;
const raw = try std.mem.concat(arena.allocator(), u8, &.{
"type=read:name=",
Encoder.encode(&buf, long),
});
try testing.expect((try Metadata.parse(arena.allocator(), raw)) == null);
}
test "metadata: empty name" {
@@ -359,7 +366,7 @@ test "metadata: empty name" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const meta = (try Metadata.parse(arena.allocator(), "type=read:pw=c2VjcmV0:name=")).?;
try testing.expect(!meta.has_name);
try testing.expectEqual(@as(usize, 0), meta.name.len);
}
test "payload: mime iterator" {

View File

@@ -36,7 +36,7 @@ pub const WriteState = struct {
loc: clipboard.Location,
id: []const u8,
pw: []const u8,
has_name: bool,
name: []const u8,
spool: std.ArrayListUnmanaged(u8) = .empty,
entries: std.ArrayListUnmanaged(Entry) = .empty,
aliases: std.ArrayListUnmanaged(Alias) = .empty,
@@ -68,12 +68,13 @@ pub const WriteState = struct {
errdefer arena.deinit();
const id = try arena.allocator().dupe(u8, meta.id);
const pw = try arena.allocator().dupe(u8, meta.pw);
const name = try arena.allocator().dupe(u8, meta.name);
return .{
.arena = arena,
.loc = meta.loc,
.id = id,
.pw = pw,
.has_name = meta.has_name,
.name = name,
};
}
@@ -220,7 +221,7 @@ pub const WriteState = struct {
loc: clipboard.Location,
id: []const u8,
pw: []const u8,
has_name: bool,
name: []const u8,
truncated: bool,
contents: []const Content,
@@ -289,7 +290,7 @@ pub const WriteState = struct {
.loc = self.loc,
.id = self.id,
.pw = self.pw,
.has_name = self.has_name,
.name = self.name,
.truncated = self.truncated,
.contents = try contents.toOwnedSlice(alloc),
};

View File

@@ -77,6 +77,12 @@ pub const Handler = struct {
/// Heap-allocated since transactions are rare and short-lived.
kitty_clipboard_write: ?*kitty_clipboard.WriteState = null,
/// Kitty clipboard protocol (OSC 5522) session password grants,
/// recorded when a clipboard_read reply asks to remember the user's
/// decision. Later requests carrying a granted password are forwarded
/// with `granted` set so the embedder can skip its prompt.
kitty_clipboard_grants: kitty_clipboard.Grants = .{},
/// Called for sequence identifiers not supported by this library.
/// Currently, only APC is reported. Content is borrowed and only valid
/// for the duration of the callback. Set `apc_handler.unknown_max_bytes`
@@ -165,16 +171,24 @@ pub const Handler = struct {
clipboard_write: ?*const fn (*Handler, clipboard.Write) clipboard.WriteResult,
/// Called when the running program requests clipboard contents
/// (OSC 52 with a "?" payload). Answering one lets the program
/// read the user's clipboard, so the embedder is expected to
/// mediate consent.
/// (OSC 52 with a "?" payload, or a Kitty clipboard (OSC 5522)
/// read). Answering one lets the program read the user's
/// clipboard, so the embedder is expected to mediate consent.
///
/// Reads are synchronous: the callback must answer through
/// `read.reply` before it returns, so an embedder that needs to
/// ask the user must block (e.g. run a modal prompt) while the
/// stream waits. Returning without a reply, or replying denied or
/// unsupported, answers the program with an empty clipboard so it
/// doesn't hang. If this is null, read requests are ignored.
/// stream waits. Returning without a reply, or replying with any
/// failure, answers the program with an empty clipboard (OSC 52)
/// or the matching protocol status (OSC 5522) so it doesn't hang.
/// If this is null, OSC 52 reads are ignored and OSC 5522 reads
/// are refused with EPERM.
///
/// OSC 5522 requests carry the program's MIME list, name, and
/// password grant state; a reply that sets `remember` records a
/// 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.
clipboard_read: ?*const fn (*Handler, clipboard.Read) void,
/// Called in response to an XTVERSION query. Returns the version
@@ -223,6 +237,7 @@ pub const Handler = struct {
pub fn deinit(self: *Handler) void {
self.kittyClipboardAbort();
self.kitty_clipboard_grants.deinit(self.terminal.gpa());
self.apc_handler.deinit();
self.dcs_handler.deinit();
}
@@ -391,7 +406,7 @@ pub const Handler = struct {
log.warn("error reporting Kitty colors err={}", .{err});
},
.kitty_clipboard => self.kittyClipboard(value) catch |err| {
// Clipboard writes are external effects, not terminal
// Clipboard operations are external effects, not terminal
// state; a failed transaction was already answered.
log.warn("error handling kitty clipboard err={}", .{err});
},
@@ -702,9 +717,8 @@ pub const Handler = struct {
payload: []const u8,
terminator: osc.Terminator,
) error{OutOfMemory}!void {
// The payload (the requested MIME list) must still decode even
// though we never serve it: kitty drops a read request with an
// undecodable payload without any response.
// The payload is the requested MIME list. Kitty drops a read
// request with an undecodable payload without any response.
const alloc = self.terminal.gpa();
const decoded = kitty_clipboard.Payload.init(
alloc,
@@ -713,17 +727,178 @@ pub const Handler = struct {
error.OutOfMemory => return error.OutOfMemory,
error.Invalid => return,
};
decoded.deinit(alloc);
defer decoded.deinit(alloc);
// For now, EPERM always
self.kittyClipboardRespond(&.{
.op = .read,
.status = .EPERM,
// Without a clipboard_read effect nothing can serve the read.
// EPERM is the protocol's denial so clients degrade gracefully.
const func = self.effects.clipboard_read orelse {
self.kittyClipboardRespond(&.{
.op = .read,
.status = .EPERM,
.id = meta.id,
.terminator = terminator,
});
return;
};
// The targets type ('.') asks for the listing of available
// types rather than data. Requested types beyond the cap are
// dropped and simply never served, which is how the protocol
// reports an unavailable type anyway.
var mimes_buf: [kitty_clipboard.max_read_mimes][]const u8 = undefined;
const mimes, const list = mimes: {
var targets = false;
var len: usize = 0;
var it = decoded.mimeIterator();
while (it.next()) |mime| {
if (std.mem.eql(u8, mime, kitty_clipboard.targets_mime)) {
targets = true;
continue;
}
if (len == mimes_buf.len) continue;
mimes_buf[len] = mime;
len += 1;
}
break :mimes .{ mimes_buf[0..len], targets };
};
// Per the spec a password without a name is no password. A
// stored grant for it lets the embedder skip its prompt.
const pw: []const u8 = if (meta.name.len > 0) meta.pw else "";
const granted = self.kitty_clipboard_grants.use(alloc, pw, .read);
var state: KittyClipboardReadState = .{
.handler = self,
.primary = meta.loc == .primary,
.id = meta.id,
.pw = pw,
.mimes = mimes,
.list = list,
.terminator = terminator,
};
func(self, .{
.location = meta.loc,
.mimes = mimes,
.list = list,
.name = meta.name,
.granted = granted,
.can_remember = pw.len > 0,
.reply_ctx = &state,
.reply_fn = &KittyClipboardReadState.reply,
});
// The program is waiting on us, so a callback that returned
// without a reply is answered as a denial rather than silence.
if (!state.replied) state.respondStatus(.EPERM);
}
/// Reply state for one synchronous Kitty clipboard read. This lives
/// on the kittyClipboardRead stack frame, so it is only valid during
/// the callback.
const KittyClipboardReadState = struct {
handler: *Handler,
primary: bool,
id: []const u8,
/// The effective password, empty when the request had none.
pw: []const u8,
/// The requested types; only these are served from a reply.
mimes: []const []const u8,
list: bool,
terminator: osc.Terminator,
replied: bool = false,
fn reply(ctx: *anyopaque, result: clipboard.Read.Result) void {
const self: *KittyClipboardReadState = @ptrCast(@alignCast(ctx));
if (self.replied) {
log.warn("clipboard read replied more than once, ignoring", .{});
return;
}
self.replied = true;
const success = switch (result) {
.denied => return self.respondStatus(.EPERM),
.unsupported => return self.respondStatus(.ENOSYS),
.busy => return self.respondStatus(.EBUSY),
.io_error => return self.respondStatus(.EIO),
.success => |s| s,
};
// Remembering is only offered when the request carried a
// usable password.
if (success.remember and self.pw.len > 0) {
self.handler.kitty_clipboard_grants.grant(
self.handler.terminal.gpa(),
self.pw,
.read,
false,
) catch |err| {
log.warn("error recording clipboard grant err={}", .{err});
};
}
self.respondSuccess(&success) catch |err| {
log.warn("error replying to clipboard read err={}", .{err});
self.respondStatus(.EIO);
};
}
/// Answer with a single status packet.
fn respondStatus(
self: *const KittyClipboardReadState,
status: kitty_clipboard.Status,
) void {
self.handler.kittyClipboardRespond(&.{
.op = .read,
.status = status,
.id = self.id,
.terminator = self.terminator,
});
}
/// Answer with the full success sequence (OK, listing, DATA
/// chunks, DONE), serving only the requested representations
/// in request order.
fn respondSuccess(
self: *const KittyClipboardReadState,
success: *const clipboard.Read.Result.Success,
) error{ OutOfMemory, WriteFailed }!void {
const handler = self.handler;
if (handler.effects.write_pty == null) return;
var served_buf: [kitty_clipboard.max_read_mimes]clipboard.Content = undefined;
var served_len: usize = 0;
for (self.mimes) |mime| {
for (success.contents) |content| {
if (!std.mem.eql(u8, content.mime, mime)) continue;
served_buf[served_len] = content;
served_len += 1;
break;
}
}
// Status packets fit on the stack; DATA packets carry the
// clipboard contents and fall back to the heap.
var stack = std.heap.stackFallback(1024, handler.terminal.gpa());
const alloc = stack.get();
var aw: std.Io.Writer.Allocating = .init(alloc);
defer aw.deinit();
try (kitty_clipboard.ReadSuccess{
.primary = self.primary,
.id = self.id,
.list = self.list,
.available = success.available,
.contents = served_buf[0..served_len],
.terminator = self.terminator,
}).encode(&aw.writer);
const written = try aw.toOwnedSliceSentinel(0);
defer alloc.free(written);
handler.writePty(written);
}
};
fn kittyClipboardWriteBegin(
self: *Handler,
meta: *const kitty_clipboard.Metadata,
@@ -3159,6 +3334,20 @@ const KittyClipboardCapture = struct {
var last_data: [8][256]u8 = undefined;
var last_data_lens: [8]usize = @splat(0);
// Read capture. A null read_result returns without replying.
var read_count: usize = 0;
var read_result: ?clipboard.Read.Result = null;
var read_reply_twice: bool = false;
var last_read_location: clipboard.Location = .standard;
var last_read_mimes: [8][64]u8 = undefined;
var last_read_mime_lens: [8]usize = @splat(0);
var last_read_mimes_len: usize = 0;
var last_read_list: bool = false;
var last_read_name: [64]u8 = undefined;
var last_read_name_len: usize = 0;
var last_read_granted: bool = false;
var last_read_can_remember: bool = false;
fn reset() void {
responses_len = 0;
write_count = 0;
@@ -3167,6 +3356,16 @@ const KittyClipboardCapture = struct {
last_contents_len = 0;
last_mime_lens = @splat(0);
last_data_lens = @splat(0);
read_count = 0;
read_result = null;
read_reply_twice = false;
last_read_location = .standard;
last_read_mime_lens = @splat(0);
last_read_mimes_len = 0;
last_read_list = false;
last_read_name_len = 0;
last_read_granted = false;
last_read_can_remember = false;
}
fn writePty(_: *Handler, data: [:0]const u8) void {
@@ -3187,10 +3386,35 @@ const KittyClipboardCapture = struct {
return result;
}
fn clipboardRead(_: *Handler, read: clipboard.Read) void {
read_count += 1;
last_read_location = read.location;
last_read_mimes_len = read.mimes.len;
for (read.mimes[0..@min(read.mimes.len, last_read_mimes.len)], 0..) |mime, i| {
last_read_mime_lens[i] = mime.len;
@memcpy(last_read_mimes[i][0..mime.len], mime);
}
last_read_list = read.list;
last_read_name_len = read.name.len;
@memcpy(last_read_name[0..read.name.len], read.name);
last_read_granted = read.granted;
last_read_can_remember = read.can_remember;
if (read_result) |r| read.reply(r);
if (read_reply_twice) read.reply(.denied);
}
fn responseSlice() []const u8 {
return responses[0..responses_len];
}
fn readMimeAt(i: usize) []const u8 {
return last_read_mimes[i][0..last_read_mime_lens[i]];
}
fn readName() []const u8 {
return last_read_name[0..last_read_name_len];
}
fn mimeAt(i: usize) []const u8 {
return last_mimes[i][0..last_mime_lens[i]];
}
@@ -3321,7 +3545,7 @@ test "kitty clipboard write without clipboard effect responds ENOSYS" {
);
}
test "kitty clipboard read is denied with EPERM" {
test "kitty clipboard read without effect is denied with EPERM" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
@@ -3356,6 +3580,210 @@ test "kitty clipboard read is denied with EPERM" {
try testing.expectEqual(@as(usize, 0), S.responses_len);
}
test "kitty clipboard read round trip" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
S.reset();
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();
S.read_result = .{
.success = .{
.contents = &.{
// Unrequested representations are never served, and the
// served ones follow request order, not reply order.
.{ .mime = "image/png", .data = "\x89PNG" },
.{ .mime = "text/html", .data = "<b>hi</b>" },
.{ .mime = "text/plain", .data = "Ghostty" },
},
.available = &.{ "text/plain", "text/html" },
},
};
// Request the targets listing plus two types from the primary
// selection: ". text/plain text/html".
s.nextSlice("\x1B]5522;type=read:loc=primary:id=r1;LiB0ZXh0L3BsYWluIHRleHQvaHRtbA==\x1B\\");
try testing.expectEqual(@as(usize, 1), S.read_count);
try testing.expectEqual(clipboard.Location.primary, S.last_read_location);
try testing.expectEqual(@as(usize, 2), S.last_read_mimes_len);
try testing.expectEqualStrings("text/plain", S.readMimeAt(0));
try testing.expectEqualStrings("text/html", S.readMimeAt(1));
try testing.expect(S.last_read_list);
try testing.expectEqualStrings("", S.readName());
try testing.expect(!S.last_read_granted);
try testing.expect(!S.last_read_can_remember);
try testing.expectEqualStrings(
"\x1B]5522;type=read:status=OK:loc=primary:id=r1\x1B\\" ++
"\x1B]5522;type=read:status=DATA:id=r1:mime=Lg==;dGV4dC9wbGFpbiB0ZXh0L2h0bWwK\x1B\\" ++
"\x1B]5522;type=read:status=DATA:id=r1:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==\x1B\\" ++
"\x1B]5522;type=read:status=DATA:id=r1:mime=dGV4dC9odG1s;PGI+aGk8L2I+\x1B\\" ++
"\x1B]5522;type=read:status=DONE:id=r1\x1B\\",
S.responseSlice(),
);
// Without the listing request `available` is ignored. The response
// echoes the request terminator.
S.responses_len = 0;
s.nextSlice("\x1B]5522;type=read:id=r2;dGV4dC9wbGFpbg==\x07");
try testing.expectEqual(clipboard.Location.standard, S.last_read_location);
try testing.expectEqual(@as(usize, 1), S.last_read_mimes_len);
try testing.expect(!S.last_read_list);
try testing.expectEqualStrings(
"\x1B]5522;type=read:status=OK:id=r2\x07" ++
"\x1B]5522;type=read:status=DATA:id=r2:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==\x07" ++
"\x1B]5522;type=read:status=DONE:id=r2\x07",
S.responseSlice(),
);
// A listing-only request carries no types.
S.responses_len = 0;
s.nextSlice("\x1B]5522;type=read;Lg==\x1B\\");
try testing.expectEqual(@as(usize, 0), S.last_read_mimes_len);
try testing.expect(S.last_read_list);
try testing.expectEqualStrings(
"\x1B]5522;type=read:status=OK\x1B\\" ++
"\x1B]5522;type=read:status=DATA:mime=Lg==;dGV4dC9wbGFpbiB0ZXh0L2h0bWwK\x1B\\" ++
"\x1B]5522;type=read:status=DONE\x1B\\",
S.responseSlice(),
);
}
test "kitty clipboard read result maps to response status" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
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();
const cases = [_]struct {
result: ?clipboard.Read.Result,
response: []const u8,
}{
.{ .result = .denied, .response = "\x1B]5522;type=read:status=EPERM:id=x\x1B\\" },
.{ .result = .unsupported, .response = "\x1B]5522;type=read:status=ENOSYS:id=x\x1B\\" },
.{ .result = .busy, .response = "\x1B]5522;type=read:status=EBUSY:id=x\x1B\\" },
.{ .result = .io_error, .response = "\x1B]5522;type=read:status=EIO:id=x\x1B\\" },
// No reply at all is a denial rather than silence.
.{ .result = null, .response = "\x1B]5522;type=read:status=EPERM:id=x\x1B\\" },
// A success with nothing to serve is still OK then DONE.
.{ .result = .{ .success = .{} }, .response = "\x1B]5522;type=read:status=OK:id=x\x1B\\" ++
"\x1B]5522;type=read:status=DONE:id=x\x1B\\" },
};
for (cases) |case| {
S.reset();
S.read_result = case.result;
s.nextSlice("\x1B]5522;type=read:id=x;dGV4dC9wbGFpbg==\x1B\\");
try testing.expectEqual(@as(usize, 1), S.read_count);
try testing.expectEqualStrings(case.response, S.responseSlice());
}
// A second reply is ignored.
S.reset();
S.read_result = .{ .success = .{ .contents = &.{.{ .mime = "text/plain", .data = "hello" }} } };
S.read_reply_twice = true;
s.nextSlice("\x1B]5522;type=read;dGV4dC9wbGFpbg==\x1B\\");
try testing.expectEqualStrings(
"\x1B]5522;type=read:status=OK\x1B\\" ++
"\x1B]5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==;aGVsbG8=\x1B\\" ++
"\x1B]5522;type=read:status=DONE\x1B\\",
S.responseSlice(),
);
}
test "kitty clipboard read caps requested types" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
S.reset();
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();
// "a/0 a/1 a/2 a/3 a/4 a/5 .": extras are dropped but the listing
// request after them still counts.
S.read_result = .{ .success = .{} };
s.nextSlice("\x1B]5522;type=read;YS8wIGEvMSBhLzIgYS8zIGEvNCBhLzUgLg==\x1B\\");
try testing.expectEqual(@as(usize, 1), S.read_count);
try testing.expectEqual(kitty_clipboard.max_read_mimes, S.last_read_mimes_len);
try testing.expectEqualStrings("a/0", S.readMimeAt(0));
try testing.expectEqualStrings("a/3", S.readMimeAt(kitty_clipboard.max_read_mimes - 1));
try testing.expect(S.last_read_list);
}
test "kitty clipboard read password grants" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
S.reset();
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();
// pw="secret", name="app": the first request isn't granted but the
// reply may ask to remember it.
S.read_result = .{ .success = .{ .remember = true } };
s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0:name=YXBw\x1B\\");
try testing.expectEqual(@as(usize, 1), S.read_count);
try testing.expectEqualStrings("app", S.readName());
try testing.expect(!S.last_read_granted);
try testing.expect(S.last_read_can_remember);
// The same password is now granted; a different one is not.
S.read_result = .{ .success = .{} };
s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0:name=YXBw\x1B\\");
try testing.expect(S.last_read_granted);
s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=:name=YXBw\x1B\\");
try testing.expect(!S.last_read_granted);
try testing.expect(S.last_read_can_remember);
// A password without a name doesn't count: it is neither granted
// nor rememberable, even if the reply asks.
S.read_result = .{ .success = .{ .remember = true } };
s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0\x1B\\");
try testing.expectEqualStrings("", S.readName());
try testing.expect(!S.last_read_granted);
try testing.expect(!S.last_read_can_remember);
s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=\x1B\\");
try testing.expect(!S.last_read_can_remember);
S.read_result = .{ .success = .{} };
s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=:name=YXBw\x1B\\");
try testing.expect(!S.last_read_granted);
// A grant is advisory: the request is still forwarded and the
// embedder may deny it.
S.responses_len = 0;
S.read_result = .denied;
s.nextSlice("\x1B]5522;type=read:id=d:pw=c2VjcmV0:name=YXBw\x1B\\");
try testing.expect(S.last_read_granted);
try testing.expectEqualStrings(
"\x1B]5522;type=read:status=EPERM:id=d\x1B\\",
S.responseSlice(),
);
// Grants are freed with the stream (the testing allocator catches
// the leak otherwise).
}
test "kitty clipboard malformed packets are silently dropped" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);