//! 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);
}
