terminal/kitty: graphics S value is exact byte, not max (#13892)

File transmissions with exactly S bytes or trailing file data previously
failed because appendRemaining reports StreamTooLong when it reaches its
limit. This broke the protocol's partial-file transmission path.

Use an exact-length allocation and read when S is nonzero, rejecting
premature EOF and values above the image limit. Preserve the existing
bounded read-to-EOF behavior for S=0.

https://sw.kovidgoyal.net/kitty/graphics-protocol/#local-client
This commit is contained in:
Mitchell Hashimoto
2026-08-18 10:53:15 -07:00
committed by GitHub

View File

@@ -377,11 +377,28 @@ pub const LoadingImage = struct {
// Read the file
var managed: std.ArrayList(u8) = .empty;
errdefer managed.deinit(alloc);
const size: usize = if (t.size > 0) @min(t.size, max_size) else max_size;
reader.appendRemaining(alloc, &managed, .limited(size)) catch {
log.warn("failed to read temporary file: {?}", .{buf_reader.err});
return error.InvalidData;
};
if (t.size > 0) {
// S is the exact number of bytes to read, not a maximum:
// https://sw.kovidgoyal.net/kitty/graphics-protocol/#local-client
const size = std.math.cast(usize, t.size) orelse
return error.InvalidData;
if (size > max_size) return error.InvalidData;
// Read exact size
const data = reader.readAlloc(alloc, size) catch |err| {
log.warn("failed to read image file: {}", .{err});
return switch (err) {
error.OutOfMemory => error.OutOfMemory,
else => error.InvalidData,
};
};
managed = .{ .items = data, .capacity = data.len };
} else {
reader.appendRemaining(alloc, &managed, .limited(max_size)) catch {
log.warn("failed to read image file: {?}", .{buf_reader.err});
return error.InvalidData;
};
}
// Set our data
assert(self.data.items.len == 0);
@@ -1151,6 +1168,101 @@ test "image load: rgb, not compressed, regular file" {
try tmp_dir.dir.access(testing.io, path, .{});
}
test "image load: regular file size reads exactly requested bytes" {
const testing = std.testing;
const alloc = testing.allocator;
const io = testing.io;
var tmp_dir = testing.tmpDir(.{});
defer tmp_dir.cleanup();
try tmp_dir.dir.writeFile(io, .{
.sub_path = "image.data",
.data = &.{ 1, 2, 3, 4, 5, 6 },
});
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const path = path_buf[0..try tmp_dir.dir.realPathFile(
io,
"image.data",
&path_buf,
)];
const cases = [_]struct {
offset: u32,
expected: [3]u8,
}{
.{ .offset = 0, .expected = .{ 1, 2, 3 } },
.{ .offset = 3, .expected = .{ 4, 5, 6 } },
};
for (cases) |case| {
var cmd: command.Command = .{
.control = .{ .transmit = .{
.format = .rgb,
.medium = .file,
.width = 1,
.height = 1,
.size = 3,
.offset = case.offset,
.image_id = 31,
} },
.data = try alloc.dupe(u8, path),
};
defer cmd.deinit(alloc);
var loading = try LoadingImage.init(io, alloc, &cmd, .{
.file = true,
.temporary_file = .disabled,
.shared_memory = false,
});
defer loading.deinit(alloc);
var img = try loading.complete(alloc);
defer img.deinit(alloc);
try testing.expectEqualSlices(u8, &case.expected, img.data.complete);
}
}
test "image load: regular file size rejects short data" {
const testing = std.testing;
const alloc = testing.allocator;
const io = testing.io;
var tmp_dir = testing.tmpDir(.{});
defer tmp_dir.cleanup();
try tmp_dir.dir.writeFile(io, .{
.sub_path = "image.data",
.data = &.{ 1, 2 },
});
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const path = path_buf[0..try tmp_dir.dir.realPathFile(
io,
"image.data",
&path_buf,
)];
var cmd: command.Command = .{
.control = .{ .transmit = .{
.format = .rgb,
.medium = .file,
.width = 1,
.height = 1,
.size = 3,
.image_id = 31,
} },
.data = try alloc.dupe(u8, path),
};
defer cmd.deinit(alloc);
try testing.expectError(
error.InvalidData,
LoadingImage.init(io, alloc, &cmd, .{
.file = true,
.temporary_file = .disabled,
.shared_memory = false,
}),
);
}
test "image load: rgb, not compressed, relative regular file" {
const testing = std.testing;
const alloc = testing.allocator;