| 1 | //! https://devblogs.microsoft.com/oldnewthing/20120720-00/?p=7083 |
| 2 | //! https://learn.microsoft.com/en-us/previous-versions/ms997538(v=msdn.10) |
| 3 | //! https://learn.microsoft.com/en-us/windows/win32/menurc/newheader |
| 4 | //! https://learn.microsoft.com/en-us/windows/win32/menurc/resdir |
| 5 | //! https://learn.microsoft.com/en-us/windows/win32/menurc/localheader |
| 6 | |
| 7 | const std = @import("std"); |
| 8 | const builtin = @import("builtin"); |
| 9 | const native_endian = builtin.cpu.arch.endian(); |
| 10 | |
| 11 | pub const ReadError = std.mem.Allocator.Error || error{ InvalidHeader, InvalidImageType, ImpossibleDataSize, UnexpectedEOF, ReadFailed }; |
| 12 | |
| 13 | pub fn read(allocator: std.mem.Allocator, reader: *std.Io.Reader, max_size: u64) ReadError!IconDir { |
| 14 | return readInner(allocator, reader, max_size) catch |err| switch (err) { |
| 15 | error.OutOfMemory, |
| 16 | error.InvalidHeader, |
| 17 | error.InvalidImageType, |
| 18 | error.ImpossibleDataSize, |
| 19 | error.ReadFailed, |
| 20 | => |e| return e, |
| 21 | error.EndOfStream => error.UnexpectedEOF, |
| 22 | }; |
| 23 | } |
| 24 | |
| 25 | // TODO: This seems like a somewhat strange pattern, could be a better way |
| 26 | // to do this. Maybe it makes more sense to handle the translation |
| 27 | // at the call site instead of having a helper function here. |
| 28 | fn readInner(allocator: std.mem.Allocator, reader: *std.Io.Reader, max_size: u64) !IconDir { |
| 29 | const reserved = try reader.takeInt(u16, .little); |
| 30 | if (reserved != 0) { |
| 31 | return error.InvalidHeader; |
| 32 | } |
| 33 | |
| 34 | const image_type = reader.takeEnum(ImageType, .little) catch |err| switch (err) { |
| 35 | error.InvalidEnumTag => return error.InvalidImageType, |
| 36 | else => |e| return e, |
| 37 | }; |
| 38 | |
| 39 | const num_images = try reader.takeInt(u16, .little); |
| 40 | |
| 41 | // To avoid over-allocation in the case of a file that says it has way more |
| 42 | // entries than it actually does, we use an ArrayList with a conservatively |
| 43 | // limited initial capacity instead of allocating the entire slice at once. |
| 44 | const initial_capacity = @min(num_images, 8); |
| 45 | var entries = try std.ArrayList(Entry).initCapacity(allocator, initial_capacity); |
| 46 | errdefer entries.deinit(allocator); |
| 47 | |
| 48 | var i: usize = 0; |
| 49 | while (i < num_images) : (i += 1) { |
| 50 | var entry: Entry = undefined; |
| 51 | entry.width = try reader.takeByte(); |
| 52 | entry.height = try reader.takeByte(); |
| 53 | entry.num_colors = try reader.takeByte(); |
| 54 | entry.reserved = try reader.takeByte(); |
| 55 | switch (image_type) { |
| 56 | .icon => { |
| 57 | entry.type_specific_data = .{ .icon = .{ |
| 58 | .color_planes = try reader.takeInt(u16, .little), |
| 59 | .bits_per_pixel = try reader.takeInt(u16, .little), |
| 60 | } }; |
| 61 | }, |
| 62 | .cursor => { |
| 63 | entry.type_specific_data = .{ .cursor = .{ |
| 64 | .hotspot_x = try reader.takeInt(u16, .little), |
| 65 | .hotspot_y = try reader.takeInt(u16, .little), |
| 66 | } }; |
| 67 | }, |
| 68 | } |
| 69 | entry.data_size_in_bytes = try reader.takeInt(u32, .little); |
| 70 | entry.data_offset_from_start_of_file = try reader.takeInt(u32, .little); |
| 71 | // Validate that the offset/data size is feasible |
| 72 | if (@as(u64, entry.data_offset_from_start_of_file) + entry.data_size_in_bytes > max_size) { |
| 73 | return error.ImpossibleDataSize; |
| 74 | } |
| 75 | // and that the data size is large enough for at least the header of an image |
| 76 | // Note: This avoids needing to deal with a miscompilation from the Win32 RC |
| 77 | // compiler when the data size of an image is specified as zero but there |
| 78 | // is data to-be-read at the offset. The Win32 RC compiler will output |
| 79 | // an ICON/CURSOR resource with a bogus size in its header but with no actual |
| 80 | // data bytes in it, leading to an invalid .res. Similarly, if, for example, |
| 81 | // there is valid PNG data at the image's offset, but the size is specified |
| 82 | // as fewer bytes than the PNG header, then the Win32 RC compiler will still |
| 83 | // treat it as a PNG (e.g. unconditionally set num_planes to 1) but the data |
| 84 | // of the resource will only be 1 byte so treating it as a PNG doesn't make |
| 85 | // sense (especially not when you have to read past the data size to determine |
| 86 | // that it's a PNG). |
| 87 | if (entry.data_size_in_bytes < 16) { |
| 88 | return error.ImpossibleDataSize; |
| 89 | } |
| 90 | try entries.append(allocator, entry); |
| 91 | } |
| 92 | |
| 93 | return .{ |
| 94 | .image_type = image_type, |
| 95 | .entries = try entries.toOwnedSlice(allocator), |
| 96 | .allocator = allocator, |
| 97 | }; |
| 98 | } |
| 99 | |
| 100 | pub const ImageType = enum(u16) { |
| 101 | icon = 1, |
| 102 | cursor = 2, |
| 103 | }; |
| 104 | |
| 105 | pub const IconDir = struct { |
| 106 | image_type: ImageType, |
| 107 | /// Note: entries.len will always fit into a u16, since the field containing the |
| 108 | /// number of images in an ico file is a u16. |
| 109 | entries: []Entry, |
| 110 | allocator: std.mem.Allocator, |
| 111 | |
| 112 | pub fn deinit(self: IconDir) void { |
| 113 | self.allocator.free(self.entries); |
| 114 | } |
| 115 | |
| 116 | pub const res_header_byte_len = 6; |
| 117 | |
| 118 | pub fn getResDataSize(self: IconDir) u32 { |
| 119 | // maxInt(u16) * Entry.res_byte_len = 917,490 which is well within the u32 range. |
| 120 | // Note: self.entries.len is limited to maxInt(u16) |
| 121 | return @intCast(IconDir.res_header_byte_len + self.entries.len * Entry.res_byte_len); |
| 122 | } |
| 123 | |
| 124 | pub fn writeResData(self: IconDir, writer: *std.Io.Writer, first_image_id: u16) !void { |
| 125 | try writer.writeInt(u16, 0, .little); |
| 126 | try writer.writeInt(u16, @backingInt(self.image_type), .little); |
| 127 | // We know that entries.len must fit into a u16 |
| 128 | try writer.writeInt(u16, @as(u16, @intCast(self.entries.len)), .little); |
| 129 | |
| 130 | var image_id = first_image_id; |
| 131 | for (self.entries) |entry| { |
| 132 | try entry.writeResData(writer, image_id); |
| 133 | image_id += 1; |
| 134 | } |
| 135 | } |
| 136 | }; |
| 137 | |
| 138 | pub const Entry = struct { |
| 139 | // Icons are limited to u8 sizes, cursors can have u16, |
| 140 | // so we store as u16 and truncate when needed. |
| 141 | width: u16, |
| 142 | height: u16, |
| 143 | num_colors: u8, |
| 144 | /// This should always be zero, but whatever value it is gets |
| 145 | /// carried over so we need to store it |
| 146 | reserved: u8, |
| 147 | type_specific_data: union(ImageType) { |
| 148 | icon: struct { |
| 149 | color_planes: u16, |
| 150 | bits_per_pixel: u16, |
| 151 | }, |
| 152 | cursor: struct { |
| 153 | hotspot_x: u16, |
| 154 | hotspot_y: u16, |
| 155 | }, |
| 156 | }, |
| 157 | data_size_in_bytes: u32, |
| 158 | data_offset_from_start_of_file: u32, |
| 159 | |
| 160 | pub const res_byte_len = 14; |
| 161 | |
| 162 | pub fn writeResData(self: Entry, writer: *std.Io.Writer, id: u16) !void { |
| 163 | switch (self.type_specific_data) { |
| 164 | .icon => |icon_data| { |
| 165 | try writer.writeInt(u8, @as(u8, @truncate(self.width)), .little); |
| 166 | try writer.writeInt(u8, @as(u8, @truncate(self.height)), .little); |
| 167 | try writer.writeInt(u8, self.num_colors, .little); |
| 168 | try writer.writeInt(u8, self.reserved, .little); |
| 169 | try writer.writeInt(u16, icon_data.color_planes, .little); |
| 170 | try writer.writeInt(u16, icon_data.bits_per_pixel, .little); |
| 171 | try writer.writeInt(u32, self.data_size_in_bytes, .little); |
| 172 | }, |
| 173 | .cursor => |cursor_data| { |
| 174 | try writer.writeInt(u16, self.width, .little); |
| 175 | try writer.writeInt(u16, self.height, .little); |
| 176 | try writer.writeInt(u16, cursor_data.hotspot_x, .little); |
| 177 | try writer.writeInt(u16, cursor_data.hotspot_y, .little); |
| 178 | try writer.writeInt(u32, self.data_size_in_bytes + 4, .little); |
| 179 | }, |
| 180 | } |
| 181 | try writer.writeInt(u16, id, .little); |
| 182 | } |
| 183 | }; |
| 184 | |
| 185 | test "icon" { |
| 186 | const data = "\x00\x00\x01\x00\x01\x00\x10\x10\x00\x00\x01\x00\x10\x00\x10\x00\x00\x00\x16\x00\x00\x00" ++ @as([16]u8, @splat(0)); |
| 187 | var fbs: std.Io.Reader = .fixed(data); |
| 188 | const icon = try read(std.testing.allocator, &fbs, data.len); |
| 189 | defer icon.deinit(); |
| 190 | |
| 191 | try std.testing.expectEqual(ImageType.icon, icon.image_type); |
| 192 | try std.testing.expectEqual(@as(usize, 1), icon.entries.len); |
| 193 | } |
| 194 | |
| 195 | test "icon too many images" { |
| 196 | // Note that with verifying that all data sizes are within the file bounds and >= 16, |
| 197 | // it's not possible to hit EOF when looking for more RESDIR structures, since they are |
| 198 | // themselves 16 bytes long, so we'll always hit ImpossibleDataSize instead. |
| 199 | const data = "\x00\x00\x01\x00\x02\x00\x10\x10\x00\x00\x01\x00\x10\x00\x10\x00\x00\x00\x16\x00\x00\x00" ++ @as([16]u8, @splat(0)); |
| 200 | var fbs: std.Io.Reader = .fixed(data); |
| 201 | try std.testing.expectError(error.ImpossibleDataSize, read(std.testing.allocator, &fbs, data.len)); |
| 202 | } |
| 203 | |
| 204 | test "icon data size past EOF" { |
| 205 | const data = "\x00\x00\x01\x00\x01\x00\x10\x10\x00\x00\x01\x00\x10\x00\x10\x01\x00\x00\x16\x00\x00\x00" ++ @as([16]u8, @splat(0)); |
| 206 | var fbs: std.Io.Reader = .fixed(data); |
| 207 | try std.testing.expectError(error.ImpossibleDataSize, read(std.testing.allocator, &fbs, data.len)); |
| 208 | } |
| 209 | |
| 210 | test "icon data offset past EOF" { |
| 211 | const data = "\x00\x00\x01\x00\x01\x00\x10\x10\x00\x00\x01\x00\x10\x00\x10\x00\x00\x00\x17\x00\x00\x00" ++ @as([16]u8, @splat(0)); |
| 212 | var fbs: std.Io.Reader = .fixed(data); |
| 213 | try std.testing.expectError(error.ImpossibleDataSize, read(std.testing.allocator, &fbs, data.len)); |
| 214 | } |
| 215 | |
| 216 | test "icon data size too small" { |
| 217 | const data = "\x00\x00\x01\x00\x01\x00\x10\x10\x00\x00\x01\x00\x10\x00\x0F\x00\x00\x00\x16\x00\x00\x00"; |
| 218 | var fbs: std.Io.Reader = .fixed(data); |
| 219 | try std.testing.expectError(error.ImpossibleDataSize, read(std.testing.allocator, &fbs, data.len)); |
| 220 | } |
| 221 | |
| 222 | pub const ImageFormat = enum(u2) { |
| 223 | dib, |
| 224 | png, |
| 225 | riff, |
| 226 | |
| 227 | const riff_header = std.mem.readInt(u32, "RIFF", native_endian); |
| 228 | const png_signature = std.mem.readInt(u64, "\x89PNG\r\n\x1a\n", native_endian); |
| 229 | const ihdr_code = std.mem.readInt(u32, "IHDR", native_endian); |
| 230 | const acon_form_type = std.mem.readInt(u32, "ACON", native_endian); |
| 231 | |
| 232 | pub fn detect(header_bytes: *const [16]u8) ImageFormat { |
| 233 | if (std.mem.readInt(u32, header_bytes[0..4], native_endian) == riff_header) return .riff; |
| 234 | if (std.mem.readInt(u64, header_bytes[0..8], native_endian) == png_signature) return .png; |
| 235 | return .dib; |
| 236 | } |
| 237 | |
| 238 | pub fn validate(format: ImageFormat, header_bytes: *const [16]u8) bool { |
| 239 | return switch (format) { |
| 240 | .png => std.mem.readInt(u32, header_bytes[12..16], native_endian) == ihdr_code, |
| 241 | .riff => std.mem.readInt(u32, header_bytes[8..12], native_endian) == acon_form_type, |
| 242 | .dib => true, |
| 243 | }; |
| 244 | } |
| 245 | }; |
| 246 | |
| 247 | /// Contains only the fields of BITMAPINFOHEADER (WinGDI.h) that are both: |
| 248 | /// - relevant to what we need, and |
| 249 | /// - are shared between all versions of BITMAPINFOHEADER (V4, V5). |
| 250 | pub const BitmapHeader = extern struct { |
| 251 | bcSize: u32, |
| 252 | bcWidth: i32, |
| 253 | bcHeight: i32, |
| 254 | bcPlanes: u16, |
| 255 | bcBitCount: u16, |
| 256 | |
| 257 | pub fn version(self: *const BitmapHeader) Version { |
| 258 | return Version.get(self.bcSize); |
| 259 | } |
| 260 | |
| 261 | /// https://en.wikipedia.org/wiki/BMP_file_format#DIB_header_(bitmap_information_header) |
| 262 | pub const Version = enum(u3) { |
| 263 | unknown, |
| 264 | @"win2.0", // Windows 2.0 or later |
| 265 | @"nt3.1", // Windows NT, 3.1x or later |
| 266 | @"nt4.0", // Windows NT 4.0, 95 or later |
| 267 | @"nt5.0", // Windows NT 5.0, 98 or later |
| 268 | |
| 269 | pub fn get(header_size: u32) Version { |
| 270 | return switch (header_size) { |
| 271 | len(.@"win2.0") => .@"win2.0", |
| 272 | len(.@"nt3.1") => .@"nt3.1", |
| 273 | len(.@"nt4.0") => .@"nt4.0", |
| 274 | len(.@"nt5.0") => .@"nt5.0", |
| 275 | else => .unknown, |
| 276 | }; |
| 277 | } |
| 278 | |
| 279 | pub fn len(comptime v: Version) comptime_int { |
| 280 | return switch (v) { |
| 281 | .@"win2.0" => 12, |
| 282 | .@"nt3.1" => 40, |
| 283 | .@"nt4.0" => 108, |
| 284 | .@"nt5.0" => 124, |
| 285 | .unknown => unreachable, |
| 286 | }; |
| 287 | } |
| 288 | |
| 289 | pub fn nameForErrorDisplay(v: Version) []const u8 { |
| 290 | return switch (v) { |
| 291 | .unknown => "unknown", |
| 292 | .@"win2.0" => "Windows 2.0 (BITMAPCOREHEADER)", |
| 293 | .@"nt3.1" => "Windows NT, 3.1x (BITMAPINFOHEADER)", |
| 294 | .@"nt4.0" => "Windows NT 4.0, 95 (BITMAPV4HEADER)", |
| 295 | .@"nt5.0" => "Windows NT 5.0, 98 (BITMAPV5HEADER)", |
| 296 | }; |
| 297 | } |
| 298 | }; |
| 299 | }; |