// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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)); }