Files
nxdns/src/licenses_drift_test.zig
mokhtar 728b8d64e1 admin: draw the overview charts with visx
the hand-written scale, tick, stacking and arc math is replaced by visx 4.0.0
primitives; rendering, colours and themes stay the app's own. all four charts
share one hover treatment: the client chart gains the tooltip and dimming the
query timeline had, the donuts gain both, an open tooltip follows a data
refresh instead of going stale, and it retires when the window rolls. the
timeline's third series is named allowed instead of other, and the client
chart's other aggregate disappears from a window where it counted nothing.
licenses gain the isc text for the bundled d3 modules.
2026-08-24 18:40:17 +02:00

1183 lines
47 KiB
Zig

//! Drift guards for the third-party licence inventory (milestone-14 ruling 3).
//!
//! They record the *identity* of the inputs that decide what `licenses/` has to
//! cover — the `build.zig.zon` dependencies, the npm closure and the container
//! base image — and fail when any of them moves without a matching change under
//! `licenses/`. They detect drift; they do not derive the inventory. Deriving it
//! is a human job, because a notices file assembled by a script is a statement
//! nobody has read.
//!
//! Recording an identity is not enough on its own. The message a drift failure
//! prints is the replacement text, so a developer who pastes it has silenced
//! the guard without touching the inventory. Every identity here is therefore
//! tied back to `licenses/inventory.zon` by a second check: a dependency's URL
//! must name the version the inventory records, an npm package must appear at
//! the version it records, the base image must be the one the CA bundle entry
//! claims. Pasting alone never passes.
//!
//! "What `licenses/` has to cover" is the union of the released artifacts, not
//! the binary alone: the container image redistributes a Mozilla CA bundle the
//! executable never contains, and carries the same THIRD-PARTY-NOTICES. The
//! guards below therefore also check that `licenses/preamble.txt` — the head of
//! the assembled notices — says which artifacts the file speaks for.
//!
//! One question is out of reach here and lives in `gates.yml` instead: which
//! packages actually contribute a module to `admin/dist`. Answering it means
//! running the bundler. The frontend gate does that, compares the result with
//! the `[npm packages bundled into admin/dist]` section, and this file checks the
//! inventory against that same section.
//!
//! Everything is embedded at compile time through the `licenses_files` module:
//! no network, no node, no filesystem access at test time.
const std = @import("std");
const builtin = @import("builtin");
const licenses = @import("licenses_files");
/// The npm packages that are `devDependencies` in the lockfile but whose own
/// output ends up in `admin/dist`, and so in the binary. None of them runs in the
/// browser; their emitted bytes do.
///
/// The set was settled by building `admin/` with `--sourcemap` and reading two
/// things off the result: the sourcemap `sources` lists, which name the
/// packages whose modules were bundled, and the regions of each chunk that no
/// sourcemap segment maps back to a source file, which are the bytes the
/// toolchain injected on its own. Tailwind is here for its generated
/// stylesheet; vite for `vite/preload-helper` and `vite/modulepreload-polyfill`;
/// rolldown for the CommonJS interop helpers it prepends to any chunk pulling in
/// a CommonJS module. Every other devDependency left `admin/dist` untouched.
/// Anything added here must also be inventoried.
const npm_generators = [_][]const u8{ "rolldown", "vite" };
/// Packages of the recorded runtime closure that put no byte in `admin/dist`, so
/// the inventory names them in a note rather than carrying their licence. The
/// note has to exist: each name below must still appear in
/// `licenses/inventory.zon`.
///
/// This list is a claim about the build, and the build is what settles it: a
/// name here that turns up in the recorded `admin/dist` bundle fails, so a
/// package that starts shipping cannot stay on this list quietly.
const npm_not_shipped = [_][]const u8{
"cookie-es",
"isbot",
"seroval",
"seroval-plugins",
"css-mediaquery",
"invariant",
"js-tokens",
"loose-envify",
"@internationalized/date",
"@internationalized/number",
"@react-types/shared",
"@swc/helpers",
"aria-hidden",
"client-only",
"tslib",
"@types/d3-array",
"@types/d3-color",
"@types/d3-delaunay",
"@types/d3-format",
"@types/d3-geo",
"@types/d3-interpolate",
"@types/d3-path",
"@types/d3-scale",
"@types/d3-shape",
"@types/d3-time",
"@types/d3-time-format",
"@types/geojson",
"@types/react",
"@types/react-dom",
"csstype",
"@visx/curve",
"@visx/vendor",
"d3-delaunay",
"d3-geo",
"d3-time-format",
"delaunator",
"robust-predicates",
"react-use-measure",
};
/// Components the shipped artifacts contain that no dependency file mentions,
/// so no automatic check would ever notice their disappearance from the
/// inventory. Matched against the `.component` field as substrings.
const required_components = [_][]const u8{
"musl",
"Zig",
"SQLite",
"Mbed TLS",
"Everest",
"p256-m",
"React",
"TanStack",
"Vite",
"Rolldown",
"Mozilla CA certificate bundle",
};
/// Every `build.zig.zon` dependency, the inventory entry that covers it, and
/// how that entry's `.version` field is spelled inside the dependency URL.
///
/// Without this the Zig half of the guard is one-sided: bumping a dependency
/// and pasting the identity section `reportDrift` prints satisfies every check
/// while `licenses/inventory.zon` and the assembled notices still name the old
/// version. The npm half already closes that (see the identity backstop below);
/// this is the same closure for the vendored C libraries. A dependency absent
/// from this table fails too, so a new one cannot arrive uninventoried.
const zig_dependency_versions = [_]ZigDependencyVersion{
.{ .dependency = "mbedtls", .component = "Mbed TLS", .form = .dotted },
.{ .dependency = "sqlite", .component = "SQLite", .form = .sqlite_packed },
};
const ZigDependencyVersion = struct {
dependency: []const u8,
component: []const u8,
form: enum {
/// `3.6.7` appears verbatim in the URL.
dotted,
/// SQLite spells 3.53.4 as 3530400 in the amalgamation filename:
/// major, then minor, patch and build as two digits each.
sqlite_packed,
},
};
/// The licence each npm package of the recorded sets carries today. The identity
/// file records a licence token per package; nothing used to read it, so a
/// package that relicensed passed as long as its version had not moved.
///
/// MIT is the default because almost every package here is MIT, so only the
/// exceptions are written down. A package whose recorded token stops matching
/// its expectation fails — including the packages below, which is the point of
/// naming them rather than waving them through: a new licence on any of them is
/// a new decision, not a fact to absorb quietly.
///
/// Adding a name here is a human review, not a paste. A licence this project has
/// not taken before needs its own text under `licenses/`, an inventory entry
/// that says who accepted it and when, and whatever that licence's attribution
/// terms require. Apache-2.0 arrived that way: Mokhtar Mial accepted it inbound
/// on 2026-08-12, and `licenses/react-aria-apache-2.0.txt` carries the text
/// Section 4 asks for.
///
/// Packages that put no byte in `admin/dist` are here too. They redistribute
/// nothing today, so their licence carries no obligation today — but "not
/// shipped" is a claim about the build that a future build can falsify, and
/// recording the licence now means the answer is already reviewed when it does.
const npm_expected_licence = "MIT";
const npm_licence_exceptions = [_]NpmLicence{
// Shipped: React Aria and the one @internationalized package it pulls into
// the bundle.
.{ .name = "react-aria-components", .licence = "Apache-2.0" },
.{ .name = "react-aria", .licence = "Apache-2.0" },
.{ .name = "react-stately", .licence = "Apache-2.0" },
.{ .name = "@internationalized/string", .licence = "Apache-2.0" },
// Not shipped: the rest of the React Aria closure.
.{ .name = "@internationalized/date", .licence = "Apache-2.0" },
.{ .name = "@internationalized/number", .licence = "Apache-2.0" },
.{ .name = "@react-types/shared", .licence = "Apache-2.0" },
.{ .name = "@swc/helpers", .licence = "Apache-2.0" },
.{ .name = "tslib", .licence = "0BSD" },
// Not shipped: reached only through @tanstack/router-core's server paths
// and @stylexjs/stylex's compiler.
.{ .name = "isbot", .licence = "Unlicense" },
.{ .name = "css-mediaquery", .licence = "BSD" },
// Shipped: the d3 modules visx computes its geometry with. Mokhtar Mial
// accepted ISC inbound on 2026-08-24; licenses/d3-isc.txt carries the text.
.{ .name = "d3-array", .licence = "ISC" },
.{ .name = "d3-color", .licence = "ISC" },
.{ .name = "d3-format", .licence = "ISC" },
.{ .name = "d3-interpolate", .licence = "ISC" },
.{ .name = "d3-path", .licence = "ISC" },
.{ .name = "d3-scale", .licence = "ISC" },
.{ .name = "d3-shape", .licence = "ISC" },
.{ .name = "d3-time", .licence = "ISC" },
.{ .name = "internmap", .licence = "ISC" },
// Not shipped: the rest of the d3 closure, reached only through
// @visx/vendor's map and Voronoi re-exports, which no chart here imports.
.{ .name = "d3-delaunay", .licence = "ISC" },
.{ .name = "d3-geo", .licence = "ISC" },
.{ .name = "d3-time-format", .licence = "ISC" },
.{ .name = "delaunator", .licence = "ISC" },
.{ .name = "robust-predicates", .licence = "Unlicense" },
};
const NpmLicence = struct {
name: []const u8,
licence: []const u8,
};
/// The reviewed licence for one package: its exception, or MIT.
fn expectedLicence(name: []const u8) []const u8 {
for (npm_licence_exceptions) |entry| {
if (std.mem.eql(u8, entry.name, name)) return entry.licence;
}
return npm_expected_licence;
}
/// The two licence texts that must be reproduced in full, pinned by content.
/// The marker-string probes below prove the right *document* is present; they
/// cannot tell a complete Apache-2.0 from one with its middle sections deleted,
/// because the markers would survive that. A digest can.
const pinned_texts = [_]PinnedText{
.{
.file = "mbedtls-apache-2.0.txt",
.sha256 = "4d7a30e3855c270da7b2661620ca56ef073ecc46cc1868196d5a4d1964e548a5",
},
.{
.file = "mozilla-ca-bundle-mpl-2.0.txt",
.sha256 = "abfe0353239f65ce36fb876e13d50b856b7b6bfc14b9b03e0075b9a4bbbc415b",
},
};
const PinnedText = struct {
file: []const u8,
sha256: []const u8,
};
const zig_section = "[build.zig.zon dependencies]";
const npm_section = "[npm runtime closure]";
const generator_section = "[npm build-time generators whose output ships]";
const base_image_section = "[container base image]";
const bundled_section = "[npm packages bundled into admin/dist]";
/// The shape `tools/dist_stage.zig` parses when it assembles
/// THIRD-PARTY-NOTICES. Parsing `licenses/inventory.zon` into it here is itself
/// a guard: the two declarations must agree, and a field added on one side
/// alone fails on the other.
pub const Entry = struct {
component: []const u8,
version: []const u8,
note: []const u8,
file: []const u8,
};
test "build.zig.zon dependencies match the recorded identity" {
const gpa = std.testing.allocator;
const computed = try renderZigDependencies(gpa);
defer gpa.free(computed);
const recorded = try recordedSection(zig_section);
if (!std.mem.eql(u8, trimTrailing(computed), recorded)) {
reportDrift(zig_section, recorded, computed);
return error.ZigDependencyIdentityDrift;
}
}
test "the npm runtime closure matches the recorded identity" {
const gpa = std.testing.allocator;
const computed = try renderNpmClosure(gpa, .runtime);
defer gpa.free(computed);
const recorded = try recordedSection(npm_section);
if (!std.mem.eql(u8, trimTrailing(computed), recorded)) {
reportDrift(npm_section, recorded, computed);
return error.NpmClosureIdentityDrift;
}
}
test "the npm build-time generators match the recorded identity" {
const gpa = std.testing.allocator;
const computed = try renderNpmClosure(gpa, .generators);
defer gpa.free(computed);
const recorded = try recordedSection(generator_section);
if (!std.mem.eql(u8, trimTrailing(computed), recorded)) {
reportDrift(generator_section, recorded, computed);
return error.NpmGeneratorIdentityDrift;
}
}
test "every inventory entry names a licence text that exists and is not empty" {
const gpa = std.testing.allocator;
const entries = try parseInventory(gpa);
defer std.zon.parse.free(gpa, entries);
for (entries) |entry| {
const body = textBody(entry.file) orelse {
std.debug.print(
"licenses/inventory.zon entry '{s}' names '{s}', which licenses/licenses.zig does not embed\n",
.{ entry.component, entry.file },
);
return error.InventoryFileMissing;
};
if (std.mem.trim(u8, body, " \t\r\n").len == 0) {
std.debug.print("licenses/{s} is empty\n", .{entry.file});
return error.InventoryFileEmpty;
}
if (entry.component.len == 0 or entry.version.len == 0 or entry.note.len == 0) {
std.debug.print("licenses/inventory.zon entry '{s}' has an empty field\n", .{entry.file});
return error.InventoryEntryIncomplete;
}
}
}
// The floor for the entries no dependency file gives a version for: musl, the
// Zig runtime, the vendored C libraries, the CA bundle. The npm entries get the
// exact check below.
test "every inventory entry names the version it ships" {
const gpa = std.testing.allocator;
const entries = try parseInventory(gpa);
defer std.zon.parse.free(gpa, entries);
for (entries) |entry| {
var digits: usize = 0;
for (entry.version) |c| {
if (std.ascii.isDigit(c)) digits += 1;
}
if (digits == 0) {
std.debug.print(
"licenses/inventory.zon entry '{s}' records no version number\n",
.{entry.component},
);
return error.InventoryEntryHasNoVersion;
}
}
}
test "every licence text under licenses/ is named by exactly one inventory entry" {
const gpa = std.testing.allocator;
const entries = try parseInventory(gpa);
defer std.zon.parse.free(gpa, entries);
for (licenses.texts) |text| {
var uses: usize = 0;
for (entries) |entry| {
if (std.mem.eql(u8, entry.file, text.name)) uses += 1;
}
if (uses != 1) {
std.debug.print(
"licenses/{s} is named by {d} inventory entries, expected exactly 1\n",
.{ text.name, uses },
);
return error.LicenceTextNotInventoried;
}
}
}
test "the inventory covers every component the shipped artifacts contain" {
const gpa = std.testing.allocator;
const entries = try parseInventory(gpa);
defer std.zon.parse.free(gpa, entries);
for (required_components) |needle| {
var found = false;
for (entries) |entry| {
if (std.mem.indexOf(u8, entry.component, needle) != null) found = true;
}
if (!found) {
std.debug.print("no licenses/inventory.zon entry covers '{s}'\n", .{needle});
return error.ComponentMissingFromInventory;
}
}
}
// The backstop that ties licenses/dependency-identity.txt to
// licenses/inventory.zon. Three cases:
//
// - A package appears in the closure that the inventory says nothing about.
// Matching is on whole names, not substrings: `router` must not be
// satisfied by `@tanstack/react-router` already being in the file.
// - A package the inventory covers moves to a new version. The identity file
// is the text `reportDrift` tells the developer to paste, so a check that
// only looked at the identity file would be satisfied by that paste alone.
// The version has to appear in the inventory too, as the exact
// `<package> <version>` pair the entry's `.version` field spells out.
// - Tree-shaken packages are in the closure but in no shipped byte. They are
// listed in `npm_not_shipped`, and the inventory has to explain each.
test "every npm package in the recorded identity is inventoried at the version it records" {
const gpa = std.testing.allocator;
const entries = try parseInventory(gpa);
defer std.zon.parse.free(gpa, entries);
for ([_][]const u8{ npm_section, generator_section }) |header| {
const recorded = try recordedSection(header);
var lines = std.mem.tokenizeScalar(u8, recorded, '\n');
while (lines.next()) |line| {
const pkg = parseRecordedPackage(line) orelse {
std.debug.print(
"licenses/dependency-identity.txt section {s} has a line that is not" ++
" '<package> <version> <licence>': '{s}'\n",
.{ header, line },
);
return error.MalformedIdentityLine;
};
if (isNotShipped(pkg.name)) {
if (namesToken(licenses.inventory_zon, pkg.name)) continue;
std.debug.print(
"npm package '{s}' ships nothing, but licenses/inventory.zon nowhere says so:" ++
" record why in a note\n",
.{pkg.name},
);
return error.NpmPackageNotInInventory;
}
if (declaresPackageVersion(entries, pkg.name, pkg.version)) continue;
if (!inventoryNamesPackage(entries, pkg.name)) {
std.debug.print(
"npm package '{s}' is in the recorded closure but no licenses/inventory.zon entry" ++
" names it: inventory it, or add it to npm_not_shipped with a note saying" ++
" why nothing of it ships\n",
.{pkg.name},
);
return error.NpmPackageNotInInventory;
}
std.debug.print(
"npm package '{s}' is recorded at version {s}, but no licenses/inventory.zon entry" ++
" spells '{s} {s}' in its .version field. The inventory has to move with the" ++
" dependency; pasting the identity section alone is not the update.\n",
.{ pkg.name, pkg.version, pkg.name, pkg.version },
);
return error.InventoryVersionStale;
}
}
}
test "every build.zig.zon dependency is inventoried at the version its URL names" {
const gpa = std.testing.allocator;
const entries = try parseInventory(gpa);
defer std.zon.parse.free(gpa, entries);
var deps: std.ArrayList(Dependency) = .empty;
defer deps.deinit(gpa);
try collectDependencies(gpa, try dependenciesBody(licenses.build_zig_zon), &deps);
for (deps.items) |dep| {
const expected = for (zig_dependency_versions) |known| {
if (std.mem.eql(u8, known.dependency, dep.name)) break known;
} else {
std.debug.print(
"build.zig.zon dependency '{s}' is in no zig_dependency_versions entry: add it there" ++
" and inventory it in licenses/inventory.zon\n",
.{dep.name},
);
return error.ZigDependencyNotInventoried;
};
const entry = entryContaining(entries, expected.component) orelse {
std.debug.print(
"no licenses/inventory.zon entry covers '{s}', which build.zig.zon depends on\n",
.{expected.component},
);
return error.ComponentMissingFromInventory;
};
var buffer: [64]u8 = undefined;
const needle = try urlVersionForm(&buffer, expected.form, entry.version);
if (std.mem.indexOf(u8, dep.url, needle) == null) {
std.debug.print(
"licenses/inventory.zon records '{s}' at version '{s}', which would appear as '{s}'" ++
" in the dependency URL, but build.zig.zon fetches:\n {s}\n" ++
"The inventory has to move with the dependency; pasting the identity section alone" ++
" is not the update.\n",
.{ expected.component, entry.version, needle, dep.url },
);
return error.InventoryVersionStale;
}
}
}
// Everest and p256-m are vendored inside Mbed TLS, so their inventory entries
// spell the Mbed TLS version they came with. A bump that updates the Mbed TLS
// entry alone leaves those two claiming the old release.
test "the entries vendored inside Mbed TLS record the Mbed TLS version" {
const gpa = std.testing.allocator;
const entries = try parseInventory(gpa);
defer std.zon.parse.free(gpa, entries);
const mbedtls = entryContaining(entries, "Mbed TLS") orelse return error.ComponentMissingFromInventory;
const marker = "vendored in Mbed TLS ";
for (entries) |entry| {
const at = std.mem.indexOf(u8, entry.version, marker) orelse continue;
const rest = entry.version[at + marker.len ..];
if (!std.mem.startsWith(u8, rest, mbedtls.version)) {
std.debug.print(
"licenses/inventory.zon entry '{s}' says it is vendored in Mbed TLS '{s}', but the" ++
" Mbed TLS entry records '{s}'\n",
.{ entry.component, rest, mbedtls.version },
);
return error.VendoredVersionStale;
}
}
}
// The compiler is not a dependency file, so nothing else would notice it
// moving. It is in the artifact all the same: the standard library,
// compiler-rt and the safety runtime are linked into every released binary.
test "the inventory records the Zig version that builds the artifacts" {
const gpa = std.testing.allocator;
const entries = try parseInventory(gpa);
defer std.zon.parse.free(gpa, entries);
var buffer: [32]u8 = undefined;
const version = try std.fmt.bufPrint(&buffer, "{d}.{d}.{d}", .{
builtin.zig_version.major,
builtin.zig_version.minor,
builtin.zig_version.patch,
});
// Both entries name it: the Zig entry as its own version, the musl entry
// because musl is shipped as the copy Zig bundles.
for ([_][]const u8{ "Zig standard library", "musl libc" }) |component| {
const entry = entryContaining(entries, component) orelse return error.ComponentMissingFromInventory;
if (!namesToken(entry.version, version)) {
std.debug.print(
"licenses/inventory.zon entry '{s}' records version '{s}', which does not name the" ++
" Zig {s} that compiled this test\n",
.{ entry.component, entry.version, version },
);
return error.ZigVersionStale;
}
}
}
// The base image is the source of the one third-party file the image carries
// that the binary does not. Recording it in the identity file means a base bump
// fails here until somebody re-reads what the new base ships.
test "the container base image matches the recorded identity" {
const gpa = std.testing.allocator;
const computed = try renderBaseImage(gpa);
defer gpa.free(computed);
const recorded = try recordedSection(base_image_section);
if (!std.mem.eql(u8, trimTrailing(computed), recorded)) {
reportDrift(base_image_section, recorded, computed);
return error.BaseImageIdentityDrift;
}
}
test "the CA bundle entry names the Alpine release the Dockerfile pins" {
const gpa = std.testing.allocator;
const entries = try parseInventory(gpa);
defer std.zon.parse.free(gpa, entries);
const bundle = entryContaining(entries, "Mozilla CA certificate bundle") orelse
return error.ComponentMissingFromInventory;
const marker = "alpine ";
const at = std.mem.indexOf(u8, bundle.version, marker) orelse {
std.debug.print(
"the CA bundle entry's version '{s}' does not name an Alpine release\n",
.{bundle.version},
);
return error.BaseImageNotRecorded;
};
var release = bundle.version[at + marker.len ..];
if (std.mem.indexOfAny(u8, release, " )")) |end| release = release[0..end];
var buffer: [64]u8 = undefined;
const reference = try std.fmt.bufPrint(&buffer, "alpine:{s}@sha256:", .{release});
if (std.mem.indexOf(u8, licenses.dockerfile, reference) == null) {
std.debug.print(
"the CA bundle entry claims alpine {s}, but deploy/docker/Dockerfile pins no" ++
" '{s}' base\n",
.{ release, reference },
);
return error.BaseImageStale;
}
}
test "every npm package in the recorded closure carries the licence the inventory expects" {
for ([_][]const u8{ npm_section, generator_section }) |header| {
const recorded = try recordedSection(header);
var lines = std.mem.tokenizeScalar(u8, recorded, '\n');
while (lines.next()) |line| {
const pkg = parseRecordedPackage(line) orelse return error.MalformedIdentityLine;
const expected = expectedLicence(pkg.name);
if (!std.mem.eql(u8, pkg.licence, expected)) {
std.debug.print(
"npm package '{s}' is recorded under '{s}', but the reviewed expectation is" ++
" '{s}'. Work out what the new licence means for the shipped artifacts, give" ++
" it a text under licenses/ and an inventory entry naming who accepted it if" ++
" this project has not taken it before, then record the new expectation in" ++
" npm_licence_exceptions.\n",
.{ pkg.name, pkg.licence, expected },
);
return error.NpmLicenceChanged;
}
}
}
}
test "every npm licence exception names a package the identity file records" {
const closure = try recordedSection(npm_section);
const generators = try recordedSection(generator_section);
for (npm_licence_exceptions) |entry| {
// A stale exception is how a package quietly regains the MIT default
// after it leaves the tree and comes back under a different licence.
if (recordsPackage(closure, entry.name) or recordsPackage(generators, entry.name)) continue;
std.debug.print(
"npm_licence_exceptions names '{s}', which licenses/dependency-identity.txt no longer" ++
" records. Drop the exception when the package leaves the closure.\n",
.{entry.name},
);
return error.StaleLicenceException;
}
}
/// Whether a recorded `<package> <version> <licence>` section names a package.
fn recordsPackage(section: []const u8, name: []const u8) bool {
var lines = std.mem.tokenizeScalar(u8, section, '\n');
while (lines.next()) |line| {
const pkg = parseRecordedPackage(line) orelse continue;
if (std.mem.eql(u8, pkg.name, name)) return true;
}
return false;
}
test "the licence texts that must be reproduced in full are unmodified" {
for (pinned_texts) |pinned| {
const body = textBody(pinned.file) orelse {
std.debug.print("licenses/{s} is not embedded\n", .{pinned.file});
return error.InventoryFileMissing;
};
var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
std.crypto.hash.sha2.Sha256.hash(body, &digest, .{});
var hex: [digest.len * 2]u8 = undefined;
const actual = try std.fmt.bufPrint(&hex, "{x}", .{&digest});
if (!std.mem.eql(u8, actual, pinned.sha256)) {
std.debug.print(
"licenses/{s} hashes to {s}, pinned at {s}.\n" ++
"A licence that has to be reproduced in full is pinned by content, because the" ++
" marker strings the other guards look for survive most of the document being" ++
" deleted. If the change is deliberate, update pinned_texts.\n",
.{ pinned.file, actual, pinned.sha256 },
);
return error.PinnedLicenceTextChanged;
}
}
}
// The other half of the frontend gate's bundled-package check. That gate keeps
// the recorded list honest against the build; this keeps the inventory honest
// against the recorded list. Neither works alone: the gate cannot say what the
// inventory covers, and this test cannot run rollup.
test "every package bundled into admin/dist is inventoried" {
const gpa = std.testing.allocator;
const entries = try parseInventory(gpa);
defer std.zon.parse.free(gpa, entries);
const recorded = try recordedSection(bundled_section);
var lines = std.mem.tokenizeScalar(u8, recorded, '\n');
while (lines.next()) |raw| {
const name = std.mem.trim(u8, raw, " \t\r");
if (name.len == 0) continue;
if (isNotShipped(name)) {
std.debug.print(
"npm package '{s}' is in the recorded admin/dist bundle but npm_not_shipped still" ++
" claims it ships nothing. It does now: inventory it and take it out of that" ++
" list.\n",
.{name},
);
return error.NotShippedPackageIsShipped;
}
if (!inventoryNamesPackage(entries, name)) {
std.debug.print(
"npm package '{s}' is bundled into admin/dist but no licenses/inventory.zon entry" ++
" names it\n",
.{name},
);
return error.NpmPackageNotInInventory;
}
}
}
test "the notices preamble covers the image, not only the binary" {
for ([_][]const u8{
"THIRD-PARTY-NOTICES",
"tarballs",
"container image",
}) |needle| {
if (std.mem.indexOf(u8, licenses.preamble_txt, needle) == null) {
std.debug.print(
"licenses/preamble.txt is missing '{s}': the notices file has to state that it" ++
" covers what the image ships as well as what the binary embeds\n",
.{needle},
);
return error.PreambleDoesNotCoverTheImage;
}
}
}
test "the CA bundle the image redistributes is inventoried and marked image-only" {
const gpa = std.testing.allocator;
const entries = try parseInventory(gpa);
defer std.zon.parse.free(gpa, entries);
const bundle = entryContaining(entries, "Mozilla CA certificate bundle") orelse
return error.ComponentMissingFromInventory;
if (std.mem.indexOf(u8, bundle.note, "container image only") == null) {
std.debug.print(
"the CA bundle inventory note does not say it is in the container image only\n",
.{},
);
return error.ImageOnlyScopeNotRecorded;
}
const body = textBody(bundle.file) orelse return error.InventoryFileMissing;
for ([_][]const u8{
"/etc/ssl/certs/ca-certificates.crt",
"L:MPL-2.0 AND MIT",
"Mozilla Public License Version 2.0",
"5. Termination",
"8. Litigation",
"Exhibit B - \"Incompatible With Secondary Licenses\" Notice",
}) |needle| {
if (std.mem.indexOf(u8, body, needle) == null) {
std.debug.print("licenses/{s} is missing '{s}'\n", .{ bundle.file, needle });
return error.CaBundleTextIncomplete;
}
}
}
test "the Mbed TLS entry records the Apache-2.0 selection and carries the full text" {
const gpa = std.testing.allocator;
const entries = try parseInventory(gpa);
defer std.zon.parse.free(gpa, entries);
const mbedtls = entryContaining(entries, "Mbed TLS") orelse return error.ComponentMissingFromInventory;
if (std.mem.indexOf(u8, mbedtls.note, "Apache-2.0 option") == null) {
std.debug.print("the Mbed TLS inventory note does not record the Apache-2.0 selection\n", .{});
return error.ApacheSelectionNotRecorded;
}
const body = textBody(mbedtls.file) orelse return error.InventoryFileMissing;
for ([_][]const u8{
"nxdns takes Mbed TLS under the Apache-2.0 option",
"Apache License",
"Version 2.0, January 2004",
"TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION",
"END OF TERMS AND CONDITIONS",
}) |needle| {
if (std.mem.indexOf(u8, body, needle) == null) {
std.debug.print("licenses/{s} is missing '{s}'\n", .{ mbedtls.file, needle });
return error.ApacheTextIncomplete;
}
}
}
test "the entries that refer back to the Apache-2.0 text follow the one that carries it" {
const gpa = std.testing.allocator;
const entries = try parseInventory(gpa);
defer std.zon.parse.free(gpa, entries);
const carrier = indexOfComponent(entries, "Mbed TLS") orelse return error.ComponentMissingFromInventory;
for ([_][]const u8{ "Everest", "p256-m" }) |needle| {
const idx = indexOfComponent(entries, needle) orelse return error.ComponentMissingFromInventory;
const body = textBody(entries[idx].file) orelse return error.InventoryFileMissing;
if (std.mem.indexOf(u8, body, "reproduced above") == null) {
std.debug.print("licenses/{s} does not refer back to the Apache-2.0 text\n", .{entries[idx].file});
return error.ApacheBackReferenceMissing;
}
if (idx < carrier) {
std.debug.print(
"licenses/inventory.zon lists '{s}' before the entry carrying the Apache-2.0 text," ++
" so THIRD-PARTY-NOTICES would refer backwards to text that has not appeared yet\n",
.{entries[idx].component},
);
return error.ApacheBackReferenceOutOfOrder;
}
}
}
/// Sections of `licenses/dependency-identity.txt` are compared without their
/// surrounding blank lines, so a stray newline in the file is not drift.
fn trimTrailing(text: []const u8) []const u8 {
return std.mem.trim(u8, text, " \t\r\n");
}
const RecordedPackage = struct {
name: []const u8,
version: []const u8,
licence: []const u8,
};
/// One `<package> <version> <licence>` line of a recorded npm section.
fn parseRecordedPackage(line: []const u8) ?RecordedPackage {
var fields = std.mem.tokenizeAny(u8, line, " \t\r");
const name = fields.next() orelse return null;
const version = fields.next() orelse return null;
const licence = fields.next() orelse return null;
return .{ .name = name, .version = version, .licence = licence };
}
/// How an inventory `.version` field appears inside a dependency URL.
fn urlVersionForm(
buffer: []u8,
form: @FieldType(ZigDependencyVersion, "form"),
version: []const u8,
) ![]const u8 {
switch (form) {
.dotted => return version,
.sqlite_packed => {
var parts = std.mem.splitScalar(u8, version, '.');
const major = parts.next() orelse return error.UnparsableVersion;
const minor = parts.next() orelse return error.UnparsableVersion;
const patch = parts.next() orelse return error.UnparsableVersion;
return std.fmt.bufPrint(buffer, "{s}{d:0>2}{d:0>2}00", .{
major,
try std.fmt.parseInt(u8, minor, 10),
try std.fmt.parseInt(u8, patch, 10),
});
},
}
}
/// The `FROM ... AS builder` reference of the image Dockerfile, tag and digest
/// together. That one line is the whole identity of the base: a new tag or a
/// rebuilt digest both change it.
fn renderBaseImage(gpa: std.mem.Allocator) ![]u8 {
var lines = std.mem.splitScalar(u8, licenses.dockerfile, '\n');
while (lines.next()) |line| {
if (!std.mem.startsWith(u8, line, "FROM ")) continue;
var fields = std.mem.tokenizeAny(u8, line, " \t\r");
while (fields.next()) |field| {
if (std.mem.indexOf(u8, field, "@sha256:") != null) {
return std.fmt.allocPrint(gpa, "{s}\n", .{field});
}
}
}
std.debug.print("deploy/docker/Dockerfile has no digest-pinned FROM line\n", .{});
return error.BaseImageNotPinned;
}
fn isNotShipped(name: []const u8) bool {
for (npm_not_shipped) |excluded| {
if (std.mem.eql(u8, excluded, name)) return true;
}
return false;
}
/// Whether some entry's `.version` field spells the exact `<package> <version>`
/// pair. The inventory writes npm versions that way precisely so this can be an
/// equality check rather than a "contains a digit" check.
fn declaresPackageVersion(entries: []const Entry, name: []const u8, version: []const u8) bool {
var buffer: [256]u8 = undefined;
const pair = std.fmt.bufPrint(&buffer, "{s} {s}", .{ name, version }) catch return false;
for (entries) |entry| {
if (namesToken(entry.version, pair)) return true;
}
return false;
}
/// Whether any entry names the package at all, which separates "not inventoried"
/// from "inventoried at a stale version" in the failure message.
fn inventoryNamesPackage(entries: []const Entry, name: []const u8) bool {
for (entries) |entry| {
if (namesToken(entry.component, name)) return true;
if (namesToken(entry.version, name)) return true;
}
return false;
}
/// `std.mem.indexOf`, but only accepting occurrences that are not part of a
/// longer package name or version. Without this, `router` is satisfied by
/// `@tanstack/react-router` and `1.6` by `1.6.0`.
fn namesToken(haystack: []const u8, needle: []const u8) bool {
if (needle.len == 0) return false;
var from: usize = 0;
while (std.mem.indexOfPos(u8, haystack, from, needle)) |at| {
from = at + 1;
if (at > 0 and isPackageNameChar(haystack[at - 1])) continue;
const after = at + needle.len;
if (after < haystack.len and isPackageNameChar(haystack[after])) continue;
return true;
}
return false;
}
fn isPackageNameChar(c: u8) bool {
return std.ascii.isAlphanumeric(c) or switch (c) {
'@', '/', '-', '_', '.', '+' => true,
else => false,
};
}
fn entryContaining(entries: []const Entry, needle: []const u8) ?Entry {
const idx = indexOfComponent(entries, needle) orelse return null;
return entries[idx];
}
fn indexOfComponent(entries: []const Entry, needle: []const u8) ?usize {
for (entries, 0..) |entry, i| {
if (std.mem.indexOf(u8, entry.component, needle) != null) return i;
}
return null;
}
fn textBody(name: []const u8) ?[]const u8 {
for (licenses.texts) |text| {
if (std.mem.eql(u8, text.name, name)) return text.body;
}
return null;
}
fn parseInventory(gpa: std.mem.Allocator) ![]const Entry {
var diagnostics: std.zon.parse.Diagnostics = .{};
defer diagnostics.deinit(gpa);
return std.zon.parse.fromSliceAlloc(
[]const Entry,
gpa,
licenses.inventory_zon,
&diagnostics,
.{},
) catch |err| {
std.debug.print("licenses/inventory.zon does not parse: {f}\n", .{diagnostics});
return err;
};
}
/// The body of one section of `licenses/dependency-identity.txt`: everything
/// between its header line and the next header line, with the surrounding blank
/// lines trimmed. Text before the first header is free-form prose.
fn recordedSection(header: []const u8) ![]const u8 {
const text = licenses.dependency_identity_txt;
var search: usize = 0;
const start = while (std.mem.indexOfPos(u8, text, search, header)) |at| {
const line_start = if (std.mem.lastIndexOfScalar(u8, text[0..at], '\n')) |nl| nl + 1 else 0;
if (line_start == at) break at + header.len;
search = at + header.len;
} else {
std.debug.print("licenses/dependency-identity.txt has no '{s}' section\n", .{header});
return error.IdentitySectionMissing;
};
const end = if (std.mem.indexOfPos(u8, text, start, "\n[")) |at| at + 1 else text.len;
return std.mem.trim(u8, text[start..end], " \t\r\n");
}
fn reportDrift(header: []const u8, recorded: []const u8, computed: []const u8) void {
std.debug.print(
\\licenses/dependency-identity.txt is stale in section {s}.
\\Review licenses/inventory.zon against the change, then replace that
\\section with the text between the markers.
\\--- recorded ---
\\{s}
\\--- current ---
\\{s}
\\--- end ---
\\
, .{ header, recorded, computed });
}
fn renderZigDependencies(gpa: std.mem.Allocator) ![]u8 {
const body = try dependenciesBody(licenses.build_zig_zon);
var deps: std.ArrayList(Dependency) = .empty;
defer deps.deinit(gpa);
try collectDependencies(gpa, body, &deps);
std.mem.sort(Dependency, deps.items, {}, Dependency.lessThan);
var out: std.Io.Writer.Allocating = .init(gpa);
errdefer out.deinit();
for (deps.items) |dep| {
try out.writer.print("{s} url={s} hash={s}\n", .{ dep.name, dep.url, dep.hash });
}
return out.toOwnedSlice();
}
const Dependency = struct {
name: []const u8,
url: []const u8,
hash: []const u8,
fn lessThan(_: void, a: Dependency, b: Dependency) bool {
return std.mem.lessThan(u8, a.name, b.name);
}
};
/// The text between the braces of `build.zig.zon`'s `.dependencies` struct.
fn dependenciesBody(zon: []const u8) ![]const u8 {
const at = std.mem.indexOf(u8, zon, ".dependencies") orelse {
std.debug.print("build.zig.zon has no .dependencies field\n", .{});
return error.DependenciesFieldMissing;
};
const open = std.mem.indexOfScalarPos(u8, zon, at, '{') orelse return error.DependenciesFieldMissing;
const close = try matchingBrace(zon, open + 1);
return zon[open + 1 .. close];
}
/// Every `.name = .{ ... }` at the top level of `body`. Nested braces, string
/// literals and `//` comments are skipped, so a URL or a hash containing a
/// brace cannot end an entry early.
fn collectDependencies(
gpa: std.mem.Allocator,
body: []const u8,
out: *std.ArrayList(Dependency),
) !void {
var i: usize = 0;
while (i < body.len) {
switch (body[i]) {
'"' => i = try skipString(body, i),
'/' => {
if (i + 1 < body.len and body[i + 1] == '/') {
i = std.mem.indexOfScalarPos(u8, body, i, '\n') orelse body.len;
} else i += 1;
},
'.' => {
var j = i + 1;
while (j < body.len and (std.ascii.isAlphanumeric(body[j]) or body[j] == '_')) j += 1;
if (j == i + 1) {
i += 1;
continue;
}
const name = body[i + 1 .. j];
var k = skipWhitespace(body, j);
if (k >= body.len or body[k] != '=') {
i = j;
continue;
}
k = skipWhitespace(body, k + 1);
if (k + 1 >= body.len or body[k] != '.' or body[k + 1] != '{') {
i = j;
continue;
}
const close = try matchingBrace(body, k + 2);
const entry = body[k + 2 .. close];
try out.append(gpa, .{
.name = name,
.url = fieldString(entry, "url") orelse "-",
.hash = fieldString(entry, "hash") orelse "-",
});
i = close + 1;
},
else => i += 1,
}
}
}
/// The string value of `.<field> = "..."` inside one dependency's body.
fn fieldString(entry: []const u8, field: []const u8) ?[]const u8 {
var search: usize = 0;
while (std.mem.indexOfPos(u8, entry, search, field)) |at| {
search = at + field.len;
if (at == 0 or entry[at - 1] != '.') continue;
if (search < entry.len and (std.ascii.isAlphanumeric(entry[search]) or entry[search] == '_')) continue;
const open = std.mem.indexOfScalarPos(u8, entry, search, '"') orelse return null;
const close = std.mem.indexOfScalarPos(u8, entry, open + 1, '"') orelse return null;
return entry[open + 1 .. close];
}
return null;
}
/// The index of the `}` closing a block whose contents start at `start`.
fn matchingBrace(text: []const u8, start: usize) !usize {
var depth: usize = 1;
var i = start;
while (i < text.len) {
switch (text[i]) {
'"' => {
i = try skipString(text, i);
continue;
},
'/' => {
if (i + 1 < text.len and text[i + 1] == '/') {
i = std.mem.indexOfScalarPos(u8, text, i, '\n') orelse text.len;
continue;
}
},
'{' => depth += 1,
'}' => {
depth -= 1;
if (depth == 0) return i;
},
else => {},
}
i += 1;
}
return error.UnbalancedBraces;
}
/// The index just past the string literal starting at `open`.
fn skipString(text: []const u8, open: usize) !usize {
var i = open + 1;
while (i < text.len) : (i += 1) {
switch (text[i]) {
'\\' => i += 1,
'"' => return i + 1,
else => {},
}
}
return error.UnterminatedString;
}
fn skipWhitespace(text: []const u8, from: usize) usize {
var i = from;
while (i < text.len and std.ascii.isWhitespace(text[i])) i += 1;
return i;
}
const NpmSet = enum { runtime, generators };
/// `name version licence` for every package in `admin/package-lock.json` that is
/// not marked `dev`, or for the build-time generators, sorted by name. The
/// non-dev set is a superset of what the bundler emits — it cannot run rollup —
/// so a new runtime dependency always trips the guard even when tree-shaking
/// would have dropped it.
fn renderNpmClosure(gpa: std.mem.Allocator, set: NpmSet) ![]u8 {
const parsed = try std.json.parseFromSlice(std.json.Value, gpa, licenses.package_lock_json, .{});
defer parsed.deinit();
const packages = switch (parsed.value) {
.object => |root| root.get("packages") orelse return error.LockfileHasNoPackages,
else => return error.LockfileHasNoPackages,
};
var names: std.ArrayList([]const u8) = .empty;
defer names.deinit(gpa);
var it = packages.object.iterator();
while (it.next()) |kv| {
const key = kv.key_ptr.*;
if (key.len == 0) continue;
const marker = "node_modules/";
const at = std.mem.lastIndexOf(u8, key, marker) orelse continue;
const name = key[at + marker.len ..];
const dev = switch (kv.value_ptr.*) {
.object => |o| if (o.get("dev")) |flag| switch (flag) {
.bool => |on| on,
else => false,
} else false,
else => false,
};
const wanted = switch (set) {
.runtime => !dev,
.generators => isGenerator(name),
};
if (wanted) try names.append(gpa, key);
}
std.mem.sort([]const u8, names.items, {}, lessThanString);
var out: std.Io.Writer.Allocating = .init(gpa);
errdefer out.deinit();
for (names.items) |key| {
const entry = packages.object.get(key).?.object;
const name = key[std.mem.lastIndexOf(u8, key, "node_modules/").? + "node_modules/".len ..];
const version = if (entry.get("version")) |v| v.string else "-";
const licence = if (entry.get("license")) |v| v.string else "-";
try out.writer.print("{s} {s} {s}\n", .{ name, version, licence });
}
return out.toOwnedSlice();
}
fn isGenerator(name: []const u8) bool {
for (npm_generators) |generator| {
if (std.mem.eql(u8, generator, name)) return true;
}
return false;
}
fn lessThanString(_: void, a: []const u8, b: []const u8) bool {
return std.mem.lessThan(u8, a, b);
}