//! Release payload staging for `zig build dist` (milestone-14 rulings 3 and 4). //! //! Two modes, both writing only into paths the build system handed them: //! //! dist_stage stage --out --binary --service //! --sysusers --license --install-md //! --licenses //! //! Fills `` with the six files of the tarball payload, each with the //! mode the release specifies. `THIRD-PARTY-NOTICES` is concatenated from //! the reviewed inventory at `/inventory.zon`; it is never //! scraped from the dependency tree, because a generated notices file that //! nobody reads rots silently into a false statement. //! //! dist_stage sums --out [--entry ]... //! //! Writes `sha256sum`-format lines, one per `--entry`, in argument order. //! `` is the name the asset carries in the release, not the cache //! path the bytes were read from. //! //! Modes are set with an explicit `setPermissions` after creation rather than //! through the creation mode, which the process umask would mask. const std = @import("std"); const Allocator = std.mem.Allocator; const Io = std.Io; const max_input_bytes = 256 << 20; /// One reviewed component of the shipped artifacts. The build system owns this /// shape; `licenses/inventory.zon` is a tuple of these, in the order they /// should appear in `THIRD-PARTY-NOTICES`. /// Mirrors `Entry` in `src/licenses_drift_test.zig`, which type-checks the same /// file at compile time. The two must agree; a mismatch fails one of them. const Component = struct { /// The component as an operator would name it. component: []const u8, /// The version(s) actually shipped. version: []const u8, /// Why its licence applies to what we ship, and which option was taken /// when the component offers a choice. note: []const u8, /// Licence text, relative to `licenses/`. file: []const u8, }; const PayloadFile = struct { name: []const u8, mode: std.posix.mode_t, }; const payload_dir_mode: std.posix.mode_t = 0o755; pub fn main(init: std.process.Init) !void { const arena = init.arena.allocator(); const io = init.io; const args = try init.minimal.args.toSlice(arena); if (args.len < 2) std.process.fatal("usage: dist_stage ...", .{}); if (std.mem.eql(u8, args[1], "stage")) return stage(arena, io, args[2..]); if (std.mem.eql(u8, args[1], "sums")) return sums(arena, io, args[2..]); std.process.fatal("unknown mode '{s}': expected `stage` or `sums`", .{args[1]}); } fn stage(arena: Allocator, io: Io, args: []const []const u8) !void { var out_path: ?[]const u8 = null; var binary: ?[]const u8 = null; var service: ?[]const u8 = null; var sysusers: ?[]const u8 = null; var license: ?[]const u8 = null; var install_md: ?[]const u8 = null; var licenses: ?[]const u8 = null; var i: usize = 0; while (i < args.len) : (i += 2) { if (i + 1 >= args.len) std.process.fatal("'{s}' needs a value", .{args[i]}); const value = args[i + 1]; const flag = args[i]; if (std.mem.eql(u8, flag, "--out")) { out_path = value; } else if (std.mem.eql(u8, flag, "--binary")) { binary = value; } else if (std.mem.eql(u8, flag, "--service")) { service = value; } else if (std.mem.eql(u8, flag, "--sysusers")) { sysusers = value; } else if (std.mem.eql(u8, flag, "--license")) { license = value; } else if (std.mem.eql(u8, flag, "--install-md")) { install_md = value; } else if (std.mem.eql(u8, flag, "--licenses")) { licenses = value; } else { std.process.fatal("unknown flag '{s}'", .{flag}); } } // `iterate` is what makes the handle usable with `setPermissions` // (Io/Dir.zig:1941), which the payload directory's own mode needs. var out = Io.Dir.cwd().openDir(io, required(out_path, "--out"), .{ .iterate = true }) catch |err| { std.process.fatal("cannot open --out directory '{s}': {t}", .{ out_path.?, err }); }; defer out.close(io); const notices = try renderNotices(arena, io, required(licenses, "--licenses")); try copyInto(arena, io, out, .{ .name = "nxdns", .mode = 0o755 }, required(binary, "--binary")); try copyInto(arena, io, out, .{ .name = "nxdns.service", .mode = 0o644 }, required(service, "--service")); try copyInto(arena, io, out, .{ .name = "nxdns.conf", .mode = 0o644 }, required(sysusers, "--sysusers")); try copyInto(arena, io, out, .{ .name = "LICENSE", .mode = 0o644 }, required(license, "--license")); try copyInto(arena, io, out, .{ .name = "INSTALL.md", .mode = 0o644 }, required(install_md, "--install-md")); try writeInto(io, out, .{ .name = "THIRD-PARTY-NOTICES", .mode = 0o644 }, notices); // The payload directory is a tar member too, and the mode `--out` was // created with came from the build runner's umask. out.setPermissions(io, .fromMode(payload_dir_mode)) catch |err| { std.process.fatal("cannot set the mode of '{s}': {t}", .{ out_path.?, err }); }; } fn sums(arena: Allocator, io: Io, args: []const []const u8) !void { var out_path: ?[]const u8 = null; var line_buffer: std.Io.Writer.Allocating = try .initCapacity(arena, 256); var i: usize = 0; while (i < args.len) { if (std.mem.eql(u8, args[i], "--out")) { if (i + 1 >= args.len) std.process.fatal("'--out' needs a value", .{}); out_path = args[i + 1]; i += 2; } else if (std.mem.eql(u8, args[i], "--entry")) { if (i + 2 >= args.len) std.process.fatal("'--entry' needs a name and a path", .{}); const name = args[i + 1]; const path = args[i + 2]; const bytes = Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(max_input_bytes)) catch |err| { std.process.fatal("cannot read '{s}': {t}", .{ path, err }); }; var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{}); // The two-space separator is `sha256sum`'s text mode, which is what // `sha256sum -c SHA256SUMS` expects on the operator's machine. try line_buffer.writer.print("{s} {s}\n", .{ std.fmt.bytesToHex(digest, .lower), name }); i += 3; } else { std.process.fatal("unknown flag '{s}'", .{args[i]}); } } try writeFileWithMode(io, Io.Dir.cwd(), required(out_path, "--out"), line_buffer.written(), 0o644); } fn required(value: ?[]const u8, flag: []const u8) []const u8 { return value orelse std.process.fatal("'{s}' is required", .{flag}); } fn copyInto(arena: Allocator, io: Io, out: Io.Dir, file: PayloadFile, source: []const u8) !void { const bytes = Io.Dir.cwd().readFileAlloc(io, source, arena, .limited(max_input_bytes)) catch |err| { std.process.fatal("cannot read '{s}': {t}", .{ source, err }); }; try writeInto(io, out, file, bytes); } fn writeInto(io: Io, out: Io.Dir, file: PayloadFile, bytes: []const u8) !void { try writeFileWithMode(io, out, file.name, bytes, file.mode); } fn writeFileWithMode( io: Io, dir: Io.Dir, sub_path: []const u8, bytes: []const u8, mode: std.posix.mode_t, ) !void { var handle = dir.createFile(io, sub_path, .{}) catch |err| { std.process.fatal("cannot create '{s}': {t}", .{ sub_path, err }); }; defer handle.close(io); handle.writeStreamingAll(io, bytes) catch |err| { std.process.fatal("cannot write '{s}': {t}", .{ sub_path, err }); }; // After the write, not through the creation mode: `open(2)` masks the // creation mode with the process umask, and the release modes are fixed. handle.setPermissions(io, .fromMode(mode)) catch |err| { std.process.fatal("cannot set the mode of '{s}': {t}", .{ sub_path, err }); }; } /// `THIRD-PARTY-NOTICES`: the inventory's entries, in order, each with its /// selection note and the full text of its licence. fn renderNotices(arena: Allocator, io: Io, dir_path: []const u8) ![]const u8 { var dir = Io.Dir.cwd().openDir(io, dir_path, .{}) catch |err| { std.process.fatal("cannot open --licenses directory '{s}': {t}", .{ dir_path, err }); }; defer dir.close(io); const source = dir.readFileAllocOptions( io, "inventory.zon", arena, .limited(max_input_bytes), .of(u8), 0, ) catch |err| { std.process.fatal("cannot read '{s}/inventory.zon': {t}", .{ dir_path, err }); }; const components = try parseInventory(arena, source); if (components.len == 0) { std.process.fatal("'{s}/inventory.zon' lists no components", .{dir_path}); } // The preamble is a reviewed file rather than a literal here so that one // text states the artifact scope and the drift guards in // src/licenses_drift_test.zig can assert against it. const preamble = dir.readFileAlloc(io, "preamble.txt", arena, .limited(max_input_bytes)) catch |err| { std.process.fatal("cannot read '{s}/preamble.txt': {t}", .{ dir_path, err }); }; if (std.mem.trim(u8, preamble, " \t\r\n").len == 0) { std.process.fatal("'{s}/preamble.txt' is empty", .{dir_path}); } var sink: std.Io.Writer.Allocating = try .initCapacity(arena, 64 << 10); const w = &sink.writer; try w.writeAll(preamble); if (preamble[preamble.len - 1] != '\n') try w.writeByte('\n'); try w.writeByte('\n'); for (components) |component| { const text = dir.readFileAlloc(io, component.file, arena, .limited(max_input_bytes)) catch |err| { std.process.fatal("cannot read '{s}/{s}': {t}", .{ dir_path, component.file, err }); }; try w.splatByteAll('=', 76); try w.print("\n{s}\n", .{component.component}); try w.splatByteAll('=', 76); try w.print("\n\nVersion: {s}\n\n{s}\n\n{s}", .{ component.version, component.note, text }); if (text.len != 0 and text[text.len - 1] != '\n') try w.writeByte('\n'); try w.writeByte('\n'); } return sink.written(); } /// The inventory is a tuple of components, in the order they appear in the /// notices file. fn parseInventory(arena: Allocator, source: [:0]const u8) ![]const Component { var diagnostics: std.zon.parse.Diagnostics = .{}; return std.zon.parse.fromSliceAlloc( []const Component, arena, source, &diagnostics, .{ .free_on_error = false }, ) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.ParseZon => std.process.fatal("licenses/inventory.zon:\n{f}", .{&diagnostics}), }; }