mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-09-14 18:01:58 +00:00
1801 lines
61 KiB
Zig
1801 lines
61 KiB
Zig
const std = @import("std");
|
|
const builtin = @import("builtin");
|
|
const assert = @import("../../quirks.zig").inlineAssert;
|
|
const Allocator = std.mem.Allocator;
|
|
const ArenaAllocator = std.heap.ArenaAllocator;
|
|
const posix = std.posix;
|
|
|
|
const fastmem = @import("../../fastmem.zig");
|
|
const animation = @import("graphics_animation.zig");
|
|
const command = @import("graphics_command.zig");
|
|
const PageList = @import("../PageList.zig");
|
|
const sys = @import("../sys.zig");
|
|
const LimitedAllocator = @import("../../datastruct/main.zig").LimitedAllocator;
|
|
|
|
const log = std.log.scoped(.kitty_gfx);
|
|
|
|
/// Maximum width or height of an image. Taken directly from Kitty.
|
|
const max_dimension = 10000;
|
|
|
|
/// Maximum size in bytes, taken from Kitty.
|
|
const max_size = 400 * 1024 * 1024; // 400MB
|
|
|
|
/// An image that is still being loaded. The image should be initialized
|
|
/// using init on the first chunk and then addData for each subsequent
|
|
/// chunk. Once all chunks have been added, complete should be called
|
|
/// to finalize the image.
|
|
pub const LoadingImage = struct {
|
|
/// The in-progress image. The first chunk must have all the metadata
|
|
/// so this comes from that initially.
|
|
image: Image,
|
|
|
|
/// The data that is being built up.
|
|
data: std.ArrayListUnmanaged(u8) = .empty,
|
|
|
|
/// This is non-null when a transmit and display command is given
|
|
/// so that we display the image after it is fully loaded.
|
|
display: ?command.Display = null,
|
|
|
|
/// This is non-null when this load is an animation frame
|
|
/// transmission (a=f) rather than a new image. On completion the
|
|
/// data is composed into the target image's animation instead of
|
|
/// being stored as an image.
|
|
frame: ?FrameContext = null,
|
|
|
|
/// Quiet is the quiet settings for the initial load command. This is
|
|
/// used if q isn't set on subsequent chunks.
|
|
quiet: command.Command.Quiet,
|
|
|
|
/// Response identifiers from the initial load command. Subsequent chunks
|
|
/// omit these, so completion responses must use the saved values.
|
|
response: command.Response = .{},
|
|
|
|
/// The temporary directory for file transmission (null means that
|
|
/// temporary directory transmission is disabled).
|
|
temporary_directory: ?[]const u8,
|
|
|
|
pub const FrameContext = struct {
|
|
/// The frame parameters from the initial a=f command. Chunked
|
|
/// continuations only contribute payload bytes; all parameters
|
|
/// come from the command that started the load, matching the
|
|
/// protocol's requirement that chunks repeat a=f.
|
|
cmd: command.AnimationFrameLoading,
|
|
|
|
/// The generation of the target image when the load began.
|
|
/// A different generation at completion means the image was
|
|
/// replaced or evicted mid-transmission and the frame must be
|
|
/// discarded rather than composed onto the wrong image.
|
|
image_generation: u64,
|
|
};
|
|
|
|
/// The limits of the Kitty Graphics protocol we should allow.
|
|
///
|
|
/// This can be used to restrict the type of images and other
|
|
/// parameters for resource or security reasons. Note that depending
|
|
/// on how libghostty is compiled, some of these may be fully unsupported
|
|
/// and ignored (e.g. "file" on wasm32-freestanding).
|
|
pub const Limits = struct {
|
|
file: bool,
|
|
temporary_file: union(enum) {
|
|
enabled: struct {
|
|
/// The directory to expect temporary files in.
|
|
directory: []const u8,
|
|
},
|
|
disabled: void,
|
|
},
|
|
shared_memory: bool,
|
|
|
|
/// Enables all filesystem-related image transmission mediums. `path`
|
|
/// is the temporary directory to expect files in when files are
|
|
/// transmitted using said medium.
|
|
pub fn allWithTempDir(path: []const u8) Limits {
|
|
return .{
|
|
.file = true,
|
|
.temporary_file = .{ .enabled = .{ .directory = path } },
|
|
.shared_memory = true,
|
|
};
|
|
}
|
|
|
|
pub const direct: Limits = .{
|
|
.file = false,
|
|
.temporary_file = .disabled,
|
|
.shared_memory = false,
|
|
};
|
|
};
|
|
|
|
/// Initialize a chunked immage from the first image transmission.
|
|
/// If this is a multi-chunk image, this should only be the FIRST
|
|
/// chunk.
|
|
pub fn init(
|
|
io: std.Io,
|
|
alloc: Allocator,
|
|
cmd: *const command.Command,
|
|
limits: Limits,
|
|
) !LoadingImage {
|
|
// Build our initial image from the properties sent via the control.
|
|
// These can be overwritten by the data loading process. For example,
|
|
// PNG loading sets the width/height from the data.
|
|
const t = cmd.transmission().?;
|
|
|
|
// Validated here rather than while parsing so the response can
|
|
// carry the image id, matching Kitty's initialize_load_data.
|
|
if (t.format_unknown) return error.UnsupportedFormat;
|
|
var result: LoadingImage = .{
|
|
.image = .{
|
|
.id = t.image_id,
|
|
.number = t.image_number,
|
|
.width = t.width,
|
|
.height = t.height,
|
|
.compression = t.compression,
|
|
.format = t.format,
|
|
.metadata = .{ .transient = t.usage.transient },
|
|
},
|
|
|
|
.display = cmd.display(),
|
|
.quiet = cmd.quiet,
|
|
.response = .{
|
|
.id = t.image_id,
|
|
.image_number = t.image_number,
|
|
.placement_id = t.placement_id,
|
|
},
|
|
.temporary_directory = switch (limits.temporary_file) {
|
|
.enabled => |d| d.directory,
|
|
.disabled => null,
|
|
},
|
|
};
|
|
|
|
// Special case for the direct medium, we just add the chunk directly.
|
|
if (t.medium == .direct) {
|
|
try result.addData(alloc, cmd.data);
|
|
return result;
|
|
}
|
|
|
|
// Verify our capabilities and limits allow this.
|
|
{
|
|
// Special case if we don't support decoding PNGs and the format
|
|
// is a PNG we can save a lot of memory/effort buffering the
|
|
// data but failing up front.
|
|
if (t.format == .png and
|
|
sys.decode_png == null)
|
|
{
|
|
return error.UnsupportedMedium;
|
|
}
|
|
|
|
// Verify the medium is allowed
|
|
switch (t.medium) {
|
|
.direct => unreachable,
|
|
.file => if (!limits.file) return error.UnsupportedMedium,
|
|
.temporary_file => if (limits.temporary_file == .disabled) return error.UnsupportedMedium,
|
|
.shared_memory => if (!limits.shared_memory) return error.UnsupportedMedium,
|
|
}
|
|
}
|
|
|
|
// Otherwise, the payload data is guaranteed to be a path.
|
|
|
|
if (comptime builtin.os.tag != .windows) {
|
|
if (std.mem.indexOfScalar(u8, cmd.data, 0) != null) {
|
|
// POSIX paths cannot contain internal nulls.
|
|
log.warn("invalid image path: BadPathName", .{});
|
|
return error.InvalidData;
|
|
}
|
|
}
|
|
|
|
// Depending on the medium, load the data from the path.
|
|
switch (t.medium) {
|
|
.direct => unreachable, // handled above
|
|
.file => try result.readFile(.file, io, alloc, t, cmd.data),
|
|
.temporary_file => try result.readFile(.temporary_file, io, alloc, t, cmd.data),
|
|
.shared_memory => try result.readSharedMemory(io, alloc, t, cmd.data),
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// Reads the data from a shared memory segment.
|
|
fn readSharedMemory(
|
|
self: *LoadingImage,
|
|
io: std.Io,
|
|
alloc: Allocator,
|
|
t: command.Transmission,
|
|
path: []const u8,
|
|
) !void {
|
|
// android does not support POSIX shared memory.
|
|
// windows is currently unsupported, does it support shm?
|
|
if (comptime builtin.abi.isAndroid() or builtin.target.os.tag == .windows) {
|
|
return error.UnsupportedMedium;
|
|
}
|
|
|
|
// libc is required for shm_open
|
|
if (comptime !builtin.link_libc) {
|
|
return error.UnsupportedMedium;
|
|
}
|
|
|
|
// POSIX shared memory names must begin with a slash, contain at
|
|
// least one character after it, contain no other slashes, and fit
|
|
// within NAME_MAX. Some shm_open implementations accept names
|
|
// without the leading slash, but the Kitty protocol does not.
|
|
if (!validSharedMemoryName(path, posix.NAME_MAX)) {
|
|
log.warn("invalid shared memory name", .{});
|
|
return error.InvalidData;
|
|
}
|
|
|
|
// Since we're only supporting posix then max_path_bytes should
|
|
// be enough to stack allocate the path.
|
|
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const pathz = std.fmt.bufPrintZ(&buf, "{s}", .{path}) catch return error.InvalidData;
|
|
|
|
const fd = std.c.shm_open(pathz, @as(c_int, @bitCast(std.c.O{ .ACCMODE = .RDONLY })), @as(u16, 0));
|
|
switch (std.posix.errno(fd)) {
|
|
.SUCCESS => {},
|
|
else => |err| {
|
|
log.warn("unable to open shared memory {s}: {}", .{ path, err });
|
|
return error.InvalidData;
|
|
},
|
|
}
|
|
const file: std.Io.File = .{
|
|
.handle = fd,
|
|
.flags = .{ .nonblocking = false },
|
|
};
|
|
|
|
defer file.close(io);
|
|
defer _ = std.c.shm_unlink(pathz);
|
|
|
|
// The size from stat on may be larger than our expected size because
|
|
// shared memory has to be a multiple of the page size.
|
|
const stat_size: usize = stat: {
|
|
const stat = file.stat(io) catch |err| {
|
|
log.warn("unable to fstat shared memory {s}: {}", .{ path, err });
|
|
return error.InvalidData;
|
|
};
|
|
if (stat.size <= 0) return error.InvalidData;
|
|
break :stat std.math.cast(usize, stat.size) orelse
|
|
return error.InvalidData;
|
|
};
|
|
|
|
// Get the memory range we'll read. Validate it to make sure
|
|
// it doesn't overflow.
|
|
const range = try self.sharedMemoryRange(t, stat_size);
|
|
|
|
const map = std.posix.mmap(
|
|
null,
|
|
stat_size, // mmap always uses the stat size
|
|
.{ .READ = true },
|
|
std.c.MAP{ .TYPE = .SHARED },
|
|
fd,
|
|
0,
|
|
) catch |err| {
|
|
log.warn("unable to mmap shared memory {s}: {}", .{ path, err });
|
|
return error.InvalidData;
|
|
};
|
|
defer std.posix.munmap(map);
|
|
|
|
assert(self.data.items.len == 0);
|
|
try self.data.appendSlice(alloc, map[range.start..range.end]);
|
|
}
|
|
|
|
const SharedMemoryRange = struct {
|
|
start: usize,
|
|
end: usize,
|
|
};
|
|
|
|
/// Returns the byte range to copy from a shared memory object.
|
|
fn sharedMemoryRange(
|
|
self: *const LoadingImage,
|
|
t: command.Transmission,
|
|
stat_size: usize,
|
|
) error{
|
|
InvalidData,
|
|
DimensionsTooLarge,
|
|
}!SharedMemoryRange {
|
|
const expected_size: ?usize = switch (self.image.format) {
|
|
// PNG dimensions come from the decoded data.
|
|
.png => null,
|
|
|
|
// Validate before multiplying because protocol dimensions are
|
|
// u32 values and may otherwise overflow in safe builds.
|
|
.gray, .gray_alpha, .rgb, .rgba => size: {
|
|
if (self.image.width > max_dimension or
|
|
self.image.height > max_dimension)
|
|
{
|
|
return error.DimensionsTooLarge;
|
|
}
|
|
|
|
const bpp: usize = command.Transmission.formatBpp(self.image.format);
|
|
break :size @as(usize, self.image.width) *
|
|
@as(usize, self.image.height) * bpp;
|
|
},
|
|
};
|
|
|
|
// Get our start offset and validate its within the range of
|
|
// the statted data.
|
|
const start = std.math.cast(usize, t.offset) orelse
|
|
return error.InvalidData;
|
|
if (start > stat_size) return error.InvalidData;
|
|
|
|
// Validate that our length is within the stat range too.
|
|
const available = stat_size - start;
|
|
const data_size: usize = if (t.size > 0)
|
|
std.math.cast(usize, t.size) orelse return error.InvalidData
|
|
else if (self.image.compression == .none and expected_size != null)
|
|
expected_size.?
|
|
else
|
|
available;
|
|
if (data_size > max_size or data_size > available) {
|
|
return error.InvalidData;
|
|
}
|
|
|
|
// data_size <= available guarantees this addition cannot overflow.
|
|
return .{ .start = start, .end = start + data_size };
|
|
}
|
|
|
|
/// Reads the data from a temporary file and returns it. This allocates
|
|
/// and does not free any of the data, so the caller must free it.
|
|
///
|
|
/// This will also delete the temporary file if it is in a safe location.
|
|
fn readFile(
|
|
self: *LoadingImage,
|
|
comptime medium: command.Transmission.Medium,
|
|
io: std.Io,
|
|
alloc: Allocator,
|
|
t: command.Transmission,
|
|
path: []const u8,
|
|
) !void {
|
|
switch (medium) {
|
|
.file, .temporary_file => {},
|
|
else => @compileError("readFile only supports file and temporary_file"),
|
|
}
|
|
|
|
// Open our file right away before we do validation. This avoids
|
|
// TOCTOU issues.
|
|
var file = std.Io.Dir.cwd().openFile(
|
|
io,
|
|
path,
|
|
.{},
|
|
) catch |err| {
|
|
log.warn("failed to open image file: {}", .{err});
|
|
return error.InvalidData;
|
|
};
|
|
|
|
// We'll populate a delete path if this is a temporary file.
|
|
var delete_path: ?[]const u8 = null;
|
|
defer {
|
|
file.close(io);
|
|
if (delete_path) |p| {
|
|
std.Io.Dir.cwd().deleteFile(io, p) catch |err| {
|
|
log.warn("failed to delete temporary file: {}", .{err});
|
|
};
|
|
}
|
|
}
|
|
|
|
// Derive the path from the open handle so the file we validate is the
|
|
// exact file we read. Resolving a path before opening it would allow a
|
|
// cooperating process to swap a symlink or directory entry in between.
|
|
var abs_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const abs_path = validatedFilePath(
|
|
io,
|
|
file,
|
|
&abs_buf,
|
|
) catch |err| {
|
|
log.warn("failed to validate image file path: {}", .{err});
|
|
return error.InvalidData;
|
|
};
|
|
|
|
// Temporary file logic
|
|
if (medium == .temporary_file) {
|
|
assert(self.temporary_directory != null);
|
|
if (!isPathInTempDir(
|
|
io,
|
|
self.temporary_directory.?,
|
|
abs_path,
|
|
)) return error.TemporaryFileNotInTempDir;
|
|
if (std.mem.indexOf(
|
|
u8,
|
|
abs_path,
|
|
"tty-graphics-protocol",
|
|
) == null) return error.TemporaryFileNotNamedCorrectly;
|
|
delete_path = abs_path;
|
|
}
|
|
|
|
// File must be a regular file
|
|
if (file.stat(io)) |stat| {
|
|
if (stat.kind != .file) {
|
|
log.warn("file is not a regular file kind={}", .{stat.kind});
|
|
return error.InvalidData;
|
|
}
|
|
} else |err| {
|
|
log.warn("failed to stat file: {}", .{err});
|
|
return error.InvalidData;
|
|
}
|
|
|
|
var buf: [4096]u8 = undefined;
|
|
var buf_reader = file.reader(io, &buf);
|
|
if (t.offset > 0) {
|
|
buf_reader.seekTo(@intCast(t.offset)) catch |err| {
|
|
log.warn("failed to seek to offset {}: {}", .{ t.offset, err });
|
|
return error.InvalidData;
|
|
};
|
|
}
|
|
const reader = &buf_reader.interface;
|
|
|
|
// Read the file
|
|
var managed: std.ArrayList(u8) = .empty;
|
|
errdefer managed.deinit(alloc);
|
|
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);
|
|
self.data = .{ .items = managed.items, .capacity = managed.capacity };
|
|
}
|
|
|
|
/// Returns the canonical path of an open file after applying the file
|
|
/// transmission blocklist.
|
|
fn validatedFilePath(io: std.Io, file: std.Io.File, buf: []u8) ![]const u8 {
|
|
const path = buf[0..try file.realPath(io, buf)];
|
|
|
|
// This is logic copied directly from Kitty, mostly. This is really
|
|
// rough but it will catch obvious bad actors.
|
|
if (std.mem.startsWith(u8, path, "/proc/") or
|
|
std.mem.startsWith(u8, path, "/sys/") or
|
|
(std.mem.startsWith(u8, path, "/dev/") and
|
|
!std.mem.startsWith(u8, path, "/dev/shm/")))
|
|
{
|
|
return error.InvalidData;
|
|
}
|
|
|
|
return path;
|
|
}
|
|
|
|
/// Returns true if path appears to be in a temporary directory.
|
|
/// Copies logic from Kitty.
|
|
fn isPathInTempDir(io: std.Io, dir: []const u8, path: []const u8) bool {
|
|
if (isPathInDir("/tmp", path)) return true;
|
|
if (isPathInDir("/dev/shm", path)) return true;
|
|
if (isPathInDir(dir, path)) return true;
|
|
|
|
// The temporary dir is sometimes a symlink. On macOS for
|
|
// example /tmp is /private/var/...
|
|
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const real_dir = buf[0 .. std.Io.Dir.cwd().realPathFile(
|
|
io,
|
|
dir,
|
|
&buf,
|
|
) catch return false];
|
|
if (isPathInDir(real_dir, path)) return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
pub fn deinit(self: *LoadingImage, alloc: Allocator) void {
|
|
self.image.deinit(alloc);
|
|
self.data.deinit(alloc);
|
|
}
|
|
|
|
pub fn destroy(self: *LoadingImage, alloc: Allocator) void {
|
|
self.deinit(alloc);
|
|
alloc.destroy(self);
|
|
}
|
|
|
|
/// Adds a chunk of data to the image. Use this if the image
|
|
/// is coming in chunks (the "m" parameter in the protocol).
|
|
pub fn addData(self: *LoadingImage, alloc: Allocator, data: []const u8) !void {
|
|
// If no data, skip
|
|
if (data.len == 0) return;
|
|
|
|
// If our data would get too big, return an error
|
|
if (self.data.items.len + data.len > max_size) {
|
|
log.warn("image data too large max_size={}", .{max_size});
|
|
return error.InvalidData;
|
|
}
|
|
|
|
// Ensure we have enough room to add the data
|
|
// to the end of the ArrayList before doing so.
|
|
try self.data.ensureUnusedCapacity(alloc, data.len);
|
|
|
|
const start_i = self.data.items.len;
|
|
self.data.items.len = start_i + data.len;
|
|
fastmem.copy(u8, self.data.items[start_i..], data);
|
|
}
|
|
|
|
/// Complete the chunked image, returning a completed image.
|
|
pub fn complete(self: *LoadingImage, alloc: Allocator) !Image {
|
|
const img = &self.image;
|
|
|
|
// Decompress the data if it is compressed.
|
|
try self.decompress(alloc);
|
|
|
|
// Decode the png if we have to
|
|
if (img.format == .png) try self.decodePng(alloc);
|
|
|
|
// Validate our dimensions.
|
|
if (img.width == 0 or img.height == 0) return error.DimensionsRequired;
|
|
if (img.width > max_dimension or img.height > max_dimension) return error.DimensionsTooLarge;
|
|
|
|
// Data length must be what we expect.
|
|
const bpp = command.Transmission.formatBpp(img.format);
|
|
const expected_len = img.width * img.height * bpp;
|
|
const actual_len = self.data.items.len;
|
|
if (self.frame != null) {
|
|
// Kitty allows animation frames to exceed their expected length
|
|
// and just truncates it. Not sure if thats expected but lets
|
|
// allow it too.
|
|
if (actual_len < expected_len) {
|
|
std.log.warn(
|
|
"insufficient frame data image id={} expected_len={} actual_len={}",
|
|
.{ img.id, expected_len, actual_len },
|
|
);
|
|
return error.InsufficientData;
|
|
}
|
|
self.data.items.len = expected_len;
|
|
} else if (actual_len != expected_len) {
|
|
std.log.warn(
|
|
"unexpected length image id={} width={} height={} bpp={} expected_len={} actual_len={}",
|
|
.{ img.id, img.width, img.height, bpp, expected_len, actual_len },
|
|
);
|
|
return error.InvalidData;
|
|
}
|
|
|
|
// Everything looks good, copy the image data over.
|
|
var result = self.image;
|
|
result.data = .{ .complete = try self.data.toOwnedSlice(alloc) };
|
|
errdefer result.deinit(alloc);
|
|
self.image = .{};
|
|
return result;
|
|
}
|
|
|
|
/// Debug function to write the data to a file. This is useful for
|
|
/// capturing some test data for unit tests.
|
|
pub fn debugDump(io: std.Io, self: LoadingImage) !void {
|
|
if (comptime builtin.mode != .Debug) @compileError("debugDump in non-debug");
|
|
|
|
var buf: [1024]u8 = undefined;
|
|
const filename = try std.fmt.bufPrint(
|
|
&buf,
|
|
"image-{s}-{s}-{d}x{d}-{}.data",
|
|
.{
|
|
@tagName(self.image.format),
|
|
@tagName(self.image.compression),
|
|
self.image.width,
|
|
self.image.height,
|
|
self.image.id,
|
|
},
|
|
);
|
|
const cwd = std.Io.Dir.cwd();
|
|
const f = try cwd.createFile(io, filename, .{});
|
|
defer f.close(io);
|
|
|
|
const writer = f.writer();
|
|
try writer.writeAll(self.data.items);
|
|
}
|
|
|
|
/// Decompress the data in-place.
|
|
fn decompress(self: *LoadingImage, alloc: Allocator) !void {
|
|
return switch (self.image.compression) {
|
|
.none => {},
|
|
.zlib_deflate => self.decompressZlib(alloc),
|
|
};
|
|
}
|
|
|
|
fn decompressZlib(self: *LoadingImage, alloc: Allocator) !void {
|
|
// Open our zlib stream
|
|
var buf: [std.compress.flate.max_window_len]u8 = undefined;
|
|
var reader: std.Io.Reader = .fixed(self.data.items);
|
|
var stream: std.compress.flate.Decompress = .init(&reader, .zlib, &buf);
|
|
|
|
// Write it to an array list
|
|
var list: std.ArrayList(u8) = .empty;
|
|
errdefer list.deinit(alloc);
|
|
stream.reader.appendRemaining(alloc, &list, .limited(max_size)) catch {
|
|
log.warn("failed to read decompressed data: {?}", .{stream.err});
|
|
return error.DecompressionFailed;
|
|
};
|
|
|
|
// Empty our current data list, take ownership over managed array list
|
|
self.data.deinit(alloc);
|
|
self.data = .{ .items = list.items, .capacity = list.capacity };
|
|
|
|
// Make sure we note that our image is no longer compressed
|
|
self.image.compression = .none;
|
|
}
|
|
|
|
/// Decode the data as PNG. This will also updated the image dimensions.
|
|
fn decodePng(self: *LoadingImage, alloc: Allocator) !void {
|
|
assert(self.image.format == .png);
|
|
|
|
const decode_png_fn = sys.decode_png orelse
|
|
return error.UnsupportedFormat;
|
|
|
|
var limited: LimitedAllocator = .init(alloc, max_size);
|
|
const decode_alloc = limited.allocator();
|
|
const result = decode_png_fn(
|
|
decode_alloc,
|
|
self.data.items,
|
|
) catch |err| switch (err) {
|
|
error.InvalidData => return error.InvalidData,
|
|
error.OutOfMemory => if (limited.limit_exceeded)
|
|
return error.InvalidData
|
|
else
|
|
return error.OutOfMemory,
|
|
};
|
|
defer decode_alloc.free(result.data);
|
|
|
|
if (result.data.len > max_size) {
|
|
log.warn("png image too large size={} max_size={}", .{ result.data.len, max_size });
|
|
return error.InvalidData;
|
|
}
|
|
|
|
// Replace our data
|
|
self.data.deinit(alloc);
|
|
self.data = .empty;
|
|
try self.data.ensureUnusedCapacity(alloc, result.data.len);
|
|
try self.data.appendSlice(alloc, result.data[0..result.data.len]);
|
|
|
|
// Store updated image dimensions
|
|
self.image.width = result.width;
|
|
self.image.height = result.height;
|
|
self.image.format = .rgba;
|
|
}
|
|
};
|
|
|
|
/// Image represents a single image whose metadata is fully known.
|
|
///
|
|
/// Complete image data is always fully decoded raw pixels: loading inflates
|
|
/// any zlib-compressed payload and decodes PNG into RGBA before an image is
|
|
/// completed, so `compression` is always `.none` and `format` is never `.png`
|
|
/// for a stored image. Pending image data reserves the exact decoded byte
|
|
/// length that will be attached later.
|
|
pub const Image = struct {
|
|
id: u32 = 0,
|
|
number: u32 = 0,
|
|
width: u32 = 0,
|
|
height: u32 = 0,
|
|
format: command.Transmission.Format = .rgb,
|
|
compression: command.Transmission.Compression = .none,
|
|
data: Data = .{ .complete = "" },
|
|
metadata: packed struct(u32) {
|
|
/// The image's transient usage hint, used to prioritize eviction.
|
|
transient: bool = false,
|
|
|
|
/// Set this if the image was loaded without an ID or number. Such
|
|
/// images must not receive responses. Kitty gives these client ID
|
|
/// 0 (unaddressable); our storage keys everything by one public
|
|
/// u32 ID, so they get an ID from the upper half of the range
|
|
/// that is guaranteed unused at assignment time, but a client
|
|
/// that explicitly transmits that ID later can still replace
|
|
/// them.
|
|
implicit_id: bool = false,
|
|
|
|
/// Number of placements referencing this image.
|
|
placement_count: u30 = 0,
|
|
} = .{},
|
|
|
|
/// Unique, monotonically increasing stamp assigned each time an
|
|
/// image is added to (or replaced in) an ImageStorage. A changed
|
|
/// generation for a given image ID means the image contents may
|
|
/// have changed, even if the dimensions and byte length are the
|
|
/// same (e.g. a retransmission of the same ID). Stamps order by
|
|
/// transmission time. Zero means "never stored".
|
|
///
|
|
/// For animated images this also changes whenever the frame that
|
|
/// should be displayed changes (advance, edit, or delete of the
|
|
/// current frame), since consumers key texture caches off it.
|
|
generation: u64 = 0,
|
|
|
|
/// Animation state, non-null once any animation command (a=f,
|
|
/// a=a) has attached animation state to this image. Owned by the
|
|
/// image; replaced/retransmitted images drop it, which implements
|
|
/// the protocol's "retransmission resets the animation" rule.
|
|
///
|
|
/// This is only ever attached to images stored in an ImageStorage
|
|
/// and must only be mutated through the storage's own pointer
|
|
/// (Image values are copied around freely; copies share this
|
|
/// pointer and never own it).
|
|
animation: ?*animation.Animation = null,
|
|
|
|
pub const Error = error{
|
|
InsufficientData,
|
|
InvalidData,
|
|
DecompressionFailed,
|
|
DimensionsRequired,
|
|
DimensionsTooLarge,
|
|
FilePathTooLong,
|
|
TemporaryFileNotInTempDir,
|
|
TemporaryFileNotNamedCorrectly,
|
|
UnsupportedFormat,
|
|
UnsupportedMedium,
|
|
UnsupportedDepth,
|
|
};
|
|
|
|
pub const Data = union(enum) {
|
|
/// Owned, decoded image bytes. The empty default is not allocated.
|
|
complete: []const u8,
|
|
|
|
/// Expected decoded byte length for a payload that has not arrived.
|
|
pending: usize,
|
|
|
|
/// Bytes reserved against the storage limit.
|
|
pub fn len(self: Data) usize {
|
|
return switch (self) {
|
|
.complete => |data| data.len,
|
|
.pending => |expected_len| expected_len,
|
|
};
|
|
}
|
|
|
|
/// Returns decoded bytes when the payload is complete.
|
|
pub fn bytes(self: Data) ?[]const u8 {
|
|
return switch (self) {
|
|
.complete => |data| data,
|
|
.pending => null,
|
|
};
|
|
}
|
|
|
|
pub fn isPending(self: Data) bool {
|
|
return self == .pending;
|
|
}
|
|
|
|
pub fn deinit(self: *Data, alloc: Allocator) void {
|
|
switch (self.*) {
|
|
.complete => |data| if (data.len > 0) alloc.free(data),
|
|
.pending => {},
|
|
}
|
|
}
|
|
};
|
|
|
|
pub fn deinit(self: *Image, alloc: Allocator) void {
|
|
self.data.deinit(alloc);
|
|
if (self.animation) |anim| {
|
|
anim.deinit(alloc);
|
|
alloc.destroy(anim);
|
|
self.animation = null;
|
|
}
|
|
}
|
|
|
|
/// The pixel data that should be displayed for this image. For an
|
|
/// animated image this is the current animation frame; otherwise
|
|
/// (and for the root frame) it is the image's own data.
|
|
pub fn renderData(self: *const Image) Data {
|
|
if (self.animation) |anim| {
|
|
if (anim.current_index > 0) {
|
|
return .{ .complete = anim.frames.items[anim.current_index - 1].data };
|
|
}
|
|
}
|
|
|
|
return self.data;
|
|
}
|
|
|
|
/// The pixel data of the given 1-based animation frame number, or
|
|
/// null if the frame doesn't exist. Frame 1 (the root frame)
|
|
/// always exists as long as the image data is complete, even for
|
|
/// images without animation state.
|
|
///
|
|
/// The returned slice is owned by the image (or its animation)
|
|
/// and remains valid until the image or frame is mutated.
|
|
pub fn frameData(self: *const Image, number: u32) ?[]const u8 {
|
|
switch (number) {
|
|
0 => return null,
|
|
1 => return self.data.bytes(),
|
|
else => {
|
|
const anim = self.animation orelse return null;
|
|
// Minus 2 because frame is 1-based and frame 1 is the
|
|
// image base data, so the animation frames start at frame 2.
|
|
const idx = number - 2;
|
|
if (idx >= anim.frames.items.len) return null;
|
|
return anim.frames.items[idx].data;
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Total bytes of pixel data reserved against the storage limit
|
|
/// for this image: the base data plus any animation frames.
|
|
pub fn storageSize(self: *const Image) usize {
|
|
var total: usize = self.data.len();
|
|
if (self.animation) |anim| total += anim.frameBytes();
|
|
return total;
|
|
}
|
|
|
|
/// Mostly for logging
|
|
pub fn withoutData(self: *const Image) Image {
|
|
var copy = self.*;
|
|
if (copy.data == .complete) copy.data = .{ .complete = "" };
|
|
return copy;
|
|
}
|
|
};
|
|
|
|
/// The rect taken up by some image placement, in grid cells. This will
|
|
/// be rounded up to the nearest grid cell since we can't place images
|
|
/// in partial grid cells.
|
|
pub const Rect = struct {
|
|
top_left: PageList.Pin,
|
|
bottom_right: PageList.Pin,
|
|
|
|
/// Returns true if the grid cell is inside this rectangle. Pin.isBetween
|
|
/// compares page order, so its interior rows intentionally do not constrain
|
|
/// x. Check the column independently and use isBetween only for the row.
|
|
pub fn contains(self: Rect, cell: PageList.Pin) bool {
|
|
if (cell.x < self.top_left.x or cell.x > self.bottom_right.x) return false;
|
|
|
|
var row = cell;
|
|
row.x = self.top_left.x;
|
|
return row.isBetween(self.top_left, self.bottom_right);
|
|
}
|
|
};
|
|
|
|
/// Returns whether a name follows the POSIX shared memory name format.
|
|
fn validSharedMemoryName(name: []const u8, name_max: usize) bool {
|
|
if (name.len < 2 or name.len > name_max or name[0] != '/') return false;
|
|
for (name[1..]) |c| {
|
|
if (c == '/' or c == 0) return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// Returns true if `path` is `dir` or is contained within it, requiring a
|
|
/// path-separator boundary so similarly prefixed directories do not match.
|
|
fn isPathInDir(dir: []const u8, path: []const u8) bool {
|
|
if (dir.len == 0 or !std.mem.startsWith(u8, path, dir)) return false;
|
|
if (path.len == dir.len or std.fs.path.isSep(dir[dir.len - 1])) return true;
|
|
return std.fs.path.isSep(path[dir.len]);
|
|
}
|
|
|
|
test "temporary file path must be inside directory" {
|
|
const testing = std.testing;
|
|
|
|
try testing.expect(isPathInDir("/tmp", "/tmp/tty-graphics-protocol-image.data"));
|
|
try testing.expect(isPathInDir("/tmp/", "/tmp/tty-graphics-protocol-image.data"));
|
|
try testing.expect(isPathInDir("/tmp", "/tmp"));
|
|
|
|
try testing.expect(!isPathInDir("", "/tmp/tty-graphics-protocol-image.data"));
|
|
try testing.expect(!isPathInDir("/tmp", "/tmpX/tty-graphics-protocol-image.data"));
|
|
try testing.expect(!isPathInDir("/dev/shm", "/dev/shm-evil/tty-graphics-protocol-image.data"));
|
|
try testing.expect(!isPathInDir("/custom/tmp", "/custom/tmp-suffix/tty-graphics-protocol-image.data"));
|
|
}
|
|
|
|
test "shared memory names follow POSIX rules" {
|
|
const testing = std.testing;
|
|
|
|
try testing.expect(validSharedMemoryName("/kitty", 8));
|
|
try testing.expect(validSharedMemoryName("/1234567", 8));
|
|
|
|
try testing.expect(!validSharedMemoryName("", 8));
|
|
try testing.expect(!validSharedMemoryName("/", 8));
|
|
try testing.expect(!validSharedMemoryName("kitty", 8));
|
|
try testing.expect(!validSharedMemoryName("/kitty/image", 16));
|
|
try testing.expect(!validSharedMemoryName("/kitty\x00image", 16));
|
|
try testing.expect(!validSharedMemoryName("/12345678", 8));
|
|
}
|
|
|
|
test "image load rejects invalid POSIX shared memory names" {
|
|
if (comptime builtin.abi.isAndroid() or
|
|
builtin.target.os.tag == .windows or
|
|
!builtin.link_libc)
|
|
{
|
|
return error.SkipZigTest;
|
|
}
|
|
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
|
|
var cmd: command.Command = .{
|
|
.control = .{ .transmit = .{
|
|
.format = .rgb,
|
|
.medium = .shared_memory,
|
|
.width = 1,
|
|
.height = 1,
|
|
.image_id = 31,
|
|
} },
|
|
.data = try alloc.dupe(u8, "kitty-without-leading-slash"),
|
|
};
|
|
defer cmd.deinit(alloc);
|
|
|
|
try testing.expectError(
|
|
error.InvalidData,
|
|
LoadingImage.init(testing.io, alloc, &cmd, .{
|
|
.file = false,
|
|
.temporary_file = .disabled,
|
|
.shared_memory = true,
|
|
}),
|
|
);
|
|
}
|
|
|
|
test "shared memory range with offset and size" {
|
|
const testing = std.testing;
|
|
|
|
const loading: LoadingImage = .{
|
|
.image = .{
|
|
.width = 1,
|
|
.height = 1,
|
|
.format = .rgb,
|
|
},
|
|
.quiet = .no,
|
|
.temporary_directory = null,
|
|
};
|
|
|
|
const explicit = try loading.sharedMemoryRange(.{
|
|
.offset = 2,
|
|
.size = 3,
|
|
}, 5);
|
|
try testing.expectEqual(@as(usize, 2), explicit.start);
|
|
try testing.expectEqual(@as(usize, 5), explicit.end);
|
|
|
|
const implicit = try loading.sharedMemoryRange(.{
|
|
.offset = 2,
|
|
}, 5);
|
|
try testing.expectEqual(@as(usize, 2), implicit.start);
|
|
try testing.expectEqual(@as(usize, 5), implicit.end);
|
|
}
|
|
|
|
test "shared memory range rejects out of bounds offset" {
|
|
const loading: LoadingImage = .{
|
|
.image = .{
|
|
.width = 1,
|
|
.height = 1,
|
|
.format = .rgb,
|
|
},
|
|
.quiet = .no,
|
|
.temporary_directory = null,
|
|
};
|
|
|
|
try std.testing.expectError(
|
|
error.InvalidData,
|
|
loading.sharedMemoryRange(.{ .offset = 4 }, 3),
|
|
);
|
|
}
|
|
|
|
test "shared memory range validates dimensions before multiplication" {
|
|
const loading: LoadingImage = .{
|
|
.image = .{
|
|
.width = std.math.maxInt(u32),
|
|
.height = std.math.maxInt(u32),
|
|
.format = .rgba,
|
|
},
|
|
.quiet = .no,
|
|
.temporary_directory = null,
|
|
};
|
|
|
|
try std.testing.expectError(
|
|
error.DimensionsTooLarge,
|
|
loading.sharedMemoryRange(.{}, 1),
|
|
);
|
|
}
|
|
|
|
// This specifically tests we ALLOW invalid RGB data because Kitty
|
|
// documents that this should work.
|
|
test "image load with invalid RGB data" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
// <ESC>_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA<ESC>\
|
|
var cmd: command.Command = .{
|
|
.control = .{ .transmit = .{
|
|
.format = .rgb,
|
|
.width = 1,
|
|
.height = 1,
|
|
.image_id = 31,
|
|
} },
|
|
.data = try alloc.dupe(u8, "AAAA"),
|
|
};
|
|
defer cmd.deinit(alloc);
|
|
var loading = try LoadingImage.init(io, alloc, &cmd, .direct);
|
|
defer loading.deinit(alloc);
|
|
}
|
|
|
|
test "image load with image too wide" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
var cmd: command.Command = .{
|
|
.control = .{ .transmit = .{
|
|
.format = .rgb,
|
|
.width = max_dimension + 1,
|
|
.height = 1,
|
|
.image_id = 31,
|
|
} },
|
|
.data = try alloc.dupe(u8, "AAAA"),
|
|
};
|
|
defer cmd.deinit(alloc);
|
|
var loading = try LoadingImage.init(io, alloc, &cmd, .direct);
|
|
defer loading.deinit(alloc);
|
|
try testing.expectError(error.DimensionsTooLarge, loading.complete(alloc));
|
|
}
|
|
|
|
test "image load with image too tall" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
var cmd: command.Command = .{
|
|
.control = .{ .transmit = .{
|
|
.format = .rgb,
|
|
.height = max_dimension + 1,
|
|
.width = 1,
|
|
.image_id = 31,
|
|
} },
|
|
.data = try alloc.dupe(u8, "AAAA"),
|
|
};
|
|
defer cmd.deinit(alloc);
|
|
var loading = try LoadingImage.init(io, alloc, &cmd, .direct);
|
|
defer loading.deinit(alloc);
|
|
try testing.expectError(error.DimensionsTooLarge, loading.complete(alloc));
|
|
}
|
|
|
|
test "image load: rgb, zlib compressed, direct" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
var cmd: command.Command = .{
|
|
.control = .{ .transmit = .{
|
|
.format = .rgb,
|
|
.medium = .direct,
|
|
.compression = .zlib_deflate,
|
|
.height = 96,
|
|
.width = 128,
|
|
.image_id = 31,
|
|
} },
|
|
.data = try alloc.dupe(
|
|
u8,
|
|
@embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647-raw.data"),
|
|
),
|
|
};
|
|
defer cmd.deinit(alloc);
|
|
var loading = try LoadingImage.init(io, alloc, &cmd, .direct);
|
|
defer loading.deinit(alloc);
|
|
var img = try loading.complete(alloc);
|
|
defer img.deinit(alloc);
|
|
|
|
// should be decompressed
|
|
try testing.expect(img.compression == .none);
|
|
}
|
|
|
|
test "image load: rgb, not compressed, direct" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
var cmd: command.Command = .{
|
|
.control = .{ .transmit = .{
|
|
.format = .rgb,
|
|
.medium = .direct,
|
|
.compression = .none,
|
|
.width = 20,
|
|
.height = 15,
|
|
.image_id = 31,
|
|
} },
|
|
.data = try alloc.dupe(
|
|
u8,
|
|
@embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"),
|
|
),
|
|
};
|
|
defer cmd.deinit(alloc);
|
|
var loading = try LoadingImage.init(io, alloc, &cmd, .direct);
|
|
defer loading.deinit(alloc);
|
|
var img = try loading.complete(alloc);
|
|
defer img.deinit(alloc);
|
|
|
|
// should be decompressed
|
|
try testing.expect(img.compression == .none);
|
|
}
|
|
|
|
test "image load: rgb, zlib compressed, direct, chunked" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
const data = @embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647-raw.data");
|
|
|
|
// Setup our initial chunk
|
|
var cmd: command.Command = .{
|
|
.control = .{ .transmit = .{
|
|
.format = .rgb,
|
|
.medium = .direct,
|
|
.compression = .zlib_deflate,
|
|
.height = 96,
|
|
.width = 128,
|
|
.image_id = 31,
|
|
.more_chunks = true,
|
|
} },
|
|
.data = try alloc.dupe(u8, data[0..1024]),
|
|
};
|
|
defer cmd.deinit(alloc);
|
|
var loading = try LoadingImage.init(io, alloc, &cmd, .direct);
|
|
defer loading.deinit(alloc);
|
|
|
|
// Read our remaining chunks
|
|
var fbs: std.Io.Reader = .fixed(data[1024..]);
|
|
var buf: [1024]u8 = undefined;
|
|
while (fbs.readSliceShort(&buf)) |size| {
|
|
try loading.addData(alloc, buf[0..size]);
|
|
if (size < buf.len) break;
|
|
} else |err| return err;
|
|
|
|
// Complete
|
|
var img = try loading.complete(alloc);
|
|
defer img.deinit(alloc);
|
|
try testing.expect(img.compression == .none);
|
|
}
|
|
|
|
test "image load: rgb, zlib compressed, direct, chunked with zero initial chunk" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
const data = @embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647-raw.data");
|
|
|
|
// Setup our initial chunk
|
|
var cmd: command.Command = .{
|
|
.control = .{ .transmit = .{
|
|
.format = .rgb,
|
|
.medium = .direct,
|
|
.compression = .zlib_deflate,
|
|
.height = 96,
|
|
.width = 128,
|
|
.image_id = 31,
|
|
.more_chunks = true,
|
|
} },
|
|
};
|
|
defer cmd.deinit(alloc);
|
|
var loading = try LoadingImage.init(io, alloc, &cmd, .direct);
|
|
defer loading.deinit(alloc);
|
|
|
|
// Read our remaining chunks
|
|
var fbs: std.Io.Reader = .fixed(data);
|
|
var buf: [1024]u8 = undefined;
|
|
while (fbs.readSliceShort(&buf)) |size| {
|
|
try loading.addData(alloc, buf[0..size]);
|
|
if (size < buf.len) break;
|
|
} else |err| return err;
|
|
|
|
// Complete
|
|
var img = try loading.complete(alloc);
|
|
defer img.deinit(alloc);
|
|
try testing.expect(img.compression == .none);
|
|
}
|
|
|
|
test "image load: temporary file without correct path" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
var tmp_dir = testing.tmpDir(.{});
|
|
defer tmp_dir.cleanup();
|
|
const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data");
|
|
try tmp_dir.dir.writeFile(testing.io, .{
|
|
.sub_path = "image.data",
|
|
.data = data,
|
|
});
|
|
|
|
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const path = buf[0..try tmp_dir.dir.realPathFile(testing.io, "image.data", &buf)];
|
|
|
|
var cmd: command.Command = .{
|
|
.control = .{ .transmit = .{
|
|
.format = .rgb,
|
|
.medium = .temporary_file,
|
|
.compression = .none,
|
|
.width = 20,
|
|
.height = 15,
|
|
.image_id = 31,
|
|
} },
|
|
.data = try alloc.dupe(u8, path),
|
|
};
|
|
defer cmd.deinit(alloc);
|
|
var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
try testing.expectError(error.TemporaryFileNotNamedCorrectly, LoadingImage.init(
|
|
io,
|
|
alloc,
|
|
&cmd,
|
|
.allWithTempDir(dir_path_buf[0..try tmp_dir.dir.realPath(testing.io, &dir_path_buf)]),
|
|
));
|
|
|
|
// Temporary file should still be there
|
|
try tmp_dir.dir.access(testing.io, path, .{});
|
|
}
|
|
|
|
test "image load: temporary file outside directory prefix is rejected" {
|
|
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.createDir(io, "temp", .default_dir);
|
|
try tmp_dir.dir.createDir(io, "temp-suffix", .default_dir);
|
|
|
|
var trusted_dir = try tmp_dir.dir.openDir(io, "temp", .{});
|
|
defer trusted_dir.close(io);
|
|
var outside_dir = try tmp_dir.dir.openDir(io, "temp-suffix", .{});
|
|
defer outside_dir.close(io);
|
|
|
|
const filename = "tty-graphics-protocol-image.data";
|
|
const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data");
|
|
try outside_dir.writeFile(io, .{
|
|
.sub_path = filename,
|
|
.data = data,
|
|
});
|
|
|
|
var trusted_path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const trusted_path = trusted_path_buf[0..try trusted_dir.realPath(io, &trusted_path_buf)];
|
|
var outside_path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const outside_path = outside_path_buf[0..try outside_dir.realPathFile(io, filename, &outside_path_buf)];
|
|
|
|
var cmd: command.Command = .{
|
|
.control = .{ .transmit = .{
|
|
.format = .rgb,
|
|
.medium = .temporary_file,
|
|
.compression = .none,
|
|
.width = 20,
|
|
.height = 15,
|
|
.image_id = 31,
|
|
} },
|
|
.data = try alloc.dupe(u8, outside_path),
|
|
};
|
|
defer cmd.deinit(alloc);
|
|
try testing.expectError(
|
|
error.TemporaryFileNotInTempDir,
|
|
LoadingImage.init(io, alloc, &cmd, .allWithTempDir(trusted_path)),
|
|
);
|
|
|
|
// Rejection must happen before temporary-file cleanup is armed.
|
|
try outside_dir.access(io, filename, .{});
|
|
}
|
|
|
|
test "image load: rgb, not compressed, temporary file" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
var tmp_dir = testing.tmpDir(.{});
|
|
defer tmp_dir.cleanup();
|
|
const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data");
|
|
try tmp_dir.dir.writeFile(testing.io, .{
|
|
.sub_path = "tty-graphics-protocol-image.data",
|
|
.data = data,
|
|
});
|
|
|
|
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const path = buf[0..try tmp_dir.dir.realPathFile(testing.io, "tty-graphics-protocol-image.data", &buf)];
|
|
|
|
var cmd: command.Command = .{
|
|
.control = .{ .transmit = .{
|
|
.format = .rgb,
|
|
.medium = .temporary_file,
|
|
.compression = .none,
|
|
.width = 20,
|
|
.height = 15,
|
|
.image_id = 31,
|
|
} },
|
|
.data = try alloc.dupe(u8, path),
|
|
};
|
|
defer cmd.deinit(alloc);
|
|
var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
var loading = try LoadingImage.init(
|
|
io,
|
|
alloc,
|
|
&cmd,
|
|
.allWithTempDir(dir_path_buf[0..try tmp_dir.dir.realPath(testing.io, &dir_path_buf)]),
|
|
);
|
|
defer loading.deinit(alloc);
|
|
var img = try loading.complete(alloc);
|
|
defer img.deinit(alloc);
|
|
try testing.expect(img.compression == .none);
|
|
|
|
// Temporary file should be gone
|
|
try testing.expectError(error.FileNotFound, tmp_dir.dir.access(testing.io, path, .{}));
|
|
}
|
|
|
|
test "image load: rgb, not compressed, regular file" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
var tmp_dir = testing.tmpDir(.{});
|
|
defer tmp_dir.cleanup();
|
|
const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data");
|
|
try tmp_dir.dir.writeFile(testing.io, .{
|
|
.sub_path = "image.data",
|
|
.data = data,
|
|
});
|
|
|
|
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const path = buf[0..try tmp_dir.dir.realPathFile(testing.io, "image.data", &buf)];
|
|
|
|
var cmd: command.Command = .{
|
|
.control = .{ .transmit = .{
|
|
.format = .rgb,
|
|
.medium = .file,
|
|
.compression = .none,
|
|
.width = 20,
|
|
.height = 15,
|
|
.image_id = 31,
|
|
} },
|
|
.data = try alloc.dupe(u8, path),
|
|
};
|
|
defer cmd.deinit(alloc);
|
|
var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
var loading = try LoadingImage.init(
|
|
io,
|
|
alloc,
|
|
&cmd,
|
|
.allWithTempDir(dir_path_buf[0..try tmp_dir.dir.realPath(testing.io, &dir_path_buf)]),
|
|
);
|
|
defer loading.deinit(alloc);
|
|
var img = try loading.complete(alloc);
|
|
defer img.deinit(alloc);
|
|
try testing.expect(img.compression == .none);
|
|
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;
|
|
const io = testing.io;
|
|
|
|
var tmp_dir = testing.tmpDir(.{});
|
|
defer tmp_dir.cleanup();
|
|
const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data");
|
|
try tmp_dir.dir.writeFile(testing.io, .{
|
|
.sub_path = "image.data",
|
|
.data = data,
|
|
});
|
|
|
|
var cmd: command.Command = .{
|
|
.control = .{ .transmit = .{
|
|
.format = .rgb,
|
|
.medium = .file,
|
|
.compression = .none,
|
|
.width = 20,
|
|
.height = 15,
|
|
.image_id = 31,
|
|
} },
|
|
.data = try std.fmt.allocPrint(
|
|
alloc,
|
|
".zig-cache/tmp/{s}/image.data",
|
|
.{tmp_dir.sub_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.expect(img.compression == .none);
|
|
}
|
|
|
|
test "image load: blocklist applies to opened file after symlink swap" {
|
|
if (builtin.os.tag == .windows) return error.SkipZigTest;
|
|
|
|
const testing = std.testing;
|
|
const io = testing.io;
|
|
|
|
var tmp_dir = testing.tmpDir(.{});
|
|
defer tmp_dir.cleanup();
|
|
try tmp_dir.dir.writeFile(io, .{
|
|
.sub_path = "safe.data",
|
|
.data = "safe",
|
|
});
|
|
try tmp_dir.dir.symLink(io, "/dev/null", "image.data", .{});
|
|
|
|
// Pin the blocked file, then simulate the cooperating process replacing
|
|
// the path with a safe target before validation.
|
|
const blocked_file = try tmp_dir.dir.openFile(io, "image.data", .{});
|
|
defer blocked_file.close(io);
|
|
try tmp_dir.dir.symLinkAtomic(io, "safe.data", "image.data", .{});
|
|
|
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
try testing.expectError(
|
|
error.InvalidData,
|
|
LoadingImage.validatedFilePath(io, blocked_file, &path_buf),
|
|
);
|
|
|
|
// The pathname now resolves to the safe replacement, demonstrating that
|
|
// the rejection above came from the already-open file handle.
|
|
const safe_file = try tmp_dir.dir.openFile(io, "image.data", .{});
|
|
defer safe_file.close(io);
|
|
_ = try LoadingImage.validatedFilePath(io, safe_file, &path_buf);
|
|
}
|
|
|
|
test "image load: png, not compressed, regular file" {
|
|
if (sys.decode_png == null) return error.SkipZigTest;
|
|
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
var tmp_dir = testing.tmpDir(.{});
|
|
defer tmp_dir.cleanup();
|
|
const data = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data");
|
|
try tmp_dir.dir.writeFile(testing.io, .{
|
|
.sub_path = "tty-graphics-protocol-image.data",
|
|
.data = data,
|
|
});
|
|
|
|
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const path = buf[0..try tmp_dir.dir.realPathFile(testing.io, "tty-graphics-protocol-image.data", &buf)];
|
|
|
|
var cmd: command.Command = .{
|
|
.control = .{ .transmit = .{
|
|
.format = .png,
|
|
.medium = .file,
|
|
.compression = .none,
|
|
.width = 0,
|
|
.height = 0,
|
|
.image_id = 31,
|
|
} },
|
|
.data = try alloc.dupe(u8, path),
|
|
};
|
|
defer cmd.deinit(alloc);
|
|
var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
var loading = try LoadingImage.init(
|
|
io,
|
|
alloc,
|
|
&cmd,
|
|
.allWithTempDir(dir_path_buf[0..try tmp_dir.dir.realPath(testing.io, &dir_path_buf)]),
|
|
);
|
|
defer loading.deinit(alloc);
|
|
var img = try loading.complete(alloc);
|
|
defer img.deinit(alloc);
|
|
try testing.expect(img.compression == .none);
|
|
try testing.expect(img.format == .rgba);
|
|
try tmp_dir.dir.access(testing.io, path, .{});
|
|
}
|
|
|
|
test "image load: png rejects oversized decoder allocation" {
|
|
const testing = std.testing;
|
|
|
|
const oversized_decoder = struct {
|
|
fn decode(
|
|
alloc: Allocator,
|
|
_: []const u8,
|
|
) sys.DecodeError!sys.Image {
|
|
const data = try alloc.alloc(u8, max_size + 1);
|
|
return .{
|
|
.width = 1,
|
|
.height = 1,
|
|
.data = data,
|
|
};
|
|
}
|
|
}.decode;
|
|
|
|
const original_decode_png = sys.decode_png;
|
|
defer sys.decode_png = original_decode_png;
|
|
sys.decode_png = &oversized_decoder;
|
|
|
|
// Fail any allocation which reaches the underlying allocator. The size
|
|
// limiter should reject the decoder's request before it gets that far.
|
|
var failing = testing.FailingAllocator.init(testing.allocator, .{
|
|
.fail_index = 0,
|
|
});
|
|
const alloc = failing.allocator();
|
|
|
|
var loading: LoadingImage = .{
|
|
.image = .{ .format = .png },
|
|
.quiet = .no,
|
|
.temporary_directory = null,
|
|
};
|
|
defer loading.deinit(alloc);
|
|
|
|
try testing.expectError(error.InvalidData, loading.complete(alloc));
|
|
try testing.expect(!failing.has_induced_failure);
|
|
}
|
|
|
|
test "image load: png rejects oversized Wuffs image before allocation" {
|
|
if (sys.decode_png == null) return error.SkipZigTest;
|
|
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
|
|
// Turn the small test PNG into a 32768x32767 image. Its decoded RGBA
|
|
// size is just under Wuffs' 4 GiB package limit but over Kitty's 400 MiB
|
|
// limit, which previously allowed the large allocation to happen first.
|
|
var data = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data").*;
|
|
std.mem.writeInt(u32, data[16..20], 32768, .big);
|
|
std.mem.writeInt(u32, data[20..24], 32767, .big);
|
|
std.mem.writeInt(u32, data[29..33], std.hash.Crc32.hash(data[12..29]), .big);
|
|
|
|
const cmd: command.Command = .{
|
|
.control = .{ .transmit = .{
|
|
.format = .png,
|
|
.medium = .direct,
|
|
} },
|
|
.data = &data,
|
|
};
|
|
var loading = try LoadingImage.init(testing.io, alloc, &cmd, .direct);
|
|
defer loading.deinit(alloc);
|
|
|
|
try testing.expectError(error.InvalidData, loading.complete(alloc));
|
|
}
|
|
|
|
test "limits: direct medium always allowed" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
var cmd: command.Command = .{
|
|
.control = .{ .transmit = .{
|
|
.format = .rgb,
|
|
.medium = .direct,
|
|
.width = 1,
|
|
.height = 1,
|
|
.image_id = 31,
|
|
} },
|
|
.data = try alloc.dupe(u8, "AAAA"),
|
|
};
|
|
defer cmd.deinit(alloc);
|
|
|
|
// Direct medium should work even with the most restrictive limits
|
|
var loading = try LoadingImage.init(io, alloc, &cmd, .direct);
|
|
defer loading.deinit(alloc);
|
|
}
|
|
|
|
test "limits: file medium blocked by limits" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
var tmp_dir = testing.tmpDir(.{});
|
|
defer tmp_dir.cleanup();
|
|
const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data");
|
|
try tmp_dir.dir.writeFile(testing.io, .{
|
|
.sub_path = "image.data",
|
|
.data = data,
|
|
});
|
|
|
|
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const path = buf[0..try tmp_dir.dir.realPathFile(testing.io, "image.data", &buf)];
|
|
|
|
var cmd: command.Command = .{
|
|
.control = .{ .transmit = .{
|
|
.format = .rgb,
|
|
.medium = .file,
|
|
.compression = .none,
|
|
.width = 20,
|
|
.height = 15,
|
|
.image_id = 31,
|
|
} },
|
|
.data = try alloc.dupe(u8, path),
|
|
};
|
|
defer cmd.deinit(alloc);
|
|
try testing.expectError(error.UnsupportedMedium, LoadingImage.init(io, alloc, &cmd, .direct));
|
|
}
|
|
|
|
test "limits: file medium allowed by limits" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
var tmp_dir = testing.tmpDir(.{});
|
|
defer tmp_dir.cleanup();
|
|
const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data");
|
|
try tmp_dir.dir.writeFile(testing.io, .{
|
|
.sub_path = "image.data",
|
|
.data = data,
|
|
});
|
|
|
|
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const path = buf[0..try tmp_dir.dir.realPathFile(testing.io, "image.data", &buf)];
|
|
|
|
var cmd: command.Command = .{
|
|
.control = .{ .transmit = .{
|
|
.format = .rgb,
|
|
.medium = .file,
|
|
.compression = .none,
|
|
.width = 20,
|
|
.height = 15,
|
|
.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);
|
|
}
|
|
|
|
test "limits: temporary file medium blocked by limits" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
var tmp_dir = testing.tmpDir(.{});
|
|
defer tmp_dir.cleanup();
|
|
const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data");
|
|
try tmp_dir.dir.writeFile(testing.io, .{
|
|
.sub_path = "tty-graphics-protocol-image.data",
|
|
.data = data,
|
|
});
|
|
|
|
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const path = buf[0..try tmp_dir.dir.realPathFile(testing.io, "tty-graphics-protocol-image.data", &buf)];
|
|
|
|
var cmd: command.Command = .{
|
|
.control = .{ .transmit = .{
|
|
.format = .rgb,
|
|
.medium = .temporary_file,
|
|
.compression = .none,
|
|
.width = 20,
|
|
.height = 15,
|
|
.image_id = 31,
|
|
} },
|
|
.data = try alloc.dupe(u8, path),
|
|
};
|
|
defer cmd.deinit(alloc);
|
|
try testing.expectError(error.UnsupportedMedium, LoadingImage.init(io, alloc, &cmd, .{
|
|
.file = true,
|
|
.temporary_file = .disabled,
|
|
.shared_memory = true,
|
|
}));
|
|
|
|
// File should still exist since we blocked before reading
|
|
try tmp_dir.dir.access(testing.io, "tty-graphics-protocol-image.data", .{});
|
|
}
|
|
|
|
test "limits: temporary file medium allowed by limits" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
var tmp_dir = testing.tmpDir(.{});
|
|
defer tmp_dir.cleanup();
|
|
const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data");
|
|
try tmp_dir.dir.writeFile(testing.io, .{
|
|
.sub_path = "tty-graphics-protocol-image.data",
|
|
.data = data,
|
|
});
|
|
|
|
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const path = buf[0..try tmp_dir.dir.realPathFile(testing.io, "tty-graphics-protocol-image.data", &buf)];
|
|
|
|
var cmd: command.Command = .{
|
|
.control = .{ .transmit = .{
|
|
.format = .rgb,
|
|
.medium = .temporary_file,
|
|
.compression = .none,
|
|
.width = 20,
|
|
.height = 15,
|
|
.image_id = 31,
|
|
} },
|
|
.data = try alloc.dupe(u8, path),
|
|
};
|
|
defer cmd.deinit(alloc);
|
|
var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
var loading = try LoadingImage.init(
|
|
io,
|
|
alloc,
|
|
&cmd,
|
|
|
|
.{
|
|
.file = false,
|
|
.temporary_file = .{
|
|
.enabled = .{ .directory = dir_path_buf[0..try tmp_dir.dir.realPath(testing.io, &dir_path_buf)] },
|
|
},
|
|
.shared_memory = false,
|
|
},
|
|
);
|
|
defer loading.deinit(alloc);
|
|
}
|