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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Writing: laying an installer database out as the bytes of an installer//! package.//!//! An installer package is a compound file. Each table with rows is a stream//! of its own, its cells stored column by column; every string in every//! table is an index into one string pool; `_Tables` and `_Columns` describe//! the tables; and the summary information is a property set. Microsoft never//! documented the layout between the compound file and the tables, and//! `docs/msi-layout.md` describes what this follows.
const std = @import("std");const Allocator = std.mem.Allocator;const Io = std.Io;
const Database = @import("Database.zig");const Column = Database.Column;const Value = Database.Value;const Diagnostics = @import("Diagnostics.zig");const cab = @import("write/cab.zig");const cfb = @import("write/cfb.zig");const names = @import("names.zig");const summary = @import("write/summary.zig");const codepage = @import("codepage.zig");
/// The root storage CLSID that marks a compound file as an installer/// package, {000C1084-0000-0000-C000-000000000046}, as it appears on disk.pub const installer_package_clsid = [16]u8{ 0x84, 0x10, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46 };
pub const Error = error{ /// The database cannot be written; why is in the diagnostics. Invalid,} || Allocator.Error || Io.Writer.Error;
/// The columns of the two tables that describe the others, which describe/// neither themselves nor each other.pub const tables_columns = [_]Column{.parse("Name", "s64", true)};pub const columns_columns = [_]Column{ .parse("Table", "s64", true), .parse("Number", "i2", true), .parse("Name", "s64", false), .parse("Type", "i2", false),};
/// Where a cabinet the Media table keeps outside the installer package --/// one whose Cabinet is not written `#name` -- is written, by its name.pub const CabinetWriter = struct { context: *anyopaque, writeFn: *const fn (context: *anyopaque, name: []const u8, bytes: []const u8) anyerror!void,
pub fn write(cw: CabinetWriter, name: []const u8, bytes: []const u8) anyerror!void { return cw.writeFn(cw.context, name, bytes); }};
/// Writes `db` as an installer package to `w`, for a package whose cabinets/// are all embedded. `gpa` holds what is built along the way, all of it/// freed before returning.pub fn write(gpa: Allocator, diags: *Diagnostics, db: *const Database, w: *Io.Writer) Error!void { return writeWithCabinets(gpa, diags, db, w, null);}
/// Writes `db` as an installer package to `w`, and each cabinet it keeps/// outside the package through `cabinets`.pub fn writeWithCabinets(gpa: Allocator, diags: *Diagnostics, db: *const Database, w: *Io.Writer, cabinets: ?CabinetWriter) Error!void { var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator();
// The rows of `_Tables` and `_Columns`, built like any other table's. var tables_table: Database.Table = .{ .name = "_Tables", .columns = &tables_columns }; var columns_table: Database.Table = .{ .name = "_Columns", .columns = &columns_columns }; for (db.tables.items) |t| { try tables_table.insert(arena, &.{.{ .string = t.name }}); for (t.columns, 1..) |c, n| { try columns_table.insert(arena, &.{ .{ .string = t.name }, .{ .int = @intCast(n) }, .{ .string = c.name }, .{ .int = columnType(c) }, }); } }
var pool: StringPool = .{ .codepage = db.codepage }; var all_tables: std.ArrayList(*const Database.Table) = .empty; try all_tables.append(arena, &tables_table); try all_tables.append(arena, &columns_table); for (db.tables.items) |t| try all_tables.append(arena, t);
// Every string cell is interned before any table is laid out, since // whether string references take two bytes or three depends on how many // strings there are. for (all_tables.items) |t| { for (t.rows.items) |row| { for (row, t.columns) |v, c| { if (c.type != .string) continue; switch (v) { .string => |s| _ = pool.intern(arena, s) catch |e| switch (e) { error.Unencodable => { try diags.err(null, "\"{s}\" in table {s} cannot be stored in code page {d}", .{ s, t.name, db.codepage }); }, else => |other| return other, }, .null => {}, else => { try diags.err(null, "table {s}, column {s}: not a string", .{ t.name, c.name }); }, } } } } if (diags.hasErrors()) return error.Invalid; const long_refs = pool.strings.items.len > 0xFFFF;
var streams: std.ArrayList(cfb.Stream) = .empty; try streams.append(arena, .{ .name = names.table(arena, "_StringPool") catch unreachable, .data = try pool.poolStream(arena, long_refs) }); try streams.append(arena, .{ .name = names.table(arena, "_StringData") catch unreachable, .data = try pool.dataStream(arena) }); for (all_tables.items) |t| { // A table with no rows has no stream at all. if (t.rows.items.len == 0) continue; try streams.append(arena, .{ .name = names.table(arena, t.name) catch { try diags.err(null, "the name of table {s} is too long", .{t.name}); return error.Invalid; }, .data = try tableStream(arena, t, &pool, long_refs), }); // A binary cell's contents live in a stream named for its table and // the row's key. for (t.rows.items) |row| { for (row, t.columns) |v, c| { if (c.type != .binary or v != .stream) continue; const name = try t.streamName(arena, row); try streams.append(arena, .{ .name = names.stream(arena, name) catch { try diags.err(null, "the stream name {s} is too long", .{name}); return error.Invalid; }, .data = v.stream, }); } } } try buildCabinets(arena, diags, db, &streams, cabinets); try streams.append(arena, .{ .name = &summary.stream_name, .data = summary.encode(arena, db.summary) catch |e| switch (e) { error.Unencodable => { try diags.err(null, "the summary information cannot be stored in code page {d}", .{db.summary.codepage}); return error.Invalid; }, else => |other| return other, }, });
cfb.write(arena, w, installer_package_clsid, streams.items) catch |e| switch (e) { error.InvalidName => { try diags.err(null, "two streams have the same name", .{}); return error.Invalid; }, error.StreamTooLarge => { try diags.err(null, "a stream is larger than an installer package can hold", .{}); return error.Invalid; }, else => |other| return other, };}
/// Builds each cabinet the Media table names, holding the files whose/// sequence numbers fall on its disk: a stream of the package for one/// written `#name`, and given to `external` for any other.fn buildCabinets( arena: Allocator, diags: *Diagnostics, db: *const Database, streams: *std.ArrayList(cfb.Stream), external: ?CabinetWriter,) Error!void { const media = db.table("Media") orelse return; const files = db.table("File") orelse return; const key_col = files.columnIndex("File").?; const seq_col = files.columnIndex("Sequence").?;
// Media rows in disk order, so that each one's files are those numbered // after the previous one's last. const rows = try arena.dupe([]Value, media.rows.items); std.mem.sort([]Value, rows, {}, struct { fn lessThan(_: void, a: []Value, b: []Value) bool { return a[0].int < b[0].int; } }.lessThan);
var first: i32 = 1; for (rows) |row| { const disk = row[0].int; const last = row[1].int; defer first = last + 1; const cabinet = switch (row[3]) { .string => |c| c, else => continue, }; if (cabinet.len == 0) continue; const embedded = cabinet[0] == '#'; const name = if (embedded) cabinet[1..] else cabinet; if (!embedded and external == null) { try diags.err(null, "cabinet {s} goes beside the installer package, and nothing was given to write it", .{name}); return error.Invalid; }
const flate = std.compress.flate.Compress.Options; const options: cab.Options = switch (db.compression.get(disk) orelse .mszip) { .none => .{ .compression = .none }, .low => .{ .compression = .mszip, .level = flate.fastest }, .medium, .mszip => .{ .compression = .mszip, .level = flate.default }, .high => .{ .compression = .mszip, .level = flate.best }, };
var on_disk: std.ArrayList(struct { i32, cab.File }) = .empty; for (files.rows.items) |f| { const sequence = f[seq_col].int; if (sequence < first or sequence > last) continue; const key = f[key_col].string; const data = db.file_contents.get(key) orelse { try diags.err(null, "file {s} has no contents to put in cabinet {s}", .{ key, name }); return error.Invalid; }; try on_disk.append(arena, .{ sequence, .{ .name = key, .data = data } }); } std.mem.sort(struct { i32, cab.File }, on_disk.items, {}, struct { fn lessThan(_: void, a: struct { i32, cab.File }, b: struct { i32, cab.File }) bool { return a[0] < b[0]; } }.lessThan); const cab_files = try arena.alloc(cab.File, on_disk.items.len); for (on_disk.items, cab_files) |f, *c| c.* = f[1];
const bytes = cab.build(arena, cab_files, db.summary.create_time orelse 0, options) catch |e| switch (e) { error.TooLarge => { try diags.err(null, "cabinet {s} holds more than one cabinet can", .{name}); return error.Invalid; }, error.OutOfMemory => return error.OutOfMemory, }; if (!embedded) { external.?.write(name, bytes) catch |e| { try diags.err(null, "cabinet {s} cannot be written: {s}", .{ name, @errorName(e) }); return error.Invalid; }; continue; } try streams.append(arena, .{ .name = names.stream(arena, name) catch { try diags.err(null, "the cabinet name {s} is too long for a stream name", .{name}); return error.Invalid; }, .data = bytes, }); }}
/// The `_Columns` type of a column: its size in the low byte, then flags for/// localizable (0x200), string (0x800), nullable (0x1000) and primary key/// (0x2000). 0x100 is set on every persistent column, and 0x400 on 16-bit/// integers and strings; what they mean was never documented.pub fn columnType(c: Column) i32 { var t: i32 = switch (c.type) { .short => 0x0502, .long => 0x0104, .string => 0x0D00 | @as(i32, c.size & 0xFF), .binary => 0x0900, }; if (c.localizable) t |= 0x0200; if (c.nullable) t |= 0x1000; if (c.key) t |= 0x2000; return t;}
const StringPool = struct { codepage: u16, /// Index n is `strings.items[n - 1]`, as stored; 0 means null. strings: std.ArrayList([]const u8) = .empty, refcounts: std.ArrayList(u16) = .empty, indices: std.StringHashMapUnmanaged(u32) = .empty,
const InternError = error{Unencodable} || Allocator.Error;
/// Counts one more reference to `s`, adding it if it is new, and returns /// its index. The empty string is null and is not stored. fn intern(p: *StringPool, gpa: Allocator, s: []const u8) InternError!u32 { if (s.len == 0) return 0; if (p.indices.get(s)) |i| { // A count that has reached the top of its 16 bits stays there. p.refcounts.items[i - 1] +|= 1; return i; } // Kept as it is stored, in the database's code page; looked up by // the text it is. const encoded = codepage.encode(gpa, p.codepage, s) catch |e| switch (e) { error.Unencodable => return error.Unencodable, error.OutOfMemory => return error.OutOfMemory, }; try p.strings.append(gpa, encoded); try p.refcounts.append(gpa, 1); const i: u32 = @intCast(p.strings.items.len); try p.indices.put(gpa, s, i); return i; }
/// The index of `s`, which must already have been interned. fn index(p: *const StringPool, s: []const u8) u32 { if (s.len == 0) return 0; return p.indices.get(s).?; }
fn poolStream(p: *const StringPool, gpa: Allocator, long_refs: bool) Allocator.Error![]u8 { var out: std.Io.Writer.Allocating = .init(gpa); const w = &out.writer; const header: u32 = @as(u32, p.codepage) | if (long_refs) @as(u32, 0x8000_0000) else 0; w.writeInt(u32, header, .little) catch return error.OutOfMemory; for (p.strings.items, p.refcounts.items) |s, rc| { if (s.len > 0xFFFF) { // A long string: a zero length, then its count, then the // real length. w.writeInt(u16, 0, .little) catch return error.OutOfMemory; w.writeInt(u16, rc, .little) catch return error.OutOfMemory; w.writeInt(u32, @intCast(s.len), .little) catch return error.OutOfMemory; } else { w.writeInt(u16, @intCast(s.len), .little) catch return error.OutOfMemory; w.writeInt(u16, rc, .little) catch return error.OutOfMemory; } } return out.toOwnedSlice(); }
fn dataStream(p: *const StringPool, gpa: Allocator) Allocator.Error![]u8 { var out: std.ArrayList(u8) = .empty; for (p.strings.items) |s| try out.appendSlice(gpa, s); return out.toOwnedSlice(gpa); }};
/// A cell as stored: a biased integer, a string index, or the marker a/// binary cell holds.fn stored(v: Value, c: Column, pool: *const StringPool) u32 { return switch (c.type) { .short => switch (v) { .int => |i| @as(u16, @bitCast(@as(i16, @intCast(i)))) ^ 0x8000, else => 0, }, .long => switch (v) { .int => |i| @as(u32, @bitCast(i)) ^ 0x8000_0000, else => 0, }, .string => switch (v) { .string => |s| pool.index(s), else => 0, }, // What a binary cell holds was never documented; rust-msi writes 1, // "found from inspecting MSIs". .binary => switch (v) { .stream => 1, else => 0, }, };}
fn width(c: Column, long_refs: bool) usize { return switch (c.type) { .short, .binary => 2, .long => 4, .string => if (long_refs) 3 else 2, };}
fn tableStream(gpa: Allocator, t: *const Database.Table, pool: *const StringPool, long_refs: bool) Allocator.Error![]u8 { const rows = t.rows.items.len; const cols = t.columns.len; const cells = try gpa.alloc(u32, rows * cols); for (t.rows.items, 0..) |row, r| { for (row, t.columns, 0..) |v, c, k| cells[r * cols + k] = stored(v, c, pool); }
// Rows are kept sorted by their stored cells, column by column, skipping // binary columns: Windows Installer looks rows up by key, and refuses a // table that is out of order. const order = try gpa.alloc(usize, rows); for (order, 0..) |*o, i| o.* = i; const Context = struct { cells: []const u32, columns: []const Column, fn lessThan(ctx: @This(), a: usize, b: usize) bool { const n = ctx.columns.len; for (ctx.columns, 0..) |c, k| { if (c.type == .binary) continue; const x = ctx.cells[a * n + k]; const y = ctx.cells[b * n + k]; if (x != y) return x < y; } return false; } }; std.mem.sort(usize, order, Context{ .cells = cells, .columns = t.columns }, Context.lessThan);
var out: std.ArrayList(u8) = .empty; for (t.columns, 0..) |c, k| { const n = width(c, long_refs); for (order) |r| { var bytes: [4]u8 = undefined; std.mem.writeInt(u32, &bytes, cells[r * cols + k], .little); try out.appendSlice(gpa, bytes[0..n]); } } return out.toOwnedSlice(gpa);}
test "column types" { try std.testing.expectEqual(@as(i32, 0x2D48), columnType(comptime .parse("A", "s72", true))); try std.testing.expectEqual(@as(i32, 0x1DFF), columnType(comptime .parse("A", "S255", false))); try std.testing.expectEqual(@as(i32, 0x0F00), columnType(comptime .parse("A", "l0", false))); try std.testing.expectEqual(@as(i32, 0x1F40), columnType(comptime .parse("A", "L64", false))); try std.testing.expectEqual(@as(i32, 0x0502), columnType(comptime .parse("A", "i2", false))); try std.testing.expectEqual(@as(i32, 0x2502), columnType(comptime .parse("A", "i2", true))); try std.testing.expectEqual(@as(i32, 0x1104), columnType(comptime .parse("A", "I4", false))); try std.testing.expectEqual(@as(i32, 0x0900), columnType(comptime .parse("A", "v0", false)));}
test "the string pool" { var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); var pool: StringPool = .{ .codepage = 1252 }; try std.testing.expectEqual(@as(u32, 1), try pool.intern(a, "Foo")); try std.testing.expectEqual(@as(u32, 2), try pool.intern(a, "Quux")); try std.testing.expectEqual(@as(u32, 1), try pool.intern(a, "Foo")); try std.testing.expectEqual(@as(u32, 0), try pool.intern(a, "")); try std.testing.expectEqualSlices(u8, &.{ 0xE4, 0x04, 0x00, 0x00, 3, 0, 2, 0, 4, 0, 1, 0 }, try pool.poolStream(a, false)); try std.testing.expectEqualStrings("FooQuux", try pool.dataStream(a));}