From 25b1170d422f06146661eb1531c1b574d20f1771 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 10:43:06 -0700 Subject: [PATCH] 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, "!!!"), + ); +}