mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-26 17:11:40 +00:00
terminal: exempt Kitty clipboard listing reads from permission prompts (#14001)
A Kitty clipboard protocol (OSC 5522) read that only requests the
targets type ('.') is now served without a permission prompt and never
consults (or consumes) session password grants.
The spec requires this so that a client listing the available data types
before reading one doesn't present the user with a double permission
prompt.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user