aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/simd/base64.zig
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-10 21:51:57 +0300
committerGitHub <noreply@github.com>2026-09-10 21:51:57 +0300
commit425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch)
treee52c4383a6d69216cbe2a32638bf90841d7e2189 /vendor/libghostty-vt/src/simd/base64.zig
parent90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff)
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'vendor/libghostty-vt/src/simd/base64.zig')
-rw-r--r--vendor/libghostty-vt/src/simd/base64.zig363
1 files changed, 359 insertions, 4 deletions
diff --git a/vendor/libghostty-vt/src/simd/base64.zig b/vendor/libghostty-vt/src/simd/base64.zig
index 81feeb72..b86b71a6 100644
--- a/vendor/libghostty-vt/src/simd/base64.zig
+++ b/vendor/libghostty-vt/src/simd/base64.zig
@@ -1,7 +1,8 @@
const std = @import("std");
const options = @import("build_options");
const assert = @import("../quirks.zig").inlineAssert;
-const scalar_decoder = @import("base64_scalar.zig").scalar_decoder;
+const base64_scalar = @import("base64_scalar.zig");
+const scalar_decoder = base64_scalar.scalar_decoder;
const log = std.log.scoped(.simd_base64);
@@ -53,11 +54,170 @@ fn decodeScalar(
/// For non-SIMD enabled builds, we trim the padding from the end of the
/// base64 input in order to get identical output with the SIMD version.
fn scalarInput(input: []const u8) []const u8 {
- var i: usize = 0;
- while (input[input.len - i - 1] == '=') i += 1;
- return input[0 .. input.len - i];
+ var end = input.len;
+ while (end > 0 and input[end - 1] == '=') end -= 1;
+ return input[0..end];
}
+/// Whether strict decoding requires the input to be padded to a
+/// multiple of four bytes (RFC 4648 section 3.2). The Kitty clipboard
+/// protocol requires padding; the legacy OSC 52 protocol tolerates a
+/// missing-padding tail because it has no way to report errors to the
+/// client.
+pub const Padding = enum { required, optional };
+
+/// Decode strict RFC 4648 standard-alphabet base64: characters outside
+/// the alphabet (including whitespace) and misplaced padding are
+/// errors rather than being skipped, and padding is validated per the
+/// given requirement. This is the decoding the Kitty clipboard
+/// protocol specifies:
+/// https://sw.kovidgoyal.net/kitty/clipboard/#encoding-of-payloads
+///
+/// The output must be at least maxLen(input) bytes.
+pub fn decodeStrict(
+ input: []const u8,
+ output: []u8,
+ padding: Padding,
+) error{Base64Invalid}![]const u8 {
+ // Padding can only be a suffix of at most two bytes; any '='
+ // elsewhere is rejected by the underlying decode.
+ var pad: usize = 0;
+ if (input.len > 0 and input[input.len - 1] == '=') pad += 1;
+ if (input.len > 1 and input[input.len - 2] == '=') pad += 1;
+
+ switch (padding) {
+ .required => if (input.len % 4 != 0) return error.Base64Invalid,
+ // Present padding must still complete a four byte group; only
+ // fully absent padding is tolerated. A single leftover byte
+ // can never carry a decodable value.
+ .optional => if (pad > 0) {
+ if (input.len % 4 != 0) return error.Base64Invalid;
+ } else if (input.len % 4 == 1) return error.Base64Invalid,
+ }
+
+ // The permissive decode already rejects every invalid character
+ // except whitespace, which simdutf silently skips. Skipped
+ // characters make the decoded length fall short of the exact
+ // length the input length implies, so comparing the two rejects
+ // whitespace without a separate validation pass over the input.
+ const decoded = decode(input, output) catch return error.Base64Invalid;
+ const expected = switch (input.len % 4) {
+ 0 => input.len / 4 * 3 - pad,
+ 2 => input.len / 4 * 3 + 1,
+ 3 => input.len / 4 * 3 + 2,
+ else => unreachable, // rejected above
+ };
+ if (decoded.len != expected) return error.Base64Invalid;
+ return decoded;
+}
+
+/// A streaming strict base64 decoder for one logical payload split
+/// across multiple chunks at arbitrary byte boundaries (e.g. the Kitty
+/// clipboard protocol's wdata packets): the concatenation of the fed
+/// chunks must be valid RFC 4648 standard-alphabet base64.
+///
+/// Padding is terminal within a feed: a padded group followed by more
+/// data in the same feed is an error. A feed that ends exactly at
+/// terminal padding resets the decoder so the next feed starts a
+/// fresh stream, exactly like the kitty reference implementation
+/// (which resets its aklomp streaming decoder on EOF); this keeps
+/// clients that pad each chunk independently working.
+pub const Streaming = struct {
+ /// Partial group carried between feeds; a group only decodes once
+ /// all four of its characters have arrived.
+ carry: [4]u8 = undefined,
+ carry_len: u3 = 0,
+
+ /// Maximum decoded bytes one feed of input can produce.
+ pub fn maxLen(self: *const Streaming, input: []const u8) usize {
+ return (self.carry_len + input.len) / 4 * 3;
+ }
+
+ /// Decode the complete groups of input (with any carried bytes
+ /// prepended) into output, which must be at least maxLen(input)
+ /// bytes, and carry the remainder for the next feed.
+ pub fn feed(
+ self: *Streaming,
+ input: []const u8,
+ output: []u8,
+ ) error{Base64Invalid}![]const u8 {
+ assert(output.len >= self.maxLen(input));
+ if (input.len == 0) return output[0..0];
+
+ var rem = input;
+ var written: usize = 0;
+
+ // Complete a carried partial group first.
+ if (self.carry_len > 0) {
+ const take = @min(4 - @as(usize, self.carry_len), rem.len);
+ for (rem[0..take], self.carry_len..) |c, pos| {
+ if (!validPartialChar(pos, c)) return error.Base64Invalid;
+ }
+ @memcpy(self.carry[self.carry_len..][0..take], rem[0..take]);
+ self.carry_len += @intCast(take);
+ rem = rem[take..];
+ if (self.carry_len < 4) return output[0..0];
+ self.carry_len = 0;
+ const decoded, const padded = try group(self.carry, output);
+ written += decoded;
+ if (padded) {
+ if (rem.len > 0) return error.Base64Invalid;
+ return output[0..written];
+ }
+ }
+
+ // Bulk-decode all complete groups with the strict single-shot
+ // decode, which also enforces that padding only appears as a
+ // terminal suffix. Terminal padding must then end the feed.
+ const bulk = rem[0 .. rem.len - (rem.len % 4)];
+ written += (try decodeStrict(bulk, output[written..], .required)).len;
+ if (bulk.len > 0 and bulk[bulk.len - 1] == '=') {
+ if (bulk.len != rem.len) return error.Base64Invalid;
+ return output[0..written];
+ }
+
+ // Carry the trailing partial group, validated eagerly so
+ // garbage is reported on the feed that contains it.
+ const tail = rem[bulk.len..];
+ for (tail, 0..) |c, pos| {
+ if (!validPartialChar(pos, c)) return error.Base64Invalid;
+ }
+ @memcpy(self.carry[0..tail.len], tail);
+ self.carry_len = @intCast(tail.len);
+ return output[0..written];
+ }
+
+ /// Decode one complete four character group, the only place
+ /// padding is legal: the last two characters may be '=' ('=' in
+ /// the second-to-last position requires it in the last). Returns
+ /// the decoded length and whether the group was padded.
+ fn group(
+ g: [4]u8,
+ output: []u8,
+ ) error{Base64Invalid}!struct { usize, bool } {
+ const padded = g[3] == '=';
+ const decoded = try decodeStrict(&g, output, .required);
+ return .{ decoded.len, padded };
+ }
+
+ /// Whether c is valid at the given position of a partial group
+ /// still waiting for its remaining characters: the first two
+ /// positions must be alphabet characters and only the last two
+ /// may open the padding suffix.
+ fn validPartialChar(pos: usize, c: u8) bool {
+ return base64_scalar.isAlphabetChar(c) or (pos >= 2 and c == '=');
+ }
+
+ /// Finish the stream: the concatenation of the fed chunks must
+ /// have formed complete groups, so a carried partial group is an
+ /// error (the stream was not correctly padded). The decoder is
+ /// ready for a fresh stream afterwards either way.
+ pub fn finish(self: *Streaming) error{Base64Invalid}!void {
+ defer self.* = .{};
+ if (self.carry_len != 0) return error.Base64Invalid;
+ }
+};
+
// base64.cpp
extern "c" fn ghostty_simd_base64_max_length(
input: [*]const u8,
@@ -75,6 +235,14 @@ test "base64 maxLen" {
try testing.expectEqual(11, len);
}
+test "base64 empty input" {
+ const testing = std.testing;
+ var output: [0]u8 = .{};
+
+ try testing.expectEqual(0, maxLen(""));
+ try testing.expectEqualStrings("", try decode("", &output));
+}
+
test "base64 decode" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -85,3 +253,190 @@ test "base64 decode" {
const str = try decode(input, output);
try testing.expectEqualStrings("hello world", str);
}
+
+test "base64 strict decode valid" {
+ const testing = std.testing;
+ var output: [128]u8 = undefined;
+
+ const cases = [_]struct { input: []const u8, expect: []const u8 }{
+ .{ .input = "", .expect = "" },
+ .{ .input = "aGVsbG8gd29ybGQ=", .expect = "hello world" },
+ .{ .input = "bGlnaHQgdw==", .expect = "light w" },
+ .{ .input = "Zm9vYmFy", .expect = "foobar" },
+ // Every alphabet character in one input.
+ .{
+ .input = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
+ .expect = "\x00\x10\x83\x10\x51\x87\x20\x92\x8b\x30\xd3\x8f\x41\x14\x93\x51\x55\x97\x61\x96\x9b\x71\xd7\x9f\x82\x18\xa3\x92\x59\xa7\xa2\x9a\xab\xb2\xdb\xaf\xc3\x1c\xb3\xd3\x5d\xb7\xe3\x9e\xbb\xf3\xdf\xbf",
+ },
+ };
+ for (cases) |case| {
+ try testing.expectEqualStrings(
+ case.expect,
+ try decodeStrict(case.input, &output, .required),
+ );
+ try testing.expectEqualStrings(
+ case.expect,
+ try decodeStrict(case.input, &output, .optional),
+ );
+ }
+}
+
+test "base64 strict decode invalid" {
+ const testing = std.testing;
+ var output: [128]u8 = undefined;
+
+ // The invalid inputs from kitty's own strict decoding tests plus
+ // some extra padding-placement cases. All of these are invalid for
+ // both padding requirements.
+ const cases = [_][]const u8{
+ "bGlnaHQgdw=", // missing one padding byte
+ "bGln!!Qgdw==", // invalid characters
+ "bGlnaHQgdw==\n", // trailing whitespace
+ "\nbGlnaHQgdw==", // leading whitespace
+ "bGlnaHQg dw==", // interior whitespace
+ "!!!!",
+ "=",
+ "==",
+ "A===",
+ "AB=C", // padding must be the suffix
+ "Zm9v YmFy",
+ };
+ for (cases) |case| {
+ try testing.expectError(
+ error.Base64Invalid,
+ decodeStrict(case, &output, .required),
+ );
+ try testing.expectError(
+ error.Base64Invalid,
+ decodeStrict(case, &output, .optional),
+ );
+ }
+
+ // Unpadded input is only tolerated when padding is optional. A
+ // single leftover character can never be decoded.
+ try testing.expectError(
+ error.Base64Invalid,
+ decodeStrict("bGlnaHQgdw", &output, .required),
+ );
+ try testing.expectEqualStrings(
+ "light w",
+ try decodeStrict("bGlnaHQgdw", &output, .optional),
+ );
+ try testing.expectError(
+ error.Base64Invalid,
+ decodeStrict("bGl", &output, .required),
+ );
+ try testing.expectEqualStrings(
+ "li",
+ try decodeStrict("bGl", &output, .optional),
+ );
+ try testing.expectError(
+ error.Base64Invalid,
+ decodeStrict("bGlna", &output, .optional),
+ );
+}
+
+test "base64 streaming decode chunk boundaries" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // Decoding a stream split at every possible boundary, including
+ // one byte at a time, matches the single-shot decode.
+ const input = "c29tZSBsb25nZXIgZGF0YSB3aXRoIHBhZGRpbmc+Pz8=";
+ const expect = "some longer data with padding>??";
+ for (0..input.len + 1) |split| {
+ var s: Streaming = .{};
+ var result: std.ArrayListUnmanaged(u8) = .empty;
+ defer result.deinit(alloc);
+ var output: [64]u8 = undefined;
+ try result.appendSlice(alloc, try s.feed(input[0..split], &output));
+ try result.appendSlice(alloc, try s.feed(input[split..], &output));
+ try s.finish();
+ try testing.expectEqualStrings(expect, result.items);
+ }
+ {
+ var s: Streaming = .{};
+ var result: std.ArrayListUnmanaged(u8) = .empty;
+ defer result.deinit(alloc);
+ var output: [4]u8 = undefined;
+ for (0..input.len) |i| {
+ try result.appendSlice(alloc, try s.feed(input[i..][0..1], &output));
+ }
+ try s.finish();
+ try testing.expectEqualStrings(expect, result.items);
+ }
+}
+
+test "base64 streaming decode invalid" {
+ const testing = std.testing;
+ var output: [64]u8 = undefined;
+
+ // Invalid characters are rejected wherever they appear.
+ {
+ var s: Streaming = .{};
+ try testing.expectError(error.Base64Invalid, s.feed("!!!!", &output));
+ }
+ {
+ var s: Streaming = .{};
+ try testing.expectError(error.Base64Invalid, s.feed("SGVs!!!bG8=", &output));
+ }
+ {
+ var s: Streaming = .{};
+ try testing.expectError(
+ error.Base64Invalid,
+ s.feed("\nc29tZSBkYXRh", &output),
+ );
+ }
+
+ // Data after terminal padding within one feed is rejected, even
+ // when the padded group only completes in that feed.
+ {
+ var s: Streaming = .{};
+ try testing.expectError(error.Base64Invalid, s.feed("Z29vZA==SGVsbG8=", &output));
+ }
+ {
+ var s: Streaming = .{};
+ _ = try s.feed("Z29vZA=", &output);
+ try testing.expectError(error.Base64Invalid, s.feed("=SGVs", &output));
+ }
+
+ // A feed that ends exactly at terminal padding resets the stream:
+ // the next feed starts fresh, so clients that pad every chunk
+ // independently keep working (matching the kitty implementation,
+ // which resets its streaming decoder on EOF).
+ {
+ var s: Streaming = .{};
+ try testing.expectEqualStrings("good", try s.feed("Z29vZA==", &output));
+ try testing.expectEqualStrings("Hello", try s.feed("SGVsbG8=", &output));
+ try s.finish();
+ }
+ {
+ // Padding split across feeds resets too.
+ var s: Streaming = .{};
+ try testing.expectEqualStrings("goo", try s.feed("Z29vZA=", &output));
+ try testing.expectEqualStrings("d", try s.feed("=", &output));
+ try testing.expectEqualStrings("more", try s.feed("bW9yZQ==", &output));
+ try s.finish();
+ }
+
+ // Misplaced padding within a group.
+ {
+ var s: Streaming = .{};
+ try testing.expectError(error.Base64Invalid, s.feed("YQ=X", &output));
+ }
+ {
+ var s: Streaming = .{};
+ try testing.expectError(error.Base64Invalid, s.feed("=AAA", &output));
+ }
+
+ // A stream that ends in a partial group is missing its padding.
+ // The failed finish resets the decoder for the next stream.
+ {
+ var s: Streaming = .{};
+ try testing.expectEqualStrings("Hel", try s.feed("SGVsbG8", &output));
+ try testing.expectError(error.Base64Invalid, s.finish());
+ try testing.expectEqualStrings("Hel", try s.feed("SGVsbG8", &output));
+ try testing.expectEqualStrings("lo", try s.feed("=", &output));
+ try s.finish();
+ }
+}