// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! An installer database: the contents of an installer package as tables of //! rows plus summary information, with nothing yet decided about how they are //! laid out in a file. //! //! Linking produces one from a WiX document, and writing turns one into the //! bytes of an installer package. It knows nothing about WiX, so that a reader //! of installer packages can produce the same thing. const std = @import("std"); const Allocator = std.mem.Allocator; const Database = @This(); /// Where the tables, rows and copied strings are allocated. The database is /// meant to live in an arena and be freed all at once. gpa: Allocator, /// The code page the database's strings are stored in. 0 is the neutral code /// page, in which only ASCII can be stored. codepage: u16 = 0, tables: std.ArrayList(*Table) = .empty, summary: SummaryInformation = .{}, /// The contents of each file the File table names, by the file's key. They /// go into the cabinets the Media table names. file_contents: std.StringArrayHashMapUnmanaged([]const u8) = .empty, /// How the cabinet for each disk is compressed, by the Media table's DiskId. /// A disk missing from here has its files compressed with MSZIP. compression: std.AutoArrayHashMapUnmanaged(i32, Compression) = .empty, /// How a cabinet's files are stored: as they are, or compressed with MSZIP /// and some amount of effort. pub const Compression = enum { none, low, medium, high, mszip, }; pub fn init(gpa: Allocator) Database { return .{ .gpa = gpa }; } /// The table called `name`, if the database has one. pub fn table(db: *const Database, name: []const u8) ?*Table { for (db.tables.items) |t| { if (std.mem.eql(u8, t.name, name)) return t; } return null; } /// Adds an empty table with the given columns, or returns the existing one of /// that name. The columns are not copied, so they must outlive the database; /// a column list from `schema` always does. pub fn addTable(db: *Database, name: []const u8, columns: []const Column) Allocator.Error!*Table { if (db.table(name)) |t| return t; const t = try db.gpa.create(Table); t.* = .{ .name = name, .columns = columns }; try db.tables.append(db.gpa, t); return t; } pub const ColumnType = enum { /// A 16-bit integer. short, /// A 32-bit integer. long, /// A string, held in the database's string pool. string, /// A stream of bytes, held in a stream of its own. binary, }; pub const Column = struct { name: []const u8, type: ColumnType, /// For a string, the most characters it may hold, with 0 meaning no limit. /// For the others, unused. size: u16 = 0, nullable: bool = false, localizable: bool = false, key: bool = false, /// A column from Windows Installer's column definition format: a letter /// for the type (`s` string, `l` localizable string, `i` integer, `v` /// binary), upper case when the column may be null, then a size. `i2` and /// `i4` are the two integer widths; a string's size is its maximum length. pub fn parse(comptime name: []const u8, comptime definition: []const u8, comptime key: bool) Column { const letter = definition[0]; const size = std.fmt.parseInt(u16, definition[1..], 10) catch @compileError("bad column definition " ++ definition); const nullable = std.ascii.isUpper(letter); return switch (std.ascii.toLower(letter)) { 's' => .{ .name = name, .type = .string, .size = size, .nullable = nullable, .key = key }, 'l' => .{ .name = name, .type = .string, .size = size, .nullable = nullable, .localizable = true, .key = key }, 'i' => .{ .name = name, .type = switch (size) { 2 => .short, 4 => .long, else => @compileError("bad integer width in " ++ definition), }, .nullable = nullable, .key = key, }, 'v' => .{ .name = name, .type = .binary, .nullable = nullable, .key = key }, else => @compileError("bad column type in " ++ definition), }; } }; pub const Value = union(enum) { null, int: i32, string: []const u8, /// The contents of a binary cell's stream. stream: []const u8, pub fn eql(a: Value, b: Value) bool { return switch (a) { .null => b == .null, .int => |x| b == .int and b.int == x, .string => |x| b == .string and std.mem.eql(u8, b.string, x), .stream => |x| b == .stream and std.mem.eql(u8, b.stream, x), }; } }; pub const Table = struct { name: []const u8, columns: []const Column, rows: std.ArrayList([]Value) = .empty, pub fn columnIndex(t: *const Table, name: []const u8) ?usize { for (t.columns, 0..) |c, i| { if (std.mem.eql(u8, c.name, name)) return i; } return null; } /// Adds a row. `values` are given in column order and copied; strings /// are not, so they must outlive the database. pub fn insert(t: *Table, gpa: Allocator, values: []const Value) Allocator.Error!void { std.debug.assert(values.len == t.columns.len); try t.rows.append(gpa, try gpa.dupe(Value, values)); } /// The row whose leading key columns hold `key`, if there is one. pub fn find(t: *const Table, key: []const Value) ?[]Value { rows: for (t.rows.items) |row| { for (key, 0..) |k, i| { if (!k.eql(row[i])) continue :rows; } return row; } return null; } /// The name of the stream holding the contents of a binary cell in /// `row`: the table's name and the row's key values, joined by periods. pub fn streamName(t: *const Table, gpa: Allocator, row: []const Value) Allocator.Error![]u8 { var name: std.Io.Writer.Allocating = .init(gpa); errdefer name.deinit(); const w = &name.writer; w.writeAll(t.name) catch return error.OutOfMemory; for (row, t.columns) |v, c| { if (!c.key) break; switch (v) { .string => |s| w.print(".{s}", .{s}) catch return error.OutOfMemory, .int => |i| w.print(".{d}", .{i}) catch return error.OutOfMemory, else => {}, } } return name.toOwnedSlice(); } /// The value in column `column` of the row with key `key`, or null when /// there is no such row. A shorthand for tests and for single-key tables. pub fn get(t: *const Table, key: []const u8, column: []const u8) ?Value { const row = t.find(&.{.{ .string = key }}) orelse return null; return row[t.columnIndex(column) orelse return null]; } }; /// What Windows Installer reads from the summary information stream. A null /// field is left out of the stream. pub const SummaryInformation = struct { /// The code page the summary information's strings are stored in. This is /// separate from the database's code page. codepage: u16 = 1252, title: ?[]const u8 = null, subject: ?[]const u8 = null, author: ?[]const u8 = null, keywords: ?[]const u8 = null, comments: ?[]const u8 = null, /// The platform and languages, as `x64;1033`. template: ?[]const u8 = null, last_saved_by: ?[]const u8 = null, /// The package code. revision_number: ?[]const u8 = null, /// Seconds since the Unix epoch. create_time: ?i64 = null, /// Seconds since the Unix epoch. last_save_time: ?i64 = null, /// The minimum Windows Installer version, times 100. page_count: ?i32 = null, /// Source flags: 1 short names, 2 compressed, 4 administrative image, /// 8 no elevated privileges needed. word_count: ?i32 = null, character_count: ?i32 = null, creating_application: ?[]const u8 = null, security: ?i32 = null, }; test "column definitions" { const c: Column = comptime .parse("Value", "L255", false); try std.testing.expectEqual(ColumnType.string, c.type); try std.testing.expectEqual(@as(u16, 255), c.size); try std.testing.expect(c.nullable and c.localizable and !c.key); const i: Column = comptime .parse("Sequence", "i4", true); try std.testing.expectEqual(ColumnType.long, i.type); try std.testing.expect(!i.nullable and i.key); } test "find a row by key" { var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); var db: Database = .init(arena.allocator()); const t = try db.addTable("Property", &.{ comptime Column.parse("Property", "s72", true), comptime Column.parse("Value", "l0", false), }); try t.insert(db.gpa, &.{ .{ .string = "A" }, .{ .string = "1" } }); try t.insert(db.gpa, &.{ .{ .string = "B" }, .{ .string = "2" } }); try std.testing.expectEqualStrings("2", t.get("B", "Value").?.string); try std.testing.expect(t.get("C", "Value") == null); }