// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! The version of a Windows program or library: what the File table's //! Version and Language columns record, from the VS_VERSIONINFO resource //! of a file in the Portable Executable format. //! //! Windows Installer compares a file it is installing with one already on //! disk by these, and a file with a version always wins over one without: //! a package that leaves them blank leaves an older program in place. //! //! Written from Microsoft's "PE Format" specification and its documentation //! of VS_VERSIONINFO, VS_FIXEDFILEINFO, VarFileInfo and Var. Anything that //! is not a well-formed version resource in a well-formed PE file is no //! version at all, rather than an error: the file is installed as data. const std = @import("std"); pub const FileVersion = struct { /// From VS_FIXEDFILEINFO's dwFileVersionMS and dwFileVersionLS. version: [4]u16, /// The languages the resource is translated into, as the File table's /// Language column lists them: decimal language IDs, separated by /// commas. From VarFileInfo's Translation, or, without one, the /// language the resource itself is filed under. language_buffer: [max_language]u8 = undefined, language_len: usize = 0, /// The File table's Language column is an S20. pub const max_language = 20; pub fn language(v: *const FileVersion) []const u8 { return v.language_buffer[0..v.language_len]; } pub fn format(v: FileVersion, w: *std.Io.Writer) std.Io.Writer.Error!void { try w.print("{d}.{d}.{d}.{d}", .{ v.version[0], v.version[1], v.version[2], v.version[3] }); } /// Adds a language to the list, unless it is there already or would /// not fit. fn addLanguage(v: *FileVersion, id: u16) void { var digits: [5]u8 = undefined; const text = std.fmt.bufPrint(&digits, "{d}", .{id}) catch unreachable; var it = std.mem.splitScalar(u8, v.language(), ','); if (v.language_len != 0) while (it.next()) |have| { if (std.mem.eql(u8, have, text)) return; }; const comma: usize = @intFromBool(v.language_len != 0); if (v.language_len + comma + text.len > max_language) return; if (comma != 0) v.language_buffer[v.language_len] = ','; @memcpy(v.language_buffer[v.language_len + comma ..][0..text.len], text); v.language_len += comma + text.len; } }; /// The file's version, or null if it has none: if it is not a PE file, has /// no version resource, or the resource has no VS_FIXEDFILEINFO. pub fn fileVersion(bytes: []const u8) ?FileVersion { const image = Image.parse(bytes) orelse return null; const resource = image.versionResource() orelse return null; return parseVersionInfo(resource.data, resource.language); } fn read(comptime T: type, bytes: []const u8, offset: usize) ?T { const end = std.math.add(usize, offset, @sizeOf(T)) catch return null; if (end > bytes.len) return null; return std.mem.readInt(T, bytes[offset..][0..@sizeOf(T)], .little); } fn align4(n: usize) usize { return std.mem.alignForward(usize, n, 4); } /// The parts of a PE file that lead to its resources. const Image = struct { bytes: []const u8, /// Where the section table begins, and how many sections it holds. sections: usize, section_count: u16, /// The resource table's address, as an RVA. resource_rva: u32, resource_size: u32, /// The resource table's index among the optional header's data /// directories. const resource_directory = 2; const section_header_size = 40; fn parse(bytes: []const u8) ?Image { if (bytes.len < 0x40 or !std.mem.eql(u8, bytes[0..2], "MZ")) return null; const pe = read(u32, bytes, 0x3C) orelse return null; if ((read(u32, bytes, pe) orelse return null) != 0x00004550) return null; // "PE\0\0" const coff = @as(usize, pe) + 4; const section_count = read(u16, bytes, coff + 2) orelse return null; const optional_size = read(u16, bytes, coff + 16) orelse return null; const optional = coff + 20; // PE32 and PE32+ differ in where the data directories begin. const directories: usize = switch (read(u16, bytes, optional) orelse return null) { 0x10B => 96, 0x20B => 112, else => return null, }; const count = read(u32, bytes, optional + directories - 4) orelse return null; if (count <= resource_directory) return null; const entry = directories + resource_directory * 8; if (entry + 8 > optional_size) return null; return .{ .bytes = bytes, .sections = optional + optional_size, .section_count = section_count, .resource_rva = read(u32, bytes, optional + entry) orelse return null, .resource_size = read(u32, bytes, optional + entry + 4) orelse return null, }; } /// The bytes at an RVA, as far as the section holding it has them in /// the file. fn at(image: Image, rva: u32, size: u32) ?[]const u8 { for (0..image.section_count) |i| { const header = image.sections + i * section_header_size; const address = read(u32, image.bytes, header + 12) orelse return null; const raw_size = read(u32, image.bytes, header + 16) orelse return null; const raw = read(u32, image.bytes, header + 20) orelse return null; if (rva < address or rva - address >= raw_size) continue; const offset = rva - address; if (@as(u64, offset) + size > raw_size) return null; const start = @as(u64, raw) + offset; if (start + size > image.bytes.len) return null; return image.bytes[@intCast(start)..][0..size]; } return null; } const Resource = struct { data: []const u8, language: u16 }; /// The first version resource: the resource directory's first entry of /// type RT_VERSION, the first name under it, and the first language /// under that. fn versionResource(image: Image) ?Resource { if (image.resource_size == 0) return null; const table = image.at(image.resource_rva, image.resource_size) orelse return null; const rt_version = 16; const names = subdirectory(table, 0, rt_version) orelse return null; const languages = subdirectory(table, names, null) orelse return null; const leaf = entryOf(table, languages, null) orelse return null; // A leaf, not another directory. if (leaf.offset & 0x8000_0000 != 0) return null; const data_rva = read(u32, table, leaf.offset) orelse return null; const data_size = read(u32, table, leaf.offset + 4) orelse return null; return .{ .data = image.at(data_rva, data_size) orelse return null, // A language filed under a name rather than an ID is none. .language = if (leaf.id & 0x8000_0000 != 0) 0 else @truncate(leaf.id), }; } const Entry = struct { id: u32, offset: u32 }; /// The entry of the directory at `offset` with the integer ID `id`, or /// its first entry of any kind when `id` is null. fn entryOf(table: []const u8, offset: u32, id: ?u32) ?Entry { const named = read(u16, table, offset + 12) orelse return null; const numbered = read(u16, table, offset + 14) orelse return null; for (0..@as(usize, named) + numbered) |i| { const e = @as(usize, offset) + 16 + i * 8; const entry: Entry = .{ .id = read(u32, table, e) orelse return null, .offset = read(u32, table, e + 4) orelse return null, }; if (id) |want| { if (entry.id & 0x8000_0000 != 0 or entry.id != want) continue; } return entry; } return null; } /// The offset of the subdirectory an entry leads to. fn subdirectory(table: []const u8, offset: u32, id: ?u32) ?u32 { const entry = entryOf(table, offset, id) orelse return null; if (entry.offset & 0x8000_0000 == 0) return null; const sub = entry.offset & 0x7FFF_FFFF; // Each level is deeper in the table than the one before, so a // malformed table cannot send this round in a circle. if (sub <= offset) return null; return sub; } }; /// A block of a version resource: VS_VERSIONINFO, or one of the /// StringFileInfo, VarFileInfo and Var structures under it, each a length, /// a value's length, a type, a key, then the value and the blocks it holds. const Block = struct { bytes: []const u8, value_length: u16, /// Where the key ends, and the value begins. value: usize, fn parse(bytes: []const u8) ?Block { const length = read(u16, bytes, 0) orelse return null; if (length < 6 or length > bytes.len) return null; const block = bytes[0..length]; var end: usize = 6; while (true) : (end += 2) { if ((read(u16, block, end) orelse return null) == 0) break; } return .{ .bytes = block, .value_length = read(u16, block, 2).?, .value = align4(end + 2), }; } /// Whether the key, which is UTF-16, is this ASCII text. fn keyIs(b: Block, key: []const u8) bool { if (b.value - 6 < key.len * 2 + 2) return false; for (key, 0..) |c, i| { if (read(u16, b.bytes, 6 + i * 2) != c) return false; } return read(u16, b.bytes, 6 + key.len * 2) == 0; } /// The blocks this one holds, beginning after a value of `value_size` /// bytes. fn children(b: Block, value_size: usize) Children { return .{ .bytes = b.bytes, .at = align4(b.value + value_size) }; } const Children = struct { bytes: []const u8, at: usize, fn next(c: *Children) ?Block { if (c.at >= c.bytes.len) return null; const child = Block.parse(c.bytes[c.at..]) orelse return null; c.at = align4(c.at + child.bytes.len); return child; } }; }; fn parseVersionInfo(data: []const u8, resource_language: u16) ?FileVersion { const root = Block.parse(data) orelse return null; if (!root.keyIs("VS_VERSION_INFO")) return null; // VS_FIXEDFILEINFO: a signature, the structure's version, then the // file's version, most significant half first. const fixed_size = 52; if (root.value_length < fixed_size) return null; if (read(u32, root.bytes, root.value) != 0xFEEF04BD) return null; const ms = read(u32, root.bytes, root.value + 8) orelse return null; const ls = read(u32, root.bytes, root.value + 12) orelse return null; var result: FileVersion = .{ .version = .{ @truncate(ms >> 16), @truncate(ms), @truncate(ls >> 16), @truncate(ls), } }; var blocks = root.children(root.value_length); while (blocks.next()) |block| { if (!block.keyIs("VarFileInfo")) continue; var vars = block.children(0); while (vars.next()) |v| { if (!v.keyIs("Translation")) continue; // Pairs of a language ID and a code page, a word each. var i: usize = 0; while (i + 4 <= v.value_length) : (i += 4) { const language = read(u16, v.bytes, v.value + i) orelse break; result.addLanguage(language); } } } if (result.language_len == 0) result.addLanguage(resource_language); return result; } test "not a PE file has no version" { try std.testing.expect(fileVersion("not really a program\n") == null); try std.testing.expect(fileVersion("") == null); try std.testing.expect(fileVersion("MZ" ++ @as([0x3E]u8, @splat(0))) == null); } test "languages are listed once each, as long as they fit" { var v: FileVersion = .{ .version = @splat(0) }; v.addLanguage(1033); v.addLanguage(1031); v.addLanguage(1033); try std.testing.expectEqualStrings("1033,1031", v.language()); v.addLanguage(1036); v.addLanguage(3082); // "1033,1031,1036,3082" is 19; another would make 24. v.addLanguage(2052); try std.testing.expectEqualStrings("1033,1031,1036,3082", v.language()); }