//! 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", "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", "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 // ` ` 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" ++ " ' ': '{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 ` ` 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 ` ` 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 ` ` /// 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 `. = "..."` 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); }