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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! The `zixl-info` command line: what is inside an installer package. Its//! subcommands and their output follow those of msitools' `msiinfo`, except//! that times are printed in UTC, and that tables are exported in the text//! archive format as Microsoft documents it//! (`docs/adr/0006-text-archives-follow-microsofts-format.md`).
const std = @import("std");const Io = std.Io;
const zixl = @import("zixl");
const usage = \\Usage: zixl-info SUBCOMMAND COMMAND-OPTIONS... \\ \\Options: \\ -h, --help Show program usage \\ -v, --version Display program version \\ \\Available subcommands: \\ help Show program or subcommand usage \\ streams List streams in a .msi file \\ tables List tables in a .msi file \\ extract Extract a binary stream from an .msi file \\ export Export a table in text form from an .msi file \\ suminfo Print summary information \\;
const Subcommand = struct { name: []const u8, usage: []const u8, run: *const fn (*Context, []const []const u8) anyerror!u8,};
const subcommands = [_]Subcommand{ .{ .name = "streams", .usage = "zixl-info streams FILE\n\nList streams in a .msi file.\n", .run = streams }, .{ .name = "tables", .usage = "zixl-info tables FILE\n\nList tables in a .msi file.\n", .run = tables }, .{ .name = "extract", .usage = "zixl-info extract FILE STREAM\n\nExtract a binary stream from an .msi file.\n", .run = extract }, .{ .name = "export", .usage = "zixl-info export [-s] FILE TABLE\n\nOptions:\n -s Format output as an SQL query\n\n" ++ "Export a table in text form from an .msi file, as a text archive (.idt) file.\n", .run = exportTable, }, .{ .name = "suminfo", .usage = "zixl-info suminfo FILE\n\nPrint summary information, with times in UTC.\n", .run = suminfo },};
const Context = struct { arena: std.mem.Allocator, io: Io, stdout: *Io.Writer, stderr: *Io.Writer,
fn fail(c: *Context, comptime fmt: []const u8, args: anytype) !u8 { try c.stderr.print("zixl-info: " ++ fmt ++ "\n", args); try c.stderr.flush(); return 1; }
/// The installer package at `path`, or null once it has said why not. fn open(c: *Context, path: []const u8) !?zixl.Package { const bytes = Io.Dir.cwd().readFileAlloc(c.io, path, c.arena, .unlimited) catch |e| { _ = try c.fail("{s}: cannot be read: {s}", .{ path, @errorName(e) }); return null; }; var diags: zixl.Diagnostics = .init(c.arena); const package = zixl.read(c.arena, &diags, path, bytes) catch |e| switch (e) { error.Invalid => { try diags.render(c.stderr); try c.stderr.flush(); return null; }, else => |other| return other, }; try diags.render(c.stderr); return package; }
/// The single file a subcommand takes, or null after printing its usage. fn file(c: *Context, sub: []const u8, args: []const []const u8) !?[]const u8 { if (args.len != 1) { try c.stderr.writeAll(find(sub).?.usage); try c.stderr.flush(); return null; } return args[0]; }};
fn find(name: []const u8) ?Subcommand { for (subcommands) |s| { if (std.mem.eql(u8, s.name, name)) return s; } return null;}
pub fn main(init: std.process.Init) !u8 { const arena = init.arena.allocator(); const args = try init.minimal.args.toSlice(arena);
var stdout_buffer: [4096]u8 = undefined; var stdout_writer = Io.File.stdout().writer(init.io, &stdout_buffer); var stderr_buffer: [4096]u8 = undefined; var stderr_writer = Io.File.stderr().writer(init.io, &stderr_buffer); var c: Context = .{ .arena = arena, .io = init.io, .stdout = &stdout_writer.interface, .stderr = &stderr_writer.interface, };
if (args.len < 2) { try c.stderr.writeAll(usage); try c.stderr.flush(); return 1; } const first: []const u8 = args[1]; if (eql(first, "-h") or eql(first, "--help")) { try c.stdout.writeAll(usage); try c.stdout.flush(); return 0; } if (eql(first, "-v") or eql(first, "--version")) { try c.stdout.print("zixl-info {s}\n", .{zixl.version}); try c.stdout.flush(); return 0; } if (eql(first, "help")) { if (args.len == 2) { try c.stdout.writeAll(usage); } else if (find(args[2])) |s| { try c.stdout.writeAll(s.usage); } else { return c.fail("unknown subcommand {s}", .{args[2]}); } try c.stdout.flush(); return 0; } const sub = find(first) orelse return c.fail("unknown subcommand {s}", .{first}); const rest = try arena.alloc([]const u8, args.len - 2); for (rest, args[2..]) |*r, a| r.* = a; const status = try sub.run(&c, rest); try c.stdout.flush(); try c.stderr.flush(); return status;}
fn eql(a: []const u8, b: []const u8) bool { return std.mem.eql(u8, a, b);}
/// Every table, starting with the two that are not stored as tables but are/// exported as though they were: the summary information and the code page.fn tables(c: *Context, args: []const []const u8) !u8 { const path = try c.file("tables", args) orelse return 1; const package = try c.open(path) orelse return 1; try c.stdout.writeAll("_SummaryInformation\n_ForceCodepage\n"); for (package.database.tables.items) |t| try c.stdout.print("{s}\n", .{t.name}); return 0;}
/// A stream's name without the control character the summary/// information's begins with, so that it can be named on a command line.fn listedName(name: []const u8) []const u8 { return if (name.len > 0 and name[0] == 5) name[1..] else name;}
fn streams(c: *Context, args: []const []const u8) !u8 { const path = try c.file("streams", args) orelse return 1; const package = try c.open(path) orelse return 1; // Each name as it is stored, the summary information's with its leading // control character. for (package.streams) |s| try c.stdout.print("{s}\n", .{s.name}); return 0;}
fn extract(c: *Context, args: []const []const u8) !u8 { if (args.len != 2) { try c.stderr.writeAll(find("extract").?.usage); return 1; } const package = try c.open(args[0]) orelse return 1; for (package.streams) |s| { if (!eql(listedName(s.name), args[1]) and !eql(s.name, args[1])) continue; try c.stdout.writeAll(s.data); return 0; } return c.fail("{s}: no stream named {s}", .{ args[0], args[1] });}
fn exportTable(c: *Context, args: []const []const u8) !u8 { const sql = args.len > 0 and eql(args[0], "-s"); const rest = if (sql) args[1..] else args; if (rest.len != 2) { try c.stderr.writeAll(find("export").?.usage); return 1; } const package = try c.open(rest[0]) orelse return 1; const db = &package.database; const name = rest[1]; if (eql(name, "_SummaryInformation") and !sql) { try zixl.archive.writeSummary(c.arena, c.stdout, db.summary); return 0; } if (eql(name, "_ForceCodepage") and !sql) { try zixl.archive.writeForceCodepage(c.stdout, db.codepage); return 0; } const t = db.table(name) orelse try zixl.archive.systemTable(c.arena, db, name) orelse return c.fail("{s}: no table named {s}", .{ rest[0], name }); if (sql) { try zixl.archive.writeSql(c.stdout, t); } else { try zixl.archive.writeTable(c.arena, c.stdout, db.codepage, t); } return 0;}
fn suminfo(c: *Context, args: []const []const u8) !u8 { const path = try c.file("suminfo", args) orelse return 1; const package = try c.open(path) orelse return 1; try writeSummary(c.stdout, package.database.summary); return 0;}
/// The summary information as `msiinfo suminfo` prints it, a field to a line/// and only the fields that are present.fn writeSummary(w: *Io.Writer, s: zixl.Database.SummaryInformation) !void { const strings = [_]struct { []const u8, ?[]const u8 }{ .{ "Title", s.title }, .{ "Subject", s.subject }, .{ "Author", s.author }, .{ "Keywords", s.keywords }, .{ "Comments", s.comments }, .{ "Template", s.template }, .{ "Last author", s.last_saved_by }, .{ "Revision number (UUID)", s.revision_number }, }; for (strings) |f| if (f[1]) |v| try w.print("{s}: {s}\n", .{ f[0], v }); if (s.create_time) |t| try w.print("Created: {f}\n", .{zixl.archive.ctime(t)}); if (s.last_save_time) |t| try w.print("Last saved: {f}\n", .{zixl.archive.ctime(t)}); const ints = [_]struct { []const u8, ?i32 }{ .{ "Version", s.page_count }, .{ "Source", s.word_count }, .{ "Restrict", s.character_count }, }; for (ints) |f| if (f[1]) |v| try w.print("{s}: {d} ({x})\n", .{ f[0], v, v }); if (s.creating_application) |v| try w.print("Application: {s}\n", .{v}); if (s.security) |v| try w.print("Security: {d} ({x})\n", .{ v, v });}
test "summary information is printed a field to a line" { var buf: [1024]u8 = undefined; var w: Io.Writer = .fixed(&buf); try writeSummary(&w, .{ .title = "Installation Database", .template = "x64;1033", .create_time = 315_532_800, .page_count = 500, .word_count = 2, .security = 2, }); try std.testing.expectEqualStrings( \\Title: Installation Database \\Template: x64;1033 \\Created: Tue Jan 1 00:00:00 1980 \\Version: 500 (1f4) \\Source: 2 (2) \\Security: 2 (2) \\ , w.buffered());}