mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 16:11:43 +00:00
terminal: add kitty clipboard protocol (OSC 5522) session password grants
This commit is contained in:
@@ -35,6 +35,7 @@ const protocol = @import("../osc/parsers/kitty_clipboard_protocol.zig");
|
||||
const command = @import("clipboard_command.zig");
|
||||
const write = @import("clipboard_write.zig");
|
||||
const response = @import("clipboard_response.zig");
|
||||
const grants = @import("clipboard_grants.zig");
|
||||
|
||||
pub const OSC = protocol.OSC;
|
||||
pub const Operation = protocol.Operation;
|
||||
@@ -60,6 +61,10 @@ pub const read_chunk_size = response.read_chunk_size;
|
||||
pub const max_read_mimes = response.max_read_mimes;
|
||||
pub const targets_mime = response.targets_mime;
|
||||
|
||||
pub const Grants = grants.Grants;
|
||||
pub const otp_len = grants.otp_len;
|
||||
pub const generateOtp = grants.generateOtp;
|
||||
|
||||
test {
|
||||
@import("std").testing.refAllDecls(@This());
|
||||
}
|
||||
|
||||
184
src/terminal/kitty/clipboard_grants.zig
Normal file
184
src/terminal/kitty/clipboard_grants.zig
Normal file
@@ -0,0 +1,184 @@
|
||||
//! Kitty clipboard protocol (OSC 5522) session password grants:
|
||||
//! requests carrying a granted password skip the permission prompt, and
|
||||
//! paste events mint one-time passwords.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const clipboard_command = @import("clipboard_command.zig");
|
||||
|
||||
const max_pw_len = clipboard_command.max_pw_len;
|
||||
|
||||
/// Session password grants, used to skip permission prompts for
|
||||
/// requests carrying a known pw. Callers can choose to scope these
|
||||
/// however they want, e.g. Kitty does it per window and the spec
|
||||
/// doesn't demand anything.
|
||||
pub const Grants = struct {
|
||||
entries: std.ArrayListUnmanaged(Entry) = .empty,
|
||||
|
||||
const max_entries = 32;
|
||||
|
||||
pub const Direction = enum { read, write };
|
||||
|
||||
const Entry = struct {
|
||||
/// Owned by the allocator passed to grant.
|
||||
pw: []const u8,
|
||||
read: bool = false,
|
||||
write: bool = false,
|
||||
one_time: bool = false,
|
||||
};
|
||||
|
||||
pub fn deinit(self: *Grants, alloc: Allocator) void {
|
||||
for (self.entries.items) |entry| alloc.free(entry.pw);
|
||||
self.entries.deinit(alloc);
|
||||
}
|
||||
|
||||
/// Record a grant for pw. An existing grant for the same password
|
||||
/// gains the new direction.
|
||||
pub fn grant(
|
||||
self: *Grants,
|
||||
alloc: Allocator,
|
||||
pw: []const u8,
|
||||
dir: Direction,
|
||||
one_time: bool,
|
||||
) Allocator.Error!void {
|
||||
if (pw.len == 0 or pw.len > max_pw_len) return;
|
||||
|
||||
const entry: *Entry = entry: {
|
||||
if (self.findIndex(pw)) |idx| {
|
||||
const entry = &self.entries.items[idx];
|
||||
entry.one_time = entry.one_time and one_time;
|
||||
break :entry entry;
|
||||
}
|
||||
|
||||
// Evict the oldest grant once full.
|
||||
if (self.entries.items.len >= max_entries) {
|
||||
const oldest = self.entries.orderedRemove(0);
|
||||
alloc.free(oldest.pw);
|
||||
}
|
||||
|
||||
const owned = try alloc.dupe(u8, pw);
|
||||
errdefer alloc.free(owned);
|
||||
try self.entries.append(alloc, .{
|
||||
.pw = owned,
|
||||
.one_time = one_time,
|
||||
});
|
||||
break :entry &self.entries.items[self.entries.items.len - 1];
|
||||
};
|
||||
|
||||
switch (dir) {
|
||||
.read => entry.read = true,
|
||||
.write => entry.write = true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether pw grants the given direction. A one-time grant is
|
||||
/// consumed by this check even when the direction doesn't match,
|
||||
/// matching kitty's pop-on-check behavior.
|
||||
pub fn use(
|
||||
self: *Grants,
|
||||
alloc: Allocator,
|
||||
pw: []const u8,
|
||||
dir: Direction,
|
||||
) bool {
|
||||
if (pw.len == 0) return false;
|
||||
const idx = self.findIndex(pw) orelse return false;
|
||||
const entry = &self.entries.items[idx];
|
||||
const allowed = switch (dir) {
|
||||
.read => entry.read,
|
||||
.write => entry.write,
|
||||
};
|
||||
if (entry.one_time) {
|
||||
const removed = self.entries.swapRemove(idx);
|
||||
alloc.free(removed.pw);
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
fn findIndex(self: *const Grants, pw: []const u8) ?usize {
|
||||
for (self.entries.items, 0..) |*entry, idx| {
|
||||
if (std.mem.eql(u8, entry.pw, pw)) return idx;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/// The length of a one-time password generated for paste events.
|
||||
pub const otp_len = 22;
|
||||
|
||||
/// Generate a one-time password for a paste event. The alphabet matches
|
||||
/// kitty (alphanumeric without easily-confused characters), but the spec
|
||||
/// doesn't demand this.
|
||||
pub fn generateOtp(random: std.Random) [otp_len]u8 {
|
||||
const alphabet = "23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ";
|
||||
var result: [otp_len]u8 = undefined;
|
||||
for (&result) |*c| c.* = alphabet[random.uintLessThan(usize, alphabet.len)];
|
||||
return result;
|
||||
}
|
||||
|
||||
test "grants: basic grant and use" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var grants: Grants = .{};
|
||||
defer grants.deinit(alloc);
|
||||
try testing.expect(!grants.use(alloc, "pw1", .read));
|
||||
|
||||
try grants.grant(alloc, "pw1", .read, false);
|
||||
try testing.expect(grants.use(alloc, "pw1", .read));
|
||||
// Persistent grants survive use.
|
||||
try testing.expect(grants.use(alloc, "pw1", .read));
|
||||
try testing.expect(!grants.use(alloc, "pw1", .write));
|
||||
}
|
||||
|
||||
test "grants: one-time consumed on check" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var grants: Grants = .{};
|
||||
defer grants.deinit(alloc);
|
||||
try grants.grant(alloc, "otp", .read, true);
|
||||
try testing.expect(grants.use(alloc, "otp", .read));
|
||||
try testing.expect(!grants.use(alloc, "otp", .read));
|
||||
}
|
||||
|
||||
test "grants: one-time consumed even on direction mismatch" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var grants: Grants = .{};
|
||||
defer grants.deinit(alloc);
|
||||
try grants.grant(alloc, "otp", .read, true);
|
||||
try testing.expect(!grants.use(alloc, "otp", .write));
|
||||
try testing.expect(!grants.use(alloc, "otp", .read));
|
||||
}
|
||||
|
||||
test "grants: directions are independent" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var grants: Grants = .{};
|
||||
defer grants.deinit(alloc);
|
||||
try grants.grant(alloc, "pw", .read, false);
|
||||
try grants.grant(alloc, "pw", .write, false);
|
||||
try testing.expect(grants.use(alloc, "pw", .read));
|
||||
try testing.expect(grants.use(alloc, "pw", .write));
|
||||
}
|
||||
|
||||
test "grants: capacity evicts the oldest" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var grants: Grants = .{};
|
||||
defer grants.deinit(alloc);
|
||||
|
||||
var buf: [8]u8 = undefined;
|
||||
for (0..Grants.max_entries + 1) |i| {
|
||||
const pw = try std.fmt.bufPrint(&buf, "pw{}", .{i});
|
||||
try grants.grant(alloc, pw, .read, false);
|
||||
}
|
||||
|
||||
// The oldest grant was evicted; the newest survives.
|
||||
try testing.expect(!grants.use(alloc, "pw0", .read));
|
||||
const newest = try std.fmt.bufPrint(&buf, "pw{}", .{Grants.max_entries});
|
||||
try testing.expect(grants.use(alloc, newest, .read));
|
||||
}
|
||||
Reference in New Issue
Block a user