Gates / package (push) Successful in 8m51s
Release / guard (push) Successful in 1m33s
Gates / test-aarch64 (push) Successful in 4m24s
Gates / frontend (push) Successful in 44s
Gates / package (push) Successful in 33s
Gates / container (push) Successful in 35s
Release / gates (push) Successful in 8m24s
Gates / test-aarch64 (push) Successful in 7m56s
Gates / frontend (push) Successful in 1m31s
Gates / test (push) Failing after 16m8s
Gates / container (push) Successful in 5m11s
CI / gates (push) Failing after 30m3s
Gates / test (push) Successful in 2m7s
Release / publish (push) Failing after 10m20s
859 lines
38 KiB
Zig
859 lines
38 KiB
Zig
const std = @import("std");
|
|
const builtin = @import("builtin");
|
|
|
|
comptime {
|
|
if (builtin.zig_version.major != 0 or builtin.zig_version.minor != 16) {
|
|
@compileError("nxdns requires Zig 0.16.x, found " ++ builtin.zig_version_string);
|
|
}
|
|
}
|
|
|
|
const cross_targets = [_][]const u8{
|
|
"x86_64-linux-musl",
|
|
"aarch64-linux-musl",
|
|
};
|
|
|
|
/// The deploy target, read out of `cross_targets` so `test-aarch64` and `dist`
|
|
/// cannot describe different machines. Reordering the array is caught here
|
|
/// rather than by a qemu job that quietly ran the wrong architecture.
|
|
const aarch64_triple = cross_targets[1];
|
|
comptime {
|
|
if (!std.mem.startsWith(u8, aarch64_triple, "aarch64-")) {
|
|
@compileError("cross_targets[1] must be the aarch64 triple, found " ++ aarch64_triple);
|
|
}
|
|
}
|
|
|
|
pub fn build(b: *std.Build) void {
|
|
const target = b.standardTargetOptions(.{});
|
|
const optimize = b.standardOptimizeOption(.{});
|
|
|
|
const integration = b.option(bool, "integration", "Run hermetic integration tests (loopback sockets only)") orelse false;
|
|
const live = b.option(bool, "live", "Run tests that reach external network hosts") orelse false;
|
|
const fuzz = b.option(bool, "fuzz", "Build the fuzz targets with the LLVM backend (required for --fuzz)") orelse false;
|
|
// Milestone-17 ruling 5. The embedded golden carries no source-tree path,
|
|
// so regeneration names the destination explicitly:
|
|
// zig build test -Dintegration \
|
|
// -Dcontract-samples-out="$PWD/web/src/lib/contractSamples.gen.ts"
|
|
const contract_samples_out = b.option(
|
|
[]const u8,
|
|
"contract-samples-out",
|
|
"Absolute path the contract-sample generator writes instead of comparing",
|
|
) orelse "";
|
|
// `dist` requires this option (milestone-14 ruling 4): a release must carry
|
|
// the tag's version, never a development default. Every other step keeps
|
|
// the default, so `zig build` and `zig build test` need no flag.
|
|
const version_option = b.option([]const u8, "version-string", "Version reported by `nxdns version` (required by `dist`)");
|
|
const version_string = version_option orelse "0.1.0-dev";
|
|
const git_commit = b.option([]const u8, "git-commit", "Git commit reported by `nxdns version`") orelse "unknown";
|
|
const web_dist = b.option(
|
|
[]const u8,
|
|
"web-dist",
|
|
"Built web UI directory to embed (default: the placeholder page). " ++
|
|
"Only the exact value `web/dist` gets the freshness check; " ++
|
|
"the placeholder and any other path skip it.",
|
|
) orelse "web/dist-placeholder";
|
|
|
|
// `b.path` panics on absolute paths, and a CI artifact directory is one.
|
|
const web_dist_path: std.Build.LazyPath = if (std.fs.path.isAbsolute(web_dist))
|
|
.{ .cwd_relative = web_dist }
|
|
else
|
|
b.path(web_dist);
|
|
|
|
// A stale `web/dist` shipped a crashing settings page once (milestone-15
|
|
// ruling 5). The stamp is checked only for the real dist tree: the
|
|
// placeholder has no sources to be stale against, and an explicit path is a
|
|
// CI artifact that was built elsewhere.
|
|
const web_dist_check: ?*std.Build.Step = if (std.mem.eql(u8, web_dist, "web/dist")) check: {
|
|
const run_check = b.addSystemCommand(&.{"node"});
|
|
// The script path goes through `b.path` so it resolves against the
|
|
// build root: `zig build` run from any other directory would not find
|
|
// a cwd-relative one.
|
|
run_check.addFileArg(b.path("web/scripts/stamp-dist.mjs"));
|
|
run_check.addArg("--check");
|
|
break :check &run_check.step;
|
|
} else null;
|
|
const web_assets = webAssetsIndex(b, web_dist_path, web_dist_check);
|
|
|
|
const options = b.addOptions();
|
|
options.addOption(bool, "integration", integration);
|
|
options.addOption(bool, "live", live);
|
|
options.addOption([]const u8, "version_string", version_string);
|
|
options.addOption([]const u8, "git_commit", git_commit);
|
|
options.addOption([]const u8, "zig_version_string", builtin.zig_version_string);
|
|
options.addOption([]const u8, "contract_samples_out", contract_samples_out);
|
|
|
|
const exe = addExecutable(b, target, optimize, options, web_assets);
|
|
b.installArtifact(exe);
|
|
|
|
const run = b.addRunArtifact(exe);
|
|
run.step.dependOn(b.getInstallStep());
|
|
if (b.args) |args| run.addArgs(args);
|
|
b.step("run", "Run nxdns").dependOn(&run.step);
|
|
|
|
// Zig collects tests only from the root module, so a file missing from
|
|
// src/tests.zig silently contributes no tests. The list stays hand-written
|
|
// (generating it from a staged copy would point diagnostics at cache
|
|
// paths); this makes it complete by construction instead.
|
|
checkTestImports(b);
|
|
|
|
// A successful `zig build test` still prints `failed command: .../test
|
|
// ... --listen=-` as its last line. This is an upstream zig 0.16.0
|
|
// build-runner labelling defect, not a failure here, and not something
|
|
// this build script can suppress without hiding real failures.
|
|
//
|
|
// Mechanism, verified against the 0.16.0 sources on 2026-08-07:
|
|
// - std/Build/Step/Run.zig:1540 sets `result_failed_command` for every
|
|
// spawn, unconditionally ("if an error occurs, it's caused by this
|
|
// command"). Nothing clears it when the child succeeds.
|
|
// - compiler/build_runner.zig:1381 prints a step's diagnostics whenever
|
|
// `result_stderr` is non-empty, explicitly "no matter the result".
|
|
// - compiler/build_runner.zig:1515, reached from there, emits the
|
|
// `failed command: ` line because `result_failed_command` is non-null.
|
|
// So any Run step that both succeeds and writes one byte to stderr gets
|
|
// the label. Our suite writes plenty: the tests that exercise the warning
|
|
// paths log through the real sink.
|
|
//
|
|
// Minimal reproducer, no mbedTLS and no C: one passing test whose body is
|
|
// `std.debug.print` plus `try expect(true)`, in a build.zig with nothing
|
|
// but `addTest` + `addRunArtifact`. It prints the label and reports
|
|
// "3/3 steps succeeded; 1/1 tests passed". Deleting the print removes the
|
|
// label. The test child does not crash and does not abort in teardown.
|
|
// (Running the cached test binary by hand with `--listen=-` does abort,
|
|
// but only because stdin is then closed and the IPC runner panics on
|
|
// `EndOfStream`; that is an artifact of the manual invocation.)
|
|
//
|
|
// No upstream issue matched a search of ziglang/zig for this behaviour;
|
|
// the reference is the 0.16.0 source lines above. See AGENTS.md.
|
|
const licenses_files = licensesFilesRoot(b);
|
|
|
|
const tests = addTestSuite(b, target, optimize, options, web_assets, licenses_files);
|
|
const test_step = b.step("test", "Run the test suite");
|
|
test_step.dependOn(&b.addRunArtifact(tests).step);
|
|
|
|
// Fuzz targets compile as a second test artifact with `dns` as a named module
|
|
// (a file belongs to one module per compilation; the aggregator keeps owning
|
|
// the in-file tests). `-Dfuzz` opts into the LLVM backend, which `--fuzz`
|
|
// needs for sanitizer coverage; stock 0.16.0 also requires a patched
|
|
// test_runner.zig for fuzz mode — see specs/milestone-2.md.
|
|
addFuzzSuite(b, target, optimize, fuzz, test_step, .{
|
|
.name = "fuzz",
|
|
.root = "tests/fuzz/dns_fuzz.zig",
|
|
.import_name = "dns",
|
|
.import_module = sourceModule(b, target, optimize, "src/dns/dns.zig"),
|
|
});
|
|
|
|
addFuzzSuite(b, target, optimize, fuzz, test_step, .{
|
|
.name = "blocklist-fuzz",
|
|
.root = "tests/fuzz/blocklist_fuzz.zig",
|
|
.import_name = "parsers",
|
|
.import_module = sourceModule(b, target, optimize, "src/filter/parsers.zig"),
|
|
});
|
|
|
|
// `src/web/http_util.zig` imports only std, so its fuzz module roots
|
|
// directly at the file — no aggregator needed (milestone-15 ruling 6c).
|
|
addFuzzSuite(b, target, optimize, fuzz, test_step, .{
|
|
.name = "http-util-fuzz",
|
|
.root = "tests/fuzz/http_util_fuzz.zig",
|
|
.import_name = "http_util",
|
|
.import_module = sourceModule(b, target, optimize, "src/web/http_util.zig"),
|
|
});
|
|
|
|
// The bench harness (milestone-12 ruling 1). The measured roots
|
|
// (matcher.zig, dns_cache.zig, compiler.zig) share files in their relative
|
|
// import closures (model.zig, types.zig, ...), and a file may belong to
|
|
// only one module per compilation — separate modules per root cannot link
|
|
// into one executable. So one staged module: a copy of src/ plus a
|
|
// generated aggregator root, imported by the bench as `core`. No sqlite,
|
|
// no mbedTLS: the closure is pure Zig.
|
|
//
|
|
// The compiler fuzz target reuses the same staged tree (milestone-15
|
|
// ruling 6b): `compiler.zig` imports `../dns/`, so a module rooted under
|
|
// `src/filter/` fails with ImportOutsideModulePath.
|
|
const bench_stage = b.addWriteFiles();
|
|
_ = bench_stage.addCopyDirectory(b.path("src"), "src", .{});
|
|
const bench_core = bench_stage.add("bench_core.zig",
|
|
\\pub const matcher = @import("src/filter/matcher.zig");
|
|
\\pub const dns_cache = @import("src/cache/dns_cache.zig");
|
|
\\pub const compiler = @import("src/filter/compiler.zig");
|
|
\\pub const model = @import("src/config/model.zig");
|
|
\\pub const dns_name = @import("src/dns/name.zig");
|
|
\\pub const dns_types = @import("src/dns/types.zig");
|
|
\\pub const packet = @import("src/dns/packet.zig");
|
|
\\
|
|
);
|
|
const bench_core_mod = b.createModule(.{
|
|
.root_source_file = bench_core,
|
|
.target = target,
|
|
.optimize = optimize,
|
|
});
|
|
addFuzzSuite(b, target, optimize, fuzz, test_step, .{
|
|
.name = "compiler-fuzz",
|
|
.root = "tests/fuzz/compiler_fuzz.zig",
|
|
.import_name = "core",
|
|
.import_module = bench_core_mod,
|
|
});
|
|
|
|
const bench_mod = b.createModule(.{
|
|
.root_source_file = b.path("tools/bench.zig"),
|
|
.target = target,
|
|
.optimize = optimize,
|
|
});
|
|
bench_mod.addImport("core", bench_core_mod);
|
|
const bench_exe = b.addExecutable(.{ .name = "bench", .root_module = bench_mod });
|
|
const bench_run = b.addRunArtifact(bench_exe);
|
|
if (b.args) |args| bench_run.addArgs(args);
|
|
b.step("bench", "Run the performance benchmarks (PLAN §18)").dependOn(&bench_run.step);
|
|
|
|
// aarch64 test execution (milestone-12 ruling 6): the plain suite
|
|
// cross-built for the deploy target and run under qemu-user
|
|
// (`zig build test-aarch64 -fqemu`). Fuzz artifacts stay native-only, and
|
|
// -Dintegration stays out (ruling 7): qemu-user's slowdown makes the
|
|
// wall-clock-budgeted loopback TLS tests a flake source.
|
|
const aarch64_target = b.resolveTargetQuery(
|
|
std.Target.Query.parse(.{ .arch_os_abi = aarch64_triple }) catch unreachable,
|
|
);
|
|
const aarch64_tests = addTestSuite(b, aarch64_target, optimize, options, web_assets, licenses_files);
|
|
aarch64_tests.linkage = .static;
|
|
const aarch64_run = b.addRunArtifact(aarch64_tests);
|
|
aarch64_run.skip_foreign_checks = true;
|
|
b.step("test-aarch64", "Run the test suite for aarch64-linux-musl (use -fqemu)")
|
|
.dependOn(&aarch64_run.step);
|
|
|
|
// The release publication tool (milestone-14 deviation 24). It is a host
|
|
// tool like `dist_stage` and `verify_dist`, and it is installed rather than
|
|
// run from the build graph: the workflow invokes it once per phase with the
|
|
// secrets in its environment, and a Run step would have to carry them.
|
|
const release_tool = hostTool(b, "release");
|
|
b.step("release-tool", "Install the release publication tool into zig-out/bin")
|
|
.dependOn(&b.addInstallArtifact(release_tool, .{}).step);
|
|
|
|
// Its pure decisions — semver ordering, VALIDSIG field selection, changelog
|
|
// extraction, the releases-payload shape guard — are the reason it exists,
|
|
// so they run in the same `zig build test` as everything else.
|
|
const release_tests = b.addTest(.{
|
|
.name = "release-tool",
|
|
.root_module = b.createModule(.{
|
|
.root_source_file = b.path("tools/release.zig"),
|
|
.target = b.graph.host,
|
|
.optimize = optimize,
|
|
}),
|
|
});
|
|
test_step.dependOn(&b.addRunArtifact(release_tests).step);
|
|
|
|
addDist(b, options, web_assets, .{
|
|
.version = version_option,
|
|
.version_string = version_string,
|
|
.git_commit = git_commit,
|
|
.web_dist = web_dist,
|
|
});
|
|
}
|
|
|
|
/// Byte budgets from PLAN §18, asserted by `verify-dist` rather than by shell.
|
|
const max_binary_bytes = 15_728_640;
|
|
const max_asset_free_binary_bytes = 10_485_760;
|
|
|
|
/// The default of `-Dweb-dist`. A release built without the flag would ship the
|
|
/// placeholder admin page, so `dist` refuses it (milestone-14 ruling 4). There
|
|
/// is deliberately no override.
|
|
const placeholder_web_dist = "web/dist-placeholder";
|
|
|
|
/// Repository files that go into the tarball verbatim. `nxdns.conf` is
|
|
/// `sysusers.conf` under the name it is installed with, so nothing renames a
|
|
/// file during install (milestone-14 ruling 4).
|
|
const service_unit_path = "deploy/systemd/nxdns.service";
|
|
const sysusers_path = "deploy/systemd/nxdns.conf";
|
|
const license_path = "LICENSE";
|
|
const install_md_path = "INSTALL.md";
|
|
const dockerfile_path = "deploy/docker/Dockerfile";
|
|
|
|
/// The reviewed third-party inventory `dist` turns into `THIRD-PARTY-NOTICES`
|
|
/// (milestone-14 ruling 3). It is committed and audited, never scraped.
|
|
const licenses_dir = "licenses";
|
|
const inventory_path = "licenses/inventory.zon";
|
|
|
|
const DistOptions = struct {
|
|
/// `-Dversion-string` exactly as given, so a missing one is distinguishable
|
|
/// from one that happens to equal the default.
|
|
version: ?[]const u8,
|
|
version_string: []const u8,
|
|
git_commit: []const u8,
|
|
web_dist: []const u8,
|
|
};
|
|
|
|
/// `dist` builds everything releasable; `verify-dist` asserts the result.
|
|
/// Both run on a laptop exactly as they run on the runner, which is the point:
|
|
/// release checks that only exist in CI shell are the brittleness milestone 14
|
|
/// set out to remove.
|
|
fn addDist(
|
|
b: *std.Build,
|
|
options: *std.Build.Step.Options,
|
|
web_assets: std.Build.LazyPath,
|
|
dist_options: DistOptions,
|
|
) void {
|
|
const dist_step = b.step("dist", "Build the release tarballs, checksums and staged payloads");
|
|
const verify_step = b.step("verify-dist", "Verify the release artifacts under zig-out/dist");
|
|
|
|
if (distPreflight(b, dist_options)) |problem| {
|
|
const fail = b.addFail(problem);
|
|
dist_step.dependOn(&fail.step);
|
|
verify_step.dependOn(&fail.step);
|
|
return;
|
|
}
|
|
|
|
const stage_tool = hostTool(b, "dist_stage");
|
|
|
|
// A Run step hashes only the resolved path string of a directory argument,
|
|
// not its contents, so the inventory is staged through WriteFiles first:
|
|
// the staged copy lives at a content-hashed path, and editing a licence
|
|
// text re-runs the staging tool instead of replaying a stale cache.
|
|
const licenses_stage = b.addWriteFiles();
|
|
const staged_licenses = licenses_stage.addCopyDirectory(b.path(licenses_dir), ".", .{});
|
|
|
|
const sums_run = b.addRunArtifact(stage_tool);
|
|
sums_run.addArg("sums");
|
|
sums_run.addArg("--out");
|
|
const sums_file = sums_run.addOutputFileArg("SHA256SUMS");
|
|
|
|
const verify_tool = hostTool(b, "verify_dist");
|
|
const verify_run = b.addRunArtifact(verify_tool);
|
|
// A verifier that a cache can replay is not a verifier. This also makes the
|
|
// tool's report reach the terminal instead of a captured pipe.
|
|
verify_run.has_side_effects = true;
|
|
verify_run.addArgs(&.{ "--dist-dir", b.getInstallPath(.prefix, "dist") });
|
|
verify_run.addArgs(&.{ "--work-dir", b.getInstallPath(.prefix, "dist-verify") });
|
|
verify_run.addArgs(&.{ "--version", dist_options.version_string });
|
|
verify_run.addArgs(&.{ "--git-commit", dist_options.git_commit });
|
|
verify_run.addArg("--zon");
|
|
verify_run.addFileArg(b.path("build.zig.zon"));
|
|
verify_run.addArgs(&.{ "--max-bytes", b.fmt("{d}", .{max_binary_bytes}) });
|
|
verify_run.addArgs(&.{ "--asset-free-max-bytes", b.fmt("{d}", .{max_asset_free_binary_bytes}) });
|
|
verify_run.addArgs(&.{ "--host-arch", @tagName(b.graph.host.result.cpu.arch) });
|
|
if (b.enable_qemu) verify_run.addArg("--qemu");
|
|
|
|
// The asset-free budget gets its own build against a generated empty assets
|
|
// directory (milestone-14 ruling 5). Not the placeholder: ruling 4 makes
|
|
// that unbuildable, and re-admitting it for one size check through a back
|
|
// door would defeat the point of the refusal.
|
|
const empty_web_assets = webAssetsIndex(b, b.addWriteFiles().getDirectory(), null);
|
|
|
|
for (cross_targets) |triple| {
|
|
const query = std.Target.Query.parse(.{ .arch_os_abi = triple }) catch |err| {
|
|
std.debug.panic("invalid cross target '{s}': {t}", .{ triple, err });
|
|
};
|
|
const target = b.resolveTargetQuery(query);
|
|
const name = b.fmt("nxdns-{s}-{s}", .{ dist_options.version_string, triple });
|
|
|
|
// ReleaseSafe is not read from `-Doptimize`: the release artifact must
|
|
// not change shape because a flag was forgotten. `.strip` is
|
|
// `std.Build.Module.strip`, which emits `-fstrip` (Module.zig:545), so
|
|
// no objcopy and no binutils-aarch64-linux-gnu on the runner.
|
|
const exe = addExecutable(b, target, .ReleaseSafe, options, web_assets);
|
|
exe.linkage = .static;
|
|
exe.root_module.strip = true;
|
|
|
|
const stage_run = b.addRunArtifact(stage_tool);
|
|
stage_run.addArg("stage");
|
|
stage_run.addArg("--out");
|
|
const staged = stage_run.addOutputDirectoryArg(name);
|
|
stage_run.addArg("--binary");
|
|
stage_run.addFileArg(exe.getEmittedBin());
|
|
stage_run.addArg("--service");
|
|
stage_run.addFileArg(b.path(service_unit_path));
|
|
stage_run.addArg("--sysusers");
|
|
stage_run.addFileArg(b.path(sysusers_path));
|
|
stage_run.addArg("--license");
|
|
stage_run.addFileArg(b.path(license_path));
|
|
stage_run.addArg("--install-md");
|
|
stage_run.addFileArg(b.path(install_md_path));
|
|
stage_run.addArg("--licenses");
|
|
stage_run.addDirectoryArg(staged_licenses);
|
|
|
|
// Two commands, never one: `addSystemCommand` executes argv directly
|
|
// and does not interpret `|`, and a shell pipeline without `pipefail`
|
|
// would report only gzip's status while a failed tar passed silently.
|
|
const tar_run = b.addSystemCommand(&.{
|
|
"tar",
|
|
"--format=gnu",
|
|
"--sort=name",
|
|
"--mtime=@0",
|
|
"--owner=0",
|
|
"--group=0",
|
|
"--numeric-owner",
|
|
"-c",
|
|
"-f",
|
|
});
|
|
setReproducibleEnv(tar_run);
|
|
const tar_file = tar_run.addOutputFileArg(b.fmt("{s}.tar", .{name}));
|
|
tar_run.addArg("-C");
|
|
// The staged payload is the sole entry of its cache directory, so its
|
|
// parent is what `-C` needs and declaring it declares the payload.
|
|
tar_run.addDirectoryArg(staged.dirname());
|
|
tar_run.addArg(name);
|
|
|
|
// `-n` is required because `--mtime=@0` normalises the tar member times
|
|
// but not the timestamp gzip writes into its own header. `-c` is
|
|
// required because plain `gzip <file>` rewrites its input in place, and
|
|
// the input here is a content-addressed cache entry.
|
|
const gzip_run = b.addSystemCommand(&.{ "gzip", "-n", "-9", "-c" });
|
|
setReproducibleEnv(gzip_run);
|
|
gzip_run.addFileArg(tar_file);
|
|
const tarball = gzip_run.captureStdOut(.{ .basename = b.fmt("{s}.tar.gz", .{name}) });
|
|
|
|
const install_binary = b.addInstallFile(
|
|
staged.path(b, "nxdns"),
|
|
b.fmt("dist/bin/{s}/nxdns", .{triple}),
|
|
);
|
|
const install_stage = b.addInstallDirectory(.{
|
|
.source_dir = staged,
|
|
.install_dir = .prefix,
|
|
.install_subdir = b.fmt("dist/stage/{s}", .{name}),
|
|
});
|
|
const install_tarball = b.addInstallFile(tarball, b.fmt("dist/{s}.tar.gz", .{name}));
|
|
dist_step.dependOn(&install_binary.step);
|
|
dist_step.dependOn(&install_stage.step);
|
|
dist_step.dependOn(&install_tarball.step);
|
|
|
|
// The checksum file covers the two tarballs and nothing else. It cannot
|
|
// cover the container image: that digest does not exist until buildx
|
|
// has pushed, which happens later and elsewhere.
|
|
sums_run.addArg("--entry");
|
|
sums_run.addArg(b.fmt("{s}.tar.gz", .{name}));
|
|
sums_run.addFileArg(tarball);
|
|
|
|
const asset_free_exe = addExecutable(b, target, .ReleaseSafe, options, empty_web_assets);
|
|
asset_free_exe.linkage = .static;
|
|
asset_free_exe.root_module.strip = true;
|
|
|
|
verify_run.addArgs(&.{ "--archive", triple, b.fmt("{s}.tar.gz", .{name}) });
|
|
verify_run.addArgs(&.{ "--asset-free", triple });
|
|
verify_run.addFileArg(asset_free_exe.getEmittedBin());
|
|
}
|
|
|
|
const install_sums = b.addInstallFile(sums_file, "dist/SHA256SUMS");
|
|
dist_step.dependOn(&install_sums.step);
|
|
|
|
verify_run.step.dependOn(dist_step);
|
|
verify_step.dependOn(&verify_run.step);
|
|
}
|
|
|
|
/// The one message `dist` and `verify-dist` fail with when the release inputs
|
|
/// are not there, or null when they are. Returning it rather than calling
|
|
/// `std.process.fatal` keeps `zig build` and `zig build test` working: the
|
|
/// checks gate only the two release steps.
|
|
fn distPreflight(b: *std.Build, dist_options: DistOptions) ?[]const u8 {
|
|
var problems: std.ArrayList([]const u8) = .empty;
|
|
|
|
if (dist_options.version == null) {
|
|
problems.append(b.allocator, "-Dversion-string=<version> is required by `dist` " ++
|
|
"(the release tag without its leading `v`); it has no default here.") catch @panic("OOM");
|
|
}
|
|
|
|
if (std.mem.eql(u8, b.pathFromRoot(dist_options.web_dist), b.pathFromRoot(placeholder_web_dist))) {
|
|
problems.append(b.allocator, "-Dweb-dist resolves to " ++ placeholder_web_dist ++
|
|
", which a release must never ship. Build the real admin UI " ++
|
|
"(cd web && npm ci && npm run build) and pass -Dweb-dist=web/dist.") catch @panic("OOM");
|
|
}
|
|
|
|
for ([_][]const u8{
|
|
service_unit_path,
|
|
sysusers_path,
|
|
license_path,
|
|
install_md_path,
|
|
inventory_path,
|
|
}) |path| {
|
|
_ = b.build_root.handle.statFile(b.graph.io, path, .{}) catch |err| {
|
|
problems.append(b.allocator, b.fmt(
|
|
"`dist` needs '{s}', which is not readable: {t}",
|
|
.{ path, err },
|
|
)) catch @panic("OOM");
|
|
};
|
|
}
|
|
|
|
if (problems.items.len == 0) return null;
|
|
return std.mem.join(b.allocator, "\n", problems.items) catch @panic("OOM");
|
|
}
|
|
|
|
/// A build-time helper compiled for the host: `tools/<name>.zig`.
|
|
fn hostTool(b: *std.Build, name: []const u8) *std.Build.Step.Compile {
|
|
return b.addExecutable(.{
|
|
.name = name,
|
|
.root_module = b.createModule(.{
|
|
.root_source_file = b.path(b.fmt("tools/{s}.zig", .{name})),
|
|
.target = b.graph.host,
|
|
.optimize = .ReleaseSafe,
|
|
}),
|
|
});
|
|
}
|
|
|
|
/// Locale and time zone leak into archive metadata and into tool output.
|
|
/// Pinning both is the cheap half of reproducibility (milestone-14 ruling 12).
|
|
fn setReproducibleEnv(run: *std.Build.Step.Run) void {
|
|
run.setEnvironmentVariable("LC_ALL", "C");
|
|
run.setEnvironmentVariable("TZ", "UTC");
|
|
}
|
|
|
|
/// Milestone-15 ruling 4: every `*.zig` under `src/` must appear in
|
|
/// `src/tests.zig` as a line that trims to exactly `_ = @import("<path>");`,
|
|
/// where `<path>` is relative to `src/`. Whole-line equality, not a substring
|
|
/// search: a commented-out import trims to a line starting with `//` and does
|
|
/// not match, and `db.zig` cannot satisfy the requirement for `db2.zig`.
|
|
/// Duplicate lines are an error too — they hide a botched merge. No allowlist:
|
|
/// a file with no tests still gets imported, because the import is free and an
|
|
/// exception is the thing that lets a real gap through.
|
|
fn checkTestImports(b: *std.Build) void {
|
|
const gpa = b.allocator;
|
|
const io = b.graph.io;
|
|
const root = b.build_root.handle;
|
|
|
|
const tests_src = root.readFileAlloc(io, "src/tests.zig", gpa, .limited(4 << 20)) catch |err| {
|
|
std.process.fatal("cannot read src/tests.zig: {t}", .{err});
|
|
};
|
|
|
|
var src_dir = root.openDir(io, "src", .{ .iterate = true }) catch |err| {
|
|
std.process.fatal("cannot open src/: {t}", .{err});
|
|
};
|
|
defer src_dir.close(io);
|
|
|
|
var walker = src_dir.walk(gpa) catch @panic("OOM");
|
|
defer walker.deinit();
|
|
|
|
while (walker.next(io) catch |err| {
|
|
std.process.fatal("cannot walk src/: {t}", .{err});
|
|
}) |entry| {
|
|
if (entry.kind != .file) continue;
|
|
if (!std.mem.endsWith(u8, entry.path, ".zig")) continue;
|
|
if (std.mem.eql(u8, entry.path, "tests.zig")) continue;
|
|
|
|
const needle = b.fmt("_ = @import(\"{s}\");", .{entry.path});
|
|
var matches: usize = 0;
|
|
var lines = std.mem.splitScalar(u8, tests_src, '\n');
|
|
while (lines.next()) |line| {
|
|
if (std.mem.eql(u8, std.mem.trim(u8, line, " \t\r"), needle)) matches += 1;
|
|
}
|
|
if (matches == 0) {
|
|
std.process.fatal("src/tests.zig is missing `{s}`", .{needle});
|
|
}
|
|
if (matches > 1) {
|
|
std.process.fatal("src/tests.zig repeats `{s}` {d} times", .{ needle, matches });
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The `src/tests.zig` suite, wired for one target. The host build and the
|
|
/// aarch64 build take the same artifact; their only differences (`linkage` and
|
|
/// `skip_foreign_checks`) stay at the call sites.
|
|
fn addTestSuite(
|
|
b: *std.Build,
|
|
target: std.Build.ResolvedTarget,
|
|
optimize: std.builtin.OptimizeMode,
|
|
options: *std.Build.Step.Options,
|
|
web_assets: std.Build.LazyPath,
|
|
licenses_files: std.Build.LazyPath,
|
|
) *std.Build.Step.Compile {
|
|
const tests = b.addTest(.{
|
|
.root_module = b.createModule(.{
|
|
.root_source_file = b.path("src/tests.zig"),
|
|
.target = target,
|
|
.optimize = optimize,
|
|
.link_libc = true,
|
|
}),
|
|
});
|
|
tests.root_module.addOptions("build_options", options);
|
|
tests.root_module.linkLibrary(sqliteLibrary(b, target, optimize));
|
|
tests.root_module.linkLibrary(mbedtlsLibrary(b, target, optimize));
|
|
tests.root_module.addCSourceFile(.{ .file = b.path("src/platform/mbedtls_shim.c") });
|
|
addMbedtlsThreadingMacros(tests.root_module);
|
|
tests.root_module.addAnonymousImport("test_fixtures", .{
|
|
.root_source_file = b.path("tests/fixtures/fixtures.zig"),
|
|
});
|
|
tests.root_module.addAnonymousImport("docs_files", .{
|
|
.root_source_file = b.path("docs/docs.zig"),
|
|
});
|
|
// An anonymous-import root must be Zig, so the committed TypeScript golden
|
|
// is reached through a one-decl wrapper beside it.
|
|
tests.root_module.addAnonymousImport("contract_samples", .{
|
|
.root_source_file = b.path("web/src/lib/contract_samples.zig"),
|
|
});
|
|
tests.root_module.addAnonymousImport("web_assets", .{ .root_source_file = web_assets });
|
|
tests.root_module.addAnonymousImport("licenses_files", .{ .root_source_file = licenses_files });
|
|
return tests;
|
|
}
|
|
|
|
/// The root of the `licenses_files` module (milestone-14 ruling 3), through
|
|
/// which `src/licenses_drift_test.zig` embeds the licence inventory and the two
|
|
/// dependency manifests whose identity it guards.
|
|
///
|
|
/// A module cannot embed anything above its own root directory, so the trees
|
|
/// are merged into one WriteFiles directory: `licenses/` at the root, plus
|
|
/// copies of `build.zig.zon`, `web/package-lock.json` and the image Dockerfile
|
|
/// beside it. The module root is the copy of `licenses/licenses.zig`, which is
|
|
/// why that file's `@embedFile` paths name files that do not sit beside it in
|
|
/// the repository.
|
|
fn licensesFilesRoot(b: *std.Build) std.Build.LazyPath {
|
|
const stage = b.addWriteFiles();
|
|
_ = stage.addCopyDirectory(b.path("licenses"), ".", .{});
|
|
_ = stage.addCopyFile(b.path("build.zig.zon"), "build.zig.zon");
|
|
_ = stage.addCopyFile(b.path("web/package-lock.json"), "package-lock.json");
|
|
_ = stage.addCopyFile(b.path(dockerfile_path), "Dockerfile");
|
|
return stage.getDirectory().path(b, "licenses.zig");
|
|
}
|
|
|
|
/// One fuzz test artifact: a module rooted at a `tests/fuzz/` file plus the one
|
|
/// named import through which that target reaches the code under test. The fuzz
|
|
/// suites cannot share `addTestSuite` — they take no `build_options`, no
|
|
/// anonymous imports and no C libraries, and they carry `use_llvm`, which the
|
|
/// main suite must not set.
|
|
fn addFuzzSuite(
|
|
b: *std.Build,
|
|
target: std.Build.ResolvedTarget,
|
|
optimize: std.builtin.OptimizeMode,
|
|
fuzz: bool,
|
|
test_step: *std.Build.Step,
|
|
spec: struct {
|
|
name: []const u8,
|
|
root: []const u8,
|
|
import_name: []const u8,
|
|
import_module: *std.Build.Module,
|
|
},
|
|
) void {
|
|
const mod = b.createModule(.{
|
|
.root_source_file = b.path(spec.root),
|
|
.target = target,
|
|
.optimize = optimize,
|
|
});
|
|
mod.addImport(spec.import_name, spec.import_module);
|
|
const tests = b.addTest(.{
|
|
.name = spec.name,
|
|
.use_llvm = if (fuzz) true else null,
|
|
.root_module = mod,
|
|
});
|
|
test_step.dependOn(&b.addRunArtifact(tests).step);
|
|
}
|
|
|
|
/// A plain module over one source file, with no imports of its own: what a fuzz
|
|
/// target reaches its code under test through.
|
|
fn sourceModule(
|
|
b: *std.Build,
|
|
target: std.Build.ResolvedTarget,
|
|
optimize: std.builtin.OptimizeMode,
|
|
path: []const u8,
|
|
) *std.Build.Module {
|
|
return b.createModule(.{
|
|
.root_source_file = b.path(path),
|
|
.target = target,
|
|
.optimize = optimize,
|
|
});
|
|
}
|
|
|
|
fn addExecutable(
|
|
b: *std.Build,
|
|
target: std.Build.ResolvedTarget,
|
|
optimize: std.builtin.OptimizeMode,
|
|
options: *std.Build.Step.Options,
|
|
web_assets: std.Build.LazyPath,
|
|
) *std.Build.Step.Compile {
|
|
const exe = b.addExecutable(.{
|
|
.name = "nxdns",
|
|
.root_module = b.createModule(.{
|
|
.root_source_file = b.path("src/main.zig"),
|
|
.target = target,
|
|
.optimize = optimize,
|
|
.link_libc = true,
|
|
}),
|
|
});
|
|
exe.root_module.addOptions("build_options", options);
|
|
exe.root_module.addAnonymousImport("web_assets", .{ .root_source_file = web_assets });
|
|
exe.root_module.linkLibrary(sqliteLibrary(b, target, optimize));
|
|
exe.root_module.linkLibrary(mbedtlsLibrary(b, target, optimize));
|
|
exe.root_module.addCSourceFile(.{ .file = b.path("src/platform/mbedtls_shim.c") });
|
|
addMbedtlsThreadingMacros(exe.root_module);
|
|
return exe;
|
|
}
|
|
|
|
/// The embedded web UI (milestone-8 ruling 24): the dist directory plus the
|
|
/// generated `assets.zig` index and build-time gzip siblings, merged into one
|
|
/// WriteFiles directory so every `@embedFile` path resolves inside the module
|
|
/// root. Returns the index file, the root of the `web_assets` module.
|
|
///
|
|
/// The dist is staged through its own WriteFiles step before it reaches the
|
|
/// tool because a Run step hashes only the resolved path string of a directory
|
|
/// argument, not its contents; the staged copy lives at a content-hashed path,
|
|
/// so editing an asset re-runs the tool instead of replaying a stale cache.
|
|
fn webAssetsIndex(
|
|
b: *std.Build,
|
|
dist: std.Build.LazyPath,
|
|
freshness_check: ?*std.Build.Step,
|
|
) std.Build.LazyPath {
|
|
const stage = b.addWriteFiles();
|
|
if (freshness_check) |check| stage.step.dependOn(check);
|
|
const staged = stage.addCopyDirectory(dist, ".", .{});
|
|
|
|
const tool = b.addExecutable(.{
|
|
.name = "gen_web_assets",
|
|
.root_module = b.createModule(.{
|
|
.root_source_file = b.path("tools/gen_web_assets.zig"),
|
|
.target = b.graph.host,
|
|
.optimize = .ReleaseSafe,
|
|
}),
|
|
});
|
|
|
|
const run = b.addRunArtifact(tool);
|
|
run.addDirectoryArg(staged);
|
|
const generated = run.addOutputDirectoryArg("web_assets");
|
|
|
|
const merged = b.addWriteFiles();
|
|
_ = merged.addCopyDirectory(staged, ".", .{});
|
|
_ = merged.addCopyDirectory(generated, ".", .{});
|
|
return merged.getDirectory().path(b, "assets.zig");
|
|
}
|
|
|
|
/// SQLite 3.53.4 amalgamation (see build.zig.zon for the pinned URL and hash).
|
|
fn sqliteLibrary(
|
|
b: *std.Build,
|
|
target: std.Build.ResolvedTarget,
|
|
optimize: std.builtin.OptimizeMode,
|
|
) *std.Build.Step.Compile {
|
|
const dep = b.dependency("sqlite", .{});
|
|
const lib = b.addLibrary(.{
|
|
.name = "sqlite3",
|
|
.linkage = .static,
|
|
.root_module = b.createModule(.{
|
|
.target = target,
|
|
.optimize = optimize,
|
|
.link_libc = true,
|
|
}),
|
|
});
|
|
lib.root_module.addIncludePath(dep.path(""));
|
|
lib.root_module.addCSourceFile(.{
|
|
.file = dep.path("sqlite3.c"),
|
|
.flags = &.{
|
|
"-DSQLITE_ENABLE_FTS5",
|
|
"-DSQLITE_THREADSAFE=1",
|
|
"-DSQLITE_DEFAULT_WAL_SYNCHRONOUS=1",
|
|
"-DSQLITE_OMIT_LOAD_EXTENSION",
|
|
},
|
|
});
|
|
return lib;
|
|
}
|
|
|
|
/// Mbed TLS 3.6.7 LTS, stock `mbedtls_config.h` (see build.zig.zon for the pinned URL and hash).
|
|
fn mbedtlsLibrary(
|
|
b: *std.Build,
|
|
target: std.Build.ResolvedTarget,
|
|
optimize: std.builtin.OptimizeMode,
|
|
) *std.Build.Step.Compile {
|
|
const dep = b.dependency("mbedtls", .{});
|
|
const lib = b.addLibrary(.{
|
|
.name = "mbedtls",
|
|
.linkage = .static,
|
|
.root_module = b.createModule(.{
|
|
.target = target,
|
|
.optimize = optimize,
|
|
.link_libc = true,
|
|
}),
|
|
});
|
|
|
|
addMbedtlsThreadingMacros(lib.root_module);
|
|
|
|
for ([_][]const u8{
|
|
"include",
|
|
"library",
|
|
"3rdparty/everest/include",
|
|
"3rdparty/everest/include/everest",
|
|
"3rdparty/everest/include/everest/kremlib",
|
|
"3rdparty/p256-m/p256-m/include",
|
|
"3rdparty/p256-m/p256-m_driver_interface",
|
|
}) |include_dir| {
|
|
lib.root_module.addIncludePath(dep.path(include_dir));
|
|
}
|
|
|
|
lib.root_module.addCSourceFiles(.{
|
|
.root = dep.path("library"),
|
|
.files = &mbedtls_library_sources,
|
|
});
|
|
lib.root_module.addCSourceFiles(.{
|
|
.root = dep.path("3rdparty"),
|
|
.files = &mbedtls_3rdparty_sources,
|
|
});
|
|
lib.installHeadersDirectory(dep.path("include/mbedtls"), "mbedtls", .{});
|
|
lib.installHeadersDirectory(dep.path("include/psa"), "psa", .{});
|
|
return lib;
|
|
}
|
|
|
|
/// Context sizes change with threading enabled, so every compilation unit that
|
|
/// includes mbedTLS headers (the library itself and `mbedtls_shim.c`) must see
|
|
/// the same macros. Concurrent handshakes share `ssl_config`, the CTR-DRBG, and
|
|
/// global PSA state; without MBEDTLS_THREADING_C those race.
|
|
fn addMbedtlsThreadingMacros(m: *std.Build.Module) void {
|
|
m.addCMacro("MBEDTLS_THREADING_C", "1");
|
|
m.addCMacro("MBEDTLS_THREADING_PTHREAD", "1");
|
|
}
|
|
|
|
/// Every `library/*.c` of the release, matching `library/Makefile`.
|
|
const mbedtls_library_sources = [_][]const u8{
|
|
"aes.c", "aesce.c",
|
|
"aesni.c", "aria.c",
|
|
"asn1parse.c", "asn1write.c",
|
|
"base64.c", "bignum.c",
|
|
"bignum_core.c", "bignum_mod.c",
|
|
"bignum_mod_raw.c", "block_cipher.c",
|
|
"camellia.c", "ccm.c",
|
|
"chacha20.c", "chachapoly.c",
|
|
"cipher.c", "cipher_wrap.c",
|
|
"cmac.c", "constant_time.c",
|
|
"ctr_drbg.c", "debug.c",
|
|
"des.c", "dhm.c",
|
|
"ecdh.c", "ecdsa.c",
|
|
"ecjpake.c", "ecp.c",
|
|
"ecp_curves.c", "ecp_curves_new.c",
|
|
"entropy.c", "entropy_poll.c",
|
|
"error.c", "gcm.c",
|
|
"hkdf.c", "hmac_drbg.c",
|
|
"lmots.c", "lms.c",
|
|
"md.c", "md5.c",
|
|
"memory_buffer_alloc.c", "mps_reader.c",
|
|
"mps_trace.c", "net_sockets.c",
|
|
"nist_kw.c", "oid.c",
|
|
"padlock.c", "pem.c",
|
|
"pk.c", "pk_ecc.c",
|
|
"pk_wrap.c", "pkcs12.c",
|
|
"pkcs5.c", "pkcs7.c",
|
|
"pkparse.c", "pkwrite.c",
|
|
"platform.c", "platform_util.c",
|
|
"poly1305.c", "psa_crypto.c",
|
|
"psa_crypto_aead.c", "psa_crypto_cipher.c",
|
|
"psa_crypto_client.c", "psa_crypto_driver_wrappers_no_static.c",
|
|
"psa_crypto_ecp.c", "psa_crypto_ffdh.c",
|
|
"psa_crypto_hash.c", "psa_crypto_mac.c",
|
|
"psa_crypto_pake.c", "psa_crypto_random.c",
|
|
"psa_crypto_rsa.c", "psa_crypto_se.c",
|
|
"psa_crypto_slot_management.c", "psa_crypto_storage.c",
|
|
"psa_its_file.c", "psa_util.c",
|
|
"ripemd160.c", "rsa.c",
|
|
"rsa_alt_helpers.c", "sha1.c",
|
|
"sha256.c", "sha3.c",
|
|
"sha512.c", "ssl_cache.c",
|
|
"ssl_ciphersuites.c", "ssl_client.c",
|
|
"ssl_cookie.c", "ssl_debug_helpers_generated.c",
|
|
"ssl_msg.c", "ssl_ticket.c",
|
|
"ssl_tls.c", "ssl_tls12_client.c",
|
|
"ssl_tls12_server.c", "ssl_tls13_client.c",
|
|
"ssl_tls13_generic.c", "ssl_tls13_keys.c",
|
|
"ssl_tls13_server.c", "threading.c",
|
|
"timing.c", "version.c",
|
|
"version_features.c", "x509.c",
|
|
"x509_create.c", "x509_crl.c",
|
|
"x509_crt.c", "x509_csr.c",
|
|
"x509write.c", "x509write_crt.c",
|
|
"x509write_csr.c",
|
|
};
|
|
|
|
/// The object lists of `3rdparty/everest/Makefile.inc` and `3rdparty/p256-m/Makefile.inc`.
|
|
/// The stock config enables neither driver, so these compile to empty objects.
|
|
const mbedtls_3rdparty_sources = [_][]const u8{
|
|
"everest/library/everest.c",
|
|
"everest/library/x25519.c",
|
|
"everest/library/Hacl_Curve25519_joined.c",
|
|
"p256-m/p256-m_driver_entrypoints.c",
|
|
"p256-m/p256-m/p256-m.c",
|
|
};
|