Something went wrong. Try again.
Builds Windows Installer packages from WiX sources on Linux or Windows, in pure Zig; a clean-room reimplementation of msitools' wixl.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Reading: the bytes of an installer package back into an installer//! database, the reverse of writing.//!//! The package is a compound file; `_Tables` and `_Columns` say which tables//! it has and what their columns are; each table's cells are stored column//! by column, every string as an index into one string pool; and the//! summary information is a property set. `docs/msi-layout.md` describes//! the layout. Any package is read, not only those zixl writes, and bytes//! that are not one are refused with a diagnostic saying why.
const std = @import("std");const Allocator = std.mem.Allocator;
const Database = @import("Database.zig");const Column = Database.Column;const Value = Database.Value;const Diagnostics = @import("Diagnostics.zig");const cfb = @import("read/cfb.zig");const summary = @import("read/summary.zig");const names = @import("names.zig");const codepage = @import("codepage.zig");const written = @import("write.zig");
pub const Stream = struct { /// The stream's name, unpacked, as UTF-8. The summary information's is /// `\x05SummaryInformation`, with its leading control character. name: []const u8, data: []const u8,};
/// An installer package, read.pub const Package = struct { /// Its tables and summary information. A binary cell holds its stream's /// contents. database: Database, /// Every stream that does not hold a table or the string pool: the /// summary information, the streams behind binary cells, embedded /// cabinets and anything else, in the order the compound file sorts /// them. streams: []const Stream, /// The names of the storages inside the package, such as embedded /// transforms. What is in them is not read. storages: []const []const u8,
/// The stream called `name`, if the package has one. pub fn stream(p: *const Package, name: []const u8) ?[]const u8 { for (p.streams) |s| { if (std.mem.eql(u8, s.name, name)) return s.data; } return null; }};
pub const Error = error{ /// The bytes are not an installer package that can be read; why is in /// the diagnostics. Invalid,} || Allocator.Error;
/// The CLSIDs, as they appear on disk, that mark a compound file as a/// patch and as a transform.const patch_clsid = [16]u8{ 0x86, 0x10, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46 };const transform_clsid = [16]u8{ 0x82, 0x10, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46 };
/// Reads the installer package `bytes`, read from `path`, which only names/// it in diagnostics. Everything returned is allocated in `arena`, and may/// point into `bytes`.pub fn read(arena: Allocator, diags: *Diagnostics, path: []const u8, bytes: []const u8) Error!Package { var r: Reader = .{ .arena = arena, .diags = diags, .path = path }; return r.package(bytes) catch |e| switch (e) { error.Invalid => if (diags.hasErrors()) error.Invalid else r.fail("cannot be read", .{}), error.OutOfMemory => error.OutOfMemory, };}
const Reader = struct { arena: Allocator, diags: *Diagnostics, path: []const u8, /// The string pool: index n is `strings[n]`, and 0 is null. strings: []const ?[]const u8 = &.{}, long_refs: bool = false,
fn fail(r: *Reader, comptime fmt: []const u8, args: anytype) Error { r.diags.err(null, "{s}: " ++ fmt, .{r.path} ++ args) catch return error.OutOfMemory; return error.Invalid; }
fn warn(r: *Reader, comptime fmt: []const u8, args: anytype) Allocator.Error!void { try r.diags.warn(null, "{s}: " ++ fmt, .{r.path} ++ args); }
fn package(r: *Reader, bytes: []const u8) Error!Package { const file = cfb.read(r.arena, bytes) catch |e| switch (e) { error.NotCompoundFile => return r.fail("not an installer package: not a compound file", .{}), error.Corrupt => return r.fail("the compound file is damaged", .{}), error.OutOfMemory => return error.OutOfMemory, }; if (std.mem.eql(u8, &file.clsid, &transform_clsid)) return r.fail("a transform, which zixl does not read", .{}); if (!std.mem.eql(u8, &file.clsid, &written.installer_package_clsid) and !std.mem.eql(u8, &file.clsid, &patch_clsid)) return r.fail("not an installer package: its root storage has the wrong class", .{});
// Table streams by name, and everything else in order. var tables: std.StringHashMapUnmanaged([]const u8) = .empty; var streams: std.ArrayList(Stream) = .empty; var storages: std.ArrayList([]const u8) = .empty; for (file.entries) |e| { const decoded = try names.decode(r.arena, e.name); switch (e.kind) { .storage => try storages.append(r.arena, decoded.name), .stream => if (decoded.is_table) try tables.put(r.arena, decoded.name, e.data) else try streams.append(r.arena, .{ .name = decoded.name, .data = e.data }), } }
var db: Database = .init(r.arena); db.codepage = try r.stringPool(tables.get("_StringPool"), tables.get("_StringData"));
// `_Tables` names the tables, and `_Columns` describes them. const table_names = try r.rows("_Tables", &written.tables_columns, tables.get("_Tables") orelse ""); const column_rows = try r.rows("_Columns", &written.columns_columns, tables.get("_Columns") orelse ""); for (table_names) |row| { const name = switch (row[0]) { .string => |s| s, else => return r.fail("_Tables names a table with no name", .{}), }; if (db.table(name) != null) return r.fail("_Tables names table {s} twice", .{name}); const cols = try r.columns(name, column_rows); const t = try db.addTable(name, cols); for (try r.rows(name, cols, tables.get(name) orelse "")) |cells| { try r.binaryCells(t, cells, streams.items); try t.rows.append(r.arena, cells); } }
for (streams.items) |s| { if (!std.mem.eql(u8, s.name, "\x05SummaryInformation")) continue; db.summary = summary.decode(r.arena, s.data) catch |e| switch (e) { error.Corrupt => return r.fail("the summary information is damaged", .{}), error.OutOfMemory => return error.OutOfMemory, }; break; } else return r.fail("has no summary information", .{});
return .{ .database = db, .streams = streams.items, .storages = storages.items }; }
/// Reads the string pool, returning the database's code page. fn stringPool(r: *Reader, pool_stream: ?[]const u8, data_stream: ?[]const u8) Error!u16 { const pool = pool_stream orelse return r.fail("has no string pool", .{}); const data = data_stream orelse return r.fail("has no string data", .{}); if (pool.len < 4) return r.fail("the string pool is damaged", .{}); const header = int(u32, pool, 0); r.long_refs = header & 0x8000_0000 != 0; const cp_id = header & 0x7FFF_FFFF; if (cp_id > 0xFFFF) return r.fail("the string pool's code page, {d}, is not one", .{cp_id}); const cp: u16 = @intCast(cp_id);
var strings: std.ArrayList(?[]const u8) = .empty; try strings.append(r.arena, null); var at: usize = 4; var offset: usize = 0; var known = true; while (at + 4 <= pool.len) { var len: usize = int(u16, pool, at); const refcount = int(u16, pool, at + 2); at += 4; // A string of 64 KiB or more: a zero length and a count, then // the real length. if (len == 0 and refcount != 0) { if (at + 4 > pool.len) return r.fail("the string pool is damaged", .{}); len = int(u32, pool, at); at += 4; } if (len > data.len - offset) return r.fail("the string pool holds more than its data", .{}); var this_known = true; // A slot with no references is free, whatever it once held. try strings.append(r.arena, if (refcount == 0 and len == 0) null else try codepage.decode(r.arena, cp, data[offset..][0..len], &this_known)); known = known and this_known; offset += len; } if (!known) try r.warn("strings in code page {d}, which zixl cannot decode, have their non-ASCII characters replaced", .{cp}); r.strings = strings.items; return cp; }
/// The columns `_Columns` gives table `name`, in order. fn columns(r: *Reader, name: []const u8, column_rows: []const []Value) Error![]const Column { var list: std.ArrayList(Column) = .empty; for (column_rows) |row| { if (row[0] != .string or !std.mem.eql(u8, row[0].string, name)) continue; if (row[1] != .int or row[2] != .string or row[3] != .int) return r.fail("_Columns describes table {s} with a null", .{name}); const n = row[1].int; if (n < 1 or n > 32) return r.fail("table {s} has a column numbered {d}", .{ name, n }); const index: usize = @intCast(n - 1); if (index >= list.items.len) try list.appendNTimes(r.arena, .{ .name = "", .type = .short }, index + 1 - list.items.len); if (list.items[index].name.len != 0) return r.fail("table {s} has two columns numbered {d}", .{ name, n }); list.items[index] = columnOfType(row[2].string, row[3].int); } if (list.items.len == 0) return r.fail("table {s} has no columns", .{name}); for (list.items, 1..) |c, n| { if (c.name.len == 0) return r.fail("table {s} has no column numbered {d}", .{ name, n }); } return list.items; }
/// The rows of the table stream `bytes`, whose columns are `cols`, with /// each binary cell left holding a placeholder until its stream is found. fn rows(r: *Reader, name: []const u8, cols: []const Column, bytes: []const u8) Error![]const []Value { var row_width: usize = 0; for (cols) |c| row_width += r.width(c); if (bytes.len % row_width != 0) return r.fail("table {s} is damaged: its {d} bytes are not whole rows", .{ name, bytes.len }); const count = bytes.len / row_width; const out = try r.arena.alloc([]Value, count); for (out) |*row| row.* = try r.arena.alloc(Value, cols.len); var at: usize = 0; for (cols, 0..) |c, k| { const w = r.width(c); for (out) |row| { const cell: u32 = switch (w) { 2 => int(u16, bytes, at), 3 => @as(u32, int(u16, bytes, at)) | @as(u32, bytes[at + 2]) << 16, 4 => int(u32, bytes, at), else => unreachable, }; at += w; row[k] = if (cell == 0) .null else switch (c.type) { .short => .{ .int = @as(i16, @bitCast(@as(u16, @intCast(cell)) ^ 0x8000)) }, .long => .{ .int = @bitCast(cell ^ 0x8000_0000) }, .string => if (cell < r.strings.len) if (r.strings[cell]) |s| .{ .string = s } else .null else return r.fail("table {s}, column {s}: string {d} is not in the string pool", .{ name, c.name, cell }), .binary => .{ .stream = "" }, }; } } return out; }
/// Fills each binary cell of `row` with the contents of its stream. fn binaryCells(r: *Reader, t: *const Database.Table, row: []Value, streams: []const Stream) Error!void { for (row, t.columns) |*v, c| { if (c.type != .binary or v.* == .null) continue; const name = try t.streamName(r.arena, row); v.* = for (streams) |s| { if (std.mem.eql(u8, s.name, name)) break .{ .stream = s.data }; } else blk: { try r.warn("table {s}: the stream {s} a binary cell names is missing", .{ t.name, name }); break :blk .null; }; } }
fn width(r: *const Reader, c: Column) usize { return switch (c.type) { .short, .binary => 2, .long => 4, .string => if (r.long_refs) 3 else 2, }; }};
/// A column from its name and its `_Columns.Type`: the low byte a size,/// then flags for a localizable, string, nullable and key column. A string/// column without the flag that marks it as text is binary.fn columnOfType(name: []const u8, t: i32) Column { const bits: u32 = @bitCast(t); const size: u16 = @intCast(bits & 0xFF); const string = bits & 0x0800 != 0; const text = bits & 0x0400 != 0; return .{ .name = name, .type = if (string) (if (text) .string else .binary) else if (size == 4) .long else .short, .size = if (string and text) size else 0, .nullable = bits & 0x1000 != 0, .localizable = bits & 0x0200 != 0, .key = bits & 0x2000 != 0, };}
fn int(comptime T: type, bytes: []const u8, offset: usize) T { return std.mem.readInt(T, bytes[offset..][0..@sizeOf(T)], .little);}
test "column types read back as they are written" { for (comptime [_]Column{ .parse("A", "s72", true), .parse("A", "S255", false), .parse("A", "l0", false), .parse("A", "L64", false), .parse("A", "i2", false), .parse("A", "I2", true), .parse("A", "i4", false), .parse("A", "I4", false), .parse("A", "v0", false), .parse("A", "V0", false), }) |c| { try std.testing.expectEqualDeep(c, columnOfType("A", written.columnType(c))); }}