From 5984d6f7326845312bf5c00f4d4ae181cd733c41 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 10:16:25 -0700 Subject: [PATCH 01/10] terminal: add isTextMime helper for plain text MIME type names --- src/terminal/clipboard.zig | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/terminal/clipboard.zig b/src/terminal/clipboard.zig index 554ca984b..170aa0725 100644 --- a/src/terminal/clipboard.zig +++ b/src/terminal/clipboard.zig @@ -1,3 +1,5 @@ +const std = @import("std"); + /// The clipboard destination for a write. pub const Location = enum(c_int) { standard = 0, @@ -6,6 +8,21 @@ pub const Location = enum(c_int) { _, }; +/// MIME types that name plain text across the platforms terminals run +/// on. We accept the union everywhere since serving text under any of these +/// names is harmless. +pub fn isTextMime(mime: []const u8) bool { + const names: []const []const u8 = &.{ + "text/plain", + "text/plain;charset=utf-8", + "UTF8_STRING", + "TEXT", + "STRING", + }; + for (names) |n| if (std.mem.eql(u8, mime, n)) return true; + return false; +} + /// A single representation of clipboard data. /// /// The MIME type and data are borrowed and only valid for the duration of a @@ -34,3 +51,11 @@ pub const WriteResult = enum(c_int) { io_error = 5, _, }; + +test isTextMime { + const testing = std.testing; + try testing.expect(isTextMime("text/plain")); + try testing.expect(isTextMime("UTF8_STRING")); + try testing.expect(!isTextMime("image/png")); + try testing.expect(!isTextMime(".")); +} From 25b1170d422f06146661eb1531c1b574d20f1771 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 10:43:06 -0700 Subject: [PATCH 02/10] terminal: add kitty clipboard protocol (OSC 5522) command parsing --- src/terminal/kitty/clipboard.zig | 55 ++++ src/terminal/kitty/clipboard_command.zig | 386 +++++++++++++++++++++++ 2 files changed, 441 insertions(+) create mode 100644 src/terminal/kitty/clipboard.zig create mode 100644 src/terminal/kitty/clipboard_command.zig diff --git a/src/terminal/kitty/clipboard.zig b/src/terminal/kitty/clipboard.zig new file mode 100644 index 000000000..ddc768c34 --- /dev/null +++ b/src/terminal/kitty/clipboard.zig @@ -0,0 +1,55 @@ +//! Kitty clipboard protocol (OSC 5522). +//! +//! This implements the protocol semantics on top of the raw OSC capture: +//! src/terminal/osc/parsers/kitty_clipboard_protocol.zig: +//! +//! The behavior here is modeled on the kitty reference implementation +//! (kitty/clipboard.py) rather than only the prose spec, since the two +//! disagree in places. Notable reference behaviors we reproduce: +//! +//! * Malformed metadata (any record without '=', including an empty +//! metadata section), an unknown or missing `type`, and invalid +//! base64 in `mime`, `name`, or `pw` all silently drop the request +//! with no response. +//! * `mime`, `name`, and `pw` metadata values are base64-encoded UTF-8; +//! everything else is verbatim. Unknown keys are ignored. +//! * `id` is sanitized by stripping characters outside [a-zA-Z0-9-_+.] +//! and truncating to 512 bytes, then echoed verbatim in every +//! response packet (omitted when empty). +//! * Write data chunks are decoded with a streaming base64 decoder that +//! persists across packets of the same MIME type; a '=' padding +//! mid-stream finishes the current group and resets the decoder, so +//! both per-chunk-padded and continuous unpadded streams work. A +//! chunk with invalid base64 is dropped (decoder reset) and the +//! transaction continues; it is not a protocol error. +//! * A `type=write` silently replaces any in-flight transaction. A +//! commit (`type=wdata` without a MIME type) with no in-flight +//! transaction is silently ignored. +//! * Oversized writes are truncated and still complete with DONE. +//! * Responses never send a payload section for an empty payload, +//! except the targets ('.') listing DATA packet which is always sent. +//! +//! I plan to open an upstream issue asking for clarification on these +//! once I implement this. +//! +//! Specification: https://sw.kovidgoyal.net/kitty/clipboard/ + +const oscpkg = @import("../osc.zig"); +const protocol = @import("../osc/parsers/kitty_clipboard_protocol.zig"); +const command = @import("clipboard_command.zig"); + +pub const OSC = protocol.OSC; +pub const Operation = protocol.Operation; +pub const Status = protocol.Status; +pub const Terminator = oscpkg.Terminator; + +pub const Metadata = command.Metadata; +pub const Payload = command.Payload; +pub const max_id_len = command.max_id_len; +pub const max_pw_len = command.max_pw_len; +pub const max_mime_len = command.max_mime_len; +pub const max_name_len = command.max_name_len; + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/src/terminal/kitty/clipboard_command.zig b/src/terminal/kitty/clipboard_command.zig new file mode 100644 index 000000000..5f7115d83 --- /dev/null +++ b/src/terminal/kitty/clipboard_command.zig @@ -0,0 +1,386 @@ +//! Kitty clipboard protocol (OSC 5522) request decoding. +//! +//! OSC 5522 format: `5522;metadata;payload` +//! +//! Per the spec: "metadata is a colon separated list of key-value +//! pairs and payload is base64 encoded data." +//! +//! This contains the logic for parsing this. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const simd = @import("../../simd/main.zig"); +const clipboard = @import("../clipboard.zig"); +const protocol = @import("../osc/parsers/kitty_clipboard_protocol.zig"); + +const Operation = protocol.Operation; + +/// Maximum id length, nothing specified but this is the limit used +/// in Kitty's source so we'll match it. +pub const max_id_len = 512; + +/// Maximum decoded password length. Kitty has no limit. Passwords are +/// UUID-sized in practice so anything longer simply never matches a +/// stored grant anyway. +pub const max_pw_len = 128; + +/// Maximum decoded MIME type length. Kitty has no limit but real MIME +/// 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. +pub const max_name_len = 256; + +/// The decoded, validated metadata of one OSC 5522 sequence. +/// +/// All slice values are allocated from the allocator given to parse and +/// are sized to their contents. Callers are expected to pass an arena +/// scoped to handling the packet. There is no deinit. +pub const Metadata = struct { + op: Operation, + + /// The clipboard this operation targets. Per the spec: "To read + /// from the primary selection instead of the clipboard, add the + /// key `loc=primary` to the metadata section." Any other value + /// means the clipboard, so only standard and primary are possible + /// here. + loc: clipboard.Location = .standard, + + /// Sanitized id: invalid characters stripped, truncated to + /// max_id_len. Empty means no id. + id: []const u8 = "", + + /// Decoded mime metadata value. Empty means absent; kitty treats an + /// empty mime the same as a missing one everywhere it matters (a + /// wdata packet with either commits the transaction). + mime: []const u8 = "", + + /// Decoded password. Empty means absent. Per the spec: + /// "Specifying a password without a human friendly name is + /// equivalent to not specifying a password and the terminal must + /// 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, + + /// Parse the metadata field. The raw value is expected to be exactly + /// the metadata (prefix and payload and separators stripped out). + /// + /// A null result means it was invalid but without any response. + /// Silently drop the OSC. + pub fn parse( + alloc: Allocator, + raw: []const u8, + ) Allocator.Error!?Metadata { + var op_raw: ?[]const u8 = null; + var result: Metadata = .{ .op = undefined }; + + // Note this loop visits every record even though an empty raw + // string yields a single empty record: that record has no '=' + // and correctly drops the packet, matching kitty which requires + // at least a valid `type` record. + var it = std.mem.splitScalar(u8, raw, ':'); + while (it.next()) |record| { + // Every record must be key=value. Any single invalid record + // is dropped, matching Kitty's behavior. + const eql_idx = std.mem.indexOfScalar(u8, record, '=') orelse return null; + const key = record[0..eql_idx]; + const value = record[eql_idx + 1 ..]; + + if (std.mem.eql(u8, key, "type")) { + // Validated after the loop: a duplicate key's last + // occurrence wins. This isn't specified but its how Kitty + // works. + op_raw = value; + } else if (std.mem.eql(u8, key, "loc")) { + result.loc = if (std.mem.eql(u8, value, "primary")) + .primary + else + .standard; + } else if (std.mem.eql(u8, key, "id")) { + result.id = try sanitizeId(alloc, value); + } else if (std.mem.eql(u8, key, "mime")) { + result.mime = decodeValue( + alloc, + value, + max_mime_len, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.Overflow, error.Invalid => return null, + }; + } else if (std.mem.eql(u8, key, "pw")) { + result.pw = decodeValue( + alloc, + value, + max_pw_len, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + // An over-long password behaves as if none was + // given: it can never match a stored grant. + error.Overflow => "", + 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; + }; + } + // Unknown keys are ignored. + } + + // A missing or unknown operation drops the request. + result.op = Operation.init(op_raw orelse return null) orelse return null; + return result; + } + + /// Sanitize the ID according to the spec: + /// + /// Valid ids must include only characters from the set: [a-zA-Z0-9-_+.]. + /// Any other characters must be stripped out from the id by the terminal + /// emulator before retransmitting it. + fn sanitizeId(alloc: Allocator, value: []const u8) Allocator.Error![]const u8 { + var list: std.ArrayListUnmanaged(u8) = .empty; + defer list.deinit(alloc); + for (value) |c| { + switch (c) { + 'a'...'z', 'A'...'Z', '0'...'9', '-', '_', '+', '.' => {}, + else => continue, + } + if (list.items.len >= max_id_len) break; + try list.append(alloc, c); + } + return try list.toOwnedSlice(alloc); + } + + /// Base64-decode a metadata value. Per the spec these values "are + /// UTF-8 strings that are base64 encoded", so the decoded result + /// must be valid UTF-8. + fn decodeValue(alloc: Allocator, value: []const u8, max_len: usize) error{ + OutOfMemory, + Overflow, + Invalid, + }![]const u8 { + const Encoder = std.base64.standard.Encoder; + + // Avoid hostile large payloads. + if (value.len > Encoder.calcSize(max_len)) return error.Overflow; + + // Decode + const buf = try alloc.alloc(u8, simd.base64.maxLen(value)); + errdefer alloc.free(buf); + const decoded = simd.base64.decode( + value, + buf, + ) catch return error.Invalid; + + // Must be valid UTF-8 + if (!std.unicode.utf8ValidateSlice(decoded)) return error.Invalid; + if (decoded.len > max_len) return error.Overflow; + return decoded; + } +}; + +/// A decoded base64 payload of one OSC 5522 sequence, e.g. the MIME +/// type list of a read request or the alias list of a walias packet. +/// The data slice aliases the allocated buf. +pub const Payload = struct { + buf: []u8, + data: []const u8, + + /// Decode a base64 payload into freshly allocated memory. An + /// invalid payload means the sequence is dropped. + pub fn init( + alloc: Allocator, + payload: []const u8, + ) error{ OutOfMemory, Invalid }!Payload { + const buf = try alloc.alloc(u8, simd.base64.maxLen(payload)); + errdefer alloc.free(buf); + const data = simd.base64.decode( + payload, + buf, + ) catch return error.Invalid; + return .{ .buf = buf, .data = data }; + } + + pub fn deinit(self: *const Payload, alloc: Allocator) void { + alloc.free(self.buf); + } + + /// Iterate the whitespace-separated MIME types of the payload. + /// Matches Python str.split() used by kitty. + pub fn mimeIterator(self: *const Payload) std.mem.TokenIterator(u8, .any) { + return std.mem.tokenizeAny( + u8, + self.data, + &std.ascii.whitespace, + ); + } +}; + +test "metadata: empty is dropped" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + try testing.expect((try Metadata.parse(arena.allocator(), "")) == null); +} + +test "metadata: record without = is dropped" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + try testing.expect((try Metadata.parse(arena.allocator(), "type=read:bare")) == null); + try testing.expect((try Metadata.parse(arena.allocator(), "bare:type=read")) == null); +} + +test "metadata: missing or unknown type is dropped" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + try testing.expect((try Metadata.parse(arena.allocator(), "loc=primary")) == null); + try testing.expect((try Metadata.parse(arena.allocator(), "type=bobr")) == null); + try testing.expect((try Metadata.parse(arena.allocator(), "type=")) == null); +} + +test "metadata: duplicate keys keep the last occurrence" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + try testing.expectEqual( + Operation.read, + (try Metadata.parse(arena.allocator(), "type=bobr:type=read")).?.op, + ); + try testing.expect((try Metadata.parse(arena.allocator(), "type=read:type=bobr")) == null); +} + +test "metadata: basic read" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + const meta = (try Metadata.parse(arena.allocator(), "type=read")).?; + try testing.expectEqual(Operation.read, meta.op); + try testing.expect(meta.loc == .standard); + try testing.expectEqual(@as(usize, 0), meta.id.len); +} + +test "metadata: unknown keys ignored" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + const meta = (try Metadata.parse(arena.allocator(), "type=read:bobr=kurwa")).?; + try testing.expectEqual(Operation.read, meta.op); +} + +test "metadata: loc" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + try testing.expect((try Metadata.parse(arena.allocator(), "type=read:loc=primary")).?.loc == .primary); + // Anything other than "primary" means the clipboard; it is not an + // error. + try testing.expect((try Metadata.parse(arena.allocator(), "type=read:loc=bobr")).?.loc == .standard); +} + +test "metadata: id sanitized" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + { + const meta = (try Metadata.parse(arena.allocator(), "type=read:id=abc-123_x.Y+z")).?; + try testing.expectEqualStrings("abc-123_x.Y+z", meta.id); + } + { + // Invalid characters are stripped, not rejected. + const meta = (try Metadata.parse(arena.allocator(), "type=read:id=*4 2*")).?; + try testing.expectEqualStrings("42", meta.id); + } +} + +test "metadata: id truncated to max" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + const raw = "type=read:id=" ++ "a" ** (max_id_len + 100); + const meta = (try Metadata.parse(arena.allocator(), raw)).?; + try testing.expectEqual(@as(usize, max_id_len), meta.id.len); +} + +test "metadata: mime decoded" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + // "text/plain" + const meta = (try Metadata.parse(arena.allocator(), "type=wdata:mime=dGV4dC9wbGFpbg==")).?; + try testing.expectEqualStrings("text/plain", meta.mime); +} + +test "metadata: invalid mime base64 dropped" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + try testing.expect((try Metadata.parse(arena.allocator(), "type=wdata:mime=!!!")) == null); +} + +test "metadata: invalid mime utf8 dropped" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + // base64 of 0xff 0xfe + try testing.expect((try Metadata.parse(arena.allocator(), "type=wdata:mime=//4=")) == null); +} + +test "metadata: pw and name" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + // 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); +} + +test "metadata: empty name" { + const testing = std.testing; + 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); +} + +test "payload: mime iterator" { + const testing = std.testing; + // base64 of "text/plain text/html\ntext/uri-list" + const payload = try Payload.init( + testing.allocator, + "dGV4dC9wbGFpbiAgdGV4dC9odG1sCnRleHQvdXJpLWxpc3Q=", + ); + defer payload.deinit(testing.allocator); + var it = payload.mimeIterator(); + try testing.expectEqualStrings("text/plain", it.next().?); + try testing.expectEqualStrings("text/html", it.next().?); + try testing.expectEqualStrings("text/uri-list", it.next().?); + try testing.expect(it.next() == null); +} + +test "payload: invalid base64" { + const testing = std.testing; + try testing.expectError( + error.Invalid, + Payload.init(testing.allocator, "!!!"), + ); +} From e28acd928c4c02e8a7d8999e55b4d118098ec423 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 11:34:30 -0700 Subject: [PATCH 03/10] terminal: add kitty clipboard protocol (OSC 5522) response encoding --- src/terminal/kitty/clipboard.zig | 7 + src/terminal/kitty/clipboard_response.zig | 385 ++++++++++++++++++++++ 2 files changed, 392 insertions(+) create mode 100644 src/terminal/kitty/clipboard_response.zig diff --git a/src/terminal/kitty/clipboard.zig b/src/terminal/kitty/clipboard.zig index ddc768c34..7ef850bfe 100644 --- a/src/terminal/kitty/clipboard.zig +++ b/src/terminal/kitty/clipboard.zig @@ -37,6 +37,7 @@ const oscpkg = @import("../osc.zig"); const protocol = @import("../osc/parsers/kitty_clipboard_protocol.zig"); const command = @import("clipboard_command.zig"); +const response = @import("clipboard_response.zig"); pub const OSC = protocol.OSC; pub const Operation = protocol.Operation; @@ -50,6 +51,12 @@ pub const max_pw_len = command.max_pw_len; pub const max_mime_len = command.max_mime_len; pub const max_name_len = command.max_name_len; +pub const Response = response.Response; +pub const ReadSuccess = response.ReadSuccess; +pub const read_chunk_size = response.read_chunk_size; +pub const max_read_mimes = response.max_read_mimes; +pub const targets_mime = response.targets_mime; + test { @import("std").testing.refAllDecls(@This()); } diff --git a/src/terminal/kitty/clipboard_response.zig b/src/terminal/kitty/clipboard_response.zig new file mode 100644 index 000000000..c7f01c165 --- /dev/null +++ b/src/terminal/kitty/clipboard_response.zig @@ -0,0 +1,385 @@ +//! Kitty clipboard protocol (OSC 5522) response encoding. + +const std = @import("std"); +const clipboard = @import("../clipboard.zig"); +const oscpkg = @import("../osc.zig"); +const protocol = @import("../osc/parsers/kitty_clipboard_protocol.zig"); + +const b64 = std.base64.standard.Encoder; +const Content = clipboard.Content; +const Operation = protocol.Operation; +const Status = protocol.Status; +const Terminator = oscpkg.Terminator; + +/// Maximum raw (pre-base64) bytes per DATA packet in read responses. +/// This is specified by the protocol. +pub const read_chunk_size = 4096; + +/// Maximum requested MIME types served by a single read request. +/// Requests beyond this simply see no DATA packets for the extras, +/// which is how the protocol communicates an unavailable type anyway. +pub const max_read_mimes = 4; + +/// The special MIME type that requests the list of available types. +pub const targets_mime = "."; + +/// A single response packet. +pub const Response = struct { + op: Operation, + status: Status, + primary: bool = false, + id: []const u8 = "", + mime: ?[]const u8 = null, + pw: ?[]const u8 = null, + /// The raw payload; the encoder base64-encodes it. An empty payload + /// emits no payload section at all (no ';'). + payload: []const u8 = "", + terminator: Terminator = .st, + + /// Encode the response. Errors may result in partially written data + /// so it is up to callers to buffer it if they need to. + pub fn encode( + self: *const Response, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + try self.encodeMetadata(writer); + if (self.payload.len > 0) { + try writer.writeAll(";"); + try b64.encodeWriter(writer, self.payload); + } + try writer.writeAll(self.terminator.string()); + } + + /// Encode the escape prefix and metadata section only: everything + /// up to (and not including) the payload section and terminator. + fn encodeMetadata( + self: *const Response, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + // The exact order of fields here matches what Kitty does. + try writer.print("\x1b]5522;type={t}:status={t}", .{ self.op, self.status }); + if (self.primary) try writer.writeAll(":loc=primary"); + if (self.id.len > 0) try writer.print(":id={s}", .{self.id}); + if (self.mime) |mime| { + try writer.writeAll(":mime="); + try b64.encodeWriter(writer, mime); + } + if (self.pw) |pw| { + try writer.writeAll(":pw="); + try b64.encodeWriter(writer, pw); + } + } +}; + +/// Encode a full successful read response: the OK packet, the targets +/// listing if requested, DATA chunks for each served representation, +/// and the final DONE packet. This is also the shape of an unsolicited +/// paste event (list=true, pw set to the one-time password). +pub const ReadSuccess = struct { + primary: bool = false, + id: []const u8 = "", + + /// One-time password echoed in every packet. Only used for + /// terminal-initiated paste events. + pw: ?[]const u8 = null, + + /// True when the targets ('.') listing was requested. + list: bool = false, + + /// The MIME types available on the clipboard, reported by the + /// targets listing. Kitty reports these space-separated in one + /// packet with a trailing newline when non-empty. + available: []const []const u8 = &.{}, + + /// The representations to serve, in request order. Each entry's + /// data is chunked into DATA packets under its own MIME type. An + /// entry with empty data produces no packets, which is how the + /// protocol communicates an unavailable type. + contents: []const Content = &.{}, + + terminator: Terminator = .st, + + pub fn encode( + self: *const ReadSuccess, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + // Initial read response + try (Response{ + .op = .read, + .status = .OK, + .primary = self.primary, + .id = self.id, + .pw = self.pw, + .terminator = self.terminator, + }).encode(writer); + + // Listing of mimes if requested + if (self.list) try self.encodeListing(writer); + + // Encoding of each mime-type + content + for (self.contents) |content| { + var i: usize = 0; + while (i < content.data.len) { + const n = @min(content.data.len - i, read_chunk_size); + try (Response{ + .op = .read, + .status = .DATA, + .id = self.id, + .mime = content.mime, + .pw = self.pw, + .payload = content.data[i..][0..n], + .terminator = self.terminator, + }).encode(writer); + i += n; + } + } + + // Trailing done. + try (Response{ + .op = .read, + .status = .DONE, + .id = self.id, + .pw = self.pw, + .terminator = self.terminator, + }).encode(writer); + } + + /// Encode the targets ('.') listing packet. The listing gets a + /// trailing newline when non-empty. + fn encodeListing( + self: *const ReadSuccess, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + const listing: Response = .{ + .op = .read, + .status = .DATA, + .id = self.id, + .mime = targets_mime, + .pw = self.pw, + .terminator = self.terminator, + }; + try listing.encodeMetadata(writer); + if (self.available.len > 0) { + try writer.writeAll(";"); + + // Join the types into one chunk before encoding. The + // listing is a DATA packet, so it shares the pre-encoding + // chunk size bound of any other data packet; types that + // don't fit are dropped (a listing that large doesn't + // happen in practice). + var raw: [read_chunk_size]u8 = undefined; + var fixed: std.Io.Writer = .fixed(&raw); + for (self.available, 0..) |mime, i| { + const sep: usize = if (i > 0) 1 else 0; + if (fixed.end + sep + mime.len + "\n".len > raw.len) break; + if (i > 0) fixed.writeAll(" ") catch unreachable; + fixed.writeAll(mime) catch unreachable; + } + fixed.writeAll("\n") catch unreachable; + try b64.encodeWriter(writer, fixed.buffered()); + } + try writer.writeAll(self.terminator.string()); + } +}; + +test "response: basic status packet" { + const testing = std.testing; + + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (Response{ .op = .write, .status = .DONE }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=write:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "response: id echo and terminator" { + const testing = std.testing; + + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (Response{ + .op = .write, + .status = .EPERM, + .id = "42", + .terminator = .bel, + }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=write:status=EPERM:id=42\x07", + writer.buffered(), + ); +} + +test "response: key order type,status,loc,id,mime,pw and payload" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (Response{ + .op = .read, + .status = .DATA, + .primary = true, + .id = "x", + .mime = "text/plain", + .pw = "otp", + .payload = "Ghostty", + }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=DATA:loc=primary:id=x" ++ + ":mime=dGV4dC9wbGFpbg==:pw=b3Rw;R2hvc3R0eQ==\x1b\\", + writer.buffered(), + ); +} + +test "read success: empty request is OK then DONE" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ .id = "7" }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK:id=7\x1b\\" ++ + "\x1b]5522;type=read:status=DONE:id=7\x1b\\", + writer.buffered(), + ); +} + +test "read success: targets listing with text" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ .list = true, .available = &.{"text/plain"} }).encode(&writer); + // "." => "Lg==", "text/plain\n" => "dGV4dC9wbGFpbgo=" + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=Lg==;dGV4dC9wbGFpbgo=\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "read success: targets listing joins multiple types" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ + .list = true, + .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\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=Lg==;dGV4dC9wbGFpbiBpbWFnZS9wbmcK\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "read success: empty targets listing packet still sent" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ .list = true }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=Lg==\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "read success: data chunks under requested mime" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ + .contents = &.{.{ .mime = "text/plain", .data = "Ghostty" }}, + }).encode(&writer); + 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\\", + writer.buffered(), + ); +} + +test "read success: each representation carries its own data" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ + .contents = &.{ + .{ .mime = "text/plain", .data = "hello" }, + .{ .mime = "image/png", .data = "\x89\x50\x4e\x47\x0d\x0a\x1a\x0a" }, + }, + }).encode(&writer); + 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=DATA:mime=aW1hZ2UvcG5n;iVBORw0KGgo=\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "read success: chunking at read_chunk_size" { + const testing = std.testing; + const alloc = testing.allocator; + + const data = "z" ** (read_chunk_size + 1); + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + try (ReadSuccess{ + .contents = &.{.{ .mime = "text/plain", .data = data }}, + }).encode(&aw.writer); + + // OK + 2 DATA packets + DONE = 4 packets. + const count = std.mem.count(u8, aw.written(), "\x1b]5522;"); + try testing.expectEqual(@as(usize, 4), count); + + // The first chunk is exactly read_chunk_size bytes, base64 encoded + // with padding. + const Encoder = std.base64.standard.Encoder; + var chunk_buf: [Encoder.calcSize(read_chunk_size)]u8 = undefined; + const first = Encoder.encode(&chunk_buf, data[0..read_chunk_size]); + try testing.expect(std.mem.indexOf(u8, aw.written(), first) != null); +} + +test "read success: no data packets for empty clipboard" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ + .contents = &.{.{ .mime = "text/plain", .data = "" }}, + }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "read success: paste event carries pw in every packet" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ + .list = true, + .pw = "otp", + .available = &.{"text/plain"}, + }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK:pw=b3Rw\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=Lg==:pw=b3Rw;dGV4dC9wbGFpbgo=\x1b\\" ++ + "\x1b]5522;type=read:status=DONE:pw=b3Rw\x1b\\", + writer.buffered(), + ); +} From 7a940ec02830fcd39f94ea9a28974c2a82d96486 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 12:09:53 -0700 Subject: [PATCH 04/10] terminal: add kitty clipboard protocol (OSC 5522) write transactions --- src/terminal/kitty/clipboard.zig | 13 +- src/terminal/kitty/clipboard_write.zig | 443 +++++++++++++++++++++++++ 2 files changed, 451 insertions(+), 5 deletions(-) create mode 100644 src/terminal/kitty/clipboard_write.zig diff --git a/src/terminal/kitty/clipboard.zig b/src/terminal/kitty/clipboard.zig index 7ef850bfe..4c9472e13 100644 --- a/src/terminal/kitty/clipboard.zig +++ b/src/terminal/kitty/clipboard.zig @@ -16,11 +16,7 @@ //! * `id` is sanitized by stripping characters outside [a-zA-Z0-9-_+.] //! and truncating to 512 bytes, then echoed verbatim in every //! response packet (omitted when empty). -//! * Write data chunks are decoded with a streaming base64 decoder that -//! persists across packets of the same MIME type; a '=' padding -//! mid-stream finishes the current group and resets the decoder, so -//! both per-chunk-padded and continuous unpadded streams work. A -//! chunk with invalid base64 is dropped (decoder reset) and the +//! * A write data chunk with invalid base64 is dropped and the //! transaction continues; it is not a protocol error. //! * A `type=write` silently replaces any in-flight transaction. A //! commit (`type=wdata` without a MIME type) with no in-flight @@ -37,6 +33,7 @@ const oscpkg = @import("../osc.zig"); const protocol = @import("../osc/parsers/kitty_clipboard_protocol.zig"); const command = @import("clipboard_command.zig"); +const write = @import("clipboard_write.zig"); const response = @import("clipboard_response.zig"); pub const OSC = protocol.OSC; @@ -51,6 +48,12 @@ pub const max_pw_len = command.max_pw_len; pub const max_mime_len = command.max_mime_len; pub const max_name_len = command.max_name_len; +pub const Content = write.Content; +pub const WriteState = write.WriteState; +pub const max_write_size = write.max_write_size; +pub const max_write_mimes = write.max_write_mimes; +pub const max_write_aliases = write.max_write_aliases; + pub const Response = response.Response; pub const ReadSuccess = response.ReadSuccess; pub const read_chunk_size = response.read_chunk_size; diff --git a/src/terminal/kitty/clipboard_write.zig b/src/terminal/kitty/clipboard_write.zig new file mode 100644 index 000000000..c4e33a4f3 --- /dev/null +++ b/src/terminal/kitty/clipboard_write.zig @@ -0,0 +1,443 @@ +//! Kitty clipboard protocol (OSC 5522) write transactions: the +//! stateful accumulation of wdata chunks and walias aliases until the +//! commit packet arrives. See clipboard.zig for the protocol overview. + +const std = @import("std"); +const assert = @import("../../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const clipboard = @import("../clipboard.zig"); +const clipboard_command = @import("clipboard_command.zig"); + +const Metadata = clipboard_command.Metadata; +const Payload = clipboard_command.Payload; +const max_mime_len = clipboard_command.max_mime_len; + +const log = std.log.scoped(.kitty_clipboard); + +/// Maximum total decoded bytes accumulated by one write transaction. +/// We hardcode this for now but probably will make this configurable +/// later. +pub const max_write_size = 32 * 1024 * 1024; + +/// Maximum MIME types and aliases per write transaction. +pub const max_write_mimes = 64; +pub const max_write_aliases = 64; + +/// One MIME representation of committed clipboard data. This is the +/// same type the clipboard write effect consumes so committed contents +/// can be passed through directly. +pub const Content = clipboard.Content; + +/// The state of one in-flight write transaction: a single `type=write` +/// plus all the `wdata` chunks and `walias` aliases until completion +/// or error. +pub const WriteState = struct { + arena: std.heap.ArenaAllocator, + loc: clipboard.Location, + id: []const u8, + pw: []const u8, + has_name: bool, + spool: std.ArrayListUnmanaged(u8) = .empty, + entries: std.ArrayListUnmanaged(Entry) = .empty, + aliases: std.ArrayListUnmanaged(Alias) = .empty, + + /// Index into entries currently receiving data. + current: ?usize = null, + + /// Set when max_write_size was exceeded; excess data is dropped + /// but the write still completes. + truncated: bool = false, + + const Entry = struct { + /// Owned by the transaction arena. + mime: []const u8, + start: usize = 0, + len: usize = 0, + }; + + const Alias = struct { + /// Owned by the transaction arena. + alias: []const u8, + target: []const u8, + }; + + /// Begin a transaction from a type=write packet. + pub fn init(alloc: Allocator, meta: *const Metadata) Allocator.Error!WriteState { + assert(meta.op == .write); + var arena: std.heap.ArenaAllocator = .init(alloc); + errdefer arena.deinit(); + const id = try arena.allocator().dupe(u8, meta.id); + const pw = try arena.allocator().dupe(u8, meta.pw); + return .{ + .arena = arena, + .loc = meta.loc, + .id = id, + .pw = pw, + .has_name = meta.has_name, + }; + } + + pub fn deinit(self: *WriteState, alloc: Allocator) void { + self.spool.deinit(alloc); + self.entries.deinit(alloc); + self.aliases.deinit(alloc); + self.arena.deinit(); + } + + /// Accumulate one wdata chunk carrying data for meta.mime (which + /// must be non-empty; an empty mime is a commit, not data). + pub fn data( + self: *WriteState, + alloc: Allocator, + meta: *const Metadata, + payload: []const u8, + ) error{OutOfMemory}!void { + assert(meta.op == .wdata); + assert(meta.mime.len > 0); + // Switch the receiving entry if this chunk is for a different + // MIME type than the last one. + entry: { + if (self.current) |idx| { + const entry = &self.entries.items[idx]; + if (std.mem.eql(u8, entry.mime, meta.mime)) { + break :entry; + } + + // Finalize the previous region. + entry.len = self.spool.items.len - entry.start; + } + + // Re-using an earlier MIME type starts a fresh region, + // overwriting the previous mapping. + for (self.entries.items, 0..) |*entry, idx| { + if (std.mem.eql(u8, entry.mime, meta.mime)) { + entry.start = self.spool.items.len; + entry.len = 0; + self.current = idx; + break :entry; + } + } + + if (self.entries.items.len >= max_write_mimes) { + log.warn( + "clipboard write has too many MIME types, ignoring mime={s}", + .{meta.mime}, + ); + self.current = null; + return; + } + + try self.entries.append(alloc, .{ + .mime = try self.arena.allocator().dupe(u8, meta.mime), + .start = self.spool.items.len, + }); + self.current = self.entries.items.len - 1; + } + + // Each packet's payload is independently base64-encoded: per + // the spec, "payload is base64 encoded data" and clients chunk + // the data before encoding. An invalid chunk is dropped and + // the transaction continues. + const decoded = Payload.init( + alloc, + payload, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.Invalid => { + log.warn("clipboard write chunk has invalid base64, ignoring chunk", .{}); + return; + }, + }; + defer decoded.deinit(alloc); + + const remaining = max_write_size -| self.spool.items.len; + const n = @min(decoded.data.len, remaining); + try self.spool.appendSlice(alloc, decoded.data[0..n]); + if (n < decoded.data.len) { + self.truncated = true; + logTruncatedOnce(); + } + } + + /// Register aliases from a walias packet: meta.mime is the target + /// (the type that carries data) and the payload is a base64-encoded, + /// whitespace-separated list of aliases. Returns error.Invalid for + /// an undecodable payload, which aborts the transaction with EINVAL. + pub fn alias( + self: *WriteState, + alloc: Allocator, + meta: *const Metadata, + payload: []const u8, + ) error{ OutOfMemory, Invalid }!void { + assert(meta.op == .walias); + assert(meta.mime.len > 0); + const decoded = try Payload.init(alloc, payload); + defer decoded.deinit(alloc); + var it = decoded.mimeIterator(); + + // Copy the target only if at least one valid alias exists. + const target: []const u8 = target: { + while (it.next()) |name| { + if (name.len > max_mime_len) continue; + break :target try self.arena.allocator().dupe(u8, meta.mime); + } + + // If we didn't find a target then ignore it. + return; + }; + + // Rewind so the alias that satisfied the check above is + // associated too. + it.reset(); + + // Associate the aliases + while (it.next()) |name| { + if (name.len > max_mime_len) continue; + + // A repeated alias overwrites its previous target. + for (self.aliases.items) |*a| { + if (std.mem.eql(u8, a.alias, name)) { + a.target = target; + break; + } + } else { + if (self.aliases.items.len >= max_write_aliases) { + log.warn("clipboard write has too many aliases, ignoring", .{}); + return; + } + + try self.aliases.append(alloc, .{ + .alias = try self.arena.allocator().dupe(u8, name), + .target = target, + }); + } + } + } + + /// The result of a committed transaction. All slices borrow the + /// WriteState's memory and are valid until it is deinited. + pub const Committed = struct { + loc: clipboard.Location, + id: []const u8, + pw: []const u8, + has_name: bool, + truncated: bool, + contents: []const Content, + + pub fn deinit(self: *const Committed, alloc: Allocator) void { + alloc.free(self.contents); + } + }; + + /// Commit the transaction (a wdata packet without a MIME type). + /// The caller must use the result, call Committed.deinit, and then + /// deinit this state. + pub fn commit( + self: *WriteState, + alloc: Allocator, + ) error{OutOfMemory}!Committed { + // Finalize the region receiving data. + if (self.current) |idx| { + const entry = &self.entries.items[idx]; + entry.len = self.spool.items.len - entry.start; + self.current = null; + } + + // Resolve the final MIME map: entries in arrival order, then + // aliases applied sequentially against the evolving map so + // chained aliases work like kitty's dict iteration. An alias + // whose target has no mapping is dropped; an alias colliding + // with an existing name overwrites it. + var contents: std.ArrayListUnmanaged(Content) = .empty; + defer contents.deinit(alloc); + try contents.ensureTotalCapacity( + alloc, + self.entries.items.len + self.aliases.items.len, + ); + + for (self.entries.items) |*entry| { + contents.appendAssumeCapacity(.{ + .mime = entry.mime, + .data = self.spool.items[entry.start..][0..entry.len], + }); + } + + for (self.aliases.items) |*a| { + const target: Content = target: { + for (contents.items) |c| { + if (std.mem.eql(u8, c.mime, a.target)) { + break :target c; + } + } + continue; + }; + + for (contents.items) |*c| { + if (std.mem.eql(u8, c.mime, a.alias)) { + c.data = target.data; + break; + } + } else { + contents.appendAssumeCapacity(.{ + .mime = a.alias, + .data = target.data, + }); + } + } + + return .{ + .loc = self.loc, + .id = self.id, + .pw = self.pw, + .has_name = self.has_name, + .truncated = self.truncated, + .contents = try contents.toOwnedSlice(alloc), + }; + } + + fn logTruncatedOnce() void { + // Log spam protection: this can be hit for every chunk of an + // oversized write. + const S = struct { + var logged: bool = false; + }; + if (!S.logged) { + S.logged = true; + log.warn( + "clipboard write exceeds {} bytes, truncating", + .{max_write_size}, + ); + } + } +}; + +test "write: basic transaction" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write, .id = "42" }; + var state: WriteState = try .init(alloc, &begin_meta); + defer state.deinit(alloc); + + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "R2hvc3R0eQ=="); // "Ghostty" + + const committed = try state.commit(alloc); + defer committed.deinit(alloc); + try testing.expectEqualStrings("42", committed.id); + try testing.expect(committed.loc == .standard); + try testing.expectEqual(@as(usize, 1), committed.contents.len); + try testing.expectEqualStrings("text/plain", committed.contents[0].mime); + try testing.expectEqualStrings("Ghostty", committed.contents[0].data); +} + +test "write: chunked data accumulates" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta); + defer state.deinit(alloc); + + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "SGVsbG8="); // "Hello" + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "V29ybGQ="); // "World" + + const committed = try state.commit(alloc); + defer committed.deinit(alloc); + try testing.expectEqualStrings("HelloWorld", committed.contents[0].data); +} + +test "write: multiple mimes in order" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta); + defer state.deinit(alloc); + + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "YQ=="); // "a" + try state.data(alloc, &.{ .op = .wdata, .mime = "text/html" }, "Yg=="); // "b" + + const committed = try state.commit(alloc); + defer committed.deinit(alloc); + try testing.expectEqual(@as(usize, 2), committed.contents.len); + try testing.expectEqualStrings("text/plain", committed.contents[0].mime); + try testing.expectEqualStrings("a", committed.contents[0].data); + try testing.expectEqualStrings("text/html", committed.contents[1].mime); + try testing.expectEqualStrings("b", committed.contents[1].data); +} + +test "write: reused mime overwrites" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta); + defer state.deinit(alloc); + + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "YQ=="); // "a" + try state.data(alloc, &.{ .op = .wdata, .mime = "text/html" }, "Yg=="); // "b" + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "Yw=="); // "c" + + const committed = try state.commit(alloc); + defer committed.deinit(alloc); + try testing.expectEqual(@as(usize, 2), committed.contents.len); + // Position preserved, data replaced. + try testing.expectEqualStrings("text/plain", committed.contents[0].mime); + try testing.expectEqualStrings("c", committed.contents[0].data); +} + +test "write: invalid base64 chunk is dropped, transaction continues" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta); + defer state.deinit(alloc); + + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "SGVsbG8="); // "Hello" + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "!!!bad!!!"); + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "V29ybGQ="); // "World" + + const committed = try state.commit(alloc); + defer committed.deinit(alloc); + try testing.expectEqualStrings("HelloWorld", committed.contents[0].data); +} + +test "write: aliases resolve at commit" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta); + defer state.deinit(alloc); + + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "R2hvc3R0eQ=="); // "Ghostty" + + // Alias "TEXT UTF8_STRING" -> text/plain. + const alias_meta: Metadata = .{ .op = .walias, .mime = "text/plain" }; + try state.alias(alloc, &alias_meta, "VEVYVCBVVEY4X1NUUklORw=="); + + const committed = try state.commit(alloc); + defer committed.deinit(alloc); + try testing.expectEqual(@as(usize, 3), committed.contents.len); + try testing.expectEqualStrings("TEXT", committed.contents[1].mime); + try testing.expectEqualStrings("Ghostty", committed.contents[1].data); + try testing.expectEqualStrings("UTF8_STRING", committed.contents[2].mime); + try testing.expectEqualStrings("Ghostty", committed.contents[2].data); +} + +test "write: alias without data target is dropped" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta); + defer state.deinit(alloc); + + const alias_meta: Metadata = .{ .op = .walias, .mime = "text/plain" }; + try state.alias(alloc, &alias_meta, "VEVYVA=="); // "TEXT" + + const committed = try state.commit(alloc); + defer committed.deinit(alloc); + try testing.expectEqual(@as(usize, 0), committed.contents.len); +} From 6f007e7678a4d893a5c73df2f0305b1e00609b5a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 12:13:56 -0700 Subject: [PATCH 05/10] terminal: add kitty clipboard protocol (OSC 5522) session password grants --- src/terminal/kitty/clipboard.zig | 5 + src/terminal/kitty/clipboard_grants.zig | 184 ++++++++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 src/terminal/kitty/clipboard_grants.zig diff --git a/src/terminal/kitty/clipboard.zig b/src/terminal/kitty/clipboard.zig index 4c9472e13..18271c704 100644 --- a/src/terminal/kitty/clipboard.zig +++ b/src/terminal/kitty/clipboard.zig @@ -35,6 +35,7 @@ const protocol = @import("../osc/parsers/kitty_clipboard_protocol.zig"); const command = @import("clipboard_command.zig"); const write = @import("clipboard_write.zig"); const response = @import("clipboard_response.zig"); +const grants = @import("clipboard_grants.zig"); pub const OSC = protocol.OSC; pub const Operation = protocol.Operation; @@ -60,6 +61,10 @@ pub const read_chunk_size = response.read_chunk_size; pub const max_read_mimes = response.max_read_mimes; pub const targets_mime = response.targets_mime; +pub const Grants = grants.Grants; +pub const otp_len = grants.otp_len; +pub const generateOtp = grants.generateOtp; + test { @import("std").testing.refAllDecls(@This()); } diff --git a/src/terminal/kitty/clipboard_grants.zig b/src/terminal/kitty/clipboard_grants.zig new file mode 100644 index 000000000..599e27e98 --- /dev/null +++ b/src/terminal/kitty/clipboard_grants.zig @@ -0,0 +1,184 @@ +//! Kitty clipboard protocol (OSC 5522) session password grants: +//! requests carrying a granted password skip the permission prompt, and +//! paste events mint one-time passwords. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const clipboard_command = @import("clipboard_command.zig"); + +const max_pw_len = clipboard_command.max_pw_len; + +/// Session password grants, used to skip permission prompts for +/// requests carrying a known pw. Callers can choose to scope these +/// however they want, e.g. Kitty does it per window and the spec +/// doesn't demand anything. +pub const Grants = struct { + entries: std.ArrayListUnmanaged(Entry) = .empty, + + const max_entries = 32; + + pub const Direction = enum { read, write }; + + const Entry = struct { + /// Owned by the allocator passed to grant. + pw: []const u8, + read: bool = false, + write: bool = false, + one_time: bool = false, + }; + + pub fn deinit(self: *Grants, alloc: Allocator) void { + for (self.entries.items) |entry| alloc.free(entry.pw); + self.entries.deinit(alloc); + } + + /// Record a grant for pw. An existing grant for the same password + /// gains the new direction. + pub fn grant( + self: *Grants, + alloc: Allocator, + pw: []const u8, + dir: Direction, + one_time: bool, + ) Allocator.Error!void { + if (pw.len == 0 or pw.len > max_pw_len) return; + + const entry: *Entry = entry: { + if (self.findIndex(pw)) |idx| { + const entry = &self.entries.items[idx]; + entry.one_time = entry.one_time and one_time; + break :entry entry; + } + + // Evict the oldest grant once full. + if (self.entries.items.len >= max_entries) { + const oldest = self.entries.orderedRemove(0); + alloc.free(oldest.pw); + } + + const owned = try alloc.dupe(u8, pw); + errdefer alloc.free(owned); + try self.entries.append(alloc, .{ + .pw = owned, + .one_time = one_time, + }); + break :entry &self.entries.items[self.entries.items.len - 1]; + }; + + switch (dir) { + .read => entry.read = true, + .write => entry.write = true, + } + } + + /// Check whether pw grants the given direction. A one-time grant is + /// consumed by this check even when the direction doesn't match, + /// matching kitty's pop-on-check behavior. + pub fn use( + self: *Grants, + alloc: Allocator, + pw: []const u8, + dir: Direction, + ) bool { + if (pw.len == 0) return false; + const idx = self.findIndex(pw) orelse return false; + const entry = &self.entries.items[idx]; + const allowed = switch (dir) { + .read => entry.read, + .write => entry.write, + }; + if (entry.one_time) { + const removed = self.entries.swapRemove(idx); + alloc.free(removed.pw); + } + return allowed; + } + + fn findIndex(self: *const Grants, pw: []const u8) ?usize { + for (self.entries.items, 0..) |*entry, idx| { + if (std.mem.eql(u8, entry.pw, pw)) return idx; + } + return null; + } +}; + +/// 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"; + var result: [otp_len]u8 = undefined; + for (&result) |*c| c.* = alphabet[random.uintLessThan(usize, alphabet.len)]; + return result; +} + +test "grants: basic grant and use" { + const testing = std.testing; + const alloc = testing.allocator; + + var grants: Grants = .{}; + defer grants.deinit(alloc); + try testing.expect(!grants.use(alloc, "pw1", .read)); + + try grants.grant(alloc, "pw1", .read, false); + try testing.expect(grants.use(alloc, "pw1", .read)); + // Persistent grants survive use. + try testing.expect(grants.use(alloc, "pw1", .read)); + try testing.expect(!grants.use(alloc, "pw1", .write)); +} + +test "grants: one-time consumed on check" { + const testing = std.testing; + const alloc = testing.allocator; + + var grants: Grants = .{}; + defer grants.deinit(alloc); + try grants.grant(alloc, "otp", .read, true); + try testing.expect(grants.use(alloc, "otp", .read)); + try testing.expect(!grants.use(alloc, "otp", .read)); +} + +test "grants: one-time consumed even on direction mismatch" { + const testing = std.testing; + const alloc = testing.allocator; + + var grants: Grants = .{}; + defer grants.deinit(alloc); + try grants.grant(alloc, "otp", .read, true); + try testing.expect(!grants.use(alloc, "otp", .write)); + try testing.expect(!grants.use(alloc, "otp", .read)); +} + +test "grants: directions are independent" { + const testing = std.testing; + const alloc = testing.allocator; + + var grants: Grants = .{}; + defer grants.deinit(alloc); + try grants.grant(alloc, "pw", .read, false); + try grants.grant(alloc, "pw", .write, false); + try testing.expect(grants.use(alloc, "pw", .read)); + try testing.expect(grants.use(alloc, "pw", .write)); +} + +test "grants: capacity evicts the oldest" { + const testing = std.testing; + const alloc = testing.allocator; + + var grants: Grants = .{}; + defer grants.deinit(alloc); + + var buf: [8]u8 = undefined; + for (0..Grants.max_entries + 1) |i| { + const pw = try std.fmt.bufPrint(&buf, "pw{}", .{i}); + try grants.grant(alloc, pw, .read, false); + } + + // The oldest grant was evicted; the newest survives. + try testing.expect(!grants.use(alloc, "pw0", .read)); + const newest = try std.fmt.bufPrint(&buf, "pw{}", .{Grants.max_entries}); + try testing.expect(grants.use(alloc, newest, .read)); +} From f2d4b32be3eb8c17f1ca943dda82a6506e7260b3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 12:15:07 -0700 Subject: [PATCH 06/10] terminal: expose kitty clipboard protocol (OSC 5522) for stream dispatch --- src/terminal/kitty.zig | 1 + src/terminal/osc/parsers/kitty_clipboard_protocol.zig | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/src/terminal/kitty.zig b/src/terminal/kitty.zig index 0868a7710..471fb91e7 100644 --- a/src/terminal/kitty.zig +++ b/src/terminal/kitty.zig @@ -3,6 +3,7 @@ const build_options = @import("terminal_options"); const key = @import("kitty/key.zig"); +pub const clipboard = @import("kitty/clipboard.zig"); pub const color = @import("kitty/color.zig"); pub const graphics = if (build_options.kitty_graphics) @import("kitty/graphics.zig") else struct {}; diff --git a/src/terminal/osc/parsers/kitty_clipboard_protocol.zig b/src/terminal/osc/parsers/kitty_clipboard_protocol.zig index 5bd0e2547..c257edd10 100644 --- a/src/terminal/osc/parsers/kitty_clipboard_protocol.zig +++ b/src/terminal/osc/parsers/kitty_clipboard_protocol.zig @@ -22,6 +22,12 @@ pub const OSC = struct { /// The terminator that was used in case we need to send a response. terminator: Terminator, + pub const C = void; + + pub fn cval(_: OSC) C { + return {}; + } + /// Decode an option from the metadata. pub fn readOption(self: OSC, comptime key: Option) ?key.Type() { return key.read(self.metadata); From 07c6fc21ba79f2d3d320f3300029269ebf84030b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 12:16:38 -0700 Subject: [PATCH 07/10] terminal: add kitty clipboard paste events mode (5522), disabled for now --- include/ghostty/vt/modes.h | 1 + src/terminal/modes.zig | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/include/ghostty/vt/modes.h b/include/ghostty/vt/modes.h index fbef4e52b..6786f004b 100644 --- a/include/ghostty/vt/modes.h +++ b/include/ghostty/vt/modes.h @@ -94,6 +94,7 @@ extern "C" { #define GHOSTTY_MODE_COLOR_SCHEME_REPORT (ghostty_mode_new(2031, false)) /**< Report color scheme */ #define GHOSTTY_MODE_VISIBILITY_REPORT (ghostty_mode_new(2033, false)) /**< Report terminal visibility */ #define GHOSTTY_MODE_IN_BAND_RESIZE (ghostty_mode_new(2048, false)) /**< In-band size reports */ +#define GHOSTTY_MODE_PASTE_EVENTS (ghostty_mode_new(5522, false)) /**< Kitty clipboard protocol paste events */ /** @} */ /** diff --git a/src/terminal/modes.zig b/src/terminal/modes.zig index c545b3304..9b95e85ce 100644 --- a/src/terminal/modes.zig +++ b/src/terminal/modes.zig @@ -327,6 +327,15 @@ const entries: []const ModeEntry = &.{ .{ .name = "report_color_scheme", .value = 2031 }, .{ .name = "report_visibility", .value = 2033, .default_configurable = false }, .{ .name = "in_band_size_reports", .value = 2048 }, + // Kitty clipboard protocol paste events. When set, a user-initiated + // 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/ + // + // Disabled until the apprt paste integration lands; until then the + // mode reports as unrecognized so applications correctly detect + // paste events as unsupported. + .{ .name = "paste_events", .value = 5522, .disabled = true }, }; test { From bcf44b40e69ffc4f32216016bb8e6ee68560cae2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 12:19:14 -0700 Subject: [PATCH 08/10] terminal: dispatch OSC 5522 as a kitty_clipboard stream action, unhandled --- src/terminal/stream.zig | 9 ++++++++- src/terminal/stream_terminal.zig | 2 ++ src/termio/stream_handler.zig | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 105cd1e4f..4308adf9f 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -128,6 +128,7 @@ pub const Action = union(Key) { kitty_color_report: kitty.color.OSC, color_operation: ColorOperation, semantic_prompt: SemanticPrompt, + kitty_clipboard: KittyClipboard, pub const Key = lib.Enum( lib.target, @@ -227,6 +228,7 @@ pub const Action = union(Key) { "kitty_color_report", "color_operation", "semantic_prompt", + "kitty_clipboard", }, ); @@ -444,6 +446,8 @@ pub const Action = union(Key) { }; pub const SemanticPrompt = osc.Command.SemanticPrompt; + + pub const KittyClipboard = osc.Command.KittyClipboardProtocol; }; /// Returns a type that can process a stream of tty control characters. @@ -2551,6 +2555,10 @@ pub fn Stream(comptime H: type) type { self.handler.vt(.progress_report, v); }, + .kitty_clipboard_protocol => |v| { + self.handler.vt(.kitty_clipboard, v); + }, + .conemu_sleep, .conemu_show_message_box, .conemu_change_tab_title, @@ -2561,7 +2569,6 @@ pub fn Stream(comptime H: type) type { .conemu_output_environment_variable, .conemu_run_process, .kitty_text_sizing, - .kitty_clipboard_protocol, .kitty_dnd_protocol, .context_signal, => { diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 1b1ad6c65..d19324886 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -398,6 +398,8 @@ pub const Handler = struct { // Have no terminal-modifying effect .title_push, .title_pop, + // Unimplemented; the sequence is consumed and ignored. + .kitty_clipboard, => {}, } } diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index cf6bca909..192e51644 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -356,6 +356,7 @@ pub const StreamHandler = struct { // Unimplemented .title_push, .title_pop, + .kitty_clipboard, => {}, } } From 128ec7cd047ab0d9ceb4f0b5c2c449984702266d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 12:26:56 -0700 Subject: [PATCH 09/10] terminal: rename paste_events mode to kitty_paste_events --- src/terminal/modes.zig | 7 +++---- src/terminal/snapshot/snapshot.ksy | 6 ++++-- src/terminal/snapshot/terminal.zig | 30 ++++++++++++++++++++---------- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/terminal/modes.zig b/src/terminal/modes.zig index 9b95e85ce..baacd0089 100644 --- a/src/terminal/modes.zig +++ b/src/terminal/modes.zig @@ -332,10 +332,9 @@ const entries: []const ModeEntry = &.{ // one-time password instead of pasting the text. // See https://sw.kovidgoyal.net/kitty/clipboard/ // - // Disabled until the apprt paste integration lands; until then the - // mode reports as unrecognized so applications correctly detect - // paste events as unsupported. - .{ .name = "paste_events", .value = 5522, .disabled = true }, + // 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 }, }; test { diff --git a/src/terminal/snapshot/snapshot.ksy b/src/terminal/snapshot/snapshot.ksy index 80a1d182f..2da917ea3 100644 --- a/src/terminal/snapshot/snapshot.ksy +++ b/src/terminal/snapshot/snapshot.ksy @@ -390,13 +390,13 @@ types: Each named instance exposes one bit from the little-endian integer. Arithmetic division is used instead of bitwise operations because the JavaScript target implements those operations with signed 32-bit values. - All values remain exact because the registry occupies only 42 bits, + All values remain exact because the registry occupies only 43 bits, within JavaScript's 53-bit safe integer range. seq: - id: raw type: u8 valid: - max: 4398046511103 + max: 8796093022207 instances: disable_keyboard: value: (raw / 1) % 2 != 0 @@ -482,6 +482,8 @@ types: value: (raw / 1099511627776) % 2 != 0 in_band_size_reports: value: (raw / 2199023255552) % 2 != 0 + kitty_paste_events: + value: (raw / 4398046511104) % 2 != 0 tab_stops: params: diff --git a/src/terminal/snapshot/terminal.zig b/src/terminal/snapshot/terminal.zig index f20b97707..11c9004bb 100644 --- a/src/terminal/snapshot/terminal.zig +++ b/src/terminal/snapshot/terminal.zig @@ -206,7 +206,8 @@ //! bit 39 report_color_scheme //! bit 40 report_visibility //! bit 41 in_band_size_reports -//! bits 42-63 reserved, zero +//! bit 42 kitty_paste_events +//! bits 43-63 reserved, zero //! ``` //! //! This is the packed field order of native `ModePacked`. Its layout is @@ -470,7 +471,7 @@ pub const Header = struct { try writer.writeByte(@intCast(@intFromEnum(self.mouse_shape))); try writer.writeByte(@intFromBool(self.password_input)); - // Runtime, saved, and reset mode sets. ModePacked occupies 41 bits; + // Runtime, saved, and reset mode sets. ModePacked occupies 43 bits; // its eight-byte wire slots zero-extend the native packed value. const mode_values = [_]terminal_modes.ModePacked{ self.current_modes, @@ -1203,7 +1204,7 @@ const test_header_fixture = test_fixture.parse( test "TERMINAL mode bit layout" { try std.testing.expectEqual( - @as(usize, 42), + @as(usize, 43), @bitSizeOf(terminal_modes.ModePacked), ); @@ -1212,8 +1213,8 @@ test "TERMINAL mode bit layout" { ); first.disable_keyboard = true; try std.testing.expectEqual( - @as(u42, 1) << 0, - @as(u42, @bitCast(first)), + @as(u43, 1) << 0, + @as(u43, @bitCast(first)), ); var visibility: terminal_modes.ModePacked = std.mem.zeroes( @@ -1221,17 +1222,26 @@ test "TERMINAL mode bit layout" { ); visibility.report_visibility = true; try std.testing.expectEqual( - @as(u42, 1) << 40, - @as(u42, @bitCast(visibility)), + @as(u43, 1) << 40, + @as(u43, @bitCast(visibility)), + ); + + var size_reports: terminal_modes.ModePacked = std.mem.zeroes( + terminal_modes.ModePacked, + ); + size_reports.in_band_size_reports = true; + try std.testing.expectEqual( + @as(u43, 1) << 41, + @as(u43, @bitCast(size_reports)), ); var last: terminal_modes.ModePacked = std.mem.zeroes( terminal_modes.ModePacked, ); - last.in_band_size_reports = true; + last.kitty_paste_events = true; try std.testing.expectEqual( - @as(u42, 1) << 41, - @as(u42, @bitCast(last)), + @as(u43, 1) << 42, + @as(u43, @bitCast(last)), ); } From a8c3ab1915c9dc9cecf4ae93b5337d65f1bfffbf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 12:45:35 -0700 Subject: [PATCH 10/10] simd: fix scalar base64 empty input handling causing a crash --- src/simd/base64.zig | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/simd/base64.zig b/src/simd/base64.zig index 81feeb723..a0ba45ea9 100644 --- a/src/simd/base64.zig +++ b/src/simd/base64.zig @@ -53,9 +53,9 @@ fn decodeScalar( /// For non-SIMD enabled builds, we trim the padding from the end of the /// base64 input in order to get identical output with the SIMD version. fn scalarInput(input: []const u8) []const u8 { - var i: usize = 0; - while (input[input.len - i - 1] == '=') i += 1; - return input[0 .. input.len - i]; + var end = input.len; + while (end > 0 and input[end - 1] == '=') end -= 1; + return input[0..end]; } // base64.cpp @@ -75,6 +75,14 @@ test "base64 maxLen" { try testing.expectEqual(11, len); } +test "base64 empty input" { + const testing = std.testing; + var output: [0]u8 = .{}; + + try testing.expectEqual(0, maxLen("")); + try testing.expectEqualStrings("", try decode("", &output)); +} + test "base64 decode" { const testing = std.testing; const alloc = testing.allocator;