Merge branch 'ghostty-org:main' into main

This commit is contained in:
Mohammad AlShami
2026-08-21 23:34:03 +03:00
committed by Mohammad H. AlShami
17 changed files with 1693 additions and 21 deletions

View File

@@ -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 */
/** @} */
/**

View File

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

View File

@@ -1323,14 +1323,21 @@ pub fn print(self: *Terminal, c: u21) !void {
const old_rac = old_pin.rowAndCell();
if (new_pin.node == old_pin.node) {
new_pin.node.page().moveGrapheme(prev.cell, new_rac.cell);
prev.cell.content_tag = .codepoint;
new_pin.node.page().moveGrapheme(old_rac.cell, new_rac.cell);
old_rac.cell.content_tag = .codepoint;
new_rac.cell.content_tag = .codepoint_grapheme;
new_rac.row.grapheme = true;
} else {
const cps = old_pin.node.page().lookupGrapheme(old_rac.cell).?;
for (cps) |cp| {
try self.screens.active.appendGrapheme(new_rac.cell, cp);
// appendGrapheme can grow the cursor
// page, so read the destination from
// the cursor each time rather than
// holding a pointer across the call.
try self.screens.active.appendGrapheme(
self.screens.active.cursor.page_cell,
cp,
);
}
old_pin.node.page().clearGrapheme(old_rac.cell);
}
@@ -1340,7 +1347,7 @@ pub fn print(self: *Terminal, c: u21) !void {
// Point prev.cell to our new previous cell that
// we'll be appending graphemes to
prev.cell = new_rac.cell;
prev.cell = self.screens.active.cursor.page_cell;
} else {
self.printCell(
0,
@@ -1359,8 +1366,30 @@ pub fn print(self: *Terminal, c: u21) !void {
// Write our spacer, since prev.cell is now wide
self.screens.active.cursorRight(1);
// Writing the spacer can grow the page to make room for
// the cursor hyperlink. Growing replaces the page, which
// invalidates `prev.cell`. Record the page identity first
// so the common case where nothing grows stays free.
//
// A pointer comparison alone isn't enough: pages are
// pooled, so a replacement can reuse the same address.
// The serial makes the pair a unique identity.
const spacer_node = self.screens.active.cursor.page_pin.node;
const spacer_serial = spacer_node.serial;
self.printCell(0, .spacer_tail);
if (self.screens.active.cursor.page_pin.node != spacer_node or
self.screens.active.cursor.page_pin.node.serial != spacer_serial)
{
@branchHint(.unlikely);
// The cursor is on the spacer tail we just wrote, so
// the wide cell we append to is the one to its left.
prev.cell = self.screens.active.cursorCellLeft(1);
}
// Move the cursor again so we're beyond our spacer
if (self.screens.active.cursor.x == right_limit - 1) {
self.screens.active.cursor.pending_wrap = true;
@@ -1704,7 +1733,11 @@ fn printCell(
self.screens.active.cursorSetHyperlink() catch |err| {
@branchHint(.unlikely);
log.warn("error reallocating for more hyperlink space, ignoring hyperlink err={}", .{err});
assert(!cell.hyperlink);
// A partially successful grow can replace the page even when the
// call fails, so `cell` may be stale here. The cursor pointers are
// always reloaded, so read the cell through the cursor.
assert(!self.screens.active.cursor.page_cell.hyperlink);
};
} else if (had_hyperlink) {
// If the previous cell had a hyperlink then we need to clear it.
@@ -6132,6 +6165,106 @@ test "Terminal: VS16 to make wide character on next line with hyperlink" {
}
}
test "Terminal: VS16 widening when the spacer tail grows the page" {
// Regression test for a stale cell pointer in print's grapheme `.wide`
// path: writing the spacer tail can grow the page to fit the hyperlink,
// which replaces the page and invalidates the pointer to the wide cell.
var t = try init(testing.io, testing.allocator, .{ .rows = 10, .cols = 20 });
defer t.deinit(testing.allocator);
t.modes.set(.grapheme_cluster, true);
try t.screens.active.startHyperlink("http://example.com", null);
// Fill the page hyperlink map until a single slot is left. The '#' below
// takes that slot so the spacer tail is what forces the page to grow.
while (true) {
const page = t.screens.active.cursor.page_pin.node.page();
const map = page.hyperlink_map.map(page.memory);
if (map.maxLoad() - map.count() == 1) break;
try t.print('x');
}
const x = t.screens.active.cursor.x;
const y = t.screens.active.cursor.y;
try t.print('#');
// Without the fix this crashed appending to a freed page.
try t.print(0xFE0F);
{
// '#' is wide and carries the VS16 grapheme.
const list_cell = t.screens.active.pages.getCell(.{ .active = .{
.x = x,
.y = y,
} }).?;
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, '#'), cell.content.codepoint.data);
try testing.expectEqual(Cell.Wide.wide, cell.wide);
try testing.expect(cell.hasGrapheme());
try testing.expectEqualSlices(
u21,
&.{0xFE0F},
list_cell.node.page().lookupGrapheme(cell).?,
);
}
{
const list_cell = t.screens.active.pages.getCell(.{ .active = .{
.x = x + 1,
.y = y,
} }).?;
try testing.expectEqual(Cell.Wide.spacer_tail, list_cell.cell.wide);
}
}
test "Terminal: grapheme transfer when widening wraps to the next line" {
// Covers print's grapheme `.wide` path where the previous cell already
// holds grapheme data and has to be moved to the wrapped row.
var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 3 });
defer t.deinit(testing.allocator);
t.modes.set(.grapheme_cluster, true);
t.cursorRight(2);
// A narrow emoji, then ZWJ, then a second emoji. The ZWJ attaches
// without changing the width, so the cell has grapheme data by the time
// the second emoji widens it.
try t.print(0x263A);
try t.print(0x200D);
try t.print(0x2764);
{
// The old cell becomes a spacer head on the wrapped row.
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{
.x = 2,
.y = 0,
} }).?;
try testing.expectEqual(Cell.Wide.spacer_head, list_cell.cell.wide);
try testing.expect(list_cell.row.wrap);
}
{
// The grapheme moved with the base codepoint.
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{
.x = 0,
.y = 1,
} }).?;
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 0x263A), cell.content.codepoint.data);
try testing.expectEqual(Cell.Wide.wide, cell.wide);
try testing.expectEqualSlices(
u21,
&.{ 0x200D, 0x2764 },
list_cell.node.page().lookupGrapheme(cell).?,
);
}
{
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{
.x = 1,
.y = 1,
} }).?;
try testing.expectEqual(Cell.Wide.spacer_tail, list_cell.cell.wide);
}
}
test "Terminal: VS16 to make wide character with pending wrap" {
var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 3 });
defer t.deinit(testing.allocator);

View File

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

View File

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

View File

@@ -0,0 +1,70 @@
//! 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).
//! * 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
//! 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");
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;
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;
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;
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());
}

View File

@@ -0,0 +1,386 @@
//! Kitty clipboard protocol (OSC 5522) request decoding.
//!
//! OSC 5522 format: `<OSC>5522;metadata;payload<ST>`
//!
//! 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, "!!!"),
);
}

View File

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

View File

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

View File

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

View File

@@ -327,6 +327,14 @@ 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/
//
// 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 {

View File

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

View File

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

View File

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

View File

@@ -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,
=> {

View File

@@ -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,
=> {},
}
}

View File

@@ -356,6 +356,7 @@ pub const StreamHandler = struct {
// Unimplemented
.title_push,
.title_pop,
.kitty_clipboard,
=> {},
}
}