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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! An installer database as text: what `zixl-info` and `zixl-dump` print.//!//! Tables are written as text archive (`.idt`) files, in the format//! Microsoft documents for `MsiDatabaseExport`, and can also be written as//! SQL statements, as msitools' `msiinfo export -s` writes them.//! `docs/adr/0006-text-archives-follow-microsofts-format.md` says where//! that format and msitools' part company. Times are in UTC, since zixl has//! no local time zone to put them in.
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 codepage = @import("codepage.zig");const written = @import("write.zig");
pub const Error = Allocator.Error || Io.Writer.Error;
/// Lines end as they do on Windows.const eol = "\r\n";
/// The column definition of `c`, as the second row of a text archive/// writes it: `s72`, `L0`, `i2`, `v0`.pub fn definition(c: Column) Definition { return .{ .column = c };}
pub const Definition = struct { column: Column,
pub fn format(d: Definition, w: *Io.Writer) Io.Writer.Error!void { const c = d.column; const letter: u8 = switch (c.type) { .string => if (c.localizable) 'l' else 's', .short, .long => 'i', .binary => 'v', }; try w.writeByte(if (c.nullable) std.ascii.toUpper(letter) else letter); switch (c.type) { .string => try w.print("{d}", .{c.size}), .short => try w.writeByte('2'), .long => try w.writeByte('4'), .binary => try w.writeByte('0'), } }};
/// The name of the file a binary cell's contents are exported to, in a/// folder named for its table: the row's key values joined by periods,/// then `.ibd`.pub fn binaryFileName(gpa: Allocator, t: *const Database.Table, row: []const Value) Allocator.Error![]u8 { const stream = try t.streamName(gpa, row); defer gpa.free(stream); return std.fmt.allocPrint(gpa, "{s}.ibd", .{stream[t.name.len + 1 ..]});}
/// Writes `value` with the control characters a text archive translates,/// so that none can be taken for a delimiter.fn writeTranslated(w: *Io.Writer, value: []const u8) Io.Writer.Error!void { for (value) |c| try w.writeByte(switch (c) { 0 => 21, 8 => 27, '\t' => 16, '\n' => 25, 12 => 24, '\r' => 17, else => c, });}
fn isAscii(s: []const u8) bool { for (s) |c| if (c >= 0x80) return false; return true;}
/// Writes table `t` of a database in code page `cp` as a text archive. A/// table holding non-ASCII text is written in that code page, which is/// named first on the third row; when some text cannot be stored in it, it/// is written in UTF-8 instead, and named 65001.pub fn writeTable(gpa: Allocator, w: *Io.Writer, cp: u16, t: *const Database.Table) Error!void { var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator();
// Whether any text is non-ASCII, and whether it all fits the code page. var ascii = true; var fits = true; for (t.rows.items) |row| for (row) |v| if (v == .string and !isAscii(v.string)) { ascii = false; _ = codepage.encode(arena, cp, v.string) catch |e| switch (e) { error.Unencodable => fits = false, error.OutOfMemory => return error.OutOfMemory, }; }; const out_cp: u16 = if (fits) cp else codepage.utf8;
for (t.columns, 0..) |c, i| try w.print("{s}{s}", .{ if (i == 0) "" else "\t", c.name }); try w.writeAll(eol); for (t.columns, 0..) |c, i| try w.print("{s}{f}", .{ if (i == 0) "" else "\t", definition(c) }); try w.writeAll(eol); if (!ascii) try w.print("{d}\t", .{out_cp}); try w.writeAll(t.name); for (t.columns) |c| if (c.key) try w.print("\t{s}", .{c.name}); try w.writeAll(eol);
for (t.rows.items) |row| { for (row, t.columns, 0..) |v, c, i| { if (i > 0) try w.writeByte('\t'); switch (v) { .null => {}, .int => |n| try w.print("{d}", .{n}), .string => |text| try writeTranslated(w, if (out_cp == codepage.utf8) text else codepage.encode(arena, out_cp, text) catch unreachable), .stream => { _ = c; try w.writeAll(try binaryFileName(arena, t, row)); }, } } try w.writeAll(eol); }}
/// Writes `_ForceCodepage.idt`: two blank lines, then the code page.pub fn writeForceCodepage(w: *Io.Writer, cp: u16) Io.Writer.Error!void { try w.print(eol ++ eol ++ "{d}\t_ForceCodepage" ++ eol, .{cp});}
/// Writes `_SummaryInformation.idt`: each property present, by its ID, with/// times as `YYYY/MM/DD hh:mm:ss`.pub fn writeSummary(gpa: Allocator, w: *Io.Writer, s: Database.SummaryInformation) Error!void { var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator();
const strings = [_]struct { u8, ?[]const u8 }{ .{ 2, s.title }, .{ 3, s.subject }, .{ 4, s.author }, .{ 5, s.keywords }, .{ 6, s.comments }, .{ 7, s.template }, .{ 8, s.last_saved_by }, .{ 9, s.revision_number }, .{ 18, s.creating_application }, }; var ascii = true; var fits = true; for (strings) |f| if (f[1]) |v| if (!isAscii(v)) { ascii = false; _ = codepage.encode(arena, s.codepage, v) catch |e| switch (e) { error.Unencodable => fits = false, error.OutOfMemory => return error.OutOfMemory, }; }; const out_cp: u16 = if (fits) s.codepage else codepage.utf8;
try w.writeAll("PropertyId\tValue" ++ eol ++ "i2\tl255" ++ eol); if (!ascii) try w.print("{d}\t", .{out_cp}); try w.writeAll("_SummaryInformation\tPropertyId" ++ eol); try w.print("1\t{d}" ++ eol, .{s.codepage}); const Line = struct { u8, union(enum) { text: []const u8, time: i64, int: i32 } }; var lines: std.ArrayList(Line) = .empty; for (strings) |f| if (f[1]) |v| try lines.append(arena, .{ f[0], .{ .text = v } }); if (s.create_time) |t| try lines.append(arena, .{ 12, .{ .time = t } }); if (s.last_save_time) |t| try lines.append(arena, .{ 13, .{ .time = t } }); if (s.page_count) |v| try lines.append(arena, .{ 14, .{ .int = v } }); if (s.word_count) |v| try lines.append(arena, .{ 15, .{ .int = v } }); if (s.character_count) |v| try lines.append(arena, .{ 16, .{ .int = v } }); if (s.security) |v| try lines.append(arena, .{ 19, .{ .int = v } }); std.mem.sort(Line, lines.items, {}, struct { fn lessThan(_: void, a: Line, b: Line) bool { return a[0] < b[0]; } }.lessThan); for (lines.items) |l| { try w.print("{d}\t", .{l[0]}); switch (l[1]) { .text => |text| try writeTranslated(w, if (out_cp == codepage.utf8) text else codepage.encode(arena, out_cp, text) catch unreachable), .time => |t| { const c: Civil = .fromSeconds(t); try w.print("{d:0>4}/{d:0>2}/{d:0>2} {d:0>2}:{d:0>2}:{d:0>2}", .{ @as(u64, @intCast(@max(c.year, 0))), c.month, c.day, c.hour, c.minute, c.second }); }, .int => |n| try w.print("{d}", .{n}), } try w.writeAll(eol); }}
/// The tables that describe the others, `_Tables` and `_Columns`, built as/// `write` stores them.pub fn systemTable(arena: Allocator, db: *const Database, name: []const u8) Allocator.Error!?*Database.Table { const t = try arena.create(Database.Table); if (std.mem.eql(u8, name, "_Tables")) { t.* = .{ .name = "_Tables", .columns = &written.tables_columns }; for (db.tables.items) |each| try t.insert(arena, &.{.{ .string = each.name }}); } else if (std.mem.eql(u8, name, "_Columns")) { t.* = .{ .name = "_Columns", .columns = &written.columns_columns }; for (db.tables.items) |each| for (each.columns, 1..) |c, n| try t.insert(arena, &.{ .{ .string = each.name }, .{ .int = @intCast(n) }, .{ .string = c.name }, .{ .int = written.columnType(c) }, }); } else return null; return t;}
/// Writes table `t` as SQL: a `CREATE TABLE` statement, then an `INSERT`/// for each row, naming only the columns that are not null. A binary cell/// is written as an empty string, since SQL cannot insert one. Text is/// UTF-8, with quotes, backslashes and line breaks escaped by a backslash.pub fn writeSql(w: *Io.Writer, t: *const Database.Table) Io.Writer.Error!void { try w.print("CREATE TABLE `{s}` (", .{t.name}); for (t.columns, 0..) |c, i| { if (i > 0) try w.writeAll(", "); try w.print("`{s}` ", .{c.name}); switch (c.type) { .string => try w.print("CHAR({d})", .{c.size}), .short => try w.writeAll("INT"), .long => try w.writeAll("LONG"), .binary => try w.writeAll("OBJECT"), } if (!c.nullable) try w.writeAll(" NOT NULL"); if (c.localizable) try w.writeAll(" LOCALIZABLE"); } try w.writeAll(" PRIMARY KEY "); var first = true; for (t.columns) |c| if (c.key) { try w.print("{s}`{s}`", .{ if (first) "" else ", ", c.name }); first = false; }; try w.writeAll(")\n");
for (t.rows.items) |row| { try w.print("INSERT INTO `{s}` (", .{t.name}); first = true; for (row, t.columns) |v, c| if (present(v)) { try w.print("{s}`{s}`", .{ if (first) "" else ", ", c.name }); first = false; }; try w.writeAll(") VALUES ("); first = true; for (row) |v| if (present(v)) { if (!first) try w.writeAll(", "); first = false; switch (v) { .int => |n| try w.print("{d}", .{n}), .string => |text| { try w.writeByte('\''); for (text) |ch| switch (ch) { '\'' => try w.writeAll("\\'"), '\\' => try w.writeAll("\\\\"), '\n' => try w.writeAll("\\n"), '\r' => try w.writeAll("\\r"), else => try w.writeByte(ch), }; try w.writeByte('\''); }, .stream => try w.writeAll("''"), .null => unreachable, } }; try w.writeAll(")\n"); }}
fn present(v: Value) bool { return switch (v) { .null => false, .string => |s| s.len > 0, else => true, };}
/// A date and time in the proleptic Gregorian calendar, in UTC.pub const Civil = struct { year: i64, /// 1 to 12. month: u8, /// 1 to 31. day: u8, hour: u8, minute: u8, second: u8, /// 0 for Sunday to 6 for Saturday. weekday: u8,
/// The time `seconds` after the Unix epoch. Howard Hinnant's /// civil_from_days, which is exact across the whole range. pub fn fromSeconds(seconds: i64) Civil { const days = @divFloor(seconds, 86400); const rest: u32 = @intCast(@mod(seconds, 86400)); const z = @as(i128, days) + 719468; const era = @divFloor(z, 146097); const doe: u32 = @intCast(z - era * 146097); const yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; const doy = doe - (365 * yoe + yoe / 4 - yoe / 100); const mp = (5 * doy + 2) / 153; const d: u8 = @intCast(doy - (153 * mp + 2) / 5 + 1); const m: u8 = @intCast(if (mp < 10) mp + 3 else mp - 9); const y = @as(i128, yoe) + era * 400 + @intFromBool(m <= 2); return .{ .year = @intCast(std.math.clamp(y, std.math.minInt(i64), std.math.maxInt(i64))), .month = m, .day = d, .hour = @intCast(rest / 3600), .minute = @intCast(rest / 60 % 60), .second = @intCast(rest % 60), // 1970-01-01 was a Thursday. .weekday = @intCast(@mod(@as(i128, days) + 4, 7)), }; }};
/// A time as C's `ctime` writes it: `Thu Jan 1 00:00:00 1970`.pub fn ctime(seconds: i64) Ctime { return .{ .civil = .fromSeconds(seconds) };}
pub const Ctime = struct { civil: Civil,
pub fn format(t: Ctime, w: *Io.Writer) Io.Writer.Error!void { const days = [_][]const u8{ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" }; const months = [_][]const u8{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; const c = t.civil; try w.print("{s} {s} {d:2} {d:0>2}:{d:0>2}:{d:0>2} {d}", .{ days[c.weekday], months[c.month - 1], c.day, c.hour, c.minute, c.second, c.year, }); }};
test "ctime" { var buf: [64]u8 = undefined; try std.testing.expectEqualStrings("Thu Jan 1 00:00:00 1970", try std.fmt.bufPrint(&buf, "{f}", .{ctime(0)})); try std.testing.expectEqualStrings("Tue Nov 14 22:13:20 2023", try std.fmt.bufPrint(&buf, "{f}", .{ctime(1_700_000_000)})); try std.testing.expectEqualStrings("Mon Dec 31 23:59:59 1979", try std.fmt.bufPrint(&buf, "{f}", .{ctime(315_532_799)})); try std.testing.expectEqualStrings("Wed Mar 1 00:00:00 2000", try std.fmt.bufPrint(&buf, "{f}", .{ctime(951_868_800)}));}
test "the extremes do not overflow" { _ = Civil.fromSeconds(std.math.maxInt(i64)); _ = Civil.fromSeconds(std.math.minInt(i64));}
fn expectTable(cp: u16, t: *const Database.Table, expected: []const u8) !void { var out: Io.Writer.Allocating = .init(std.testing.allocator); defer out.deinit(); try writeTable(std.testing.allocator, &out.writer, cp, t); try std.testing.expectEqualStrings(expected, out.written());}
test "a table as a text archive" { var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); var t: Database.Table = .{ .name = "Media", .columns = @import("schema.zig").find("Media").?.columns }; try t.insert(a, &.{ .{ .int = 1 }, .{ .int = 3 }, .null, .{ .string = "#one.cab" }, .null, .null }); try expectTable(1252, &t, "DiskId\tLastSequence\tDiskPrompt\tCabinet\tVolumeLabel\tSource\r\n" ++ "i2\ti4\tL64\tS255\tS32\tS72\r\n" ++ "Media\tDiskId\r\n" ++ "1\t3\t\t#one.cab\t\t\r\n");}
test "non-ASCII text is in the code page, which the third row names" { var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); var t: Database.Table = .{ .name = "Property", .columns = @import("schema.zig").find("Property").?.columns }; try t.insert(a, &.{ .{ .string = "Name" }, .{ .string = "Café" } }); try expectTable(1252, &t, "Property\tValue\r\ns72\tl0\r\n1252\tProperty\tProperty\r\nName\tCaf\xe9\r\n"); // What the code page cannot hold is written in UTF-8. try t.insert(a, &.{ .{ .string = "Greek" }, .{ .string = "Ω" } }); try expectTable(1252, &t, "Property\tValue\r\ns72\tl0\r\n65001\tProperty\tProperty\r\nName\tCafé\r\nGreek\tΩ\r\n");}
test "control characters are translated" { var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); var t: Database.Table = .{ .name = "Property", .columns = @import("schema.zig").find("Property").?.columns }; try t.insert(a, &.{ .{ .string = "P" }, .{ .string = "a\tb\r\nc\x00\x08\x0c" } }); try expectTable(0, &t, "Property\tValue\r\ns72\tl0\r\nProperty\tProperty\r\nP\ta\x10b\x11\x19c\x15\x1b\x18\r\n");}
test "a binary cell names its file" { var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); var t: Database.Table = .{ .name = "Binary", .columns = @import("schema.zig").find("Binary").?.columns }; try t.insert(a, &.{ .{ .string = "Books" }, .{ .stream = "..." } }); try expectTable(0, &t, "Name\tData\r\ns72\tv0\r\nBinary\tName\r\nBooks\tBooks.ibd\r\n");}
test "_ForceCodepage and _SummaryInformation" { var out: Io.Writer.Allocating = .init(std.testing.allocator); defer out.deinit(); try writeForceCodepage(&out.writer, 932); try std.testing.expectEqualStrings("\r\n\r\n932\t_ForceCodepage\r\n", out.written()); out.clearRetainingCapacity(); try writeSummary(std.testing.allocator, &out.writer, .{ .codepage = 1252, .title = "Installation Database", .create_time = 922_116_345, .page_count = 110 }); try std.testing.expectEqualStrings( "PropertyId\tValue\r\ni2\tl255\r\n_SummaryInformation\tPropertyId\r\n" ++ "1\t1252\r\n2\tInstallation Database\r\n12\t1999/03/22 15:25:45\r\n14\t110\r\n", out.written(), );}
test "a table as SQL" { var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); var t: Database.Table = .{ .name = "Property", .columns = @import("schema.zig").find("Property").?.columns }; try t.insert(a, &.{ .{ .string = "Q" }, .{ .string = "it's a\\b" } }); var out: Io.Writer.Allocating = .init(std.testing.allocator); defer out.deinit(); try writeSql(&out.writer, &t); try std.testing.expectEqualStrings( "CREATE TABLE `Property` (`Property` CHAR(72) NOT NULL, `Value` CHAR(0) NOT NULL LOCALIZABLE PRIMARY KEY `Property`)\n" ++ "INSERT INTO `Property` (`Property`, `Value`) VALUES ('Q', 'it\\'s a\\\\b')\n", out.written(), );}