258 lines
11 KiB
Zig
258 lines
11 KiB
Zig
//! 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 <dir> --binary <path> --service <path>
|
|
//! --sysusers <path> --license <path> --install-md <path>
|
|
//! --licenses <dir>
|
|
//!
|
|
//! Fills `<dir>` 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 `<licenses>/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 <file> [--entry <name> <path>]...
|
|
//!
|
|
//! Writes `sha256sum`-format lines, one per `--entry`, in argument order.
|
|
//! `<name>` 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 <stage|sums> ...", .{});
|
|
|
|
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}),
|
|
};
|
|
}
|