Files
nxdns/src/licenses_drift_test.zig
T

1143 lines
46 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 `web/dist`. Answering it means
//! running the bundler. The frontend gate does that, compares the result with
//! the `[npm packages bundled into web/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 `web/dist`, and so in the binary. None of them runs in the
/// browser; their emitted bytes do.
///
/// The set was settled by building `web/` 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 `web/dist` untouched.
/// Anything added here must also be inventoried.
const npm_generators = [_][]const u8{ "rolldown", "tailwindcss", "vite" };
/// Packages of the recorded runtime closure that put no byte in `web/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 `web/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",
};
/// 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",
"Tailwind",
"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 `web/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" },
};
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 web/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 web/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 web/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 web/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 `web/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);
}