milestone 14: build, package, sign and publish releases
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
//! 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}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
//! Release verification for `zig build verify-dist` (milestone-14 ruling 5).
|
||||
//!
|
||||
//! Every assert the CI shell used to make, in a program a developer can run on
|
||||
//! a laptop. Checks that only exist inside a workflow file are the brittleness
|
||||
//! this exists to remove.
|
||||
//!
|
||||
//! Usage (the build system supplies all of it):
|
||||
//!
|
||||
//! verify_dist --dist-dir <dir> --work-dir <dir> --version <v>
|
||||
//! --git-commit <c> --zon <build.zig.zon> --max-bytes <n>
|
||||
//! --asset-free-max-bytes <n> --host-arch <arch> [--qemu]
|
||||
//! [--archive <triple> <basename>]...
|
||||
//! [--asset-free <triple> <path>]...
|
||||
//!
|
||||
//! The archive checks read the tarball and then work on the *extracted* tree,
|
||||
//! never on the staging directory: a staging directory that is right proves
|
||||
//! nothing about the bytes an operator downloads. Layout, modes, symlinks and
|
||||
//! path traversal are read out of the tar headers, which is where an attacker
|
||||
//! or a broken build would put them.
|
||||
//!
|
||||
//! Every failure is reported before the process exits, so one run names every
|
||||
//! problem rather than the first.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const Io = std.Io;
|
||||
const elf = std.elf;
|
||||
|
||||
const max_input_bytes = 256 << 20;
|
||||
|
||||
/// The tarball payload, exactly (milestone-14 ruling 4). Sorted by name, which
|
||||
/// is also the order `tar --sort=name` writes them in.
|
||||
const payload = [_]PayloadFile{
|
||||
.{ .name = "INSTALL.md", .mode = 0o644 },
|
||||
.{ .name = "LICENSE", .mode = 0o644 },
|
||||
.{ .name = "THIRD-PARTY-NOTICES", .mode = 0o644 },
|
||||
.{ .name = "nxdns", .mode = 0o755 },
|
||||
.{ .name = "nxdns.conf", .mode = 0o644 },
|
||||
.{ .name = "nxdns.service", .mode = 0o644 },
|
||||
};
|
||||
|
||||
const payload_dir_mode: u32 = 0o755;
|
||||
|
||||
const PayloadFile = struct {
|
||||
name: []const u8,
|
||||
mode: u32,
|
||||
};
|
||||
|
||||
const Archive = struct {
|
||||
triple: []const u8,
|
||||
basename: []const u8,
|
||||
};
|
||||
|
||||
const AssetFree = struct {
|
||||
triple: []const u8,
|
||||
path: []const u8,
|
||||
};
|
||||
|
||||
const Extracted = struct {
|
||||
/// Payload contents keyed by the order of `payload`.
|
||||
files: [payload.len][]const u8,
|
||||
|
||||
fn get(self: Extracted, name: []const u8) []const u8 {
|
||||
for (payload, self.files) |file, bytes| {
|
||||
if (std.mem.eql(u8, file.name, name)) return bytes;
|
||||
}
|
||||
unreachable;
|
||||
}
|
||||
};
|
||||
|
||||
/// Accumulates failures so a run reports all of them, and carries the writer so
|
||||
/// every line goes through one place.
|
||||
const Report = struct {
|
||||
out: *Io.Writer,
|
||||
failures: usize = 0,
|
||||
|
||||
fn pass(self: *Report, comptime check: []const u8, comptime fmt: []const u8, args: anytype) void {
|
||||
self.out.print("verify-dist: PASS " ++ check ++ ": " ++ fmt ++ "\n", args) catch {};
|
||||
}
|
||||
|
||||
fn fail(self: *Report, comptime check: []const u8, comptime fmt: []const u8, args: anytype) void {
|
||||
self.failures += 1;
|
||||
self.out.print("verify-dist: FAIL " ++ check ++ ": " ++ fmt ++ "\n", args) catch {};
|
||||
}
|
||||
|
||||
fn skip(self: *Report, comptime check: []const u8, comptime fmt: []const u8, args: anytype) void {
|
||||
self.out.print("verify-dist: SKIP " ++ check ++ ": " ++ fmt ++ "\n", args) catch {};
|
||||
}
|
||||
};
|
||||
|
||||
const Args = struct {
|
||||
dist_dir: []const u8 = "",
|
||||
work_dir: []const u8 = "",
|
||||
version: []const u8 = "",
|
||||
git_commit: []const u8 = "",
|
||||
zon: []const u8 = "",
|
||||
max_bytes: u64 = 0,
|
||||
asset_free_max_bytes: u64 = 0,
|
||||
host_arch: []const u8 = "",
|
||||
qemu: bool = false,
|
||||
archives: []const Archive = &.{},
|
||||
asset_free: []const AssetFree = &.{},
|
||||
};
|
||||
|
||||
pub fn main(init: std.process.Init) !u8 {
|
||||
const arena = init.arena.allocator();
|
||||
const io = init.io;
|
||||
const args = try parseArgs(arena, try init.minimal.args.toSlice(arena));
|
||||
|
||||
var out_buffer: [8192]u8 = undefined;
|
||||
var out = Io.File.stdout().writerStreaming(io, &out_buffer);
|
||||
var report: Report = .{ .out = &out.interface };
|
||||
|
||||
var dist = Io.Dir.cwd().openDir(io, args.dist_dir, .{}) catch |err| {
|
||||
std.process.fatal("cannot open --dist-dir '{s}': {t}", .{ args.dist_dir, err });
|
||||
};
|
||||
defer dist.close(io);
|
||||
|
||||
checkZonVersion(arena, io, &report, args);
|
||||
|
||||
for (args.archives) |archive| {
|
||||
const extracted = checkArchive(arena, io, &report, args, dist, archive) orelse continue;
|
||||
checkInstalledCopies(arena, io, &report, dist, archive, extracted);
|
||||
checkVersionOutput(arena, io, &report, args, archive);
|
||||
}
|
||||
|
||||
checkChecksums(arena, io, &report, args, dist);
|
||||
|
||||
for (args.asset_free) |entry| {
|
||||
const bytes = Io.Dir.cwd().readFileAlloc(io, entry.path, arena, .limited(max_input_bytes)) catch |err| {
|
||||
report.fail("asset-free-build", "{s}: cannot read '{s}': {t}", .{ entry.triple, entry.path, err });
|
||||
continue;
|
||||
};
|
||||
checkElf(&report, "asset-free-elf", entry.triple, bytes);
|
||||
if (bytes.len > args.asset_free_max_bytes) {
|
||||
report.fail("asset-free-size", "{s}: {d} bytes exceeds the {d} byte budget", .{
|
||||
entry.triple, bytes.len, args.asset_free_max_bytes,
|
||||
});
|
||||
} else {
|
||||
report.pass("asset-free-size", "{s}: {d} of {d} bytes", .{
|
||||
entry.triple, bytes.len, args.asset_free_max_bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (report.failures == 0) {
|
||||
report.out.print("verify-dist: {d} archives verified, no failures\n", .{args.archives.len}) catch {};
|
||||
} else {
|
||||
report.out.print("verify-dist: {d} failures\n", .{report.failures}) catch {};
|
||||
}
|
||||
out.interface.flush() catch {};
|
||||
return if (report.failures == 0) 0 else 1;
|
||||
}
|
||||
|
||||
fn parseArgs(arena: Allocator, argv: []const []const u8) !Args {
|
||||
var args: Args = .{};
|
||||
var archives: std.ArrayList(Archive) = .empty;
|
||||
var asset_free: std.ArrayList(AssetFree) = .empty;
|
||||
|
||||
var i: usize = 1;
|
||||
while (i < argv.len) {
|
||||
const flag = argv[i];
|
||||
if (std.mem.eql(u8, flag, "--qemu")) {
|
||||
args.qemu = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (std.mem.eql(u8, flag, "--archive")) {
|
||||
requireValues(argv, i, flag, 2);
|
||||
try archives.append(arena, .{ .triple = argv[i + 1], .basename = argv[i + 2] });
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
if (std.mem.eql(u8, flag, "--asset-free")) {
|
||||
requireValues(argv, i, flag, 2);
|
||||
try asset_free.append(arena, .{ .triple = argv[i + 1], .path = argv[i + 2] });
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
requireValues(argv, i, flag, 1);
|
||||
const value = argv[i + 1];
|
||||
i += 2;
|
||||
if (std.mem.eql(u8, flag, "--dist-dir")) {
|
||||
args.dist_dir = value;
|
||||
} else if (std.mem.eql(u8, flag, "--work-dir")) {
|
||||
args.work_dir = value;
|
||||
} else if (std.mem.eql(u8, flag, "--version")) {
|
||||
args.version = value;
|
||||
} else if (std.mem.eql(u8, flag, "--git-commit")) {
|
||||
args.git_commit = value;
|
||||
} else if (std.mem.eql(u8, flag, "--zon")) {
|
||||
args.zon = value;
|
||||
} else if (std.mem.eql(u8, flag, "--host-arch")) {
|
||||
args.host_arch = value;
|
||||
} else if (std.mem.eql(u8, flag, "--max-bytes")) {
|
||||
args.max_bytes = std.fmt.parseInt(u64, value, 10) catch {
|
||||
std.process.fatal("--max-bytes '{s}' is not a number", .{value});
|
||||
};
|
||||
} else if (std.mem.eql(u8, flag, "--asset-free-max-bytes")) {
|
||||
args.asset_free_max_bytes = std.fmt.parseInt(u64, value, 10) catch {
|
||||
std.process.fatal("--asset-free-max-bytes '{s}' is not a number", .{value});
|
||||
};
|
||||
} else {
|
||||
std.process.fatal("unknown flag '{s}'", .{flag});
|
||||
}
|
||||
}
|
||||
|
||||
args.archives = archives.items;
|
||||
args.asset_free = asset_free.items;
|
||||
if (args.dist_dir.len == 0) std.process.fatal("--dist-dir is required", .{});
|
||||
if (args.work_dir.len == 0) std.process.fatal("--work-dir is required", .{});
|
||||
if (args.version.len == 0) std.process.fatal("--version is required", .{});
|
||||
return args;
|
||||
}
|
||||
|
||||
fn requireValues(argv: []const []const u8, index: usize, flag: []const u8, count: usize) void {
|
||||
if (index + count >= argv.len) std.process.fatal("'{s}' needs {d} value(s)", .{ flag, count });
|
||||
}
|
||||
|
||||
/// `build.zig.zon` holds the only other copy of the version. Zig requires the
|
||||
/// field and nothing reads it, so nothing but this catches a bump that was
|
||||
/// forgotten in the commit before the tag (milestone-14 ruling 2).
|
||||
fn checkZonVersion(arena: Allocator, io: Io, report: *Report, args: Args) void {
|
||||
const Manifest = struct { version: []const u8 };
|
||||
const source = Io.Dir.cwd().readFileAllocOptions(
|
||||
io,
|
||||
args.zon,
|
||||
arena,
|
||||
.limited(max_input_bytes),
|
||||
.of(u8),
|
||||
0,
|
||||
) catch |err| {
|
||||
report.fail("zon-version", "cannot read '{s}': {t}", .{ args.zon, err });
|
||||
return;
|
||||
};
|
||||
const manifest = std.zon.parse.fromSliceAlloc(Manifest, arena, source, null, .{
|
||||
.ignore_unknown_fields = true,
|
||||
.free_on_error = false,
|
||||
}) catch |err| {
|
||||
report.fail("zon-version", "cannot parse '{s}': {t}", .{ args.zon, err });
|
||||
return;
|
||||
};
|
||||
if (!std.mem.eql(u8, manifest.version, args.version)) {
|
||||
report.fail("zon-version", "build.zig.zon says '{s}', the build says '{s}'", .{
|
||||
manifest.version, args.version,
|
||||
});
|
||||
return;
|
||||
}
|
||||
report.pass("zon-version", "'{s}'", .{manifest.version});
|
||||
}
|
||||
|
||||
/// Reads the tarball, asserts its layout from the tar headers, extracts the
|
||||
/// payload under `--work-dir`, and returns the extracted contents.
|
||||
fn checkArchive(
|
||||
arena: Allocator,
|
||||
io: Io,
|
||||
report: *Report,
|
||||
args: Args,
|
||||
dist: Io.Dir,
|
||||
archive: Archive,
|
||||
) ?Extracted {
|
||||
const gz = dist.readFileAlloc(io, archive.basename, arena, .limited(max_input_bytes)) catch |err| {
|
||||
report.fail("archive-present", "{s}: cannot read '{s}': {t}", .{ archive.triple, archive.basename, err });
|
||||
return null;
|
||||
};
|
||||
|
||||
const expected_root = archive.basename[0 .. archive.basename.len - ".tar.gz".len];
|
||||
|
||||
var gz_reader: Io.Reader = .fixed(gz);
|
||||
const window = arena.alloc(u8, std.compress.flate.max_window_len) catch @panic("OOM");
|
||||
var decompress: std.compress.flate.Decompress = .init(&gz_reader, .gzip, window);
|
||||
|
||||
const name_buffer = arena.alloc(u8, std.fs.max_path_bytes) catch @panic("OOM");
|
||||
const link_buffer = arena.alloc(u8, std.fs.max_path_bytes) catch @panic("OOM");
|
||||
var it: std.tar.Iterator = .init(&decompress.reader, .{
|
||||
.file_name_buffer = name_buffer,
|
||||
.link_name_buffer = link_buffer,
|
||||
});
|
||||
|
||||
var found: [payload.len]?[]const u8 = @splat(null);
|
||||
var roots: usize = 0;
|
||||
var ok = true;
|
||||
|
||||
while (it.next() catch |err| {
|
||||
report.fail("archive-readable", "{s}: {s} is not a readable tar.gz: {t}", .{
|
||||
archive.triple, archive.basename, decompress.err orelse err,
|
||||
});
|
||||
return null;
|
||||
}) |entry| {
|
||||
const raw = std.mem.trimEnd(u8, entry.name, "/");
|
||||
|
||||
if (std.mem.startsWith(u8, raw, "/") or
|
||||
std.mem.indexOf(u8, raw, "..") != null or
|
||||
std.mem.indexOfScalar(u8, raw, '\\') != null)
|
||||
{
|
||||
report.fail("archive-traversal", "{s}: member '{s}' escapes the payload", .{ archive.triple, raw });
|
||||
ok = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.kind == .sym_link) {
|
||||
report.fail("archive-symlink", "{s}: member '{s}' is a symlink to '{s}'", .{
|
||||
archive.triple, raw, entry.link_name,
|
||||
});
|
||||
ok = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.kind == .directory) {
|
||||
roots += 1;
|
||||
if (!std.mem.eql(u8, raw, expected_root)) {
|
||||
report.fail("archive-root", "{s}: directory '{s}', expected '{s}'", .{
|
||||
archive.triple, raw, expected_root,
|
||||
});
|
||||
ok = false;
|
||||
} else if (entry.mode & 0o7777 != payload_dir_mode) {
|
||||
report.fail("archive-mode", "{s}: directory '{s}' has mode {o}, expected {o}", .{
|
||||
archive.triple, raw, entry.mode & 0o7777, payload_dir_mode,
|
||||
});
|
||||
ok = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const prefix = std.fmt.allocPrint(arena, "{s}/", .{expected_root}) catch @panic("OOM");
|
||||
if (!std.mem.startsWith(u8, raw, prefix)) {
|
||||
report.fail("archive-root", "{s}: member '{s}' is not under '{s}'", .{ archive.triple, raw, prefix });
|
||||
ok = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = raw[prefix.len..];
|
||||
const index = indexOfPayload(name) orelse {
|
||||
report.fail("archive-allowlist", "{s}: '{s}' is not part of the release payload", .{
|
||||
archive.triple, name,
|
||||
});
|
||||
ok = false;
|
||||
continue;
|
||||
};
|
||||
if (found[index] != null) {
|
||||
report.fail("archive-allowlist", "{s}: '{s}' appears more than once", .{ archive.triple, name });
|
||||
ok = false;
|
||||
continue;
|
||||
}
|
||||
if (entry.mode & 0o7777 != payload[index].mode) {
|
||||
report.fail("archive-mode", "{s}: '{s}' has mode {o}, expected {o}", .{
|
||||
archive.triple, name, entry.mode & 0o7777, payload[index].mode,
|
||||
});
|
||||
ok = false;
|
||||
}
|
||||
|
||||
var sink: Io.Writer.Allocating = .init(arena);
|
||||
it.streamRemaining(entry, &sink.writer) catch |err| {
|
||||
report.fail("archive-readable", "{s}: cannot read '{s}': {t}", .{ archive.triple, name, err });
|
||||
return null;
|
||||
};
|
||||
found[index] = sink.written();
|
||||
}
|
||||
|
||||
if (roots != 1) {
|
||||
report.fail("archive-root", "{s}: {d} top-level directories, expected exactly 1", .{
|
||||
archive.triple, roots,
|
||||
});
|
||||
ok = false;
|
||||
}
|
||||
|
||||
var contents: [payload.len][]const u8 = undefined;
|
||||
for (payload, found, &contents) |file, bytes, *slot| {
|
||||
slot.* = bytes orelse {
|
||||
report.fail("archive-allowlist", "{s}: '{s}' is missing from the payload", .{
|
||||
archive.triple, file.name,
|
||||
});
|
||||
ok = false;
|
||||
continue;
|
||||
};
|
||||
}
|
||||
if (!ok) return null;
|
||||
|
||||
report.pass("archive-layout", "{s}: {s} holds exactly the {d} release files", .{
|
||||
archive.triple, archive.basename, payload.len,
|
||||
});
|
||||
|
||||
const extracted: Extracted = .{ .files = contents };
|
||||
const binary = extracted.get("nxdns");
|
||||
|
||||
checkElf(report, "elf", archive.triple, binary);
|
||||
if (binary.len > args.max_bytes) {
|
||||
report.fail("binary-size", "{s}: {d} bytes exceeds the {d} byte budget", .{
|
||||
archive.triple, binary.len, args.max_bytes,
|
||||
});
|
||||
} else {
|
||||
report.pass("binary-size", "{s}: {d} of {d} bytes", .{ archive.triple, binary.len, args.max_bytes });
|
||||
}
|
||||
|
||||
writeExtracted(io, report, args, archive, extracted);
|
||||
return extracted;
|
||||
}
|
||||
|
||||
/// Materialises the payload under `--work-dir/<triple>/`, with the modes the
|
||||
/// archive declared, so `nxdns version` runs from an extracted tree and an
|
||||
/// operator can look at exactly what was checked.
|
||||
fn writeExtracted(io: Io, report: *Report, args: Args, archive: Archive, extracted: Extracted) void {
|
||||
var root = Io.Dir.cwd().createDirPathOpen(io, args.work_dir, .{}) catch |err| {
|
||||
report.fail("extract", "cannot create '{s}': {t}", .{ args.work_dir, err });
|
||||
return;
|
||||
};
|
||||
defer root.close(io);
|
||||
|
||||
var dir = root.createDirPathOpen(io, archive.triple, .{}) catch |err| {
|
||||
report.fail("extract", "{s}: cannot create the extraction directory: {t}", .{ archive.triple, err });
|
||||
return;
|
||||
};
|
||||
defer dir.close(io);
|
||||
|
||||
for (payload, extracted.files) |file, bytes| {
|
||||
var handle = dir.createFile(io, file.name, .{}) catch |err| {
|
||||
report.fail("extract", "{s}: cannot create '{s}': {t}", .{ archive.triple, file.name, err });
|
||||
return;
|
||||
};
|
||||
defer handle.close(io);
|
||||
handle.writeStreamingAll(io, bytes) catch |err| {
|
||||
report.fail("extract", "{s}: cannot write '{s}': {t}", .{ archive.triple, file.name, err });
|
||||
return;
|
||||
};
|
||||
handle.setPermissions(io, .fromMode(file.mode)) catch |err| {
|
||||
report.fail("extract", "{s}: cannot chmod '{s}': {t}", .{ archive.triple, file.name, err });
|
||||
return;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// `zig-out/dist/bin/<triple>/nxdns` and `zig-out/dist/stage/<name>/` must hold
|
||||
/// the same bytes and the same modes as the archive. They are the artifacts the
|
||||
/// container build and a local install reach for, and an install step that
|
||||
/// dropped the executable bit would otherwise only surface on an operator's
|
||||
/// machine.
|
||||
fn checkInstalledCopies(
|
||||
arena: Allocator,
|
||||
io: Io,
|
||||
report: *Report,
|
||||
dist: Io.Dir,
|
||||
archive: Archive,
|
||||
extracted: Extracted,
|
||||
) void {
|
||||
const before = report.failures;
|
||||
const root = archive.basename[0 .. archive.basename.len - ".tar.gz".len];
|
||||
const bin_path = std.fmt.allocPrint(arena, "bin/{s}/nxdns", .{archive.triple}) catch @panic("OOM");
|
||||
checkInstalledFile(arena, io, report, dist, archive.triple, bin_path, extracted.get("nxdns"), 0o755);
|
||||
|
||||
for (payload, extracted.files) |file, bytes| {
|
||||
const path = std.fmt.allocPrint(arena, "stage/{s}/{s}", .{ root, file.name }) catch @panic("OOM");
|
||||
checkInstalledFile(arena, io, report, dist, archive.triple, path, bytes, file.mode);
|
||||
}
|
||||
|
||||
if (report.failures == before) {
|
||||
report.pass("installed-copy", "{s}: bin/ and stage/ match the archive, modes included", .{archive.triple});
|
||||
}
|
||||
}
|
||||
|
||||
fn checkInstalledFile(
|
||||
arena: Allocator,
|
||||
io: Io,
|
||||
report: *Report,
|
||||
dist: Io.Dir,
|
||||
triple: []const u8,
|
||||
sub_path: []const u8,
|
||||
expected: []const u8,
|
||||
mode: u32,
|
||||
) void {
|
||||
const stat = dist.statFile(io, sub_path, .{ .follow_symlinks = false }) catch |err| {
|
||||
report.fail("installed-copy", "{s}: cannot stat '{s}': {t}", .{ triple, sub_path, err });
|
||||
return;
|
||||
};
|
||||
if (stat.kind != .file) {
|
||||
report.fail("installed-copy", "{s}: '{s}' is a {t}, not a regular file", .{ triple, sub_path, stat.kind });
|
||||
return;
|
||||
}
|
||||
if (stat.permissions.toMode() & 0o7777 != mode) {
|
||||
report.fail("installed-mode", "{s}: '{s}' has mode {o}, expected {o}", .{
|
||||
triple, sub_path, stat.permissions.toMode() & 0o7777, mode,
|
||||
});
|
||||
}
|
||||
const bytes = dist.readFileAlloc(io, sub_path, arena, .limited(max_input_bytes)) catch |err| {
|
||||
report.fail("installed-copy", "{s}: cannot read '{s}': {t}", .{ triple, sub_path, err });
|
||||
return;
|
||||
};
|
||||
if (!std.mem.eql(u8, bytes, expected)) {
|
||||
report.fail("installed-copy", "{s}: '{s}' differs from the archived copy", .{ triple, sub_path });
|
||||
}
|
||||
}
|
||||
|
||||
/// A static binary has no interpreter and no shared-library dependencies.
|
||||
/// Matching the string `statically linked` out of `file(1)` is not that test:
|
||||
/// it reads a heuristic sentence, not the headers that decide it.
|
||||
fn checkElf(report: *Report, comptime check: []const u8, triple: []const u8, bytes: []const u8) void {
|
||||
const machine = expectedMachine(triple) orelse {
|
||||
report.fail(check, "{s}: no ELF machine is known for this triple", .{triple});
|
||||
return;
|
||||
};
|
||||
|
||||
if (bytes.len < @sizeOf(elf.Elf64.Ehdr) or !std.mem.eql(u8, bytes[0..4], elf.MAGIC)) {
|
||||
report.fail(check, "{s}: not an ELF file", .{triple});
|
||||
return;
|
||||
}
|
||||
if (bytes[elf.EI.CLASS] != elf.ELFCLASS64 or bytes[elf.EI.DATA] != elf.ELFDATA2LSB) {
|
||||
report.fail(check, "{s}: not a 64-bit little-endian ELF file", .{triple});
|
||||
return;
|
||||
}
|
||||
|
||||
var header: elf.Elf64.Ehdr = undefined;
|
||||
@memcpy(std.mem.asBytes(&header), bytes[0..@sizeOf(elf.Elf64.Ehdr)]);
|
||||
|
||||
if (header.machine != machine) {
|
||||
report.fail(check, "{s}: e_machine is {t}, expected {t}", .{ triple, header.machine, machine });
|
||||
return;
|
||||
}
|
||||
|
||||
if (header.phentsize != @sizeOf(elf.Elf64.Phdr)) {
|
||||
report.fail(check, "{s}: e_phentsize is {d}, expected {d}", .{
|
||||
triple, header.phentsize, @sizeOf(elf.Elf64.Phdr),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var interps: usize = 0;
|
||||
var needed: usize = 0;
|
||||
for (0..header.phnum) |index| {
|
||||
const offset = header.phoff + index * @sizeOf(elf.Elf64.Phdr);
|
||||
if (offset + @sizeOf(elf.Elf64.Phdr) > bytes.len) {
|
||||
report.fail(check, "{s}: program header {d} is past the end of the file", .{ triple, index });
|
||||
return;
|
||||
}
|
||||
var phdr: elf.Elf64.Phdr = undefined;
|
||||
@memcpy(std.mem.asBytes(&phdr), bytes[offset..][0..@sizeOf(elf.Elf64.Phdr)]);
|
||||
|
||||
switch (phdr.type) {
|
||||
.INTERP => interps += 1,
|
||||
.DYNAMIC => needed += countNeeded(report, check, triple, bytes, phdr) orelse return,
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
if (interps != 0) {
|
||||
report.fail(check, "{s}: {d} PT_INTERP segment(s); the binary is dynamically linked", .{ triple, interps });
|
||||
return;
|
||||
}
|
||||
if (needed != 0) {
|
||||
report.fail(check, "{s}: {d} DT_NEEDED entr(ies); the binary depends on shared libraries", .{
|
||||
triple, needed,
|
||||
});
|
||||
return;
|
||||
}
|
||||
report.pass(check, "{s}: {t}, no PT_INTERP, no DT_NEEDED", .{ triple, machine });
|
||||
}
|
||||
|
||||
fn countNeeded(
|
||||
report: *Report,
|
||||
comptime check: []const u8,
|
||||
triple: []const u8,
|
||||
bytes: []const u8,
|
||||
phdr: elf.Elf64.Phdr,
|
||||
) ?usize {
|
||||
if (phdr.offset + phdr.filesz > bytes.len) {
|
||||
report.fail(check, "{s}: the dynamic segment is past the end of the file", .{triple});
|
||||
return null;
|
||||
}
|
||||
var count: usize = 0;
|
||||
var offset: u64 = phdr.offset;
|
||||
while (offset + @sizeOf(elf.Elf64_Dyn) <= phdr.offset + phdr.filesz) : (offset += @sizeOf(elf.Elf64_Dyn)) {
|
||||
var dyn: elf.Elf64_Dyn = undefined;
|
||||
@memcpy(std.mem.asBytes(&dyn), bytes[@intCast(offset)..][0..@sizeOf(elf.Elf64_Dyn)]);
|
||||
if (dyn.d_tag == elf.DT_NEEDED) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
fn expectedMachine(triple: []const u8) ?elf.EM {
|
||||
const arch = triple[0 .. std.mem.indexOfScalar(u8, triple, '-') orelse triple.len];
|
||||
if (std.mem.eql(u8, arch, "x86_64")) return .X86_64;
|
||||
if (std.mem.eql(u8, arch, "aarch64")) return .AARCH64;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// `nxdns version` must report the version and commit the build was given.
|
||||
/// Native architecture only: a foreign binary needs qemu, and a `verify-dist`
|
||||
/// that silently ran nothing would be worse than one that says it skipped.
|
||||
fn checkVersionOutput(
|
||||
arena: Allocator,
|
||||
io: Io,
|
||||
report: *Report,
|
||||
args: Args,
|
||||
archive: Archive,
|
||||
) void {
|
||||
const arch = archive.triple[0 .. std.mem.indexOfScalar(u8, archive.triple, '-') orelse archive.triple.len];
|
||||
const native = std.mem.eql(u8, arch, args.host_arch);
|
||||
|
||||
const path = std.fmt.allocPrint(arena, "{s}/{s}/nxdns", .{ args.work_dir, archive.triple }) catch @panic("OOM");
|
||||
const argv: []const []const u8 = if (native)
|
||||
&.{ path, "version" }
|
||||
else if (args.qemu)
|
||||
&.{ std.fmt.allocPrint(arena, "qemu-{s}", .{arch}) catch @panic("OOM"), path, "version" }
|
||||
else {
|
||||
report.skip("version-output", "{s}: a foreign binary needs qemu; pass -fqemu to run it", .{archive.triple});
|
||||
return;
|
||||
};
|
||||
|
||||
const result = std.process.run(arena, io, .{ .argv = argv }) catch |err| {
|
||||
report.fail("version-output", "{s}: cannot run {s}: {t}", .{ archive.triple, argv[0], err });
|
||||
return;
|
||||
};
|
||||
switch (result.term) {
|
||||
.exited => |code| if (code != 0) {
|
||||
report.fail("version-output", "{s}: `nxdns version` exited {d}: {s}", .{
|
||||
archive.triple, code, std.mem.trimEnd(u8, result.stderr, "\n"),
|
||||
});
|
||||
return;
|
||||
},
|
||||
else => {
|
||||
report.fail("version-output", "{s}: `nxdns version` did not exit normally", .{archive.triple});
|
||||
return;
|
||||
},
|
||||
}
|
||||
|
||||
const expected = std.fmt.allocPrint(arena, "nxdns {s} ({s})", .{
|
||||
args.version, args.git_commit,
|
||||
}) catch @panic("OOM");
|
||||
var lines = std.mem.splitScalar(u8, result.stdout, '\n');
|
||||
const first = lines.next() orelse "";
|
||||
if (!std.mem.eql(u8, first, expected)) {
|
||||
report.fail("version-output", "{s}: `nxdns version` printed '{s}', expected '{s}'", .{
|
||||
archive.triple, first, expected,
|
||||
});
|
||||
return;
|
||||
}
|
||||
report.pass("version-output", "{s}: {s}", .{ archive.triple, first });
|
||||
}
|
||||
|
||||
/// `SHA256SUMS` covers the tarballs and nothing else. The container image
|
||||
/// digest does not exist until buildx has pushed, so the release job appends
|
||||
/// that line later; a line here would be a hash of something this step never
|
||||
/// saw.
|
||||
fn checkChecksums(arena: Allocator, io: Io, report: *Report, args: Args, dist: Io.Dir) void {
|
||||
const text = dist.readFileAlloc(io, "SHA256SUMS", arena, .limited(max_input_bytes)) catch |err| {
|
||||
report.fail("sha256sums", "cannot read SHA256SUMS: {t}", .{err});
|
||||
return;
|
||||
};
|
||||
|
||||
var seen: usize = 0;
|
||||
var ok = true;
|
||||
var lines = std.mem.splitScalar(u8, text, '\n');
|
||||
while (lines.next()) |line| {
|
||||
if (line.len == 0) continue;
|
||||
seen += 1;
|
||||
const separator = std.mem.indexOf(u8, line, " ") orelse {
|
||||
report.fail("sha256sums", "line '{s}' is not in sha256sum format", .{line});
|
||||
ok = false;
|
||||
continue;
|
||||
};
|
||||
const hex = line[0..separator];
|
||||
const name = line[separator + 2 ..];
|
||||
|
||||
const known = for (args.archives) |candidate| {
|
||||
if (std.mem.eql(u8, candidate.basename, name)) break true;
|
||||
} else false;
|
||||
if (!known) {
|
||||
report.fail("sha256sums", "'{s}' is not one of the release tarballs", .{name});
|
||||
ok = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const bytes = dist.readFileAlloc(io, name, arena, .limited(max_input_bytes)) catch |err| {
|
||||
report.fail("sha256sums", "cannot read '{s}': {t}", .{ name, err });
|
||||
ok = false;
|
||||
continue;
|
||||
};
|
||||
var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
|
||||
std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{});
|
||||
const actual = std.fmt.bytesToHex(digest, .lower);
|
||||
if (!std.mem.eql(u8, hex, &actual)) {
|
||||
report.fail("sha256sums", "'{s}' hashes to {s}, SHA256SUMS says {s}", .{ name, &actual, hex });
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (seen != args.archives.len) {
|
||||
report.fail("sha256sums", "{d} lines for {d} tarballs", .{ seen, args.archives.len });
|
||||
ok = false;
|
||||
}
|
||||
if (ok) report.pass("sha256sums", "{d} tarball hashes match", .{seen});
|
||||
}
|
||||
|
||||
fn indexOfPayload(name: []const u8) ?usize {
|
||||
for (payload, 0..) |file, index| {
|
||||
if (std.mem.eql(u8, file.name, name)) return index;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user