mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-26 17:11:40 +00:00
terminal: make Kitty clipboard write limit configurable (#14002)
Add a new `clipboard-write-limit-bytes` option (similar to `scrollback-limit-bytes`) to limit the maximum OSC 5522 write size. Defaults to 32 MB. This also adds a new `GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE_MAX_BYTES` option for libghostty-vt embedders to control the same. Kitty has a limit too and it works by truncating all data. I decided on purpose to diverge from this because I don't think truncated binary data is useful. Instead, we reject it so the application knows the write didn't work. We truncate text data, and we try to do it at the nearest complete UTF-8 sequence (if possible). For the future: Kitty spools any write data more than some size (can't remember) to a temp file on disk. We might want to consider doing something similar since we're all in-memory at the moment. This PR doesn't change that.
This commit is contained in:
@@ -1522,6 +1522,30 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
* Input type: GhosttyTerminalClipboardReadFn
|
||||
*/
|
||||
GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ = 38,
|
||||
|
||||
/**
|
||||
* Set the maximum total decoded bytes a single Kitty clipboard protocol
|
||||
* (OSC 5522) write transaction may accumulate. The limit is captured
|
||||
* when a transaction begins; an in-flight transaction keeps the limit
|
||||
* it started with.
|
||||
*
|
||||
* Text data (text/* MIME types and the legacy X11 text names) beyond
|
||||
* the limit is truncated at a UTF-8 boundary and the write still
|
||||
* completes with DONE. Non-text data beyond the limit fails the whole
|
||||
* transaction with EFBIG and nothing reaches the clipboard write
|
||||
* callback, since binary formats are corrupted by truncation.
|
||||
*
|
||||
* Transactions are buffered in memory, so this limit bounds how much
|
||||
* memory a single write can make the terminal allocate. Pass SIZE_MAX
|
||||
* to remove the limit. A NULL value pointer reverts to the built-in
|
||||
* default of 32MiB.
|
||||
*
|
||||
* This limit doesn't apply to OSC 52 writes, which are bounded by the
|
||||
* maximum length of an escape sequence instead.
|
||||
*
|
||||
* Input type: size_t*
|
||||
*/
|
||||
GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE_MAX_BYTES = 39,
|
||||
GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyTerminalOption;
|
||||
|
||||
@@ -1919,6 +1943,15 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
* Output type: bool *
|
||||
*/
|
||||
GHOSTTY_TERMINAL_DATA_CURSOR_AT_PROMPT = 39,
|
||||
|
||||
/**
|
||||
* The configured maximum decoded bytes per Kitty clipboard protocol
|
||||
* (OSC 5522) write transaction. See
|
||||
* GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE_MAX_BYTES.
|
||||
*
|
||||
* Output type: size_t *
|
||||
*/
|
||||
GHOSTTY_TERMINAL_DATA_CLIPBOARD_WRITE_MAX_BYTES = 40,
|
||||
GHOSTTY_TERMINAL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyTerminalData;
|
||||
|
||||
|
||||
@@ -2454,6 +2454,35 @@ keybind: Keybinds = .{},
|
||||
@"clipboard-read": ClipboardAccess = .ask,
|
||||
@"clipboard-write": ClipboardAccess = .allow,
|
||||
|
||||
/// The maximum size in bytes of a single clipboard write by a program
|
||||
/// running in the terminal via the Kitty clipboard protocol (OSC 5522).
|
||||
/// This doesn't apply to OSC 52, which is limited by the maximum length
|
||||
/// of an escape sequence hardcoded into Ghostty for now.
|
||||
///
|
||||
/// Text data (`text/*` MIME types) beyond this limit is truncated at
|
||||
/// a UTF-8 boundary and the write still completes successfully with
|
||||
/// the clipped text.
|
||||
///
|
||||
/// Non-text data beyond the limit instead fails the entire write with an
|
||||
/// `EFBIG` status and leaves the clipboard untouched, since binary formats
|
||||
/// such as images are corrupted by truncation.
|
||||
///
|
||||
/// The data is buffered in memory while the write is in progress, so
|
||||
/// this limit bounds how much memory a program can make Ghostty
|
||||
/// allocate per write. A future improvement will attempt to spool large
|
||||
/// writes to disk.
|
||||
///
|
||||
/// Set this to `unlimited` to remove the limit, allowing writes bounded only
|
||||
/// by available memory. A value of `0` truncates every text write to empty
|
||||
/// contents and rejects every non-text write. To reject clipboard writes
|
||||
/// entirely, use `clipboard-write = deny` instead.
|
||||
///
|
||||
/// This can be changed at runtime and applies to writes that begin
|
||||
/// after the change.
|
||||
///
|
||||
/// Available since: 1.4.0
|
||||
@"clipboard-write-limit-bytes": Limit(usize, 32 * 1024 * 1024) = .default,
|
||||
|
||||
/// Trims trailing whitespace on data that is copied to the clipboard. This does
|
||||
/// not affect data sent to the clipboard via `clipboard-write`. This only
|
||||
/// applies to trailing whitespace on lines that have other characters.
|
||||
@@ -11036,6 +11065,38 @@ test "scrollback limits" {
|
||||
);
|
||||
}
|
||||
|
||||
test "clipboard write limit" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var cfg = try Config.default(alloc);
|
||||
defer cfg.deinit();
|
||||
try testing.expectEqual(
|
||||
@as(usize, 32 * 1024 * 1024),
|
||||
cfg.@"clipboard-write-limit-bytes".value,
|
||||
);
|
||||
|
||||
var it: TestIterator = .{ .data = &.{
|
||||
"--clipboard-write-limit-bytes=1234",
|
||||
} };
|
||||
try cfg.loadIter(alloc, &it);
|
||||
|
||||
try testing.expectEqual(
|
||||
@as(usize, 1234),
|
||||
cfg.@"clipboard-write-limit-bytes".value,
|
||||
);
|
||||
|
||||
var unlimited_it: TestIterator = .{ .data = &.{
|
||||
"--clipboard-write-limit-bytes=unlimited",
|
||||
} };
|
||||
try cfg.loadIter(alloc, &unlimited_it);
|
||||
|
||||
try testing.expectEqual(
|
||||
std.math.maxInt(usize),
|
||||
cfg.@"clipboard-write-limit-bytes".value,
|
||||
);
|
||||
}
|
||||
|
||||
test "compatibility: scrollback-limit renamed to bytes" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
@@ -28,6 +28,7 @@ const selection_c = @import("selection.zig");
|
||||
const style_c = @import("style.zig");
|
||||
const color = @import("../color.zig");
|
||||
const clipboard = @import("../clipboard.zig");
|
||||
const kitty_clipboard = @import("../kitty/clipboard.zig");
|
||||
const c_io = @import("io.zig");
|
||||
const snapshot_core = @import("../snapshot/main.zig");
|
||||
const Result = @import("result.zig").Result;
|
||||
@@ -1217,6 +1218,7 @@ pub const Option = enum(c_int) {
|
||||
unknown_max_bytes = 36,
|
||||
terminfo_name = 37,
|
||||
clipboard_read = 38,
|
||||
clipboard_write_max_bytes = 39,
|
||||
|
||||
/// Input type expected for setting the option.
|
||||
pub fn InType(comptime self: Option) type {
|
||||
@@ -1252,6 +1254,7 @@ pub const Option = enum(c_int) {
|
||||
.scrollback_max_lines,
|
||||
.continuation_max_bytes,
|
||||
.unknown_max_bytes,
|
||||
.clipboard_write_max_bytes,
|
||||
=> ?*const usize,
|
||||
.selection => ?*const selection_c.CSelection,
|
||||
.default_cursor_style => ?*const TerminalCursorStyle,
|
||||
@@ -1452,6 +1455,8 @@ fn setTyped(
|
||||
),
|
||||
.unknown_max_bytes => wrapper.stream.handler.apc_handler.unknown_max_bytes =
|
||||
if (value) |ptr| ptr.* else 0,
|
||||
.clipboard_write_max_bytes => wrapper.stream.handler.kitty_clipboard_write_max_bytes =
|
||||
if (value) |ptr| ptr.* else kitty_clipboard.max_write_size,
|
||||
.mode, .mode_default => {
|
||||
const config = (value orelse return .invalid_value).*;
|
||||
const mode = config.toMode() orelse return .invalid_value;
|
||||
@@ -1585,6 +1590,7 @@ pub const TerminalData = enum(c_int) {
|
||||
mode = 37,
|
||||
vt_ground = 38,
|
||||
cursor_at_prompt = 39,
|
||||
clipboard_write_max_bytes = 40,
|
||||
|
||||
/// Output type expected for querying the data of the given kind.
|
||||
pub fn OutType(comptime self: TerminalData) type {
|
||||
@@ -1609,6 +1615,7 @@ pub const TerminalData = enum(c_int) {
|
||||
.scrollback_max_bytes,
|
||||
.scrollback_max_lines,
|
||||
.continuation_max_bytes,
|
||||
.clipboard_write_max_bytes,
|
||||
=> usize,
|
||||
.width_px, .height_px => u32,
|
||||
.color_foreground,
|
||||
@@ -1760,6 +1767,7 @@ fn getTyped(
|
||||
out.* = max;
|
||||
},
|
||||
.continuation_max_bytes => out.* = continuationMaxBytes(wrapper),
|
||||
.clipboard_write_max_bytes => out.* = wrapper.stream.handler.kitty_clipboard_write_max_bytes,
|
||||
.mode => {
|
||||
const mode = out.toMode() orelse return .invalid_value;
|
||||
out.value = t.modes.get(mode);
|
||||
@@ -5049,6 +5057,80 @@ test "kitty clipboard write via C effects" {
|
||||
);
|
||||
}
|
||||
|
||||
test "set clipboard write max bytes" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
80,
|
||||
24,
|
||||
));
|
||||
defer free(t);
|
||||
|
||||
const S = struct {
|
||||
var responses: [512]u8 = undefined;
|
||||
var responses_len: usize = 0;
|
||||
var write_count: usize = 0;
|
||||
|
||||
fn writePty(
|
||||
_: Terminal,
|
||||
_: ?*anyopaque,
|
||||
ptr: [*]const u8,
|
||||
len: usize,
|
||||
) callconv(lib.calling_conv) void {
|
||||
@memcpy(responses[responses_len..][0..len], ptr[0..len]);
|
||||
responses_len += len;
|
||||
}
|
||||
|
||||
fn clipboardWrite(
|
||||
_: Terminal,
|
||||
_: ?*anyopaque,
|
||||
request: *const ClipboardWrite,
|
||||
) callconv(lib.calling_conv) void {
|
||||
write_count += 1;
|
||||
request.reply(request, &.{
|
||||
.size = @sizeOf(ClipboardWriteReply),
|
||||
.result = .success,
|
||||
.remember = false,
|
||||
});
|
||||
}
|
||||
};
|
||||
S.responses_len = 0;
|
||||
S.write_count = 0;
|
||||
|
||||
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
|
||||
try testing.expectEqual(Result.success, set(t, .clipboard_write, @ptrCast(&S.clipboardWrite)));
|
||||
|
||||
// The built-in default reads back.
|
||||
var max: usize = 0;
|
||||
try testing.expectEqual(Result.success, get(t, .clipboard_write_max_bytes, @ptrCast(&max)));
|
||||
try testing.expectEqual(@as(usize, kitty_clipboard.max_write_size), max);
|
||||
|
||||
// Set a tiny limit; an oversized non-text write fails with EFBIG
|
||||
// and never reaches the callback.
|
||||
const limit: usize = 4;
|
||||
try testing.expectEqual(Result.success, set(t, .clipboard_write_max_bytes, @ptrCast(&limit)));
|
||||
try testing.expectEqual(Result.success, get(t, .clipboard_write_max_bytes, @ptrCast(&max)));
|
||||
try testing.expectEqual(limit, max);
|
||||
|
||||
const seqs = [_][]const u8{
|
||||
"\x1B]5522;type=write:id=c1\x1B\\",
|
||||
"\x1B]5522;type=wdata:mime=aW1hZ2UvcG5n;SGVsbG9Xb3JsZA==\x1B\\", // "HelloWorld"
|
||||
"\x1B]5522;type=wdata\x1B\\",
|
||||
};
|
||||
for (seqs) |seq| vt_write(t, seq.ptr, seq.len);
|
||||
try testing.expectEqual(@as(usize, 0), S.write_count);
|
||||
try testing.expectEqualStrings(
|
||||
"\x1B]5522;type=write:status=EFBIG:id=c1\x1B\\",
|
||||
S.responses[0..S.responses_len],
|
||||
);
|
||||
|
||||
// A NULL value reverts to the built-in default.
|
||||
try testing.expectEqual(Result.success, set(t, .clipboard_write_max_bytes, null));
|
||||
try testing.expectEqual(Result.success, get(t, .clipboard_write_max_bytes, @ptrCast(&max)));
|
||||
try testing.expectEqual(@as(usize, kitty_clipboard.max_write_size), max);
|
||||
}
|
||||
|
||||
test "set clipboard_read callback" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
|
||||
@@ -21,7 +21,11 @@
|
||||
//! * 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.
|
||||
//! * Oversized writes of text data are truncated and still complete
|
||||
//! with DONE, matching kitty. Unlike kitty, oversized non-text data
|
||||
//! fails the whole transaction with EFBIG instead: a truncated
|
||||
//! image or other binary payload is corrupt, and the protocol has
|
||||
//! no way to report partial success.
|
||||
//! * Responses never send a payload section for an empty payload,
|
||||
//! except the targets ('.') listing DATA packet which is always sent.
|
||||
//!
|
||||
|
||||
@@ -14,9 +14,10 @@ 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.
|
||||
/// Default maximum total decoded bytes accumulated by one write
|
||||
/// transaction. Embedders can override this per-transaction via
|
||||
/// WriteState.Options (Ghostty exposes it as the
|
||||
/// `clipboard-write-limit-bytes` configuration).
|
||||
pub const max_write_size = 32 * 1024 * 1024;
|
||||
|
||||
/// Maximum MIME types and aliases per write transaction.
|
||||
@@ -41,13 +42,24 @@ pub const WriteState = struct {
|
||||
entries: std.ArrayListUnmanaged(Entry) = .empty,
|
||||
aliases: std.ArrayListUnmanaged(Alias) = .empty,
|
||||
|
||||
/// Maximum total decoded bytes this transaction will accumulate.
|
||||
/// Captured at init so a change to the configured limit doesn't
|
||||
/// apply to a transaction already in flight.
|
||||
max_size: usize,
|
||||
|
||||
/// 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.
|
||||
/// Set when max_size was exceeded on text data; excess data is
|
||||
/// dropped but the write still completes. Exceeding the limit
|
||||
/// with non-text data fails the transaction instead (see data).
|
||||
truncated: bool = false,
|
||||
|
||||
pub const Options = struct {
|
||||
/// Maximum total decoded bytes accumulated by the transaction.
|
||||
max_size: usize = max_write_size,
|
||||
};
|
||||
|
||||
const Entry = struct {
|
||||
/// Owned by the transaction arena.
|
||||
mime: []const u8,
|
||||
@@ -62,7 +74,11 @@ pub const WriteState = struct {
|
||||
};
|
||||
|
||||
/// Begin a transaction from a type=write packet.
|
||||
pub fn init(alloc: Allocator, meta: *const Metadata) Allocator.Error!WriteState {
|
||||
pub fn init(
|
||||
alloc: Allocator,
|
||||
meta: *const Metadata,
|
||||
opts: Options,
|
||||
) Allocator.Error!WriteState {
|
||||
assert(meta.op == .write);
|
||||
var arena: std.heap.ArenaAllocator = .init(alloc);
|
||||
errdefer arena.deinit();
|
||||
@@ -75,6 +91,7 @@ pub const WriteState = struct {
|
||||
.id = id,
|
||||
.pw = pw,
|
||||
.name = name,
|
||||
.max_size = opts.max_size,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,12 +104,17 @@ pub const WriteState = struct {
|
||||
|
||||
/// Accumulate one wdata chunk carrying data for meta.mime (which
|
||||
/// must be non-empty; an empty mime is a commit, not data).
|
||||
///
|
||||
/// Returns error.TooLarge when non-text data exceeds max_size:
|
||||
/// unlike text, a truncated binary payload is corrupt, and the
|
||||
/// protocol has no partial-success status, so the caller must
|
||||
/// fail the whole transaction (EFBIG) and abort it.
|
||||
pub fn data(
|
||||
self: *WriteState,
|
||||
alloc: Allocator,
|
||||
meta: *const Metadata,
|
||||
payload: []const u8,
|
||||
) error{OutOfMemory}!void {
|
||||
) error{ OutOfMemory, TooLarge }!void {
|
||||
assert(meta.op == .wdata);
|
||||
assert(meta.mime.len > 0);
|
||||
// Switch the receiving entry if this chunk is for a different
|
||||
@@ -151,12 +173,59 @@ pub const WriteState = struct {
|
||||
};
|
||||
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) {
|
||||
// Empty slice, do nothing.
|
||||
if (decoded.data.len == 0) return;
|
||||
|
||||
// Make sure it fits in our max size. If it does, easy append.
|
||||
const remaining = self.max_size -| self.spool.items.len;
|
||||
if (decoded.data.len <= remaining and !self.truncated) {
|
||||
try self.spool.appendSlice(alloc, decoded.data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bytes must be dropped. Only text (text/* plus the legacy X11
|
||||
// text names) remains usable after an arbitrary cut; non-text
|
||||
// data would be corrupted, so the whole transaction fails.
|
||||
truncatable: {
|
||||
if (std.mem.startsWith(u8, meta.mime, "text/")) break :truncatable;
|
||||
if (clipboard.isTextMime(meta.mime)) break :truncatable;
|
||||
return error.TooLarge;
|
||||
}
|
||||
|
||||
// Text is truncated at a UTF-8 boundary. Once truncated, all
|
||||
// further data is dropped entirely: the spool may sit below
|
||||
// max_size after the boundary trim, and appending later chunks
|
||||
// there would splice disjoint parts of the stream together.
|
||||
if (!self.truncated) {
|
||||
try self.spool.appendSlice(alloc, decoded.data[0..remaining]);
|
||||
self.trimPartialSequence();
|
||||
self.truncated = true;
|
||||
logTruncatedOnce();
|
||||
logTruncatedOnce(self.max_size);
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim a split multi-byte UTF-8 sequence off the spool tail so
|
||||
/// truncated text never ends in a partial codepoint.
|
||||
fn trimPartialSequence(self: *WriteState) void {
|
||||
const idx = self.current orelse return;
|
||||
const start = self.entries.items[idx].start;
|
||||
const items = self.spool.items;
|
||||
|
||||
// Skip trailing continuation bytes (0b10xxxxxx) to find the
|
||||
// sequence's first byte; valid UTF-8 has at most 3.
|
||||
var i = items.len;
|
||||
while (i > start and
|
||||
items.len - i < 3 and
|
||||
(items[i - 1] & 0xC0) == 0x80) i -= 1;
|
||||
if (i == start) return;
|
||||
const lead = items[i - 1];
|
||||
if ((lead & 0xC0) == 0x80) return;
|
||||
|
||||
const seq_len = std.unicode.utf8ByteSequenceLength(lead) catch return;
|
||||
// Equal means the tail is a complete sequence; greater means
|
||||
// invalid input (e.g. a continuation after ASCII).
|
||||
if (items.len - (i - 1) < seq_len) {
|
||||
self.spool.shrinkRetainingCapacity(i - 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +365,7 @@ pub const WriteState = struct {
|
||||
};
|
||||
}
|
||||
|
||||
fn logTruncatedOnce() void {
|
||||
fn logTruncatedOnce(limit: usize) void {
|
||||
// Log spam protection: this can be hit for every chunk of an
|
||||
// oversized write.
|
||||
const S = struct {
|
||||
@@ -306,7 +375,7 @@ pub const WriteState = struct {
|
||||
S.logged = true;
|
||||
log.warn(
|
||||
"clipboard write exceeds {} bytes, truncating",
|
||||
.{max_write_size},
|
||||
.{limit},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -317,7 +386,7 @@ test "write: basic transaction" {
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const begin_meta: Metadata = .{ .op = .write, .id = "42" };
|
||||
var state: WriteState = try .init(alloc, &begin_meta);
|
||||
var state: WriteState = try .init(alloc, &begin_meta, .{});
|
||||
defer state.deinit(alloc);
|
||||
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "R2hvc3R0eQ=="); // "Ghostty"
|
||||
@@ -336,7 +405,7 @@ test "write: chunked data accumulates" {
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const begin_meta: Metadata = .{ .op = .write };
|
||||
var state: WriteState = try .init(alloc, &begin_meta);
|
||||
var state: WriteState = try .init(alloc, &begin_meta, .{});
|
||||
defer state.deinit(alloc);
|
||||
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "SGVsbG8="); // "Hello"
|
||||
@@ -352,7 +421,7 @@ test "write: multiple mimes in order" {
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const begin_meta: Metadata = .{ .op = .write };
|
||||
var state: WriteState = try .init(alloc, &begin_meta);
|
||||
var state: WriteState = try .init(alloc, &begin_meta, .{});
|
||||
defer state.deinit(alloc);
|
||||
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "YQ=="); // "a"
|
||||
@@ -372,7 +441,7 @@ test "write: reused mime overwrites" {
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const begin_meta: Metadata = .{ .op = .write };
|
||||
var state: WriteState = try .init(alloc, &begin_meta);
|
||||
var state: WriteState = try .init(alloc, &begin_meta, .{});
|
||||
defer state.deinit(alloc);
|
||||
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "YQ=="); // "a"
|
||||
@@ -392,7 +461,7 @@ test "write: invalid base64 chunk is dropped, transaction continues" {
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const begin_meta: Metadata = .{ .op = .write };
|
||||
var state: WriteState = try .init(alloc, &begin_meta);
|
||||
var state: WriteState = try .init(alloc, &begin_meta, .{});
|
||||
defer state.deinit(alloc);
|
||||
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "SGVsbG8="); // "Hello"
|
||||
@@ -409,7 +478,7 @@ test "write: aliases resolve at commit" {
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const begin_meta: Metadata = .{ .op = .write };
|
||||
var state: WriteState = try .init(alloc, &begin_meta);
|
||||
var state: WriteState = try .init(alloc, &begin_meta, .{});
|
||||
defer state.deinit(alloc);
|
||||
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "R2hvc3R0eQ=="); // "Ghostty"
|
||||
@@ -427,12 +496,130 @@ test "write: aliases resolve at commit" {
|
||||
try testing.expectEqualStrings("Ghostty", committed.contents[2].data);
|
||||
}
|
||||
|
||||
test "write: default limit when unset" {
|
||||
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 testing.expectEqual(@as(usize, max_write_size), state.max_size);
|
||||
}
|
||||
|
||||
test "write: custom limit truncates but commit succeeds" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const begin_meta: Metadata = .{ .op = .write };
|
||||
var state: WriteState = try .init(alloc, &begin_meta, .{ .max_size = 8 });
|
||||
defer state.deinit(alloc);
|
||||
|
||||
// First chunk crosses the limit mid-chunk, second is entirely
|
||||
// beyond it and fully dropped.
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "SGVsbG9Xb3JsZA=="); // "HelloWorld"
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "IQ=="); // "!"
|
||||
|
||||
const committed = try state.commit(alloc);
|
||||
defer committed.deinit(alloc);
|
||||
try testing.expect(committed.truncated);
|
||||
try testing.expectEqual(@as(usize, 1), committed.contents.len);
|
||||
try testing.expectEqualStrings("HelloWor", committed.contents[0].data);
|
||||
}
|
||||
|
||||
test "write: truncation cuts at utf8 boundary" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const begin_meta: Metadata = .{ .op = .write };
|
||||
var state: WriteState = try .init(alloc, &begin_meta, .{ .max_size = 5 });
|
||||
defer state.deinit(alloc);
|
||||
|
||||
// "abcdé": the raw cut at 5 bytes would split é (0xC3 0xA9).
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "YWJjZMOp");
|
||||
|
||||
const committed = try state.commit(alloc);
|
||||
defer committed.deinit(alloc);
|
||||
try testing.expect(committed.truncated);
|
||||
try testing.expectEqualStrings("abcd", committed.contents[0].data);
|
||||
}
|
||||
|
||||
test "write: truncation cuts utf8 sequence split across chunks" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const begin_meta: Metadata = .{ .op = .write };
|
||||
var state: WriteState = try .init(alloc, &begin_meta, .{ .max_size = 5 });
|
||||
defer state.deinit(alloc);
|
||||
|
||||
// "abcd" + 0xC3 exactly fills the limit with a dangling lead
|
||||
// byte...
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "YWJjZMM=");
|
||||
// ...whose continuation (0xA9) arrives in the next chunk.
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "qSE=");
|
||||
|
||||
const committed = try state.commit(alloc);
|
||||
defer committed.deinit(alloc);
|
||||
try testing.expect(committed.truncated);
|
||||
try testing.expectEqualStrings("abcd", committed.contents[0].data);
|
||||
}
|
||||
|
||||
test "write: non-text under limit is unaffected" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const begin_meta: Metadata = .{ .op = .write };
|
||||
var state: WriteState = try .init(alloc, &begin_meta, .{ .max_size = 8 });
|
||||
defer state.deinit(alloc);
|
||||
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "image/png" }, "iVBORw=="); // "\x89PNG"
|
||||
|
||||
const committed = try state.commit(alloc);
|
||||
defer committed.deinit(alloc);
|
||||
try testing.expect(!committed.truncated);
|
||||
try testing.expectEqualStrings("\x89PNG", committed.contents[0].data);
|
||||
}
|
||||
|
||||
test "write: non-text over limit rejects transaction" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const begin_meta: Metadata = .{ .op = .write };
|
||||
var state: WriteState = try .init(alloc, &begin_meta, .{ .max_size = 8 });
|
||||
defer state.deinit(alloc);
|
||||
|
||||
try testing.expectError(error.TooLarge, state.data(
|
||||
alloc,
|
||||
&.{ .op = .wdata, .mime = "image/png" },
|
||||
"SGVsbG9Xb3JsZA==", // "HelloWorld"
|
||||
));
|
||||
}
|
||||
|
||||
test "write: non-text after text truncation rejects transaction" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const begin_meta: Metadata = .{ .op = .write };
|
||||
var state: WriteState = try .init(alloc, &begin_meta, .{ .max_size = 8 });
|
||||
defer state.deinit(alloc);
|
||||
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "SGVsbG9Xb3JsZA=="); // "HelloWorld"
|
||||
|
||||
// An empty chunk drops no bytes so it doesn't reject.
|
||||
try state.data(alloc, &.{ .op = .wdata, .mime = "image/png" }, "");
|
||||
|
||||
try testing.expectError(error.TooLarge, state.data(
|
||||
alloc,
|
||||
&.{ .op = .wdata, .mime = "image/png" },
|
||||
"iVBORw==",
|
||||
));
|
||||
}
|
||||
|
||||
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);
|
||||
var state: WriteState = try .init(alloc, &begin_meta, .{});
|
||||
defer state.deinit(alloc);
|
||||
|
||||
const alias_meta: Metadata = .{ .op = .walias, .mime = "text/plain" };
|
||||
|
||||
@@ -58,6 +58,7 @@ pub const Status = enum {
|
||||
DATA,
|
||||
DONE,
|
||||
EBUSY,
|
||||
EFBIG,
|
||||
EINVAL,
|
||||
EIO,
|
||||
ENOSYS,
|
||||
|
||||
@@ -85,6 +85,12 @@ pub const Handler = struct {
|
||||
/// remember the user's decision.
|
||||
kitty_clipboard_grants: kitty_clipboard.Grants = .{},
|
||||
|
||||
/// Maximum total decoded bytes accumulated by one Kitty clipboard
|
||||
/// protocol (OSC 5522) write transaction, captured when the
|
||||
/// transaction begins. Text data beyond the limit is truncated;
|
||||
/// non-text data fails the transaction with EFBIG.
|
||||
kitty_clipboard_write_max_bytes: usize = kitty_clipboard.max_write_size,
|
||||
|
||||
/// Called for sequence identifiers not supported by this library.
|
||||
/// Currently, only APC is reported. Content is borrowed and only valid
|
||||
/// for the duration of the callback. Set `apc_handler.unknown_max_bytes`
|
||||
@@ -1010,7 +1016,9 @@ pub const Handler = struct {
|
||||
const alloc = self.terminal.gpa();
|
||||
const state = try alloc.create(kitty_clipboard.WriteState);
|
||||
errdefer alloc.destroy(state);
|
||||
state.* = try .init(alloc, meta);
|
||||
state.* = try .init(alloc, meta, .{
|
||||
.max_size = self.kitty_clipboard_write_max_bytes,
|
||||
});
|
||||
self.kitty_clipboard_write = state;
|
||||
}
|
||||
|
||||
@@ -1044,6 +1052,14 @@ pub const Handler = struct {
|
||||
);
|
||||
return error.OutOfMemory;
|
||||
},
|
||||
|
||||
// Non-text data over the write limit aborts the
|
||||
// transaction: truncated binary data would be corrupt.
|
||||
error.TooLarge => self.kittyClipboardFinish(
|
||||
state,
|
||||
.EFBIG,
|
||||
terminator,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4251,6 +4267,40 @@ test "kitty clipboard invalid walias payload aborts with EINVAL" {
|
||||
);
|
||||
}
|
||||
|
||||
test "kitty clipboard oversized non-text write aborts with EFBIG" {
|
||||
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
const S = KittyClipboardCapture;
|
||||
S.reset();
|
||||
|
||||
var handler: Handler = .init(&t);
|
||||
handler.effects.write_pty = &S.writePty;
|
||||
handler.effects.clipboard_write = &S.clipboardWrite;
|
||||
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
|
||||
defer s.deinit();
|
||||
|
||||
// Shrink the limit so the test doesn't have to stream the
|
||||
// default 32MiB.
|
||||
s.handler.kitty_clipboard_write_max_bytes = 4;
|
||||
|
||||
s.nextSlice("\x1B]5522;type=write:id=w\x1B\\");
|
||||
s.nextSlice("\x1B]5522;type=wdata:mime=aW1hZ2UvcG5n;SGVsbG9Xb3JsZA==\x1B\\");
|
||||
try testing.expectEqualStrings(
|
||||
"\x1B]5522;type=write:status=EFBIG:id=w\x1B\\",
|
||||
S.responseSlice(),
|
||||
);
|
||||
try testing.expect(!s.handler.semantic_failure);
|
||||
|
||||
// The transaction is gone: a commit does nothing further.
|
||||
s.nextSlice("\x1B]5522;type=wdata\x1B\\");
|
||||
try testing.expectEqual(@as(usize, 0), S.write_count);
|
||||
try testing.expectEqualStrings(
|
||||
"\x1B]5522;type=write:status=EFBIG:id=w\x1B\\",
|
||||
S.responseSlice(),
|
||||
);
|
||||
}
|
||||
|
||||
test "kitty clipboard invalid wdata chunk is skipped" {
|
||||
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
@@ -177,6 +177,7 @@ pub const DerivedConfig = struct {
|
||||
background: configpkg.Config.Color,
|
||||
osc_color_report_format: configpkg.Config.OSCColorReportFormat,
|
||||
clipboard_write: configpkg.ClipboardAccess,
|
||||
clipboard_write_limit: usize,
|
||||
enquiry_response: []const u8,
|
||||
conditional_state: configpkg.ConditionalState,
|
||||
|
||||
@@ -213,6 +214,7 @@ pub const DerivedConfig = struct {
|
||||
.background = config.background,
|
||||
.osc_color_report_format = config.@"osc-color-report-format",
|
||||
.clipboard_write = config.@"clipboard-write",
|
||||
.clipboard_write_limit = config.@"clipboard-write-limit-bytes".value,
|
||||
.enquiry_response = try alloc.dupe(u8, config.@"enquiry-response"),
|
||||
.conditional_state = config._conditional_state,
|
||||
|
||||
@@ -297,6 +299,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void {
|
||||
.terminal = &self.terminal,
|
||||
.osc_color_report_format = opts.config.osc_color_report_format,
|
||||
.clipboard_write = opts.config.clipboard_write,
|
||||
.clipboard_write_limit = opts.config.clipboard_write_limit,
|
||||
.enquiry_response = opts.config.enquiry_response,
|
||||
};
|
||||
|
||||
|
||||
@@ -51,6 +51,10 @@ pub const StreamHandler = struct {
|
||||
/// The clipboard write access configuration.
|
||||
clipboard_write: configpkg.ClipboardAccess,
|
||||
|
||||
/// Maximum total decoded bytes per Kitty clipboard protocol
|
||||
/// (OSC 5522) write transaction; data beyond this is truncated.
|
||||
clipboard_write_limit: usize,
|
||||
|
||||
//---------------------------------------------------------------
|
||||
// Internal state
|
||||
|
||||
@@ -114,6 +118,7 @@ pub const StreamHandler = struct {
|
||||
pub fn changeConfig(self: *StreamHandler, config: *termio.DerivedConfig) void {
|
||||
self.osc_color_report_format = config.osc_color_report_format;
|
||||
self.clipboard_write = config.clipboard_write;
|
||||
self.clipboard_write_limit = config.clipboard_write_limit;
|
||||
self.enquiry_response = config.enquiry_response;
|
||||
self.terminal.setDefaultCursorStyle(config.cursor_style);
|
||||
self.terminal.setDefaultCursorBlink(config.cursor_blink);
|
||||
@@ -1174,7 +1179,9 @@ pub const StreamHandler = struct {
|
||||
|
||||
const state = try self.alloc.create(terminal.kitty.clipboard.WriteState);
|
||||
errdefer self.alloc.destroy(state);
|
||||
state.* = try .init(self.alloc, meta);
|
||||
state.* = try .init(self.alloc, meta, .{
|
||||
.max_size = self.clipboard_write_limit,
|
||||
});
|
||||
self.kitty_clipboard_write = state;
|
||||
}
|
||||
|
||||
@@ -1210,6 +1217,14 @@ pub const StreamHandler = struct {
|
||||
);
|
||||
return error.OutOfMemory;
|
||||
},
|
||||
|
||||
// Non-text data over the write limit aborts the
|
||||
// transaction: truncated binary data would be corrupt.
|
||||
error.TooLarge => try self.kittyClipboardWriteFinish(
|
||||
state,
|
||||
.EFBIG,
|
||||
terminator,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user