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