diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 16bd92c0a..66f3b7ce5 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -757,6 +757,11 @@ struct GhosttyClipboardRead { * True if the terminal already holds a session grant for this request * (kitty clipboard protocol passwords). The embedder should skip any * permission prompt and serve the read. + * + * Always false when mimes_len is zero: such a request is served + * without a prompt (see the callback docs), so the terminal never + * consults grants for it and a one-time password is preserved for + * the follow-up data read. */ bool granted; @@ -792,7 +797,9 @@ struct GhosttyClipboardRead { * state; a reply that sets `remember` records a session grant so later * requests with the same password arrive with `granted` set. Kitty itself * serves a request for only the targets listing (`list` with no `mimes`) - * without prompting. + * without prompting, and embedders are expected to do the same; the + * terminal never consults grants for such requests (`granted` is false + * and one-time passwords are not consumed). * * Installing this callback also enables Kitty paste events (mode 5522): * ghostty_terminal_paste() sends the program an event instead of the text, diff --git a/src/Surface.zig b/src/Surface.zig index b9a31e658..fefd7accd 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5931,10 +5931,13 @@ pub fn completeClipboardRequest( // If we need confirmation we return an error without // consuming the request state; the apprt keeps it alive // for the confirmation flow. A session grant carried by - // the request skips the prompt. + // the request skips the prompt, and a request with no + // data types is exempt from prompting entirely; see + // readPromptExempt. if (self.config.clipboard_read == .ask and !complete.confirmed and - !kitty.granted) + !kitty.granted and + !terminal.kitty.clipboard.readPromptExempt(kitty.mimes.len)) { return error.UnauthorizedPaste; } diff --git a/src/terminal/clipboard.zig b/src/terminal/clipboard.zig index a911c6887..0a1c5a7ae 100644 --- a/src/terminal/clipboard.zig +++ b/src/terminal/clipboard.zig @@ -173,6 +173,11 @@ pub const Read = struct { /// True if the terminal already holds a session grant for this /// request (kitty clipboard protocol passwords). The embedder should /// skip any permission prompt and serve the read. + /// + /// Always false when mimes is empty: such a request is served + /// without a prompt (kitty's targets-listing exemption), so the + /// terminal never consults grants for it and a one-time password + /// is preserved for the follow-up data read. granted: bool, /// True if the program supplied a session password, so the embedder diff --git a/src/terminal/kitty/clipboard.zig b/src/terminal/kitty/clipboard.zig index 261cb7489..063a34604 100644 --- a/src/terminal/kitty/clipboard.zig +++ b/src/terminal/kitty/clipboard.zig @@ -44,6 +44,7 @@ pub const Terminator = oscpkg.Terminator; pub const Metadata = command.Metadata; pub const Payload = command.Payload; +pub const readPromptExempt = command.readPromptExempt; 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; diff --git a/src/terminal/kitty/clipboard_command.zig b/src/terminal/kitty/clipboard_command.zig index 40718840a..0bf5a1aa7 100644 --- a/src/terminal/kitty/clipboard_command.zig +++ b/src/terminal/kitty/clipboard_command.zig @@ -226,6 +226,12 @@ pub const Payload = struct { } }; +/// Whether a read request with this many data MIME types (the targets +/// type '.' excluded) is exempt from the user permission prompt. +pub fn readPromptExempt(data_mimes: usize) bool { + return data_mimes == 0; +} + test "metadata: empty is dropped" { const testing = std.testing; var arena: std.heap.ArenaAllocator = .init(testing.allocator); @@ -369,6 +375,16 @@ test "metadata: empty name" { try testing.expectEqual(@as(usize, 0), meta.name.len); } +test "read prompt exemption: only requests without data types" { + const testing = std.testing; + + // A targets-only ('.') read parses to zero data MIME types and is + // served without a prompt; any data type, even alongside the + // targets listing, still prompts. + try testing.expect(readPromptExempt(0)); + try testing.expect(!readPromptExempt(1)); +} + test "payload: mime iterator" { const testing = std.testing; // base64 of "text/plain text/html\ntext/uri-list" diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 5630570c5..310d65d46 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -844,9 +844,14 @@ pub const Handler = struct { }; // Per the spec a password without a name is no password. A - // stored grant for it lets the embedder skip its prompt. + // stored grant for it lets the embedder skip its prompt. A + // prompt-exempt request never consults the grants: the + // embedder serves it without a prompt anyway, and consuming a + // one-time paste password on a listing would burn the grant + // before the follow-up data read. const pw: []const u8 = if (meta.name.len > 0) meta.pw else ""; - const granted = self.kitty_clipboard_grants.use(alloc, pw, .read); + const granted = !kitty_clipboard.readPromptExempt(mimes.len) and + self.kitty_clipboard_grants.use(alloc, pw, .read); var state: KittyClipboardReadState = .{ .handler = self, @@ -4011,10 +4016,13 @@ test "kitty clipboard read password grants" { var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); + // Every read requests a data type ("text/plain"): a request with + // no data types never consults the grants at all. + // // pw="secret", name="app": the first request isn't granted but the // reply may ask to remember it. S.read_result = .{ .success = .{ .remember = true } }; - s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0:name=YXBw\x1B\\"); + s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0:name=YXBw;dGV4dC9wbGFpbg==\x1B\\"); try testing.expectEqual(@as(usize, 1), S.read_count); try testing.expectEqualStrings("app", S.readName()); try testing.expect(!S.last_read_granted); @@ -4022,30 +4030,30 @@ test "kitty clipboard read password grants" { // The same password is now granted; a different one is not. S.read_result = .{ .success = .{} }; - s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0:name=YXBw\x1B\\"); + s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0:name=YXBw;dGV4dC9wbGFpbg==\x1B\\"); try testing.expect(S.last_read_granted); - s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=:name=YXBw\x1B\\"); + s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=:name=YXBw;dGV4dC9wbGFpbg==\x1B\\"); try testing.expect(!S.last_read_granted); try testing.expect(S.last_read_can_remember); // A password without a name doesn't count: it is neither granted // nor rememberable, even if the reply asks. S.read_result = .{ .success = .{ .remember = true } }; - s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0\x1B\\"); + s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0;dGV4dC9wbGFpbg==\x1B\\"); try testing.expectEqualStrings("", S.readName()); try testing.expect(!S.last_read_granted); try testing.expect(!S.last_read_can_remember); - s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=\x1B\\"); + s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=;dGV4dC9wbGFpbg==\x1B\\"); try testing.expect(!S.last_read_can_remember); S.read_result = .{ .success = .{} }; - s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=:name=YXBw\x1B\\"); + s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=:name=YXBw;dGV4dC9wbGFpbg==\x1B\\"); try testing.expect(!S.last_read_granted); // A grant is advisory: the request is still forwarded and the // embedder may deny it. S.responses_len = 0; S.read_result = .denied; - s.nextSlice("\x1B]5522;type=read:id=d:pw=c2VjcmV0:name=YXBw\x1B\\"); + s.nextSlice("\x1B]5522;type=read:id=d:pw=c2VjcmV0:name=YXBw;dGV4dC9wbGFpbg==\x1B\\"); try testing.expect(S.last_read_granted); try testing.expectEqualStrings( "\x1B]5522;type=read:status=EPERM:id=d\x1B\\", @@ -4056,6 +4064,38 @@ test "kitty clipboard read password grants" { // the leak otherwise). } +test "kitty clipboard read targets-only never consumes a one-time grant" { + 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_read = &S.clipboardRead; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // A one-time read grant, as minted for a paste event. + try s.handler.kitty_clipboard_grants.grant(testing.allocator, "otp", .read, true); + + // A targets-only read (payload ".") is prompt-exempt so it never + // consults, and must not burn, the one-time password. + S.read_result = .{ .success = .{} }; + s.nextSlice("\x1B]5522;type=read:pw=b3Rw:name=YXBw;Lg==\x1B\\"); + try testing.expectEqual(@as(usize, 1), S.read_count); + try testing.expect(S.last_read_list); + try testing.expectEqual(@as(usize, 0), S.last_read_mimes_len); + try testing.expect(!S.last_read_granted); + + // The follow-up data read still consumes the grant, exactly once. + s.nextSlice("\x1B]5522;type=read:pw=b3Rw:name=YXBw;dGV4dC9wbGFpbg==\x1B\\"); + try testing.expect(S.last_read_granted); + s.nextSlice("\x1B]5522;type=read:pw=b3Rw:name=YXBw;dGV4dC9wbGFpbg==\x1B\\"); + try testing.expect(!S.last_read_granted); +} + test "kitty clipboard write password grants" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index bd1cb883a..42977640e 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1107,7 +1107,7 @@ pub const StreamHandler = struct { // stored session grant for it lets the surface skip its // permission prompt. const pw: []const u8 = if (meta.name.len > 0) meta.pw else ""; - const granted = self.kitty_clipboard_grants.use(self.alloc, pw, .read); + const granted = self.kittyClipboardReadGranted(pw, mimes_len); const req = try alloc.create(apprt.ClipboardRequest.KittyRead); const mimes = try alloc.alloc([:0]const u8, mimes_len); @@ -1137,6 +1137,19 @@ pub const StreamHandler = struct { self.surfaceMessageWriter(.{ .kitty_clipboard_read = req }); } + /// Whether a session grant covers a read request, consuming + /// one-time grants. A prompt-exempt request never consults the + /// grants: consuming a one-time paste password on a listing would + /// burn the grant before the follow-up data read. + fn kittyClipboardReadGranted( + self: *StreamHandler, + pw: []const u8, + mimes_len: usize, + ) bool { + if (terminal.kitty.clipboard.readPromptExempt(mimes_len)) return false; + return self.kitty_clipboard_grants.use(self.alloc, pw, .read); + } + /// Begin a Kitty clipboard write transaction (type=write). fn kittyClipboardWriteBegin( self: *StreamHandler, @@ -1826,3 +1839,19 @@ pub const StreamHandler = struct { self.surfaceMessageWriter(.{ .progress_report = report }); } }; + +test "kitty clipboard read: targets-only never consumes a one-time grant" { + const testing = std.testing; + + var handler: StreamHandler = undefined; + handler.alloc = testing.allocator; + handler.kitty_clipboard_grants = .{}; + defer handler.kitty_clipboard_grants.deinit(testing.allocator); + try handler.kitty_clipboard_grants.grant(testing.allocator, "otp", .read, true); + + // A listing request must not burn the one-time paste password... + try testing.expect(!handler.kittyClipboardReadGranted("otp", 0)); + // ...so the follow-up data read is still granted, exactly once. + try testing.expect(handler.kittyClipboardReadGranted("otp", 1)); + try testing.expect(!handler.kittyClipboardReadGranted("otp", 1)); +}