diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-10 21:51:57 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-10 21:51:57 +0300 |
| commit | 425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch) | |
| tree | e52c4383a6d69216cbe2a32638bf90841d7e2189 /vendor/libghostty-vt/src/terminal/c/paste.zig | |
| parent | 90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff) | |
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/c/paste.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/c/paste.zig | 392 |
1 files changed, 392 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/terminal/c/paste.zig b/vendor/libghostty-vt/src/terminal/c/paste.zig index bce6a565..8dfa649c 100644 --- a/vendor/libghostty-vt/src/terminal/c/paste.zig +++ b/vendor/libghostty-vt/src/terminal/c/paste.zig @@ -1,8 +1,130 @@ const std = @import("std"); const lib = @import("../lib.zig"); const paste = @import("../../input/paste.zig"); +const terminal_paste_pkg = @import("../paste.zig"); +const clipboard = @import("../clipboard.zig"); +const io = @import("io.zig"); +const terminal_c = @import("terminal.zig"); +const Terminal = terminal_c.Terminal; +const ClipboardContent = terminal_c.ClipboardContent; +const ClipboardRead = terminal_c.ClipboardRead; +const ClipboardReadReply = terminal_c.ClipboardReadReply; const Result = @import("result.zig").Result; +/// Why a paste happened. The flat C form of the Zig tagged union +/// (terminal.paste.Source); the location rides alongside in +/// GhosttyPaste and only applies to a clipboard paste. +/// +/// C: GhosttyPasteSource +pub const Source = lib.Enum(lib.target, &.{ + "clipboard", + "text", +}); + +/// A paste of clipboard contents into the terminal. Sized struct. +/// +/// C: GhosttyPaste +pub const Request = extern struct { + size: usize = @sizeOf(Request), + location: clipboard.Location, + source: Source, + mimes: ?[*]const lib.String, + mimes_len: usize, + reader: io.MimeReader, + allow_unsafe: bool, +}; + +pub fn terminal_paste( + terminal_: Terminal, + req_: ?*const Request, + out_written: ?*bool, +) callconv(lib.calling_conv) Result { + const wrapper = terminal_ orelse return .invalid_value; + const req = req_ orelse return .invalid_value; + + // Every field is required; a smaller size is a caller from a + // different ABI version than any this struct has had. + if (req.size < @sizeOf(Request)) return .invalid_value; + + // The handler always has a write_pty trampoline that no-ops without + // a C callback, so the "nothing can be written" check is ours. + if (wrapper.effects.write_pty == null) return .invalid_value; + + const c_mimes: []const lib.String = if (req.mimes) |ptr| + ptr[0..req.mimes_len] + else + &.{}; + + // Only a paste with something to read needs a reader. + if (c_mimes.len > 0 and !req.reader.valid()) return .invalid_value; + + // A paste carries a handful of representations, so keep the common + // case allocation-free. + var sfa = std.heap.stackFallback(256, wrapper.terminal.gpa()); + const alloc = sfa.get(); + const mimes = alloc.alloc([]const u8, c_mimes.len) catch return .out_of_memory; + defer alloc.free(mimes); + for (mimes, c_mimes) |*mime, c_mime| mime.* = c_mime.ptr[0..c_mime.len]; + + const written = wrapper.stream.handler.paste(.{ + .source = switch (req.source) { + .clipboard => .{ .clipboard = req.location }, + .text => .text, + }, + .contents = .{ .reader = .{ + .mimes = mimes, + .read = .{ .ctx = @constCast(req), .read_fn = &readTrampoline }, + } }, + .allow_unsafe = req.allow_unsafe, + }) catch |err| return switch (err) { + error.UnsafePaste => .rejected, + error.NoWritePty => .invalid_value, + error.OutOfMemory => .out_of_memory, + error.ReadFailed, + error.EntropyUnavailable, + error.Canceled, + => .io_error, + }; + if (out_written) |ptr| ptr.* = written; + return .success; +} + +/// The sink handed to the C read callback: a GhosttyWriter over the +/// Zig sink, remembering whether the sink itself failed so that can be +/// told apart from the callback failing to read. +const Sink = struct { + writer: *std.Io.Writer, + write_failed: bool = false, + + fn write( + userdata: ?*anyopaque, + data: [*]const u8, + len: usize, + ) callconv(lib.calling_conv) bool { + const self: *Sink = @ptrCast(@alignCast(userdata.?)); + self.writer.writeAll(data[0..len]) catch { + self.write_failed = true; + return false; + }; + return true; + } +}; + +fn readTrampoline( + ctx: ?*anyopaque, + mime: []const u8, + writer: *std.Io.Writer, +) clipboard.MimeReader.Error!void { + const req: *const Request = @ptrCast(@alignCast(ctx.?)); + var sink: Sink = .{ .writer = writer }; + if (!req.reader.read.?(req.reader.userdata, .init(mime), .{ + .write = &Sink.write, + .userdata = &sink, + })) { + return if (sink.write_failed) error.WriteFailed else error.ReadFailed; + } +} + pub fn is_safe(data: ?[*]const u8, len: usize) callconv(lib.calling_conv) bool { const slice: []const u8 = if (data) |v| v[0..len] else &.{}; return paste.isSafe(slice); @@ -127,3 +249,273 @@ test "is_safe with null empty data" { const testing = std.testing; try testing.expect(is_safe(null, 0)); } + +/// Capture state for the terminal_paste tests: every pty write and the +/// clipboard reads that follow a paste event. +const TerminalPasteCapture = struct { + var written: [1024]u8 = undefined; + var written_len: usize = 0; + var write_count: usize = 0; + var read_count: usize = 0; + var last_read_granted: bool = false; + + fn reset() void { + written_len = 0; + write_count = 0; + read_count = 0; + last_read_granted = false; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void { + @memcpy(written[written_len..][0..len], ptr[0..len]); + written_len += len; + write_count += 1; + } + + fn clipboardRead(_: Terminal, _: ?*anyopaque, request: *const ClipboardRead) callconv(lib.calling_conv) void { + read_count += 1; + last_read_granted = request.granted; + const contents = [_]ClipboardContent{.{ + .mime = .init(@as([]const u8, "text/plain")), + .data = .init(@as([]const u8, "Ghostty")), + }}; + request.reply(request, &.{ + .size = @sizeOf(ClipboardReadReply), + .result = .success, + .contents = &contents, + .contents_len = contents.len, + .available = null, + .available_len = 0, + .remember = false, + }); + } + + fn writtenSlice() []const u8 { + return written[0..written_len]; + } + + /// The representations a test paste serves: the MIME list for the + /// request and the data the read callback streams, in pieces. + const Contents = struct { + mimes: [2]lib.String = undefined, + data: [2][]const u8 = undefined, + len: usize = 0, + reads: [2]usize = @splat(0), + fail: bool = false, + + fn init(entries: []const struct { []const u8, []const u8 }) Contents { + var self: Contents = .{}; + for (entries) |entry| { + self.mimes[self.len] = .init(entry[0]); + self.data[self.len] = entry[1]; + self.len += 1; + } + return self; + } + + fn request(self: *Contents) Request { + return .{ + .location = .standard, + .source = .clipboard, + .mimes = &self.mimes, + .mimes_len = self.len, + .reader = .{ .read = &read, .userdata = self }, + .allow_unsafe = false, + }; + } + + fn read(userdata: ?*anyopaque, mime: lib.String, writer: io.Writer) callconv(lib.calling_conv) bool { + const self: *Contents = @ptrCast(@alignCast(userdata.?)); + // The mime is the exact string from the request's list, so + // identifying it by pointer works. + const index: usize = for (self.mimes[0..self.len], 0..) |m, i| { + if (m.ptr == mime.ptr and m.len == mime.len) break i; + } else return false; + self.reads[index] += 1; + if (self.fail) return false; + const data = self.data[index]; + var offset: usize = 0; + while (offset < data.len) { + const n = @min(3, data.len - offset); + if (!writer.write.?(writer.userdata, data[offset..].ptr, n)) return false; + offset += n; + } + return true; + } + }; +}; + +test "terminal_paste null handling" { + const testing = std.testing; + const S = TerminalPasteCapture; + + var t: Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new(&lib.alloc.test_allocator, &t, 80, 24)); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.set(t, .write_pty, @ptrCast(&S.writePty))); + + var written: bool = true; + var contents: S.Contents = .init(&.{.{ "text/plain", "hello" }}); + const req = contents.request(); + try testing.expectEqual(Result.invalid_value, terminal_paste(null, &req, &written)); + try testing.expectEqual(Result.invalid_value, terminal_paste(t, null, &written)); + try testing.expect(written); + + // A size smaller than the struct is rejected. + var small = req; + small.size = @sizeOf(usize); + try testing.expectEqual(Result.invalid_value, terminal_paste(t, &small, &written)); + + // MIME types without a reader are rejected; none at all is fine. + var unreadable = req; + unreadable.reader.read = null; + try testing.expectEqual(Result.invalid_value, terminal_paste(t, &unreadable, &written)); + unreadable.mimes = null; + unreadable.mimes_len = 0; + try testing.expectEqual(Result.success, terminal_paste(t, &unreadable, &written)); + try testing.expect(!written); +} + +test "terminal_paste without write_pty is invalid" { + const testing = std.testing; + const S = TerminalPasteCapture; + S.reset(); + + var t: Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new(&lib.alloc.test_allocator, &t, 80, 24)); + defer terminal_c.free(t); + + var contents: S.Contents = .init(&.{.{ "text/plain", "hello" }}); + const req = contents.request(); + try testing.expectEqual(Result.invalid_value, terminal_paste(t, &req, null)); +} + +test "terminal_paste text and unsafe" { + const testing = std.testing; + const S = TerminalPasteCapture; + S.reset(); + + var t: Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new(&lib.alloc.test_allocator, &t, 80, 24)); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.set(t, .write_pty, @ptrCast(&S.writePty))); + + // Plain text, NULL out_written pointer is fine. The text is read + // once, the image never. + var contents: S.Contents = .init(&.{ + .{ "image/png", "\x89PNG" }, + .{ "text/plain", "hel\x1blo" }, + }); + const req = contents.request(); + try testing.expectEqual(Result.success, terminal_paste(t, &req, null)); + try testing.expectEqualStrings("hel lo", S.writtenSlice()); + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqual(@as(usize, 0), contents.reads[0]); + try testing.expectEqual(@as(usize, 1), contents.reads[1]); + + // Unsafe is refused with nothing written, then allowed. + S.reset(); + var written: bool = false; + var unsafe_contents: S.Contents = .init(&.{.{ "text/plain", "rm -rf /\n" }}); + var unsafe = unsafe_contents.request(); + try testing.expectEqual(Result.rejected, terminal_paste(t, &unsafe, &written)); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expect(!written); + try testing.expectEqual(@as(usize, 1), unsafe_contents.reads[0]); + + unsafe.allow_unsafe = true; + try testing.expectEqual(Result.success, terminal_paste(t, &unsafe, &written)); + try testing.expect(written); + try testing.expectEqualStrings("rm -rf /\r", S.writtenSlice()); + try testing.expectEqual(@as(usize, 2), unsafe_contents.reads[0]); + + // Bracketed paste mode frames the text through the real mode path. + S.reset(); + const decset = "\x1b[?2004h"; + terminal_c.vt_write(t, decset, decset.len); + try testing.expectEqual(Result.success, terminal_paste(t, &req, &written)); + try testing.expect(written); + try testing.expectEqualStrings("\x1b[200~hel lo\x1b[201~", S.writtenSlice()); + + // No text representation writes nothing and reads nothing. + S.reset(); + var image: S.Contents = .init(&.{.{ "image/png", "\x89PNG" }}); + const image_req = image.request(); + try testing.expectEqual(Result.success, terminal_paste(t, &image_req, &written)); + try testing.expect(!written); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expectEqual(@as(usize, 0), image.reads[0]); + + // A failing reader is an I/O error. + S.reset(); + contents.fail = true; + try testing.expectEqual(Result.io_error, terminal_paste(t, &req, &written)); + try testing.expectEqual(@as(usize, 0), S.write_count); +} + +test "terminal_paste event" { + const testing = std.testing; + const S = TerminalPasteCapture; + S.reset(); + + var t: Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new(&lib.alloc.test_allocator, &t, 80, 24)); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.set(t, .write_pty, @ptrCast(&S.writePty))); + t.?.terminal.modes.set(.kitty_paste_events, true); + + // Without a clipboard_read callback the paste stays text. + var written: bool = false; + var contents: S.Contents = .init(&.{ + .{ "text/plain", "secret" }, + .{ "image/png", "\x89PNG" }, + }); + var req = contents.request(); + req.location = .primary; + try testing.expectEqual(Result.success, terminal_paste(t, &req, &written)); + try testing.expect(written); + try testing.expectEqualStrings("secret", S.writtenSlice()); + + // With one, an event is sent listing every MIME type and no data + // is read, let alone written. + S.reset(); + contents.reads = @splat(0); + try testing.expectEqual(Result.success, terminal_c.set(t, .clipboard_read, @ptrCast(&S.clipboardRead))); + try testing.expectEqual(Result.success, terminal_paste(t, &req, &written)); + try testing.expect(written); + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqual(@as(usize, 3), std.mem.count(u8, S.writtenSlice(), "\x1b]5522;")); + try testing.expect(std.mem.startsWith(u8, S.writtenSlice(), "\x1b]5522;type=read:status=OK:loc=primary:pw=")); + try testing.expect(std.mem.indexOf(u8, S.writtenSlice(), "secret") == null); + try testing.expect(std.mem.indexOf(u8, S.writtenSlice(), ";dGV4dC9wbGFpbiBpbWFnZS9wbmcK\x1b\\") != null); + try testing.expectEqual(@as(usize, 0), contents.reads[0]); + try testing.expectEqual(@as(usize, 0), contents.reads[1]); + + // The program's read with the event password is granted once. + const ok_prefix = "\x1b]5522;type=read:status=OK:loc=primary:pw="; + const pw_end = std.mem.indexOfPos(u8, S.writtenSlice(), ok_prefix.len, "\x1b\\").?; + var read_buf: [256]u8 = undefined; + const read = try std.fmt.bufPrint( + &read_buf, + "\x1b]5522;type=read:pw={s}:name=UGFzdGUgZXZlbnQ=;dGV4dC9wbGFpbg==\x1b\\", + .{S.writtenSlice()[ok_prefix.len..pw_end]}, + ); + S.reset(); + terminal_c.vt_write(t, read.ptr, read.len); + try testing.expectEqual(@as(usize, 1), S.read_count); + try testing.expect(S.last_read_granted); + try testing.expect(std.mem.indexOf(u8, S.writtenSlice(), ";R2hvc3R0eQ==\x1b\\") != null); + + S.reset(); + terminal_c.vt_write(t, read.ptr, read.len); + try testing.expectEqual(@as(usize, 1), S.read_count); + try testing.expect(!S.last_read_granted); + + // Text sources never become events. + S.reset(); + var ime = req; + ime.source = .text; + try testing.expectEqual(Result.success, terminal_paste(t, &ime, &written)); + try testing.expect(written); + try testing.expectEqualStrings("secret", S.writtenSlice()); +} |
