From 7767a4577989b788baa8ebe74b9e6c9cd37e5cbb Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 10 Oct 2025 12:00:50 -0500 Subject: osc: do inplace decoding of cmdline passed in OSC 133;C (#9127) --- src/os/string_encoding.zig | 267 +++++++++++++++++++++++++++++++++++++++++++++ src/terminal/osc.zig | 255 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 504 insertions(+), 18 deletions(-) create mode 100644 src/os/string_encoding.zig diff --git a/src/os/string_encoding.zig b/src/os/string_encoding.zig new file mode 100644 index 000000000..162023ad2 --- /dev/null +++ b/src/os/string_encoding.zig @@ -0,0 +1,267 @@ +const std = @import("std"); + +/// Do an in-place decode of a string that has been encoded in the same way +/// that `bash`'s `printf %q` encodes a string. This is safe because a string +/// can only get shorter after decoding. This destructively modifies the buffer +/// given to it. If an error is returned the buffer may be in an unusable state. +pub fn printfQDecode(buf: [:0]u8) error{DecodeError}![:0]const u8 { + const data: [:0]u8 = data: { + // Strip off `$''` quoting. + if (std.mem.startsWith(u8, buf, "$'")) { + if (buf.len < 3 or !std.mem.endsWith(u8, buf, "'")) return error.DecodeError; + buf[buf.len - 1] = 0; + break :data buf[2 .. buf.len - 1 :0]; + } + // Strip off `''` quoting. + if (std.mem.startsWith(u8, buf, "'")) { + if (buf.len < 2 or !std.mem.endsWith(u8, buf, "'")) return error.DecodeError; + buf[buf.len - 1] = 0; + break :data buf[1 .. buf.len - 1 :0]; + } + break :data buf; + }; + + var src: usize = 0; + var dst: usize = 0; + + while (src < data.len) { + switch (data[src]) { + else => { + data[dst] = data[src]; + src += 1; + dst += 1; + }, + '\\' => { + if (src + 1 >= data.len) return error.DecodeError; + switch (data[src + 1]) { + ' ', + '\\', + '"', + '\'', + '$', + => |c| { + data[dst] = c; + src += 2; + dst += 1; + }, + 'e' => { + data[dst] = std.ascii.control_code.esc; + src += 2; + dst += 1; + }, + 'n' => { + data[dst] = std.ascii.control_code.lf; + src += 2; + dst += 1; + }, + 'r' => { + data[dst] = std.ascii.control_code.cr; + src += 2; + dst += 1; + }, + 't' => { + data[dst] = std.ascii.control_code.ht; + src += 2; + dst += 1; + }, + 'v' => { + data[dst] = std.ascii.control_code.vt; + src += 2; + dst += 1; + }, + else => return error.DecodeError, + } + }, + } + } + + data[dst] = 0; + return data[0..dst :0]; +} + +test "printf_q 1" { + const s: [:0]const u8 = "bobr\\ kurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + const dst = try printfQDecode(&src); + try std.testing.expectEqualStrings("bobr kurwa", dst); +} + +test "printf_q 2" { + const s: [:0]const u8 = "bobr\\nkurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + const dst = try printfQDecode(&src); + try std.testing.expectEqualStrings("bobr\nkurwa", dst); +} + +test "printf_q 3" { + const s: [:0]const u8 = "bobr\\dkurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, printfQDecode(&src)); +} + +test "printf_q 4" { + const s: [:0]const u8 = "bobr kurwa\\"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, printfQDecode(&src)); +} + +test "printf_q 5" { + const s: [:0]const u8 = "$'bobr kurwa'"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + const dst = try printfQDecode(&src); + try std.testing.expectEqualStrings("bobr kurwa", dst); +} + +test "printf_q 6" { + const s: [:0]const u8 = "'bobr kurwa'"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + const dst = try printfQDecode(&src); + try std.testing.expectEqualStrings("bobr kurwa", dst); +} + +test "printf_q 7" { + const s: [:0]const u8 = "$'bobr kurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, printfQDecode(&src)); +} + +test "printf_q 8" { + const s: [:0]const u8 = "$'"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, printfQDecode(&src)); +} + +test "printf_q 9" { + const s: [:0]const u8 = "'bobr kurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, printfQDecode(&src)); +} + +test "printf_q 10" { + const s: [:0]const u8 = "'"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, printfQDecode(&src)); +} + +/// Do an in-place decode of a string that has been URL percent encoded. +/// This is safe because a string can only get shorter after decoding. This +/// destructively modifies the buffer given to it. If an error is returned the +/// buffer may be in an unusable state. +pub fn urlPercentDecode(buf: [:0]u8) error{DecodeError}![:0]const u8 { + var src: usize = 0; + var dst: usize = 0; + while (src < buf.len) { + switch (buf[src]) { + else => { + buf[dst] = buf[src]; + src += 1; + dst += 1; + }, + '%' => { + if (src + 2 >= buf.len) return error.DecodeError; + switch (buf[src + 1]) { + '0'...'9', 'a'...'f', 'A'...'F' => { + switch (buf[src + 2]) { + '0'...'9', 'a'...'f', 'A'...'F' => { + buf[dst] = std.math.shl(u8, hex(buf[src + 1]), 4) | hex(buf[src + 2]); + src += 3; + dst += 1; + }, + else => return error.DecodeError, + } + }, + else => return error.DecodeError, + } + }, + } + } + buf[dst] = 0; + return buf[0..dst :0]; +} + +inline fn hex(c: u8) u4 { + switch (c) { + '0'...'9' => return @truncate(c - '0'), + 'a'...'f' => return @truncate(c - 'a' + 10), + 'A'...'F' => return @truncate(c - 'A' + 10), + else => unreachable, + } +} + +test "singles percent" { + for (0..255) |c| { + var buf_: [4]u8 = undefined; + const buf = try std.fmt.bufPrintZ(&buf_, "%{x:0>2}", .{c}); + const decoded = try urlPercentDecode(buf); + try std.testing.expectEqual(1, decoded.len); + try std.testing.expectEqual(c, decoded[0]); + } + for (0..255) |c| { + var buf_: [4]u8 = undefined; + const buf = try std.fmt.bufPrintZ(&buf_, "%{X:0>2}", .{c}); + const decoded = try urlPercentDecode(buf); + try std.testing.expectEqual(1, decoded.len); + try std.testing.expectEqual(c, decoded[0]); + } +} + +test "percent 1" { + const s: [:0]const u8 = "bobr%20kurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + const dst = try urlPercentDecode(&src); + try std.testing.expectEqualStrings("bobr kurwa", dst); +} + +test "percent 2" { + const s: [:0]const u8 = "bobr%2kurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, urlPercentDecode(&src)); +} + +test "percent 3" { + const s: [:0]const u8 = "bobr%kurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, urlPercentDecode(&src)); +} + +test "percent 4" { + const s: [:0]const u8 = "bobr%%kurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, urlPercentDecode(&src)); +} + +test "percent 5" { + const s: [:0]const u8 = "bobr%20kurwa%20"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + const dst = try urlPercentDecode(&src); + try std.testing.expectEqualStrings("bobr kurwa ", dst); +} + +test "percent 6" { + const s: [:0]const u8 = "bobr%20kurwa%2"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, urlPercentDecode(&src)); +} + +test "percent 7" { + const s: [:0]const u8 = "bobr%20kurwa%"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, urlPercentDecode(&src)); +} diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 4b0f9553c..12a6d1f5c 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -15,6 +15,7 @@ const LibEnum = @import("../lib/enum.zig").Enum; const RGB = @import("color.zig").RGB; const kitty_color = @import("kitty/color.zig"); const osc_color = @import("osc/color.zig"); +const string_encoding = @import("../os/string_encoding.zig"); pub const color = osc_color; const log = std.log.scoped(.osc); @@ -89,12 +90,7 @@ pub const Command = union(Key) { end_of_input: struct { /// The command line that the user entered. /// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers - cmdline: ?union(enum) { - /// The command line has been encoded with bash's 'printf "%q"'. - printf_q_encoded: [:0]const u8, - /// The command line has been encoded with URL percent encoding. - percent_encoded: [:0]const u8, - } = null, + cmdline: ?[:0]const u8 = null, }, /// End of current command. @@ -1482,17 +1478,13 @@ pub const Parser = struct { } else if (mem.eql(u8, self.temp_state.key, "cmdline")) { // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers switch (self.command) { - .end_of_input => |*v| v.cmdline = .{ - .printf_q_encoded = value, - }, + .end_of_input => |*v| v.cmdline = string_encoding.printfQDecode(value) catch null, else => {}, } } else if (mem.eql(u8, self.temp_state.key, "cmdline_url")) { // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers switch (self.command) { - .end_of_input => |*v| v.cmdline = .{ - .percent_encoded = value, - }, + .end_of_input => |*v| v.cmdline = string_encoding.urlPercentDecode(value) catch null, else => {}, } } else if (mem.eql(u8, self.temp_state.key, "redraw")) { @@ -3063,7 +3055,7 @@ test "OSC 133: end_of_input" { try testing.expect(cmd == .end_of_input); } -test "OSC 133: end_of_input with cmdline" { +test "OSC 133: end_of_input with cmdline 1" { const testing = std.testing; var p: Parser = .init(); @@ -3074,11 +3066,132 @@ test "OSC 133: end_of_input with cmdline" { const cmd = p.end(null).?.*; try testing.expect(cmd == .end_of_input); try testing.expect(cmd.end_of_input.cmdline != null); - try testing.expect(cmd.end_of_input.cmdline.? == .printf_q_encoded); - try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?.printf_q_encoded); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline 2" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline=echo bobr\\ kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline 3" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline=echo bobr\\nkurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr\nkurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline 4" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline=$'echo bobr kurwa'"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline 5" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline='echo bobr kurwa'"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline 6" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline='echo bobr kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline 7" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline=$'echo bobr kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline 8" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline=$'"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline 9" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline=$'"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline 10" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline="; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("", cmd.end_of_input.cmdline.?); } -test "OSC 133: end_of_input with cmdline_url" { +test "OSC 133: end_of_input with cmdline_url 1" { const testing = std.testing; var p: Parser = .init(); @@ -3089,8 +3202,114 @@ test "OSC 133: end_of_input with cmdline_url" { const cmd = p.end(null).?.*; try testing.expect(cmd == .end_of_input); try testing.expect(cmd.end_of_input.cmdline != null); - try testing.expect(cmd.end_of_input.cmdline.? == .percent_encoded); - try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?.percent_encoded); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline_url 2" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline_url=echo bobr%20kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline_url 3" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline_url=echo bobr%3bkurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr;kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline_url 4" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline_url=echo bobr%3kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline_url 5" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline_url=echo bobr%kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline_url 6" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline_url=echo bobr%kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline_url 7" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline_url=echo bobr kurwa%20"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr kurwa ", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline_url 8" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline_url=echo bobr kurwa%2"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline_url 9" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline_url=echo bobr kurwa%2"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); } test "OSC: OSC 777 show desktop notification with title" { -- cgit v1.2.3