From 4f49dc2b8bfcd8b1a33de8a95d3b1a1a7135496f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 22 Aug 2026 07:02:09 -0700 Subject: [PATCH] libghostty: implement Kitty clipboard protocol reads via clipboard_read effect --- include/ghostty/vt/terminal.h | 31 +- src/terminal/c/terminal.zig | 75 +++- src/terminal/kitty/clipboard_command.zig | 51 +-- src/terminal/kitty/clipboard_write.zig | 9 +- src/terminal/stream_terminal.zig | 460 ++++++++++++++++++++++- 5 files changed, 568 insertions(+), 58 deletions(-) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 80fdad8c3..b23303605 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -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 */ diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index ab45fdfff..ba00f1385 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -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; diff --git a/src/terminal/kitty/clipboard_command.zig b/src/terminal/kitty/clipboard_command.zig index 5f7115d83..40718840a 100644 --- a/src/terminal/kitty/clipboard_command.zig +++ b/src/terminal/kitty/clipboard_command.zig @@ -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" { diff --git a/src/terminal/kitty/clipboard_write.zig b/src/terminal/kitty/clipboard_write.zig index c4e33a4f3..fbb631446 100644 --- a/src/terminal/kitty/clipboard_write.zig +++ b/src/terminal/kitty/clipboard_write.zig @@ -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), }; diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index f0db5f773..03e6d1887 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -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 = "hi" }, + .{ .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);