1143 lines
46 KiB
Zig
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);
|
|
}
|