|
| 1 | +import assert from "node:assert/strict"; |
| 2 | +import { readFileSync } from "node:fs"; |
| 3 | +import * as semver from "semver"; |
| 4 | + |
| 5 | +const NPM_REGISTRY_URL = "https://registry.npmjs.org"; |
| 6 | + |
| 7 | +export const MINIMUM_DEPENDENCY_AGE_MS = 7 * 24 * 60 * 60 * 1000; |
| 8 | + |
| 9 | +export interface ProductionDependencyManifest { |
| 10 | + dependencies?: Record<string, string>; |
| 11 | + optionalDependencies?: Record<string, string>; |
| 12 | +} |
| 13 | + |
| 14 | +export interface ProductionDependency { |
| 15 | + name: string; |
| 16 | + version: string; |
| 17 | +} |
| 18 | + |
| 19 | +export interface RegistryResponse { |
| 20 | + json(): Promise<unknown>; |
| 21 | + ok: boolean; |
| 22 | + status: number; |
| 23 | + statusText: string; |
| 24 | +} |
| 25 | + |
| 26 | +export type RegistryRequest = (url: string) => Promise<RegistryResponse>; |
| 27 | + |
| 28 | +function isRecord(value: unknown): value is Record<string, unknown> { |
| 29 | + return typeof value === "object" && value !== null && !Array.isArray(value); |
| 30 | +} |
| 31 | + |
| 32 | +function dependencyKey(dependency: ProductionDependency): string { |
| 33 | + return `${dependency.name}@${dependency.version}`; |
| 34 | +} |
| 35 | + |
| 36 | +function readDependencySection( |
| 37 | + manifest: Record<string, unknown>, |
| 38 | + section: "dependencies" | "optionalDependencies" |
| 39 | +): Record<string, string> | undefined { |
| 40 | + const value = manifest[section]; |
| 41 | + if (value === undefined) { |
| 42 | + return undefined; |
| 43 | + } |
| 44 | + assert(isRecord(value), `${section} must be a JSON object`); |
| 45 | + const dependencies: Record<string, string> = {}; |
| 46 | + for (const [name, version] of Object.entries(value)) { |
| 47 | + assert(typeof version === "string", `${section}.${name} must be a string`); |
| 48 | + dependencies[name] = version; |
| 49 | + } |
| 50 | + return dependencies; |
| 51 | +} |
| 52 | + |
| 53 | +export function readProductionDependencyManifest(path: string): ProductionDependencyManifest { |
| 54 | + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); |
| 55 | + assert(isRecord(parsed), "package.json must contain a JSON object"); |
| 56 | + return { |
| 57 | + dependencies: readDependencySection(parsed, "dependencies"), |
| 58 | + optionalDependencies: readDependencySection(parsed, "optionalDependencies"), |
| 59 | + }; |
| 60 | +} |
| 61 | + |
| 62 | +export function assertExactProductionDependencies( |
| 63 | + manifest: ProductionDependencyManifest |
| 64 | +): ProductionDependency[] { |
| 65 | + const dependencies: ProductionDependency[] = []; |
| 66 | + for (const section of ["dependencies", "optionalDependencies"] as const) { |
| 67 | + for (const [name, version] of Object.entries(manifest[section] ?? {})) { |
| 68 | + assert.equal( |
| 69 | + semver.valid(version), |
| 70 | + version, |
| 71 | + `${section}.${name} must use an exact SemVer version; found '${version}'` |
| 72 | + ); |
| 73 | + dependencies.push({ name, version }); |
| 74 | + } |
| 75 | + } |
| 76 | + return dependencies; |
| 77 | +} |
| 78 | + |
| 79 | +function packageNameFromLockfilePath(path: string): string | undefined { |
| 80 | + return /(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)$/.exec(path)?.[1]; |
| 81 | +} |
| 82 | + |
| 83 | +export function parseResolvedProductionDependencies(lockfile: unknown): ProductionDependency[] { |
| 84 | + assert(isRecord(lockfile), "package-lock.json must contain a JSON object"); |
| 85 | + assert.equal(lockfile.lockfileVersion, 3, "package-lock.json must use lockfileVersion 3"); |
| 86 | + assert(isRecord(lockfile.packages), "package-lock.json must contain a packages object"); |
| 87 | + |
| 88 | + const dependencies = new Map<string, ProductionDependency>(); |
| 89 | + for (const [path, value] of Object.entries(lockfile.packages)) { |
| 90 | + if (path === "") { |
| 91 | + continue; |
| 92 | + } |
| 93 | + assert(isRecord(value), `Package-lock entry '${path}' must be a JSON object`); |
| 94 | + // npm marks dev=true only when a package is strictly dev-only. Optional and |
| 95 | + // devOptional packages reachable from production dependencies remain included. |
| 96 | + if (value.dev === true || value.link === true || value.inBundle === true) { |
| 97 | + continue; |
| 98 | + } |
| 99 | + const name = packageNameFromLockfilePath(path); |
| 100 | + assert(name, `Unsupported production package-lock path '${path}'`); |
| 101 | + const version = value.version; |
| 102 | + assert( |
| 103 | + typeof version === "string" && semver.valid(version) === version, |
| 104 | + `Resolved production dependency ${name} must use an exact SemVer version` |
| 105 | + ); |
| 106 | + dependencies.set(`${name}@${version}`, { name, version }); |
| 107 | + } |
| 108 | + return [...dependencies.values()]; |
| 109 | +} |
| 110 | + |
| 111 | +export function readResolvedProductionDependencies(path: string): ProductionDependency[] { |
| 112 | + return parseResolvedProductionDependencies(JSON.parse(readFileSync(path, "utf8")) as unknown); |
| 113 | +} |
| 114 | + |
| 115 | +export function requiresPublicationCooldown(dependencyName: string): boolean { |
| 116 | + return !dependencyName.startsWith("@github/"); |
| 117 | +} |
| 118 | + |
| 119 | +export async function loadNpmPublicationTimes( |
| 120 | + dependencies: readonly ProductionDependency[], |
| 121 | + request: RegistryRequest = (url) => fetch(url) |
| 122 | +): Promise<Map<string, string>> { |
| 123 | + const externalDependencies = dependencies.filter((dependency) => |
| 124 | + requiresPublicationCooldown(dependency.name) |
| 125 | + ); |
| 126 | + const packageNames = [...new Set(externalDependencies.map((dependency) => dependency.name))]; |
| 127 | + const metadata = new Map<string, Record<string, unknown>>(); |
| 128 | + |
| 129 | + await Promise.all( |
| 130 | + packageNames.map(async (packageName) => { |
| 131 | + const response = await request( |
| 132 | + `${NPM_REGISTRY_URL}/${encodeURIComponent(packageName)}` |
| 133 | + ); |
| 134 | + assert( |
| 135 | + response.ok, |
| 136 | + `npm metadata request for ${packageName} failed: ${response.status} ${response.statusText}` |
| 137 | + ); |
| 138 | + const body = await response.json(); |
| 139 | + assert(isRecord(body), `npm metadata for ${packageName} must be a JSON object`); |
| 140 | + const times = body.time; |
| 141 | + assert(isRecord(times), `npm metadata for ${packageName} is missing publication times`); |
| 142 | + metadata.set(packageName, times); |
| 143 | + }) |
| 144 | + ); |
| 145 | + |
| 146 | + return new Map( |
| 147 | + externalDependencies.map((dependency) => { |
| 148 | + const publishedAt = metadata.get(dependency.name)?.[dependency.version]; |
| 149 | + assert( |
| 150 | + typeof publishedAt === "string", |
| 151 | + `npm metadata is missing a publication time for ${dependencyKey(dependency)}` |
| 152 | + ); |
| 153 | + return [dependencyKey(dependency), publishedAt]; |
| 154 | + }) |
| 155 | + ); |
| 156 | +} |
| 157 | + |
| 158 | +export function assertMinimumPublicationAge( |
| 159 | + dependencies: readonly ProductionDependency[], |
| 160 | + publicationTimes: ReadonlyMap<string, string>, |
| 161 | + now = Date.now() |
| 162 | +): void { |
| 163 | + assert(Number.isFinite(now), "Dependency policy verification time must be finite"); |
| 164 | + |
| 165 | + for (const dependency of dependencies) { |
| 166 | + if (!requiresPublicationCooldown(dependency.name)) { |
| 167 | + continue; |
| 168 | + } |
| 169 | + const key = dependencyKey(dependency); |
| 170 | + const publishedAt = publicationTimes.get(key); |
| 171 | + assert(publishedAt, `Missing npm publication time for ${key}`); |
| 172 | + const publishedAtMs = Date.parse(publishedAt); |
| 173 | + assert(Number.isFinite(publishedAtMs), `Invalid npm publication time for ${key}`); |
| 174 | + const eligibleAtMs = publishedAtMs + MINIMUM_DEPENDENCY_AGE_MS; |
| 175 | + assert( |
| 176 | + now >= eligibleAtMs, |
| 177 | + `${key} was published at ${publishedAt} and is not eligible until ${new Date( |
| 178 | + eligibleAtMs |
| 179 | + ).toISOString()}` |
| 180 | + ); |
| 181 | + } |
| 182 | +} |
0 commit comments