diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-24 20:59:48 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-24 20:59:48 +0300 |
| commit | c411883ec639c9893ed9c33021c890485e91727b (patch) | |
| tree | 797bb6efdb3a9425e3d8c31953f62452bd564cb1 /vendor | |
| parent | 11a7ab4ae21ab9427bcb44b3be01f62a4ab58c47 (diff) | |
feat!: replace the custom graphics api with optimized native kitty rendering (#4561)
* feat!: replace the custom graphics api with optimized native kitty rendering
* fix: enable safe kitty optimizations automatically
---------
Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com>
Diffstat (limited to 'vendor')
| -rw-r--r-- | vendor/libghostty-vt.patches.md | 58 | ||||
| -rw-r--r-- | vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h | 20 | ||||
| -rw-r--r-- | vendor/libghostty-vt/include/ghostty/vt/terminal.h | 41 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig | 32 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/c/terminal.zig | 27 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig | 103 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig | 531 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig | 11 | ||||
| -rw-r--r-- | vendor/patches/libghostty-vt/0007-experimental-png-retention.patch | 1055 |
9 files changed, 1865 insertions, 13 deletions
diff --git a/vendor/libghostty-vt.patches.md b/vendor/libghostty-vt.patches.md index cd7f44f6..351b9560 100644 --- a/vendor/libghostty-vt.patches.md +++ b/vendor/libghostty-vt.patches.md @@ -170,3 +170,61 @@ just test-one clear_pane just maintenance-test just check ``` + +## 0007 experimental encoded PNG and immutable source retention + +status: active + +patch: `vendor/patches/libghostty-vt/0007-experimental-png-retention.patch` + +herdr issue: none; maintainer-directed native Kitty forwarding experiment + +upstream discussion: not opened + +upstream pr: not opened + +vendored base: `44f2a44df7e8c4a0c6df3f7d872ef3d7ead88e51` + +local files: + +- `vendor/libghostty-vt/include/ghostty/vt/terminal.h` +- `vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h` +- `vendor/libghostty-vt/src/terminal/c/terminal.zig` +- `vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig` +- `vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig` +- `vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig` +- `vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig` + +reason: An explicitly enabled embedding mode retains structurally validated, +quiet PNG uploads as encoded bytes, avoiding pixel decoding before forwarding +through a multiplexer. The existing raw getters retain their meaning; separate +getters expose retained PNG bytes. Queries and response-bearing uploads retain +full validation, and animation operations materialize pixels transactionally. +Storage reserves both encoded bytes and expected decoded size. + +This is default-off and experimental: CRC-valid corrupt compressed pixels may +be rejected later than in normal mode, including after placement. Quiet mode +suppresses replies, not validation semantics; this patch is not a claim of full +protocol-equivalent transparent forwarding. Herdr exercises this mode only in +tests; production PNG uploads retain full decoding and validation. + +A separate default-off snapshot callback retains host-owned immutable raw RGBA +file backing before reading pixels. Herdr installs this callback automatically +on Linux, using same-filesystem CoW snapshots. It never retains a mutable producer pathname. Unsupported snapshots +use the original loader; animation materializes pixels transactionally. Backing +ownership and bounded reads are explicit in the embedding ABI. + +remove when: upstream provides an equivalent opt-in owned encoded-image +representation and immutable host-backed raw sources with bounded storage, +strict query handling and lazy pixel materialization, or this experiment is retired. + +verification: + +```sh +just test-one native_source +just test-one png_forward_tests +just test-one kitty_png_replacement +just test-one kitty_file_image_survives +(cd vendor/libghostty-vt && zig build test-lib-vt -Dtest-filter='experimental PNG') +just check +``` diff --git a/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h b/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h index 99cee60a..8dad8086 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h +++ b/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h @@ -391,12 +391,13 @@ typedef enum GHOSTTY_ENUM_TYPED { /** * Borrowed pointer to the raw pixel data. Valid as long as the * underlying terminal is not mutated. Returns GHOSTTY_NO_VALUE when - * the image metadata is resident but its pixel payload is pending. + * the image metadata is resident but its pixel payload is pending, or + * when the experimental PRESERVE_PNG option retained encoded PNG data. * * The data is always fully decoded, uncompressed pixels in the * format reported by GHOSTTY_KITTY_IMAGE_DATA_FORMAT: zlib payloads - * are inflated and PNG payloads are decoded to RGBA at transmission - * time, before the image is stored. Consumers can upload this + * are inflated and PNG payloads are normally decoded to RGBA at transmission + * time, before the image is stored. Consumers can upload returned pixels * directly to the GPU without any decode step. * * For an animated image (Kitty graphics animation, actions a=f/a=a) @@ -412,7 +413,7 @@ typedef enum GHOSTTY_ENUM_TYPED { /** * Length of the raw pixel data in bytes. Always equal to * width * height * bytes-per-pixel for the reported format. For a - * pending image, this is the expected length reserved against the + * pending or encoded PNG image, this is the expected decoded length reserved against the * storage limit even though DATA_PTR is not available yet. * * Output type: size_t * @@ -439,6 +440,17 @@ typedef enum GHOSTTY_ENUM_TYPED { */ GHOSTTY_KITTY_IMAGE_DATA_GENERATION = 9, + /** Owned encoded PNG bytes, borrowed until the next mutation. NO_VALUE + * unless experimentally retained. Output type: const uint8_t **. + */ + GHOSTTY_KITTY_IMAGE_DATA_ENCODED_PNG_PTR = 10, + /** Encoded PNG length; NO_VALUE unless retained. Output type: size_t *. */ + GHOSTTY_KITTY_IMAGE_DATA_ENCODED_PNG_LEN = 11, + /** Borrowed void* backing context; valid only while image is unchanged. */ + GHOSTTY_KITTY_IMAGE_DATA_FILE_CONTEXT = 12, + /** uint64_t immutable revision fingerprint; never reads image pixels. */ + GHOSTTY_KITTY_IMAGE_DATA_FILE_IDENTITY = 13, + GHOSTTY_KITTY_IMAGE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyKittyGraphicsImageData; diff --git a/vendor/libghostty-vt/include/ghostty/vt/terminal.h b/vendor/libghostty-vt/include/ghostty/vt/terminal.h index 81f9ebd0..6404af53 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/terminal.h +++ b/vendor/libghostty-vt/include/ghostty/vt/terminal.h @@ -27,6 +27,39 @@ extern "C" { #endif +/** Borrowed opened descriptor; size is sizeof this request. No path is supplied. */ +typedef struct { + size_t size; + int64_t fd; + uint64_t expected_len; +} GhosttyKittyImageSnapshotFileRequest; + +typedef bool (*GhosttyKittyImageFileReadFn)(void *context, uint8_t *dest, size_t len); +typedef void (*GhosttyKittyImageFileReleaseFn)(void *context); + +typedef struct { + void *context; + uint64_t identity; + size_t len; + GhosttyKittyImageFileReadFn read; + GhosttyKittyImageFileReleaseFn release; +} GhosttyKittyImageFileBacking; + +/** Optional synchronous callback, default NULL. Verify exact descriptor size + * and snapshot immutable bytes before returning true. True transfers one context + * reference; false transfers nothing. Do not close the borrowed descriptor or + * reenter the terminal. Both read and release MUST be non-NULL on success. + * Read must fill exactly len bytes; release is called once. An incomplete + * backing is rejected and ordinary file loading is used instead. If release + * is provided, rejection calls it once; without release, the host remains + * responsible for cleanup (the terminal cannot release that context). + * identity identifies the immutable revision, not a pathname or source inode. + */ +typedef bool (*GhosttyKittyImageSnapshotFileFn)( + GhosttyTerminal terminal, void *userdata, + const GhosttyKittyImageSnapshotFileRequest *request, + GhosttyKittyImageFileBacking *out_backing); + /** @defgroup terminal Terminal * * Complete terminal emulator state and rendering. @@ -1545,6 +1578,14 @@ typedef enum GHOSTTY_ENUM_TYPED { * Input type: size_t* */ GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE_MAX_BYTES = 39, + + /** Experimental, default-off PNG retention for quiet base transmissions. + * Input type: bool*. NULL is a no-op. Corrupt compressed pixels may only + * be detected when an animation operation materializes the image. + */ + GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_PRESERVE_PNG = 40, + /** Optional synchronous GhosttyKittyImageSnapshotFileFn; NULL disables. */ + GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_SNAPSHOT_FILE = 41, GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalOption; diff --git a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig index 2bd5e862..c66b13be 100644 --- a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig +++ b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig @@ -183,6 +183,10 @@ pub const ImageData = enum(c_int) { data_ptr = 7, data_len = 8, generation = 9, + encoded_png_ptr = 10, + encoded_png_len = 11, + file_context = 12, + file_identity = 13, pub fn OutType(comptime self: ImageData) type { return switch (self) { @@ -190,9 +194,10 @@ pub const ImageData = enum(c_int) { .id, .number, .width, .height => u32, .format => ImageFormat, .compression => ImageCompression, - .data_ptr => [*]const u8, - .data_len => usize, - .generation => u64, + .data_ptr, .encoded_png_ptr => [*]const u8, + .data_len, .encoded_png_len => usize, + .generation, .file_identity => u64, + .file_context => ?*anyopaque, }; } }; @@ -266,7 +271,26 @@ fn imageGetTyped( // data; the image generation changes whenever the current // frame does, so generation-keyed caches stay coherent. .data_ptr => out.* = (image.renderData().bytes() orelse return .no_value).ptr, - .data_len => out.* = image.renderData().len(), + .data_len => out.* = switch (image.renderData()) { + .encoded_png => |png| png.decoded_len, + else => image.renderData().len(), + }, + .encoded_png_ptr => out.* = switch (image.data) { + .encoded_png => |png| png.bytes.ptr, + else => return .no_value, + }, + .encoded_png_len => out.* = switch (image.data) { + .encoded_png => |png| png.bytes.len, + else => return .no_value, + }, + .file_context => out.* = switch (image.data) { + .native_file => |file| file.context, + else => return .no_value, + }, + .file_identity => out.* = switch (image.data) { + .native_file => |file| file.identity, + else => return .no_value, + }, .generation => out.* = image.generation, } diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig index 91bbe310..e825183e 100644 --- a/vendor/libghostty-vt/src/terminal/c/terminal.zig +++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig @@ -13,6 +13,7 @@ const ScreenSet = @import("../ScreenSet.zig"); const PageList = @import("../PageList.zig"); const apc = @import("../apc.zig"); const kitty = @import("../kitty/key.zig"); +const kitty_image = @import("../kitty/graphics_image.zig"); const kitty_gfx_c = @import("kitty_graphics.zig"); const modes = @import("../modes.zig"); const point = @import("../point.zig"); @@ -257,6 +258,7 @@ const Effects = struct { clipboard_write: ?ClipboardWriteFn = null, clipboard_read: ?ClipboardReadFn = null, unknown_sequence: ?UnknownSequenceFn = null, + snapshot_file: ?SnapshotFileFn = null, /// Scratch buffer for DA1 feature codes. The device attributes /// trampoline converts C feature codes into this buffer and returns @@ -265,6 +267,14 @@ const Effects = struct { /// (`reportDeviceAttributes`) reads it before any re-entrant call. da_features_buf: [64]device_attributes.Primary.Feature = undefined, + pub const SnapshotFileFn = *const fn (Terminal, ?*anyopaque, *const kitty_image.SnapshotFileRequest, *kitty_image.FileBacking) callconv(lib.calling_conv) bool; + + fn snapshotFile(context: ?*anyopaque, request: *const kitty_image.SnapshotFileRequest, out: *kitty_image.FileBacking) bool { + const wrapper: *TerminalWrapper = @ptrCast(@alignCast(context.?)); + const callback = wrapper.effects.snapshot_file orelse return false; + return callback(@ptrCast(wrapper), wrapper.effects.userdata, request, out); + } + /// C function pointer type for the write_pty callback. pub const WritePtyFn = *const fn (Terminal, ?*anyopaque, [*]const u8, usize) callconv(lib.calling_conv) void; @@ -1174,12 +1184,15 @@ pub const Option = enum(c_int) { terminfo_name = 37, clipboard_read = 38, clipboard_write_max_bytes = 39, + kitty_image_preserve_png = 40, + kitty_image_snapshot_file = 41, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { return switch (self) { .userdata => ?*const anyopaque, .write_pty => ?Effects.WritePtyFn, + .kitty_image_snapshot_file => ?Effects.SnapshotFileFn, .bell => ?Effects.BellFn, .color_scheme => ?Effects.ColorSchemeFn, .desktop_notification => ?Effects.DesktopNotificationFn, @@ -1197,6 +1210,7 @@ pub const Option = enum(c_int) { .color_foreground, .color_background, .color_cursor => ?*const color.RGB.C, .color_palette => ?*const color.PaletteC, .kitty_image_storage_limit => ?*const u64, + .kitty_image_preserve_png, .kitty_image_medium_file, .kitty_image_medium_shared_mem, .glyph_protocol, @@ -1250,6 +1264,17 @@ fn setTyped( switch (option) { .userdata => wrapper.effects.userdata = @constCast(value), .write_pty => wrapper.effects.write_pty = value, + .kitty_image_snapshot_file => { + if (comptime !build_options.kitty_graphics) return .success; + wrapper.effects.snapshot_file = value; + var it = wrapper.terminal.screens.all.iterator(); + while (it.next()) |entry| { + entry.value.*.kitty_images.image_limits.snapshot_file = if (value != null) + .{ .context = @ptrCast(wrapper), .callback = &Effects.snapshotFile } + else + null; + } + }, .bell => wrapper.effects.bell = value, .color_scheme => wrapper.effects.color_scheme = value, .desktop_notification => wrapper.effects.desktop_notification = value, @@ -1330,6 +1355,7 @@ fn setTyped( screen.kitty_images.setLimit(screen.io, screen.alloc, screen, limit); } }, + .kitty_image_preserve_png, .kitty_image_medium_file, .kitty_image_medium_shared_mem, => { @@ -1339,6 +1365,7 @@ fn setTyped( while (it.next()) |entry| { const screen = entry.value.*; switch (option) { + .kitty_image_preserve_png => screen.kitty_images.image_limits.preserve_png = val, .kitty_image_medium_file => screen.kitty_images.image_limits.file = val, .kitty_image_medium_shared_mem => screen.kitty_images.image_limits.shared_memory = val, else => unreachable, diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig index b1b968b2..203190f1 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig @@ -509,6 +509,11 @@ fn transmitAnimationFrame( result.message = "ENOENT: image not found"; return result; }; + storage.materializeBacking(io, alloc, img) catch |err| { + encodeError(&result, err); + return result; + }; + result.id = img.id; var loading = LoadingImage.init( @@ -773,6 +778,10 @@ fn controlAnimation( result.message = "ENOENT: image not found"; return result; }; + storage.materializeBacking(io, alloc, img) catch |err| { + encodeError(&result, err); + return result; + }; const anim = ensureAnimation(alloc, img) catch |err| { encodeError(&result, err); @@ -857,6 +866,11 @@ fn composeAnimation( result.message = "ENOENT: image not found"; return result; }; + storage.materializeBacking(io, alloc, img) catch |err| { + encodeError(&result, err); + return result; + }; + result.id = img.id; if (img.data.bytes() == null) { result.message = "EINVAL: image data incomplete"; @@ -1058,7 +1072,10 @@ fn loadAndAddImage( // loading.debugDump() catch unreachable; // Validate and store our image - var img = try loading.complete(alloc); + var img = if (storage.image_limits.preserve_png) + try loading.completePreservingPng(alloc) + else + try loading.complete(alloc); errdefer img.deinit(alloc); try storage.addImage(io, alloc, terminal.screens.active, img); @@ -3601,3 +3618,87 @@ test "kittygfx animation: control negative gap makes frame gapless" { const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; try testing.expectEqual(@as(u32, 0), anim.frames.items[0].gap_ms); } + +test "kittygfx experimental PNG retention quiet inheritance and transactional materialization" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + const sys = @import("../sys.zig"); + const Decoder = struct { + var calls: usize = 0; + var fail: bool = false; + fn decode(a: Allocator, _: []const u8) sys.DecodeError!sys.Image { + calls += 1; + if (fail) return error.InvalidData; + const pixels = try a.alloc(u8, 50 * 76 * 4); + @memset(pixels, 0); + return .{ .width = 50, .height = 76, .data = pixels }; + } + }; + const original = sys.decode_png; + defer sys.decode_png = original; + sys.decode_png = &Decoder.decode; + Decoder.calls = 0; + Decoder.fail = false; + const png = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data"); + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + var cmd: Command = .{ + .quiet = .failures, + .control = .{ .transmit = .{ .format = .png, .image_id = 1 } }, + .data = @constCast(png), + }; + // Default off is the normal decoder path even for q=2. + try testing.expect(!storage.image_limits.preserve_png); + _ = execute(io, alloc, &t, &cmd); + try testing.expectEqual(@as(usize, 1), Decoder.calls); + try testing.expect(storage.imageById(1).?.data == .complete); + storage.image_limits.preserve_png = true; + for ([_]Command.Quiet{ .no, .ok }) |quiet| { + cmd.quiet = quiet; + _ = execute(io, alloc, &t, &cmd); + try testing.expect(storage.imageById(1).?.data == .complete); + } + try testing.expectEqual(@as(usize, 3), Decoder.calls); + cmd.quiet = .failures; + cmd.control = .{ .query = .{ .format = .png, .image_id = 2 } }; + _ = execute(io, alloc, &t, &cmd); + try testing.expectEqual(@as(usize, 4), Decoder.calls); + try testing.expect(storage.imageById(2) == null); + cmd.control = .{ .transmit = .{ .format = .png, .image_id = 1, .more_chunks = true } }; + cmd.data = @constCast(png[0..33]); + _ = execute(io, alloc, &t, &cmd); + cmd.quiet = .no; + cmd.control = .{ .transmit = .{} }; + cmd.data = @constCast(png[33..]); + _ = execute(io, alloc, &t, &cmd); + try testing.expectEqual(@as(usize, 4), Decoder.calls); + const img = storage.imagePtrByIdOrNumber(1, 0).?; + try testing.expectEqualSlices(u8, png, img.data.encoded_png.bytes); + try testing.expect(img.data.bytes() == null); + try testing.expectEqual(png.len + 50 * 76 * 4, storage.total_bytes); + const generation = img.generation; + Decoder.fail = true; + const animate: Command = .{ .control = .{ .control_animation = .{ .image_id = 1 } } }; + _ = execute(io, alloc, &t, &animate); + try testing.expectEqual(generation, img.generation); + try testing.expect(img.data == .encoded_png); + try testing.expectEqual(png.len + 50 * 76 * 4, storage.total_bytes); + Decoder.fail = false; + _ = execute(io, alloc, &t, &animate); + try testing.expect(img.generation > generation); + try testing.expect(img.data == .complete); + try testing.expectEqual(@as(usize, 50 * 76 * 4), storage.total_bytes); + + // The reservation includes both the encoded and decoded representation. + storage.setLimit(io, alloc, t.screens.active, 50 * 76 * 4); + cmd.quiet = .failures; + cmd.control = .{ .transmit = .{ .format = .png, .image_id = 3 } }; + cmd.data = @constCast(png); + const calls = Decoder.calls; + _ = execute(io, alloc, &t, &cmd); + try testing.expectEqual(calls, Decoder.calls); + try testing.expect(storage.imageById(3) == null); + try testing.expect(storage.loading == null); +} 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); +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig index 897c8944..bd8cd424 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig @@ -154,7 +154,7 @@ pub const ImageStorage = struct { if (img.generation != self.generation) return false; const expected_len = switch (img.data) { - .complete => return false, + .complete, .encoded_png, .native_file => return false, .pending => |len| len, }; if (data.len != expected_len) return false; @@ -975,6 +975,15 @@ pub const ImageStorage = struct { img.generation = self.generation; } + pub fn materializeBacking(self: *ImageStorage, io: std.Io, alloc: Allocator, img: *Image) !void { + if (img.data != .encoded_png and img.data != .native_file) return; + const before = img.data.len(); + try img.materializeFile(alloc); + try img.materializePng(alloc); + self.total_bytes -= before - img.data.len(); + self.markImageContentChanged(io, img); + } + /// Convert a stored image's base data to RGBA in place, adjusting /// byte accounting. All animation composition happens in RGBA; /// this is called before the first composition into an image. diff --git a/vendor/patches/libghostty-vt/0007-experimental-png-retention.patch b/vendor/patches/libghostty-vt/0007-experimental-png-retention.patch new file mode 100644 index 00000000..e9964ab0 --- /dev/null +++ b/vendor/patches/libghostty-vt/0007-experimental-png-retention.patch @@ -0,0 +1,1055 @@ +diff --git a/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h b/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h +index 99cee60a..8dad8086 100644 +--- a/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h ++++ b/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h +@@ -391,12 +391,13 @@ typedef enum GHOSTTY_ENUM_TYPED { + /** + * Borrowed pointer to the raw pixel data. Valid as long as the + * underlying terminal is not mutated. Returns GHOSTTY_NO_VALUE when +- * the image metadata is resident but its pixel payload is pending. ++ * the image metadata is resident but its pixel payload is pending, or ++ * when the experimental PRESERVE_PNG option retained encoded PNG data. + * + * The data is always fully decoded, uncompressed pixels in the + * format reported by GHOSTTY_KITTY_IMAGE_DATA_FORMAT: zlib payloads +- * are inflated and PNG payloads are decoded to RGBA at transmission +- * time, before the image is stored. Consumers can upload this ++ * are inflated and PNG payloads are normally decoded to RGBA at transmission ++ * time, before the image is stored. Consumers can upload returned pixels + * directly to the GPU without any decode step. + * + * For an animated image (Kitty graphics animation, actions a=f/a=a) +@@ -412,7 +413,7 @@ typedef enum GHOSTTY_ENUM_TYPED { + /** + * Length of the raw pixel data in bytes. Always equal to + * width * height * bytes-per-pixel for the reported format. For a +- * pending image, this is the expected length reserved against the ++ * pending or encoded PNG image, this is the expected decoded length reserved against the + * storage limit even though DATA_PTR is not available yet. + * + * Output type: size_t * +@@ -439,6 +440,17 @@ typedef enum GHOSTTY_ENUM_TYPED { + */ + GHOSTTY_KITTY_IMAGE_DATA_GENERATION = 9, + ++ /** Owned encoded PNG bytes, borrowed until the next mutation. NO_VALUE ++ * unless experimentally retained. Output type: const uint8_t **. ++ */ ++ GHOSTTY_KITTY_IMAGE_DATA_ENCODED_PNG_PTR = 10, ++ /** Encoded PNG length; NO_VALUE unless retained. Output type: size_t *. */ ++ GHOSTTY_KITTY_IMAGE_DATA_ENCODED_PNG_LEN = 11, ++ /** Borrowed void* backing context; valid only while image is unchanged. */ ++ GHOSTTY_KITTY_IMAGE_DATA_FILE_CONTEXT = 12, ++ /** uint64_t immutable revision fingerprint; never reads image pixels. */ ++ GHOSTTY_KITTY_IMAGE_DATA_FILE_IDENTITY = 13, ++ + GHOSTTY_KITTY_IMAGE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, + } GhosttyKittyGraphicsImageData; + +diff --git a/vendor/libghostty-vt/include/ghostty/vt/terminal.h b/vendor/libghostty-vt/include/ghostty/vt/terminal.h +index 81f9ebd0..6404af53 100644 +--- a/vendor/libghostty-vt/include/ghostty/vt/terminal.h ++++ b/vendor/libghostty-vt/include/ghostty/vt/terminal.h +@@ -27,6 +27,39 @@ + extern "C" { + #endif + ++/** Borrowed opened descriptor; size is sizeof this request. No path is supplied. */ ++typedef struct { ++ size_t size; ++ int64_t fd; ++ uint64_t expected_len; ++} GhosttyKittyImageSnapshotFileRequest; ++ ++typedef bool (*GhosttyKittyImageFileReadFn)(void *context, uint8_t *dest, size_t len); ++typedef void (*GhosttyKittyImageFileReleaseFn)(void *context); ++ ++typedef struct { ++ void *context; ++ uint64_t identity; ++ size_t len; ++ GhosttyKittyImageFileReadFn read; ++ GhosttyKittyImageFileReleaseFn release; ++} GhosttyKittyImageFileBacking; ++ ++/** Optional synchronous callback, default NULL. Verify exact descriptor size ++ * and snapshot immutable bytes before returning true. True transfers one context ++ * reference; false transfers nothing. Do not close the borrowed descriptor or ++ * reenter the terminal. Both read and release MUST be non-NULL on success. ++ * Read must fill exactly len bytes; release is called once. An incomplete ++ * backing is rejected and ordinary file loading is used instead. If release ++ * is provided, rejection calls it once; without release, the host remains ++ * responsible for cleanup (the terminal cannot release that context). ++ * identity identifies the immutable revision, not a pathname or source inode. ++ */ ++typedef bool (*GhosttyKittyImageSnapshotFileFn)( ++ GhosttyTerminal terminal, void *userdata, ++ const GhosttyKittyImageSnapshotFileRequest *request, ++ GhosttyKittyImageFileBacking *out_backing); ++ + /** @defgroup terminal Terminal + * + * Complete terminal emulator state and rendering. +@@ -1545,6 +1578,14 @@ typedef enum GHOSTTY_ENUM_TYPED { + * Input type: size_t* + */ + GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE_MAX_BYTES = 39, ++ ++ /** Experimental, default-off PNG retention for quiet base transmissions. ++ * Input type: bool*. NULL is a no-op. Corrupt compressed pixels may only ++ * be detected when an animation operation materializes the image. ++ */ ++ GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_PRESERVE_PNG = 40, ++ /** Optional synchronous GhosttyKittyImageSnapshotFileFn; NULL disables. */ ++ GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_SNAPSHOT_FILE = 41, + GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, + } GhosttyTerminalOption; + +diff --git a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig +index 2bd5e862..c66b13be 100644 +--- a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig ++++ b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig +@@ -183,6 +183,10 @@ pub const ImageData = enum(c_int) { + data_ptr = 7, + data_len = 8, + generation = 9, ++ encoded_png_ptr = 10, ++ encoded_png_len = 11, ++ file_context = 12, ++ file_identity = 13, + + pub fn OutType(comptime self: ImageData) type { + return switch (self) { +@@ -190,9 +194,10 @@ pub const ImageData = enum(c_int) { + .id, .number, .width, .height => u32, + .format => ImageFormat, + .compression => ImageCompression, +- .data_ptr => [*]const u8, +- .data_len => usize, +- .generation => u64, ++ .data_ptr, .encoded_png_ptr => [*]const u8, ++ .data_len, .encoded_png_len => usize, ++ .generation, .file_identity => u64, ++ .file_context => ?*anyopaque, + }; + } + }; +@@ -266,7 +271,26 @@ fn imageGetTyped( + // data; the image generation changes whenever the current + // frame does, so generation-keyed caches stay coherent. + .data_ptr => out.* = (image.renderData().bytes() orelse return .no_value).ptr, +- .data_len => out.* = image.renderData().len(), ++ .data_len => out.* = switch (image.renderData()) { ++ .encoded_png => |png| png.decoded_len, ++ else => image.renderData().len(), ++ }, ++ .encoded_png_ptr => out.* = switch (image.data) { ++ .encoded_png => |png| png.bytes.ptr, ++ else => return .no_value, ++ }, ++ .encoded_png_len => out.* = switch (image.data) { ++ .encoded_png => |png| png.bytes.len, ++ else => return .no_value, ++ }, ++ .file_context => out.* = switch (image.data) { ++ .native_file => |file| file.context, ++ else => return .no_value, ++ }, ++ .file_identity => out.* = switch (image.data) { ++ .native_file => |file| file.identity, ++ else => return .no_value, ++ }, + .generation => out.* = image.generation, + } + +diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig +index 91bbe310..e825183e 100644 +--- a/vendor/libghostty-vt/src/terminal/c/terminal.zig ++++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig +@@ -13,6 +13,7 @@ const ScreenSet = @import("../ScreenSet.zig"); + const PageList = @import("../PageList.zig"); + const apc = @import("../apc.zig"); + const kitty = @import("../kitty/key.zig"); ++const kitty_image = @import("../kitty/graphics_image.zig"); + const kitty_gfx_c = @import("kitty_graphics.zig"); + const modes = @import("../modes.zig"); + const point = @import("../point.zig"); +@@ -257,6 +258,7 @@ const Effects = struct { + clipboard_write: ?ClipboardWriteFn = null, + clipboard_read: ?ClipboardReadFn = null, + unknown_sequence: ?UnknownSequenceFn = null, ++ snapshot_file: ?SnapshotFileFn = null, + + /// Scratch buffer for DA1 feature codes. The device attributes + /// trampoline converts C feature codes into this buffer and returns +@@ -265,6 +267,14 @@ const Effects = struct { + /// (`reportDeviceAttributes`) reads it before any re-entrant call. + da_features_buf: [64]device_attributes.Primary.Feature = undefined, + ++ pub const SnapshotFileFn = *const fn (Terminal, ?*anyopaque, *const kitty_image.SnapshotFileRequest, *kitty_image.FileBacking) callconv(lib.calling_conv) bool; ++ ++ fn snapshotFile(context: ?*anyopaque, request: *const kitty_image.SnapshotFileRequest, out: *kitty_image.FileBacking) bool { ++ const wrapper: *TerminalWrapper = @ptrCast(@alignCast(context.?)); ++ const callback = wrapper.effects.snapshot_file orelse return false; ++ return callback(@ptrCast(wrapper), wrapper.effects.userdata, request, out); ++ } ++ + /// C function pointer type for the write_pty callback. + pub const WritePtyFn = *const fn (Terminal, ?*anyopaque, [*]const u8, usize) callconv(lib.calling_conv) void; + +@@ -1174,12 +1184,15 @@ pub const Option = enum(c_int) { + terminfo_name = 37, + clipboard_read = 38, + clipboard_write_max_bytes = 39, ++ kitty_image_preserve_png = 40, ++ kitty_image_snapshot_file = 41, + + /// Input type expected for setting the option. + pub fn InType(comptime self: Option) type { + return switch (self) { + .userdata => ?*const anyopaque, + .write_pty => ?Effects.WritePtyFn, ++ .kitty_image_snapshot_file => ?Effects.SnapshotFileFn, + .bell => ?Effects.BellFn, + .color_scheme => ?Effects.ColorSchemeFn, + .desktop_notification => ?Effects.DesktopNotificationFn, +@@ -1197,6 +1210,7 @@ pub const Option = enum(c_int) { + .color_foreground, .color_background, .color_cursor => ?*const color.RGB.C, + .color_palette => ?*const color.PaletteC, + .kitty_image_storage_limit => ?*const u64, ++ .kitty_image_preserve_png, + .kitty_image_medium_file, + .kitty_image_medium_shared_mem, + .glyph_protocol, +@@ -1250,6 +1264,17 @@ fn setTyped( + switch (option) { + .userdata => wrapper.effects.userdata = @constCast(value), + .write_pty => wrapper.effects.write_pty = value, ++ .kitty_image_snapshot_file => { ++ if (comptime !build_options.kitty_graphics) return .success; ++ wrapper.effects.snapshot_file = value; ++ var it = wrapper.terminal.screens.all.iterator(); ++ while (it.next()) |entry| { ++ entry.value.*.kitty_images.image_limits.snapshot_file = if (value != null) ++ .{ .context = @ptrCast(wrapper), .callback = &Effects.snapshotFile } ++ else ++ null; ++ } ++ }, + .bell => wrapper.effects.bell = value, + .color_scheme => wrapper.effects.color_scheme = value, + .desktop_notification => wrapper.effects.desktop_notification = value, +@@ -1330,6 +1355,7 @@ fn setTyped( + screen.kitty_images.setLimit(screen.io, screen.alloc, screen, limit); + } + }, ++ .kitty_image_preserve_png, + .kitty_image_medium_file, + .kitty_image_medium_shared_mem, + => { +@@ -1339,6 +1365,7 @@ fn setTyped( + while (it.next()) |entry| { + const screen = entry.value.*; + switch (option) { ++ .kitty_image_preserve_png => screen.kitty_images.image_limits.preserve_png = val, + .kitty_image_medium_file => screen.kitty_images.image_limits.file = val, + .kitty_image_medium_shared_mem => screen.kitty_images.image_limits.shared_memory = val, + else => unreachable, +diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig +index b1b968b2..203190f1 100644 +--- a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig ++++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig +@@ -509,6 +509,11 @@ fn transmitAnimationFrame( + result.message = "ENOENT: image not found"; + return result; + }; ++ storage.materializeBacking(io, alloc, img) catch |err| { ++ encodeError(&result, err); ++ return result; ++ }; ++ + result.id = img.id; + + var loading = LoadingImage.init( +@@ -773,6 +778,10 @@ fn controlAnimation( + result.message = "ENOENT: image not found"; + return result; + }; ++ storage.materializeBacking(io, alloc, img) catch |err| { ++ encodeError(&result, err); ++ return result; ++ }; + + const anim = ensureAnimation(alloc, img) catch |err| { + encodeError(&result, err); +@@ -857,6 +866,11 @@ fn composeAnimation( + result.message = "ENOENT: image not found"; + return result; + }; ++ storage.materializeBacking(io, alloc, img) catch |err| { ++ encodeError(&result, err); ++ return result; ++ }; ++ + result.id = img.id; + if (img.data.bytes() == null) { + result.message = "EINVAL: image data incomplete"; +@@ -1058,7 +1072,10 @@ fn loadAndAddImage( + // loading.debugDump() catch unreachable; + + // Validate and store our image +- var img = try loading.complete(alloc); ++ var img = if (storage.image_limits.preserve_png) ++ try loading.completePreservingPng(alloc) ++ else ++ try loading.complete(alloc); + errdefer img.deinit(alloc); + try storage.addImage(io, alloc, terminal.screens.active, img); + +@@ -3601,3 +3618,87 @@ test "kittygfx animation: control negative gap makes frame gapless" { + const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; + try testing.expectEqual(@as(u32, 0), anim.frames.items[0].gap_ms); + } ++ ++test "kittygfx experimental PNG retention quiet inheritance and transactional materialization" { ++ const testing = std.testing; ++ const alloc = testing.allocator; ++ const io = testing.io; ++ const sys = @import("../sys.zig"); ++ const Decoder = struct { ++ var calls: usize = 0; ++ var fail: bool = false; ++ fn decode(a: Allocator, _: []const u8) sys.DecodeError!sys.Image { ++ calls += 1; ++ if (fail) return error.InvalidData; ++ const pixels = try a.alloc(u8, 50 * 76 * 4); ++ @memset(pixels, 0); ++ return .{ .width = 50, .height = 76, .data = pixels }; ++ } ++ }; ++ const original = sys.decode_png; ++ defer sys.decode_png = original; ++ sys.decode_png = &Decoder.decode; ++ Decoder.calls = 0; ++ Decoder.fail = false; ++ const png = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data"); ++ var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); ++ defer t.deinit(alloc); ++ const storage = &t.screens.active.kitty_images; ++ var cmd: Command = .{ ++ .quiet = .failures, ++ .control = .{ .transmit = .{ .format = .png, .image_id = 1 } }, ++ .data = @constCast(png), ++ }; ++ // Default off is the normal decoder path even for q=2. ++ try testing.expect(!storage.image_limits.preserve_png); ++ _ = execute(io, alloc, &t, &cmd); ++ try testing.expectEqual(@as(usize, 1), Decoder.calls); ++ try testing.expect(storage.imageById(1).?.data == .complete); ++ storage.image_limits.preserve_png = true; ++ for ([_]Command.Quiet{ .no, .ok }) |quiet| { ++ cmd.quiet = quiet; ++ _ = execute(io, alloc, &t, &cmd); ++ try testing.expect(storage.imageById(1).?.data == .complete); ++ } ++ try testing.expectEqual(@as(usize, 3), Decoder.calls); ++ cmd.quiet = .failures; ++ cmd.control = .{ .query = .{ .format = .png, .image_id = 2 } }; ++ _ = execute(io, alloc, &t, &cmd); ++ try testing.expectEqual(@as(usize, 4), Decoder.calls); ++ try testing.expect(storage.imageById(2) == null); ++ cmd.control = .{ .transmit = .{ .format = .png, .image_id = 1, .more_chunks = true } }; ++ cmd.data = @constCast(png[0..33]); ++ _ = execute(io, alloc, &t, &cmd); ++ cmd.quiet = .no; ++ cmd.control = .{ .transmit = .{} }; ++ cmd.data = @constCast(png[33..]); ++ _ = execute(io, alloc, &t, &cmd); ++ try testing.expectEqual(@as(usize, 4), Decoder.calls); ++ const img = storage.imagePtrByIdOrNumber(1, 0).?; ++ try testing.expectEqualSlices(u8, png, img.data.encoded_png.bytes); ++ try testing.expect(img.data.bytes() == null); ++ try testing.expectEqual(png.len + 50 * 76 * 4, storage.total_bytes); ++ const generation = img.generation; ++ Decoder.fail = true; ++ const animate: Command = .{ .control = .{ .control_animation = .{ .image_id = 1 } } }; ++ _ = execute(io, alloc, &t, &animate); ++ try testing.expectEqual(generation, img.generation); ++ try testing.expect(img.data == .encoded_png); ++ try testing.expectEqual(png.len + 50 * 76 * 4, storage.total_bytes); ++ Decoder.fail = false; ++ _ = execute(io, alloc, &t, &animate); ++ try testing.expect(img.generation > generation); ++ try testing.expect(img.data == .complete); ++ try testing.expectEqual(@as(usize, 50 * 76 * 4), storage.total_bytes); ++ ++ // The reservation includes both the encoded and decoded representation. ++ storage.setLimit(io, alloc, t.screens.active, 50 * 76 * 4); ++ cmd.quiet = .failures; ++ cmd.control = .{ .transmit = .{ .format = .png, .image_id = 3 } }; ++ cmd.data = @constCast(png); ++ const calls = Decoder.calls; ++ _ = execute(io, alloc, &t, &cmd); ++ try testing.expectEqual(calls, Decoder.calls); ++ try testing.expect(storage.imageById(3) == null); ++ try testing.expect(storage.loading == null); ++} +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); ++} +diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig +index 897c8944..bd8cd424 100644 +--- a/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig ++++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig +@@ -154,7 +154,7 @@ pub const ImageStorage = struct { + if (img.generation != self.generation) return false; + + const expected_len = switch (img.data) { +- .complete => return false, ++ .complete, .encoded_png, .native_file => return false, + .pending => |len| len, + }; + if (data.len != expected_len) return false; +@@ -975,6 +975,15 @@ pub const ImageStorage = struct { + img.generation = self.generation; + } + ++ pub fn materializeBacking(self: *ImageStorage, io: std.Io, alloc: Allocator, img: *Image) !void { ++ if (img.data != .encoded_png and img.data != .native_file) return; ++ const before = img.data.len(); ++ try img.materializeFile(alloc); ++ try img.materializePng(alloc); ++ self.total_bytes -= before - img.data.len(); ++ self.markImageContentChanged(io, img); ++ } ++ + /// Convert a stored image's base data to RGBA in place, adjusting + /// byte accounting. All animation composition happens in RGBA; + /// this is called before the first composition into an image. |
