aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig
diff options
context:
space:
mode:
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig')
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig531
1 files changed, 528 insertions, 3 deletions
diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig
index e411bafe..c841c48d 100644
--- a/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig
+++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig
@@ -21,6 +21,24 @@ const max_dimension = 10000;
/// Maximum size in bytes, taken from Kitty.
const max_size = 400 * 1024 * 1024; // 400MB
+/// Host-owned immutable raw RGBA snapshot. A successful callback transfers one reference.
+pub const FileBacking = extern struct {
+ context: ?*anyopaque,
+ identity: u64,
+ len: usize,
+ read: ?*const fn (?*anyopaque, [*]u8, usize) callconv(.c) bool,
+ release: ?*const fn (?*anyopaque) callconv(.c) void,
+};
+pub const SnapshotFileRequest = extern struct {
+ size: usize = @sizeOf(SnapshotFileRequest),
+ fd: i64,
+ expected_len: u64,
+};
+pub const SnapshotFileHook = struct {
+ context: ?*anyopaque,
+ callback: *const fn (?*anyopaque, *const SnapshotFileRequest, *FileBacking) bool,
+};
+
/// 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
@@ -29,6 +47,7 @@ pub const LoadingImage = struct {
/// The in-progress image. The first chunk must have all the metadata
/// so this comes from that initially.
image: Image,
+ snapshot_file: ?SnapshotFileHook = null,
/// The data that is being built up.
data: std.ArrayListUnmanaged(u8) = .empty,
@@ -85,6 +104,8 @@ pub const LoadingImage = struct {
disabled: void,
},
shared_memory: bool,
+ preserve_png: bool = false,
+ snapshot_file: ?SnapshotFileHook = null,
/// Enables all filesystem-related image transmission mediums. `path`
/// is the temporary directory to expect files in when files are
@@ -132,6 +153,10 @@ pub const LoadingImage = struct {
.metadata = .{ .transient = t.usage.transient },
},
+ .snapshot_file = if (cmd.control == .transmit or cmd.control == .transmit_and_display)
+ limits.snapshot_file
+ else
+ null,
.display = cmd.display(),
.quiet = cmd.quiet,
.response = .{
@@ -427,6 +452,16 @@ pub const LoadingImage = struct {
return error.InvalidData;
}
+ // Only whole, uncompressed base RGBA files qualify. The host must
+ // verify the descriptor size and create an immutable private snapshot.
+ if (comptime builtin.os.tag != .windows and builtin.os.tag != .wasi and builtin.os.tag != .freestanding) {
+ if (self.snapshot_file) |hook| {
+ if (nativeFileExpectedLen(t)) |expected| {
+ if (try self.trySnapshotFile(hook, .{ .fd = @intCast(file.handle), .expected_len = expected })) return;
+ }
+ }
+ }
+
var buf: [4096]u8 = undefined;
var buf_reader = file.reader(io, &buf);
if (t.offset > 0) {
@@ -468,6 +503,32 @@ pub const LoadingImage = struct {
self.data = .{ .items = managed.items, .capacity = managed.capacity };
}
+ /// Accept only complete callback records. A malformed success falls back
+ /// to the ordinary reader, releasing the transferred reference if possible.
+ fn trySnapshotFile(self: *LoadingImage, hook: SnapshotFileHook, request: SnapshotFileRequest) !bool {
+ var backing: FileBacking = .{ .context = null, .identity = 0, .len = 0, .read = null, .release = null };
+ if (!hook.callback(hook.context, &request, &backing)) return false;
+ if (backing.read == null or backing.release == null) {
+ if (backing.release) |release| release(backing.context);
+ return false;
+ }
+ if (backing.len != request.expected_len) {
+ backing.release.?(backing.context);
+ return error.InvalidData;
+ }
+ self.image.data = .{ .native_file = backing };
+ return true;
+ }
+
+ fn nativeFileExpectedLen(t: command.Transmission) ?usize {
+ if (t.medium != .file or t.format != .rgba or t.compression != .none or
+ t.more_chunks or t.offset != 0 or t.width == 0 or t.height == 0 or
+ t.width > max_dimension or t.height > max_dimension) return null;
+ const len = @as(usize, t.width) * @as(usize, t.height) * 4;
+ if (len > 16 * 1024 * 1024 or (t.size != 0 and t.size != len)) return null;
+ return len;
+ }
+
/// 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 {
@@ -569,7 +630,7 @@ pub const LoadingImage = struct {
// 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;
+ const actual_len = if (img.data == .native_file) img.data.native_file.len else 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
@@ -592,12 +653,37 @@ pub const LoadingImage = struct {
// Everything looks good, copy the image data over.
var result = self.image;
- result.data = .{ .complete = try self.data.toOwnedSlice(alloc) };
+ if (result.data != .native_file) result.data = .{ .complete = try self.data.toOwnedSlice(alloc) };
errdefer result.deinit(alloc);
self.image = .{};
return result;
}
+ /// Experimental forwarding path: only invoked for assembled quiet base
+ /// transmissions. Never used by queries or animation frame loading.
+ pub fn completePreservingPng(self: *LoadingImage, alloc: Allocator) !Image {
+ if (self.image.format != .png or self.image.compression != .none or
+ self.quiet != .failures or self.frame != null) return self.complete(alloc);
+ const header = inspectPng(self.data.items) catch |err| switch (err) {
+ // Do not expand the deferred-corruption contract to metadata we
+ // have not validated. Such PNGs keep the normal decoder semantics.
+ error.UnsupportedPngRetention => return self.complete(alloc),
+ error.InvalidData => return error.InvalidData,
+ error.DimensionsRequired => return error.DimensionsRequired,
+ error.DimensionsTooLarge => return error.DimensionsTooLarge,
+ };
+ var result = self.image;
+ result.width = header.width;
+ result.height = header.height;
+ result.format = .rgba;
+ result.data = .{ .encoded_png = .{
+ .bytes = try self.data.toOwnedSlice(alloc),
+ .decoded_len = header.decoded_len,
+ } };
+ 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 {
@@ -768,11 +854,16 @@ pub const Image = struct {
/// Expected decoded byte length for a payload that has not arrived.
pending: usize,
+ encoded_png: struct { bytes: []const u8, decoded_len: usize },
+ native_file: FileBacking,
+
/// Bytes reserved against the storage limit.
pub fn len(self: Data) usize {
return switch (self) {
.complete => |data| data.len,
.pending => |expected_len| expected_len,
+ .native_file => |file| file.len,
+ .encoded_png => |png| png.bytes.len + png.decoded_len,
};
}
@@ -780,7 +871,7 @@ pub const Image = struct {
pub fn bytes(self: Data) ?[]const u8 {
return switch (self) {
.complete => |data| data,
- .pending => null,
+ .pending, .encoded_png, .native_file => null,
};
}
@@ -792,10 +883,43 @@ pub const Image = struct {
switch (self.*) {
.complete => |data| if (data.len > 0) alloc.free(data),
.pending => {},
+ .encoded_png => |png| alloc.free(png.bytes),
+ .native_file => |file| if (file.release) |release| release(file.context),
}
}
};
+ /// Transactional: a failed host read leaves the authoritative backing owned.
+ pub fn materializeFile(self: *Image, alloc: Allocator) !void {
+ const file = switch (self.data) {
+ .native_file => |file| file,
+ else => return,
+ };
+ const read = file.read orelse return error.InvalidData;
+ const release = file.release orelse return error.InvalidData;
+ const bytes = try alloc.alloc(u8, file.len);
+ errdefer alloc.free(bytes);
+ if (!read(file.context, bytes.ptr, bytes.len)) return error.InvalidData;
+ self.data = .{ .complete = bytes };
+ release(file.context);
+ }
+
+ /// Transactional: neither ownership nor metadata changes on failure.
+ pub fn materializePng(self: *Image, alloc: Allocator) !void {
+ const png = switch (self.data) {
+ .encoded_png => |png| png,
+ else => return,
+ };
+ const decode = sys.decode_png orelse return error.UnsupportedFormat;
+ var limited: LimitedAllocator = .init(alloc, max_size);
+ const result = try decode(limited.allocator(), png.bytes);
+ errdefer alloc.free(result.data);
+ if (result.width != self.width or result.height != self.height or
+ result.data.len != png.decoded_len) return error.InvalidData;
+ alloc.free(png.bytes);
+ self.data = .{ .complete = result.data };
+ }
+
pub fn deinit(self: *Image, alloc: Allocator) void {
self.data.deinit(alloc);
if (self.animation) |anim| {
@@ -2159,3 +2283,404 @@ test "limits: temporary file medium allowed by limits" {
);
defer loading.deinit(alloc);
}
+
+const PngHeader = struct { width: u32, height: u32, decoded_len: usize };
+
+/// Bounded structural validation, deliberately without inflating IDAT.
+/// CRC-valid but corrupt compressed pixels can fail later in this experiment.
+fn inspectPng(bytes: []const u8) !PngHeader {
+ if (bytes.len > max_size or bytes.len < 33 or
+ !std.mem.eql(u8, bytes[0..8], "\x89PNG\r\n\x1a\n")) return error.InvalidData;
+ var offset: usize = 8;
+ var header: ?PngHeader = null;
+ var idat = false;
+ var ended_idat = false;
+ var palette = false;
+ var indexed = false;
+ var color_type: u8 = 0;
+ var bit_depth: u8 = 0;
+ var palette_entries: usize = 0;
+ var transparency = false;
+ while (offset < bytes.len) {
+ if (bytes.len - offset < 12) return error.InvalidData;
+ const len: usize = std.mem.readInt(u32, bytes[offset..][0..4], .big);
+ if (len > bytes.len - offset - 12) return error.InvalidData;
+ const kind = bytes[offset + 4 ..][0..4];
+ for (kind) |ch| if (!std.ascii.isAlphabetic(ch)) return error.InvalidData;
+ if (kind[2] & 0x20 != 0) return error.InvalidData;
+ const body = bytes[offset + 8 ..][0..len];
+ const crc = std.mem.readInt(u32, bytes[offset + 8 + len ..][0..4], .big);
+ if (std.hash.crc.Crc32IsoHdlc.hash(bytes[offset + 4 ..][0 .. len + 4]) != crc)
+ return error.InvalidData;
+ offset += len + 12;
+ if (std.mem.eql(u8, kind, "IHDR")) {
+ if (header != null or len != 13) return error.InvalidData;
+ const width = std.mem.readInt(u32, body[0..4], .big);
+ const height = std.mem.readInt(u32, body[4..8], .big);
+ if (width == 0 or height == 0) return error.DimensionsRequired;
+ if (width > max_dimension or height > max_dimension) return error.DimensionsTooLarge;
+ const depth = body[8];
+ const supported = switch (body[9]) {
+ 0 => depth == 1 or depth == 2 or depth == 4 or depth == 8 or depth == 16,
+ 2, 4, 6 => depth == 8 or depth == 16,
+ 3 => depth == 1 or depth == 2 or depth == 4 or depth == 8,
+ else => false,
+ };
+ if (!supported or body[10] != 0 or body[11] != 0 or body[12] > 1)
+ return error.InvalidData;
+ color_type = body[9];
+ bit_depth = depth;
+ indexed = color_type == 3;
+ const pixels = std.math.mul(usize, width, height) catch return error.InvalidData;
+ const decoded_len = std.math.mul(usize, pixels, 4) catch return error.InvalidData;
+ if (decoded_len > max_size) return error.InvalidData;
+ header = .{ .width = width, .height = height, .decoded_len = decoded_len };
+ } else {
+ if (header == null) return error.InvalidData;
+ if (std.mem.eql(u8, kind, "IDAT")) {
+ if (ended_idat or (indexed and !palette)) return error.InvalidData;
+ idat = true;
+ } else {
+ if (idat) ended_idat = true;
+ if (std.mem.eql(u8, kind, "IEND")) {
+ if (!idat or len != 0 or offset != bytes.len) return error.InvalidData;
+ return header.?;
+ } else if (std.mem.eql(u8, kind, "PLTE")) {
+ if (palette or idat or transparency or len == 0 or len > 768 or len % 3 != 0 or
+ color_type == 0 or color_type == 4) return error.InvalidData;
+ palette_entries = len / 3;
+ if (indexed and palette_entries > (@as(usize, 1) << @intCast(bit_depth)))
+ return error.InvalidData;
+ palette = true;
+ } else if (std.mem.eql(u8, kind, "tRNS")) {
+ if (transparency or idat) return error.InvalidData;
+ switch (color_type) {
+ 0, 2 => {
+ const expected: usize = if (color_type == 0) 2 else 6;
+ if (len != expected) return error.InvalidData;
+ const max_sample: u32 = (@as(u32, 1) << @intCast(bit_depth)) - 1;
+ var i: usize = 0;
+ while (i < len) : (i += 2) {
+ if (std.mem.readInt(u16, body[i..][0..2], .big) > max_sample)
+ return error.InvalidData;
+ }
+ },
+ 3 => if (!palette or len == 0 or len > palette_entries)
+ return error.InvalidData,
+ // Alpha-bearing color types must not carry tRNS.
+ else => return error.InvalidData,
+ }
+ transparency = true;
+ } else if (kind[0] & 0x20 == 0) {
+ return error.InvalidData;
+ } else {
+ // sBIT, bKGD, color profiles, APNG, text, and any other
+ // ancillary extensions are deliberately not eligible.
+ // The strict decoder handles their validity/semantics.
+ return error.UnsupportedPngRetention;
+ }
+ }
+ }
+ }
+ return error.InvalidData;
+}
+
+test "experimental PNG inspection validates CRC and complete structure" {
+ const testing = std.testing;
+ const original = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data");
+ const header = try inspectPng(original);
+ try testing.expectEqual(@as(u32, 50), header.width);
+ try testing.expectEqual(@as(u32, 76), header.height);
+ try testing.expectEqual(@as(usize, 50 * 76 * 4), header.decoded_len);
+ try testing.expectError(error.InvalidData, inspectPng(original[0 .. original.len - 1]));
+ var corrupt = original.*;
+ corrupt[29] ^= 1; // IHDR CRC
+ try testing.expectError(error.InvalidData, inspectPng(&corrupt));
+ corrupt = original.*;
+ corrupt[0] = 0;
+ try testing.expectError(error.InvalidData, inspectPng(&corrupt));
+}
+
+test "experimental PNG inspection transparency and palette metadata" {
+ const testing = std.testing;
+ const Chunk = struct { kind: *const [4]u8, body: []const u8 };
+ const Helper = struct {
+ fn append(out: *std.ArrayList(u8), kind: *const [4]u8, body: []const u8) !void {
+ var word: [4]u8 = undefined;
+ std.mem.writeInt(u32, &word, @intCast(body.len), .big);
+ try out.appendSlice(testing.allocator, &word);
+ const crc_start = out.items.len;
+ try out.appendSlice(testing.allocator, kind);
+ try out.appendSlice(testing.allocator, body);
+ std.mem.writeInt(u32, &word, std.hash.crc.Crc32IsoHdlc.hash(out.items[crc_start..]), .big);
+ try out.appendSlice(testing.allocator, &word);
+ }
+ fn make(color: u8, depth: u8, chunks: []const Chunk) ![]u8 {
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(testing.allocator);
+ try out.appendSlice(testing.allocator, "\x89PNG\r\n\x1a\n");
+ const ihdr = [_]u8{ 0, 0, 0, 1, 0, 0, 0, 1, depth, color, 0, 0, 0 };
+ try append(&out, "IHDR", &ihdr);
+ for (chunks) |chunk| try append(&out, chunk.kind, chunk.body);
+ try append(&out, "IEND", "");
+ return out.toOwnedSlice(testing.allocator);
+ }
+ fn check(color: u8, depth: u8, chunks: []const Chunk, expected: ?anyerror) !void {
+ const png = try make(color, depth, chunks);
+ defer testing.allocator.free(png);
+ if (expected) |err| {
+ try testing.expectError(err, inspectPng(png));
+ } else {
+ _ = try inspectPng(png);
+ }
+ }
+ };
+ // IDAT is intentionally opaque; this test only exercises structure.
+ const idat: Chunk = .{ .kind = "IDAT", .body = "" };
+ const palette: Chunk = .{ .kind = "PLTE", .body = &.{ 0, 0, 0, 255, 255, 255 } };
+ const alpha: Chunk = .{ .kind = "tRNS", .body = &.{128} };
+ const gray: Chunk = .{ .kind = "tRNS", .body = &.{ 0, 1 } };
+ const rgb: Chunk = .{ .kind = "tRNS", .body = &.{ 0, 1, 0, 2, 0, 3 } };
+ try Helper.check(3, 1, &.{ palette, alpha, idat }, null);
+ try Helper.check(0, 1, &.{ gray, idat }, null);
+ try Helper.check(2, 8, &.{ rgb, idat }, null);
+ try Helper.check(2, 8, &.{ palette, rgb, idat }, null);
+ try Helper.check(3, 1, &.{ alpha, palette, idat }, error.InvalidData);
+ try Helper.check(3, 1, &.{ palette, idat, alpha }, error.InvalidData);
+ try Helper.check(3, 1, &.{ palette, alpha, alpha, idat }, error.InvalidData);
+ try Helper.check(0, 8, &.{ alpha, idat }, error.InvalidData);
+ try Helper.check(2, 8, &.{ gray, idat }, error.InvalidData);
+ try Helper.check(4, 8, &.{ gray, idat }, error.InvalidData);
+ try Helper.check(6, 8, &.{ rgb, idat }, error.InvalidData);
+ try Helper.check(0, 8, &.{ palette, idat }, error.InvalidData);
+ try Helper.check(4, 8, &.{ palette, idat }, error.InvalidData);
+ try Helper.check(2, 8, &.{ rgb, palette, idat }, error.InvalidData);
+ try Helper.check(3, 1, &.{ palette, .{ .kind = "tRNS", .body = "" }, idat }, error.InvalidData);
+ try Helper.check(3, 1, &.{ palette, .{ .kind = "tRNS", .body = &.{ 1, 2, 3 } }, idat }, error.InvalidData);
+ try Helper.check(3, 1, &.{ .{ .kind = "PLTE", .body = &.{ 0, 0, 0, 1, 1, 1, 2, 2, 2 } }, idat }, error.InvalidData);
+ try Helper.check(0, 1, &.{ .{ .kind = "tRNS", .body = &.{ 0, 2 } }, idat }, error.InvalidData);
+ try Helper.check(2, 8, &.{ .{ .kind = "tRNS", .body = &.{ 1, 0, 0, 0, 0, 0 } }, idat }, error.InvalidData);
+ try Helper.check(0, 16, &.{ .{ .kind = "tRNS", .body = &.{ 255, 255 } }, idat }, null);
+ for ([_]*const [4]u8{ "sBIT", "bKGD", "iCCP", "gAMA" }) |kind| {
+ try Helper.check(2, 8, &.{ .{ .kind = kind, .body = "" }, idat }, error.UnsupportedPngRetention);
+ }
+}
+
+test "experimental PNG unsupported metadata falls back to strict decoding" {
+ const testing = std.testing;
+ const Decoder = struct {
+ var calls: usize = 0;
+ fn decode(_: Allocator, _: []const u8) sys.DecodeError!sys.Image {
+ calls += 1;
+ return error.InvalidData;
+ }
+ };
+ const original_decoder = sys.decode_png;
+ defer sys.decode_png = original_decoder;
+ sys.decode_png = &Decoder.decode;
+ Decoder.calls = 0;
+ const png = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data");
+ var loading: LoadingImage = .{
+ .image = .{ .format = .png },
+ .quiet = .failures,
+ .temporary_directory = null,
+ };
+ defer loading.deinit(testing.allocator);
+ try loading.addData(testing.allocator, png[0..33]);
+ // CRC-valid ancillary chunk: inspection must delegate, not retain or
+ // return UnsupportedPngRetention to the caller.
+ var chunk = [_]u8{ 0, 0, 0, 1, 's', 'B', 'I', 'T', 8, 0, 0, 0, 0 };
+ std.mem.writeInt(u32, chunk[9..13], std.hash.crc.Crc32IsoHdlc.hash(chunk[4..9]), .big);
+ try loading.addData(testing.allocator, &chunk);
+ try loading.addData(testing.allocator, png[33..]);
+ try testing.expectError(error.InvalidData, loading.completePreservingPng(testing.allocator));
+ try testing.expectEqual(@as(usize, 1), Decoder.calls);
+}
+
+test "native file completion and transactional lazy materialization" {
+ const testing = std.testing;
+ const Fake = struct {
+ reads: usize = 0,
+ releases: usize = 0,
+ fail: bool = false,
+ fn read(ctx: ?*anyopaque, dest: [*]u8, len: usize) callconv(.c) bool {
+ const self: *@This() = @ptrCast(@alignCast(ctx.?));
+ self.reads += 1;
+ if (self.fail) return false;
+ @memset(dest[0..len], 123);
+ return true;
+ }
+ fn release(ctx: ?*anyopaque) callconv(.c) void {
+ const self: *@This() = @ptrCast(@alignCast(ctx.?));
+ self.releases += 1;
+ }
+ fn backing(self: *@This(), len: usize) FileBacking {
+ return .{ .context = self, .identity = 7, .len = len, .read = read, .release = release };
+ }
+ };
+ var fake: Fake = .{};
+ var loading: LoadingImage = .{
+ .image = .{ .width = 1, .height = 1, .format = .rgba, .data = .{ .native_file = fake.backing(4) } },
+ .quiet = .no,
+ .temporary_directory = null,
+ };
+ defer loading.deinit(testing.allocator);
+ var img = try loading.complete(testing.allocator);
+ defer img.deinit(testing.allocator);
+ try testing.expectEqual(@as(usize, 0), fake.reads);
+ try testing.expectEqual(@as(usize, 4), img.data.len());
+ try testing.expect(!img.data.isPending());
+ try testing.expect(img.data.bytes() == null);
+ fake.fail = true;
+ try testing.expectError(error.InvalidData, img.materializeFile(testing.allocator));
+ try testing.expect(img.data == .native_file);
+ try testing.expectEqual(@as(usize, 0), fake.releases);
+ fake.fail = false;
+ try img.materializeFile(testing.allocator);
+ try testing.expectEqual(@as(usize, 1), fake.releases);
+ try testing.expectEqualSlices(u8, &.{ 123, 123, 123, 123 }, img.data.bytes().?);
+
+ var invalid: LoadingImage = .{
+ .image = .{ .width = 1, .height = 1, .format = .rgba, .data = .{ .native_file = fake.backing(3) } },
+ .quiet = .no,
+ .temporary_directory = null,
+ };
+ try testing.expectError(error.InvalidData, invalid.complete(testing.allocator));
+ invalid.deinit(testing.allocator);
+ try testing.expectEqual(@as(usize, 2), fake.releases);
+ // q=1 also validates length without reading pixels.
+ var quiet_one: LoadingImage = .{
+ .image = .{ .width = 1, .height = 1, .format = .rgba, .data = .{ .native_file = fake.backing(4) } },
+ .quiet = .ok,
+ .temporary_directory = null,
+ };
+ const reads_before = fake.reads;
+ var quiet_img = try quiet_one.complete(testing.allocator);
+ quiet_one.deinit(testing.allocator);
+ try testing.expectEqual(reads_before, fake.reads);
+ quiet_img.deinit(testing.allocator);
+ try testing.expectEqual(@as(usize, 3), fake.releases);
+ var canceled: LoadingImage = .{
+ .image = .{ .data = .{ .native_file = fake.backing(4) } },
+ .quiet = .failures,
+ .temporary_directory = null,
+ };
+ canceled.deinit(testing.allocator);
+ try testing.expectEqual(@as(usize, 4), fake.releases);
+}
+
+test "native file eligibility is whole raw RGBA only" {
+ const testing = std.testing;
+ var t: command.Transmission = .{ .medium = .file, .format = .rgba, .width = 1, .height = 2 };
+ try testing.expectEqual(@as(?usize, 8), LoadingImage.nativeFileExpectedLen(t));
+ t.size = 8;
+ try testing.expectEqual(@as(?usize, 8), LoadingImage.nativeFileExpectedLen(t));
+ t.size = 7;
+ try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null);
+ t.size = 0;
+ t.more_chunks = true;
+ try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null);
+ t.more_chunks = false;
+ t.offset = 1;
+ try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null);
+ t.offset = 0;
+ t.format = .rgb;
+ try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null);
+ t.format = .png;
+ try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null);
+ t.format = .rgba;
+ t.medium = .temporary_file;
+ try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null);
+ t.medium = .shared_memory;
+ try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null);
+ t.medium = .file;
+ t.compression = .zlib_deflate;
+ try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null);
+ t.compression = .none;
+ t.width = 0;
+ try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null);
+ t.width = 10001;
+ try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null);
+ t.width = 10000;
+ t.height = 10000;
+ try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null);
+}
+
+test "native file snapshot rejects missing callbacks before retaining" {
+ const testing = std.testing;
+ const Fake = struct {
+ reads: usize = 0,
+ releases: usize = 0,
+ missing_read: bool = true,
+ missing_release: bool = false,
+
+ fn read(ctx: ?*anyopaque, dest: [*]u8, len: usize) callconv(.c) bool {
+ const self: *@This() = @ptrCast(@alignCast(ctx.?));
+ self.reads += 1;
+ @memset(dest[0..len], 42);
+ return true;
+ }
+
+ fn release(ctx: ?*anyopaque) callconv(.c) void {
+ const self: *@This() = @ptrCast(@alignCast(ctx.?));
+ self.releases += 1;
+ }
+
+ fn snapshot(ctx: ?*anyopaque, request: *const SnapshotFileRequest, out: *FileBacking) bool {
+ const self: *@This() = @ptrCast(@alignCast(ctx.?));
+ out.* = .{
+ .context = ctx,
+ .identity = 1,
+ .len = @intCast(request.expected_len),
+ .read = if (self.missing_read) null else &read,
+ .release = if (self.missing_release) null else &release,
+ };
+ return true;
+ }
+ };
+ var fake: Fake = .{};
+ const hook: SnapshotFileHook = .{ .context = &fake, .callback = &Fake.snapshot };
+ const request: SnapshotFileRequest = .{ .fd = -1, .expected_len = 4 };
+ var loading: LoadingImage = .{
+ .image = .{ .width = 1, .height = 1, .format = .rgba },
+ .quiet = .no,
+ .temporary_directory = null,
+ };
+ // Missing read: transferred context is immediately released, not stored.
+ try testing.expect(!try loading.trySnapshotFile(hook, request));
+ try testing.expect(loading.image.data != .native_file);
+ try testing.expectEqual(@as(usize, 1), fake.releases);
+
+ // Missing release (or both): reject without calling a null pointer.
+ fake.missing_read = false;
+ fake.missing_release = true;
+ try testing.expect(!try loading.trySnapshotFile(hook, request));
+ try testing.expect(loading.image.data != .native_file);
+ fake.missing_read = true;
+ try testing.expect(!try loading.trySnapshotFile(hook, request));
+ try testing.expect(loading.image.data != .native_file);
+ try testing.expectEqual(@as(usize, 0), fake.reads);
+ try testing.expectEqual(@as(usize, 1), fake.releases);
+
+ // The ordinary reader can still supply bytes after rejection.
+ try loading.addData(testing.allocator, &.{ 1, 2, 3, 4 });
+ var img = try loading.complete(testing.allocator);
+ loading.deinit(testing.allocator);
+ try testing.expectEqualSlices(u8, &.{ 1, 2, 3, 4 }, img.data.bytes().?);
+ img.deinit(testing.allocator);
+ try testing.expectEqual(@as(usize, 1), fake.releases);
+
+ // Valid callback ownership is unchanged: zero-read acceptance, one release.
+ fake.missing_read = false;
+ fake.missing_release = false;
+ var accepted: LoadingImage = .{
+ .image = .{ .width = 1, .height = 1, .format = .rgba },
+ .quiet = .no,
+ .temporary_directory = null,
+ };
+ try testing.expect(try accepted.trySnapshotFile(hook, request));
+ try testing.expect(accepted.image.data == .native_file);
+ try testing.expectEqual(@as(usize, 0), fake.reads);
+ accepted.deinit(testing.allocator);
+ try testing.expectEqual(@as(usize, 2), fake.releases);
+}