Files
nxdns/build.zig
T
mokhtar 9f8a5cd753
CI / test (push) Failing after 1m12s
CI / test-aarch64 (push) Failing after 2m27s
CI / frontend (push) Successful in 1m28s
CI / cross (push) Failing after 27s
CI / docker (push) Failing after 24s
milestone 15: make a green run mean a real pass
2026-08-07 01:28:13 +02:00

545 lines
24 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",
};
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;
const version_string = b.option([]const u8, "version-string", "Version reported by `nxdns version`") 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);
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 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"),
});
tests.root_module.addAnonymousImport("web_assets", .{ .root_source_file = web_assets });
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.
const dns_mod = b.createModule(.{
.root_source_file = b.path("src/dns/dns.zig"),
.target = target,
.optimize = optimize,
});
const fuzz_mod = b.createModule(.{
.root_source_file = b.path("tests/fuzz/dns_fuzz.zig"),
.target = target,
.optimize = optimize,
});
fuzz_mod.addImport("dns", dns_mod);
const fuzz_tests = b.addTest(.{
.name = "fuzz",
.use_llvm = if (fuzz) true else null,
.root_module = fuzz_mod,
});
test_step.dependOn(&b.addRunArtifact(fuzz_tests).step);
const parsers_mod = b.createModule(.{
.root_source_file = b.path("src/filter/parsers.zig"),
.target = target,
.optimize = optimize,
});
const blocklist_fuzz_mod = b.createModule(.{
.root_source_file = b.path("tests/fuzz/blocklist_fuzz.zig"),
.target = target,
.optimize = optimize,
});
blocklist_fuzz_mod.addImport("parsers", parsers_mod);
const blocklist_fuzz_tests = b.addTest(.{
.name = "blocklist-fuzz",
.use_llvm = if (fuzz) true else null,
.root_module = blocklist_fuzz_mod,
});
test_step.dependOn(&b.addRunArtifact(blocklist_fuzz_tests).step);
// `src/web/http_util.zig` imports only std, so its fuzz module roots
// directly at the file — no aggregator needed (milestone-15 ruling 6c).
const http_util_mod = b.createModule(.{
.root_source_file = b.path("src/web/http_util.zig"),
.target = target,
.optimize = optimize,
});
const http_util_fuzz_mod = b.createModule(.{
.root_source_file = b.path("tests/fuzz/http_util_fuzz.zig"),
.target = target,
.optimize = optimize,
});
http_util_fuzz_mod.addImport("http_util", http_util_mod);
const http_util_fuzz_tests = b.addTest(.{
.name = "http-util-fuzz",
.use_llvm = if (fuzz) true else null,
.root_module = http_util_fuzz_mod,
});
test_step.dependOn(&b.addRunArtifact(http_util_fuzz_tests).step);
// 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,
});
const compiler_fuzz_mod = b.createModule(.{
.root_source_file = b.path("tests/fuzz/compiler_fuzz.zig"),
.target = target,
.optimize = optimize,
});
compiler_fuzz_mod.addImport("core", bench_core_mod);
const compiler_fuzz_tests = b.addTest(.{
.name = "compiler-fuzz",
.use_llvm = if (fuzz) true else null,
.root_module = compiler_fuzz_mod,
});
test_step.dependOn(&b.addRunArtifact(compiler_fuzz_tests).step);
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-linux-musl" }) catch unreachable,
);
const aarch64_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/tests.zig"),
.target = aarch64_target,
.optimize = optimize,
.link_libc = true,
}),
});
aarch64_tests.linkage = .static;
aarch64_tests.root_module.addOptions("build_options", options);
aarch64_tests.root_module.linkLibrary(sqliteLibrary(b, aarch64_target, optimize));
aarch64_tests.root_module.linkLibrary(mbedtlsLibrary(b, aarch64_target, optimize));
aarch64_tests.root_module.addCSourceFile(.{ .file = b.path("src/platform/mbedtls_shim.c") });
addMbedtlsThreadingMacros(aarch64_tests.root_module);
aarch64_tests.root_module.addAnonymousImport("test_fixtures", .{
.root_source_file = b.path("tests/fixtures/fixtures.zig"),
});
aarch64_tests.root_module.addAnonymousImport("docs_files", .{
.root_source_file = b.path("docs/docs.zig"),
});
aarch64_tests.root_module.addAnonymousImport("web_assets", .{ .root_source_file = web_assets });
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);
const cross = b.step("cross", "Build static musl executables for every deploy target");
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 cross_exe = addExecutable(b, b.resolveTargetQuery(query), optimize, options, web_assets);
cross_exe.linkage = .static;
const install = b.addInstallArtifact(cross_exe, .{
.dest_dir = .{ .override = .{ .custom = b.fmt("cross/{s}", .{triple}) } },
});
cross.dependOn(&install.step);
}
}
/// 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 });
}
}
}
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",
};