//! 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",
));
}