diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 66f3b7ce5..e015369e3 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -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; diff --git a/src/config/Config.zig b/src/config/Config.zig index 5b2466d01..51a47c994 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -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; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 7a05a37bf..00bb3b060 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -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( diff --git a/src/terminal/kitty/clipboard.zig b/src/terminal/kitty/clipboard.zig index 063a34604..55c42fe8e 100644 --- a/src/terminal/kitty/clipboard.zig +++ b/src/terminal/kitty/clipboard.zig @@ -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. //! diff --git a/src/terminal/kitty/clipboard_write.zig b/src/terminal/kitty/clipboard_write.zig index fbb631446..7834ed631 100644 --- a/src/terminal/kitty/clipboard_write.zig +++ b/src/terminal/kitty/clipboard_write.zig @@ -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" }; diff --git a/src/terminal/osc/parsers/kitty_clipboard_protocol.zig b/src/terminal/osc/parsers/kitty_clipboard_protocol.zig index 3b32aae8b..1bcc7d380 100644 --- a/src/terminal/osc/parsers/kitty_clipboard_protocol.zig +++ b/src/terminal/osc/parsers/kitty_clipboard_protocol.zig @@ -58,6 +58,7 @@ pub const Status = enum { DATA, DONE, EBUSY, + EFBIG, EINVAL, EIO, ENOSYS, diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 310d65d46..3b3cf7eb2 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -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); diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 0ac139962..7d9fd45ff 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -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, }; diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 42977640e..356ef275e 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -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, + ), }; }