release: nix flake with tag-pinned hashes, reproducible tarballs (milestone 40)
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.
This commit is contained in:
+559
-3
@@ -1,6 +1,6 @@
|
||||
//! Release payload staging for `zig build dist` (milestone-14 rulings 3 and 4).
|
||||
//!
|
||||
//! Two modes, both writing only into paths the build system handed them:
|
||||
//! Five modes, all writing only into paths the build system handed them:
|
||||
//!
|
||||
//! dist_stage stage --out <dir> --binary <path> --service <path>
|
||||
//! --sysusers <path> --license <path> --install-md <path>
|
||||
@@ -12,6 +12,27 @@
|
||||
//! scraped from the dependency tree, because a generated notices file that
|
||||
//! nobody reads rots silently into a false statement.
|
||||
//!
|
||||
//! dist_stage archive --root <dir> --payload <name> --out <file.tar.gz>
|
||||
//!
|
||||
//! 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 <SHA256SUMS> --flake <flake.nix> --version <x.y.z>
|
||||
//! dist_stage pin-check --sums <SHA256SUMS> --flake <flake.nix> --version <x.y.z>
|
||||
//!
|
||||
//! 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 <file> [--entry <name> <path>]...
|
||||
//!
|
||||
//! Writes `sha256sum`-format lines, one per `--entry`, in argument order.
|
||||
@@ -55,11 +76,17 @@ pub fn main(init: std.process.Init) !void {
|
||||
const arena = init.arena.allocator();
|
||||
const io = init.io;
|
||||
const args = try init.minimal.args.toSlice(arena);
|
||||
if (args.len < 2) std.process.fatal("usage: dist_stage <stage|sums> ...", .{});
|
||||
if (args.len < 2) std.process.fatal("usage: dist_stage <stage|sums|archive|pin|pin-check> ...", .{});
|
||||
|
||||
if (std.mem.eql(u8, args[1], "stage")) return stage(arena, io, args[2..]);
|
||||
if (std.mem.eql(u8, args[1], "sums")) return sums(arena, io, args[2..]);
|
||||
std.process.fatal("unknown mode '{s}': expected `stage` or `sums`", .{args[1]});
|
||||
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 {
|
||||
@@ -255,3 +282,532 @@ fn parseInventory(arena: Allocator, source: [:0]const u8) ![]const Component {
|
||||
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 `<root>/<payload>`. 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 `<hex> <filename>`.
|
||||
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 `<root>/<name>`, 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",
|
||||
));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user