//! Release payload staging for `zig build dist` (milestone-14 rulings 3 and 4). //! //! Five modes, all writing only into paths the build system handed them: //! //! dist_stage stage --out --binary --service //! --sysusers --license --install-md //! --licenses //! //! Fills `` with the six files of the tarball payload, each with the //! mode the release specifies. `THIRD-PARTY-NOTICES` is concatenated from //! the reviewed inventory at `/inventory.zon`; it is never //! scraped from the dependency tree, because a generated notices file that //! nobody reads rots silently into a false statement. //! //! dist_stage archive --root --payload --out //! //! Writes the release tarball itself, so that the bytes depend on the //! staged tree and nothing else. The host's `tar` and `gzip` cannot supply //! that: their member order, their padding and their gzip header all vary //! with the implementation, the locale and the clock. //! //! dist_stage pin --sums --flake --version //! dist_stage pin-check --sums --flake --version //! //! Rewrite, or assert, the generated block of `flake.nix` from the sums //! file. `pin` backs `zig build pin-flake`, which the cut runs before its //! bump commit; `pin-check` backs `zig build verify-pins`, which CI runs on //! that commit and again on the tag, so a release whose pinned hashes do not //! describe its own bytes is refused before anything is uploaded. //! //! `verify-pins` is a step of its own and NOT part of `verify-dist`: an //! ordinary commit between two cuts builds the same `build.zig.zon` version //! from a different tree, so its bytes never match the pins and checking //! them there would fail every such build. //! //! dist_stage sums --out [--entry ]... //! //! Writes `sha256sum`-format lines, one per `--entry`, in argument order. //! `` is the name the asset carries in the release, not the cache //! path the bytes were read from. //! //! Modes are set with an explicit `setPermissions` after creation rather than //! through the creation mode, which the process umask would mask. const std = @import("std"); const Allocator = std.mem.Allocator; const Io = std.Io; const max_input_bytes = 256 << 20; /// One reviewed component of the shipped artifacts. The build system owns this /// shape; `licenses/inventory.zon` is a tuple of these, in the order they /// should appear in `THIRD-PARTY-NOTICES`. /// Mirrors `Entry` in `src/licenses_drift_test.zig`, which type-checks the same /// file at compile time. The two must agree; a mismatch fails one of them. const Component = struct { /// The component as an operator would name it. component: []const u8, /// The version(s) actually shipped. version: []const u8, /// Why its licence applies to what we ship, and which option was taken /// when the component offers a choice. note: []const u8, /// Licence text, relative to `licenses/`. file: []const u8, }; const PayloadFile = struct { name: []const u8, mode: std.posix.mode_t, }; const payload_dir_mode: std.posix.mode_t = 0o755; pub fn main(init: std.process.Init) !void { const arena = init.arena.allocator(); const io = init.io; const args = try init.minimal.args.toSlice(arena); if (args.len < 2) std.process.fatal("usage: dist_stage ...", .{}); if (std.mem.eql(u8, args[1], "stage")) return stage(arena, io, args[2..]); if (std.mem.eql(u8, args[1], "sums")) return sums(arena, io, args[2..]); if (std.mem.eql(u8, args[1], "archive")) return archive(arena, io, args[2..]); if (std.mem.eql(u8, args[1], "pin")) return pin(arena, io, args[2..], .rewrite); if (std.mem.eql(u8, args[1], "pin-check")) return pin(arena, io, args[2..], .check); std.process.fatal( "unknown mode '{s}': expected `stage`, `sums`, `archive`, `pin` or `pin-check`", .{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}), }; } // --- archive ----------------------------------------------------------------- const binary_name = "nxdns"; /// gzip level 9, the level the release archives were produced at before this /// tool owned them. The level is part of the output bytes, so it is fixed here /// rather than read from anywhere. const compression: std.compress.flate.Compress.Options = .level_9; const ArchiveEntry = struct { /// Path inside the tarball, payload directory included. path: []const u8, kind: std.Io.File.Kind, fn lessThan(_: void, a: ArchiveEntry, b: ArchiveEntry) bool { return std.mem.order(u8, a.path, b.path) == .lt; } }; fn archive(arena: Allocator, io: Io, args: []const []const u8) !void { var root_path: ?[]const u8 = null; var payload_name: ?[]const u8 = null; var out_path: ?[]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]; if (std.mem.eql(u8, args[i], "--root")) { root_path = value; } else if (std.mem.eql(u8, args[i], "--payload")) { payload_name = value; } else if (std.mem.eql(u8, args[i], "--out")) { out_path = value; } else { std.process.fatal("unknown flag '{s}'", .{args[i]}); } } const root = required(root_path, "--root"); const name = required(payload_name, "--payload"); const out = required(out_path, "--out"); const bytes = buildArchive(arena, io, root, name) catch |err| { std.process.fatal("cannot archive '{s}/{s}': {t}", .{ root, name, err }); }; try writeFileWithMode(io, Io.Dir.cwd(), out, bytes, 0o644); } /// The tarball bytes for `/`. Every field a tar member carries /// is fixed here — order, mode, mtime, uid, gid, user and group names — so two /// runs over the same file contents produce the same bytes whatever the /// absolute path, the umask, the clock or the locale is. fn buildArchive(arena: Allocator, io: Io, root: []const u8, name: []const u8) ![]const u8 { const payload_path = try std.fs.path.join(arena, &.{ root, name }); var payload_dir = Io.Dir.cwd().openDir(io, payload_path, .{ .iterate = true }) catch |err| { std.process.fatal("cannot open the payload directory '{s}': {t}", .{ payload_path, err }); }; defer payload_dir.close(io); var entries: std.ArrayList(ArchiveEntry) = .empty; var walker = try payload_dir.walk(arena); defer walker.deinit(); while (walker.next(io) catch |err| { std.process.fatal("cannot walk '{s}': {t}", .{ payload_path, err }); }) |entry| { switch (entry.kind) { .file, .directory => {}, // A payload that grew a symlink or a device node would ship // something the verifier rejects; say so here instead. else => std.process.fatal( "'{s}/{s}' is a {t}: the release payload holds only files and directories", .{ payload_path, entry.path, entry.kind }, ), } try entries.append(arena, .{ .path = try std.fs.path.join(arena, &.{ name, entry.path }), .kind = entry.kind, }); } std.mem.sort(ArchiveEntry, entries.items, {}, ArchiveEntry.lessThan); var tar_bytes: Io.Writer.Allocating = try .initCapacity(arena, 1 << 20); var tar_writer: std.tar.Writer = .{ .underlying_writer = &tar_bytes.writer }; try tar_writer.writeDir(name, .{ .mode = payload_dir_mode, .mtime = 0 }); for (entries.items) |entry| { switch (entry.kind) { .directory => try tar_writer.writeDir(entry.path, .{ .mode = payload_dir_mode, .mtime = 0 }), .file => { const relative = entry.path[name.len + 1 ..]; const bytes = payload_dir.readFileAlloc(io, relative, arena, .limited(max_input_bytes)) catch |err| { std.process.fatal("cannot read '{s}/{s}': {t}", .{ payload_path, relative, err }); }; const mode: std.posix.mode_t = if (std.mem.eql(u8, relative, binary_name)) 0o755 else 0o644; try tar_writer.writeFileBytes(entry.path, bytes, .{ .mode = mode, .mtime = 0 }); }, else => unreachable, } } try tar_writer.finishPedantically(); var gz_bytes: Io.Writer.Allocating = try .initCapacity(arena, 1 << 20); const window = try arena.alloc(u8, std.compress.flate.max_window_len); var compress: std.compress.flate.Compress = try .init(&gz_bytes.writer, window, .gzip, compression); try compress.writer.writeAll(tar_bytes.written()); try compress.finish(); return gz_bytes.written(); } // --- pin / pin-check --------------------------------------------------------- /// The two release systems, and the target triple each one's tarball carries. /// Nix names the system, the release names the triple; the block is written in /// this order. const pin_targets = [_]struct { system: []const u8, triple: []const u8 }{ .{ .system = "aarch64-linux", .triple = "aarch64-linux-musl" }, .{ .system = "x86_64-linux", .triple = "x86_64-linux-musl" }, }; const begin_marker = "# BEGIN GENERATED BY zig build cut"; const end_marker = "# END GENERATED BY zig build cut"; const PinMode = enum { rewrite, check }; const digest_length = std.crypto.hash.sha2.Sha256.digest_length; fn pin(arena: Allocator, io: Io, args: []const []const u8, mode: PinMode) !void { var sums_path: ?[]const u8 = null; var flake_path: ?[]const u8 = null; var version: ?[]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]; if (std.mem.eql(u8, args[i], "--sums")) { sums_path = value; } else if (std.mem.eql(u8, args[i], "--flake")) { flake_path = value; } else if (std.mem.eql(u8, args[i], "--version")) { version = value; } else { std.process.fatal("unknown flag '{s}'", .{args[i]}); } } const sums_file = required(sums_path, "--sums"); const flake_file = required(flake_path, "--flake"); const version_string = required(version, "--version"); const sums_text = Io.Dir.cwd().readFileAlloc(io, sums_file, arena, .limited(max_input_bytes)) catch |err| { std.process.fatal("cannot read '{s}': {t}", .{ sums_file, err }); }; const flake_text = Io.Dir.cwd().readFileAlloc(io, flake_file, arena, .limited(max_input_bytes)) catch |err| { std.process.fatal("cannot read '{s}': {t}", .{ flake_file, err }); }; var expected: [pin_targets.len][]const u8 = undefined; for (pin_targets, &expected) |target, *slot| { const asset = try std.fmt.allocPrint(arena, "nxdns-{s}-{s}.tar.gz", .{ version_string, target.triple }); slot.* = hashFromSums(sums_text, asset) orelse std.process.fatal("'{s}' has no line for '{s}'", .{ sums_file, asset }); } switch (mode) { .rewrite => { const rewritten = try rewriteBlock(arena, flake_text, flake_file, version_string, expected); try writeFileWithMode(io, Io.Dir.cwd(), flake_file, rewritten, 0o644); }, .check => { const block = try readBlock(arena, flake_text, flake_file); var failed = false; if (!std.mem.eql(u8, block.version, version_string)) { std.debug.print( "{s}: the generated block pins version {s}, the release is {s}\n", .{ flake_file, block.version, version_string }, ); failed = true; } for (pin_targets, block.hashes, expected) |target, found, want| { const want_sri = try sriFromHex(arena, want); if (!std.mem.eql(u8, found, want_sri)) { std.debug.print( "{s}: {s} is pinned to {s}, the release tarball hashes to {s}\n", .{ flake_file, target.system, found, want_sri }, ); failed = true; } } if (failed) std.process.fatal( "flake.nix does not describe this release; run `zig build cut`, which pins before it commits", .{}, ); }, } } /// The hex digest `sha256sum` prints for `asset`, or null when the file has no /// such line. Lines are ` `. fn hashFromSums(text: []const u8, asset: []const u8) ?[]const u8 { var lines = std.mem.splitScalar(u8, text, '\n'); while (lines.next()) |line| { const separator = std.mem.indexOf(u8, line, " ") orelse continue; if (!std.mem.eql(u8, std.mem.trimEnd(u8, line[separator + 2 ..], "\r"), asset)) continue; return line[0..separator]; } return null; } fn sriFromHex(arena: Allocator, hex: []const u8) ![]const u8 { if (hex.len != digest_length * 2) std.process.fatal("'{s}' is not a sha256 hex digest", .{hex}); var raw: [digest_length]u8 = undefined; _ = std.fmt.hexToBytes(&raw, hex) catch std.process.fatal("'{s}' is not a sha256 hex digest", .{hex}); const encoder = std.base64.standard.Encoder; const out = try arena.alloc(u8, "sha256-".len + encoder.calcSize(raw.len)); @memcpy(out[0.."sha256-".len], "sha256-"); _ = encoder.encode(out["sha256-".len..], &raw); return out; } const Block = struct { version: []const u8, hashes: [pin_targets.len][]const u8, }; /// Byte offsets of the generated block in `text`, marker lines included. const BlockSpan = struct { start: usize, end: usize, indent: []const u8, }; /// Why `locateBlock` refused. The generated block is rewritten in place, so a /// file that does not delimit exactly one block has no unambiguous region to /// rewrite and every reading of it is a guess. const BlockError = error{ NotOneBegin, NotOneEnd, EndBeforeBegin, }; fn locateBlock(text: []const u8) BlockError!BlockSpan { var begins: usize = 0; var ends: usize = 0; var offset: usize = 0; var begin_at: usize = 0; var end_at: usize = 0; var indent: []const u8 = ""; while (offset < text.len) { const line_end = std.mem.indexOfScalarPos(u8, text, offset, '\n') orelse text.len; const line = text[offset..line_end]; const trimmed = std.mem.trimStart(u8, line, " \t"); if (std.mem.eql(u8, trimmed, begin_marker)) { begins += 1; begin_at = offset; indent = line[0 .. line.len - trimmed.len]; } else if (std.mem.eql(u8, trimmed, end_marker)) { ends += 1; end_at = @min(line_end + 1, text.len); } offset = line_end + 1; } if (begins != 1) return error.NotOneBegin; if (ends != 1) return error.NotOneEnd; if (end_at <= begin_at) return error.EndBeforeBegin; return .{ .start = begin_at, .end = end_at, .indent = indent }; } fn findBlock(text: []const u8, path: []const u8) BlockSpan { return locateBlock(text) catch |err| switch (err) { error.NotOneBegin => std.process.fatal( "{s}: expected exactly one '{s}' line", .{ path, begin_marker }, ), error.NotOneEnd => std.process.fatal( "{s}: expected exactly one '{s}' line", .{ path, end_marker }, ), error.EndBeforeBegin => std.process.fatal( "{s}: the '{s}' line comes before the '{s}' line", .{ path, end_marker, begin_marker }, ), }; } fn readBlock(arena: Allocator, text: []const u8, path: []const u8) !Block { const span = findBlock(text, path); var block: Block = .{ .version = "", .hashes = @splat("") }; var lines = std.mem.splitScalar(u8, text[span.start..span.end], '\n'); while (lines.next()) |line| { const trimmed = std.mem.trim(u8, line, " \t"); if (std.mem.startsWith(u8, trimmed, "version = ")) { block.version = try quoted(arena, trimmed, path); } for (pin_targets, &block.hashes) |target, *slot| { const prefix = try std.fmt.allocPrint(arena, "\"{s}\" = ", .{target.system}); if (std.mem.startsWith(u8, trimmed, prefix)) { slot.* = try quoted(arena, trimmed[prefix.len..], path); } } } if (block.version.len == 0) std.process.fatal("{s}: the generated block has no `version` line", .{path}); for (pin_targets, block.hashes) |target, hash| { if (hash.len == 0) std.process.fatal( "{s}: the generated block has no hash for {s}", .{ path, target.system }, ); } return block; } /// The contents of the last double-quoted string on `line`. fn quoted(arena: Allocator, line: []const u8, path: []const u8) ![]const u8 { const open = std.mem.indexOfScalar(u8, line, '"') orelse std.process.fatal("{s}: '{s}' has no quoted value", .{ path, line }); const close = std.mem.indexOfScalarPos(u8, line, open + 1, '"') orelse std.process.fatal("{s}: '{s}' has no closing quote", .{ path, line }); return arena.dupe(u8, line[open + 1 .. close]); } fn rewriteBlock( arena: Allocator, text: []const u8, path: []const u8, version: []const u8, hex: [pin_targets.len][]const u8, ) ![]const u8 { const span = findBlock(text, path); var out: Io.Writer.Allocating = try .initCapacity(arena, text.len + 512); const w = &out.writer; try w.writeAll(text[0..span.start]); try w.print("{s}{s}\n", .{ span.indent, begin_marker }); try w.print("{s}version = \"{s}\";\n", .{ span.indent, version }); try w.print("{s}hashes = {{\n", .{span.indent}); for (pin_targets, hex) |target, digest| { try w.print("{s} \"{s}\" = \"{s}\";\n", .{ span.indent, target.system, try sriFromHex(arena, digest) }); } try w.print("{s}}};\n", .{span.indent}); try w.print("{s}{s}\n", .{ span.indent, end_marker }); try w.writeAll(text[span.end..]); return out.written(); } // --- tests ------------------------------------------------------------------- const testing = std.testing; /// The release payload, in the byte order the archive must list it in. const fixture = [_]struct { name: []const u8, contents: []const u8, mode: std.posix.mode_t }{ .{ .name = "INSTALL.md", .contents = "# install\n", .mode = 0o644 }, .{ .name = "LICENSE", .contents = "EUPL\n", .mode = 0o644 }, .{ .name = "THIRD-PARTY-NOTICES", .contents = "notices\n", .mode = 0o644 }, .{ .name = binary_name, .contents = "\x7fELF not really", .mode = 0o755 }, .{ .name = "nxdns.conf", .contents = "u nxdns\n", .mode = 0o644 }, .{ .name = "nxdns.service", .contents = "[Unit]\n", .mode = 0o644 }, }; /// Stages the fixture under `/`, writing the files in `order` and /// stamping each one with `mtime` so the two staged trees differ in everything /// but their contents. fn stageFixture( io: Io, root: Io.Dir, name: []const u8, order: []const usize, mtime: i96, ) !void { try root.createDir(io, name, .fromMode(payload_dir_mode)); var dir = try root.openDir(io, name, .{ .iterate = true }); defer dir.close(io); for (order) |index| { const file = fixture[index]; var handle = try dir.createFile(io, file.name, .{}); defer handle.close(io); try handle.writeStreamingAll(io, file.contents); try handle.setPermissions(io, .fromMode(file.mode)); try handle.setTimestamps(io, .{ .access_timestamp = .{ .new = .{ .nanoseconds = mtime } }, .modify_timestamp = .{ .new = .{ .nanoseconds = mtime } }, }); } } test "archive bytes do not depend on the stage path, the file order or the mtimes" { const io = testing.io; var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); var first_tmp = testing.tmpDir(.{ .iterate = true }); defer first_tmp.cleanup(); var second_tmp = testing.tmpDir(.{ .iterate = true }); defer second_tmp.cleanup(); const name = "nxdns-0.0.16-x86_64-linux-musl"; try stageFixture(io, first_tmp.dir, name, &.{ 0, 1, 2, 3, 4, 5 }, 0); try stageFixture(io, second_tmp.dir, name, &.{ 5, 3, 1, 4, 2, 0 }, 1_700_000_000 * std.time.ns_per_s); const first_root = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}", .{first_tmp.sub_path}); const second_root = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}", .{second_tmp.sub_path}); const first = try buildArchive(arena, io, first_root, name); const second = try buildArchive(arena, io, second_root, name); try testing.expectEqualSlices(u8, first, second); // The gzip header carries no name and no mtime: bytes 4..8 are the mtime // field of RFC 1952, and bit 3 of the flag byte would announce a name. try testing.expectEqual(@as(u8, 0x1f), first[0]); try testing.expectEqual(@as(u8, 0x8b), first[1]); try testing.expectEqual(@as(u8, 0), first[3] & 0b0000_1000); try testing.expectEqualSlices(u8, &.{ 0, 0, 0, 0 }, first[4..8]); var gz_reader: Io.Reader = .fixed(first); const window = try arena.alloc(u8, std.compress.flate.max_window_len); var decompress: std.compress.flate.Decompress = .init(&gz_reader, .gzip, window); var it: std.tar.Iterator = .init(&decompress.reader, .{ .file_name_buffer = try arena.alloc(u8, std.fs.max_path_bytes), .link_name_buffer = try arena.alloc(u8, std.fs.max_path_bytes), }); const root_entry = (try it.next()).?; try testing.expectEqual(std.tar.FileKind.directory, root_entry.kind); try testing.expectEqualStrings(name, std.mem.trimEnd(u8, root_entry.name, "/")); try testing.expectEqual(@as(u32, payload_dir_mode), root_entry.mode & 0o7777); for (fixture) |file| { const entry = (try it.next()).?; try testing.expectEqual(std.tar.FileKind.file, entry.kind); const expected_name = try std.fmt.allocPrint(arena, "{s}/{s}", .{ name, file.name }); try testing.expectEqualStrings(expected_name, entry.name); try testing.expectEqual(@as(u32, @intCast(file.mode)), entry.mode & 0o7777); try testing.expectEqual(@as(u64, file.contents.len), entry.size); var sink: Io.Writer.Allocating = .init(arena); try it.streamRemaining(entry, &sink.writer); try testing.expectEqualStrings(file.contents, sink.written()); } try testing.expect((try it.next()) == null); } test "sriFromHex encodes the raw digest, not its hex text" { var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const zeros = "00" ** digest_length; try testing.expectEqualStrings( "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", try sriFromHex(arena, zeros), ); } test "hashFromSums picks the line for the named asset" { const text = "1111111111111111111111111111111111111111111111111111111111111111 nxdns-0.0.16-aarch64-linux-musl.tar.gz\n" ++ "2222222222222222222222222222222222222222222222222222222222222222 nxdns-0.0.16-x86_64-linux-musl.tar.gz\n"; try testing.expectEqualStrings( "2222222222222222222222222222222222222222222222222222222222222222", hashFromSums(text, "nxdns-0.0.16-x86_64-linux-musl.tar.gz").?, ); try testing.expectEqual(@as(?[]const u8, null), hashFromSums(text, "nxdns-0.0.16-riscv64-linux-musl.tar.gz")); } test "rewriteBlock keeps the begin line's indentation and the rest of the file" { var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const before = "let\n" ++ " " ++ begin_marker ++ "\n" ++ " version = \"0.0.1\";\n" ++ " hashes = {\n" ++ " \"aarch64-linux\" = \"sha256-old\";\n" ++ " \"x86_64-linux\" = \"sha256-old\";\n" ++ " };\n" ++ " " ++ end_marker ++ "\n" ++ "in\n"; const rewritten = try rewriteBlock(arena, before, "flake.nix", "0.0.16", .{ "00" ** digest_length, "ff" ** digest_length, }); const expected = "let\n" ++ " " ++ begin_marker ++ "\n" ++ " version = \"0.0.16\";\n" ++ " hashes = {\n" ++ " \"aarch64-linux\" = \"sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\";\n" ++ " \"x86_64-linux\" = \"sha256-//////////////////////////////////////////8=\";\n" ++ " };\n" ++ " " ++ end_marker ++ "\n" ++ "in\n"; try testing.expectEqualStrings(expected, rewritten); const block = try readBlock(arena, rewritten, "flake.nix"); try testing.expectEqualStrings("0.0.16", block.version); try testing.expectEqualStrings("sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", block.hashes[0]); } test "locateBlock refuses a file that does not delimit exactly one block" { const body = " version = \"0.0.1\";\n" ++ " hashes = {\n" ++ " };\n"; const one = "let\n" ++ " " ++ begin_marker ++ "\n" ++ body ++ " " ++ end_marker ++ "\nin\n"; const span = try locateBlock(one); try testing.expectEqualStrings(" ", span.indent); try testing.expectError(error.NotOneEnd, locateBlock( "let\n" ++ " " ++ begin_marker ++ "\n" ++ body ++ "in\n", )); try testing.expectError(error.NotOneEnd, locateBlock( "let\n" ++ " " ++ begin_marker ++ "\n" ++ body ++ " " ++ end_marker ++ "\n" ++ " " ++ end_marker ++ "\nin\n", )); try testing.expectError(error.EndBeforeBegin, locateBlock( "let\n" ++ " " ++ end_marker ++ "\n" ++ body ++ " " ++ begin_marker ++ "\nin\n", )); try testing.expectError(error.NotOneBegin, locateBlock( "let\n" ++ body ++ " " ++ end_marker ++ "\nin\n", )); }