//! 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 --work-dir --version //! --git-commit --zon --max-bytes //! --asset-free-max-bytes --host-arch [--qemu] //! [--archive ]... //! [--asset-free ]... //! //! 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//`, 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//nxdns` and `zig-out/dist/stage//` 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; }