flake.nix fetches the release tarballs and carries their SRI hashes in a generated block. The cut tool builds the release locally with the toolchain gates.yml pins, in a normalized nine-variable environment, writes the hashes into flake.nix, and commits it with build.zig.zon as the single bump commit. The package job verifies the pins on the bump commit and the publish job verifies them again on the tag, before anything is uploaded. The tarballs are written by dist_stage (std.tar.Writer, flate gzip) instead of the runner's tar and gzip, and -ffile-prefix-map keeps checkout paths out of the C objects; two checkouts at different absolute paths produce byte-identical archives. nxdns version, /api/version and the admin footer report the version only: the bump commit cannot know its own sha.
693 lines
26 KiB
Zig
693 lines
26 KiB
Zig
//! 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>
|
|
//! --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 = "",
|
|
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, "--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}", .{args.version}) 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;
|
|
}
|