|
| 1 | +// Script to update certdata.txt from NSS. |
| 2 | +import { execFileSync } from 'node:child_process'; |
| 3 | +import { randomUUID } from 'node:crypto'; |
| 4 | +import { createWriteStream } from 'node:fs'; |
| 5 | +import { basename, join, relative } from 'node:path'; |
| 6 | +import { Readable } from 'node:stream'; |
| 7 | +import { pipeline } from 'node:stream/promises'; |
| 8 | +import { fileURLToPath } from 'node:url'; |
| 9 | +import { parseArgs } from 'node:util'; |
| 10 | + |
| 11 | +// Constants for NSS release metadata. |
| 12 | +const kNSSVersion = 'version'; |
| 13 | +const kNSSDate = 'date'; |
| 14 | +const kFirefoxVersion = 'firefoxVersion'; |
| 15 | +const kFirefoxDate = 'firefoxDate'; |
| 16 | + |
| 17 | +const __filename = fileURLToPath(import.meta.url); |
| 18 | +const now = new Date(); |
| 19 | + |
| 20 | +const formatDate = (d) => { |
| 21 | + const iso = d.toISOString(); |
| 22 | + return iso.substring(0, iso.indexOf('T')); |
| 23 | +}; |
| 24 | + |
| 25 | +const normalizeTD = (text) => { |
| 26 | + // Remove whitespace and any HTML tags. |
| 27 | + return text?.trim().replace(/<.*?>/g, ''); |
| 28 | +}; |
| 29 | +const getReleases = (text) => { |
| 30 | + const releases = []; |
| 31 | + const tableRE = /<table [^>]+>([\S\s]*?)<\/table>/g; |
| 32 | + const tableRowRE = /<tr ?[^>]*>([\S\s]*?)<\/tr>/g; |
| 33 | + const tableHeaderRE = /<th ?[^>]*>([\S\s]*?)<\/th>/g; |
| 34 | + const tableDataRE = /<td ?[^>]*>([\S\s]*?)<\/td>/g; |
| 35 | + for (const table of text.matchAll(tableRE)) { |
| 36 | + const columns = {}; |
| 37 | + const matches = table[1].matchAll(tableRowRE); |
| 38 | + // First row has the table header. |
| 39 | + let row = matches.next(); |
| 40 | + if (row.done) { |
| 41 | + continue; |
| 42 | + } |
| 43 | + const headers = Array.from(row.value[1].matchAll(tableHeaderRE), (m) => m[1]); |
| 44 | + if (headers.length > 0) { |
| 45 | + for (let i = 0; i < headers.length; i++) { |
| 46 | + if (/NSS version/i.test(headers[i])) { |
| 47 | + columns[kNSSVersion] = i; |
| 48 | + } else if (/Release.*from branch/i.test(headers[i])) { |
| 49 | + columns[kNSSDate] = i; |
| 50 | + } else if (/Firefox version/i.test(headers[i])) { |
| 51 | + columns[kFirefoxVersion] = i; |
| 52 | + } else if (/Firefox release date/i.test(headers[i])) { |
| 53 | + columns[kFirefoxDate] = i; |
| 54 | + } |
| 55 | + } |
| 56 | + } |
| 57 | + // Filter out "NSS Certificate bugs" table. |
| 58 | + if (columns[kNSSDate] === undefined) { |
| 59 | + continue; |
| 60 | + } |
| 61 | + // Scrape releases. |
| 62 | + row = matches.next(); |
| 63 | + while (!row.done) { |
| 64 | + const cells = Array.from(row.value[1].matchAll(tableDataRE), (m) => m[1]); |
| 65 | + const release = {}; |
| 66 | + release[kNSSVersion] = normalizeTD(cells[columns[kNSSVersion]]); |
| 67 | + release[kNSSDate] = new Date(normalizeTD(cells[columns[kNSSDate]])); |
| 68 | + release[kFirefoxVersion] = normalizeTD(cells[columns[kFirefoxVersion]]); |
| 69 | + release[kFirefoxDate] = new Date(normalizeTD(cells[columns[kFirefoxDate]])); |
| 70 | + releases.push(release); |
| 71 | + row = matches.next(); |
| 72 | + } |
| 73 | + } |
| 74 | + return releases; |
| 75 | +}; |
| 76 | + |
| 77 | +const getLatestVersion = (releases) => { |
| 78 | + const arrayNumberSort = (x, y, i) => { |
| 79 | + if (x[i] === undefined && y[i] === undefined) { |
| 80 | + return 0; |
| 81 | + } else if (x[i] === y[i]) { |
| 82 | + return arrayNumberSort(x, y, i + 1); |
| 83 | + } |
| 84 | + return (x[i] ?? 0) - (y[i] ?? 0); |
| 85 | + }; |
| 86 | + const extractVersion = (t) => { |
| 87 | + return t[kNSSVersion].split('.').map((n) => parseInt(n)); |
| 88 | + }; |
| 89 | + const releaseSorter = (x, y) => { |
| 90 | + return arrayNumberSort(extractVersion(x), extractVersion(y), 0); |
| 91 | + }; |
| 92 | + return releases.sort(releaseSorter).filter(pastRelease).at(-1)[kNSSVersion]; |
| 93 | +}; |
| 94 | + |
| 95 | +const pastRelease = (r) => { |
| 96 | + return r[kNSSDate] < now; |
| 97 | +}; |
| 98 | + |
| 99 | +const options = { |
| 100 | + help: { |
| 101 | + type: 'boolean', |
| 102 | + }, |
| 103 | + file: { |
| 104 | + short: 'f', |
| 105 | + type: 'string', |
| 106 | + }, |
| 107 | + verbose: { |
| 108 | + short: 'v', |
| 109 | + type: 'boolean', |
| 110 | + }, |
| 111 | +}; |
| 112 | +const { |
| 113 | + positionals, |
| 114 | + values, |
| 115 | +} = parseArgs({ |
| 116 | + allowPositionals: true, |
| 117 | + options, |
| 118 | +}); |
| 119 | + |
| 120 | +if (values.help) { |
| 121 | + console.log(`Usage: ${basename(__filename)} [OPTION]... [VERSION]...`); |
| 122 | + console.log(); |
| 123 | + console.log('Updates certdata.txt to NSS VERSION (most recent release by default).'); |
| 124 | + console.log(''); |
| 125 | + console.log(' -f, --file=FILE writes a commit message reflecting the change to the'); |
| 126 | + console.log(' specified FILE'); |
| 127 | + console.log(' -v, --verbose writes progress to stdout'); |
| 128 | + console.log(' --help display this help and exit'); |
| 129 | + process.exit(0); |
| 130 | +} |
| 131 | + |
| 132 | +if (values.verbose) { |
| 133 | + console.log('Fetching NSS release schedule'); |
| 134 | +} |
| 135 | +const scheduleURL = 'https://wiki.mozilla.org/NSS:Release_Versions'; |
| 136 | +const schedule = await fetch(scheduleURL); |
| 137 | +if (!schedule.ok) { |
| 138 | + console.error(`Failed to fetch ${scheduleURL}: ${schedule.status}: ${schedule.statusText}`); |
| 139 | + process.exit(-1); |
| 140 | +} |
| 141 | +const scheduleText = await schedule.text(); |
| 142 | +const nssReleases = getReleases(scheduleText); |
| 143 | + |
| 144 | +// Retrieve metadata for the NSS release being updated to. |
| 145 | +const version = positionals[0] ?? getLatestVersion(nssReleases); |
| 146 | +const release = nssReleases.find((r) => { |
| 147 | + return new RegExp(`^${version.replace('.', '\\.')}\\b`).test(r[kNSSVersion]); |
| 148 | +}); |
| 149 | +if (!pastRelease(release)) { |
| 150 | + console.warn(`Warning: NSS ${version} is not due to be released until ${formatDate(release[kNSSDate])}`); |
| 151 | +} |
| 152 | +if (values.verbose) { |
| 153 | + console.log('Found NSS version:'); |
| 154 | + console.log(release); |
| 155 | +} |
| 156 | + |
| 157 | +// Fetch certdata.txt and overwrite the local copy. |
| 158 | +const tag = `NSS_${version.replaceAll('.', '_')}_RTM`; |
| 159 | +const certdataURL = `https://hg.mozilla.org/projects/nss/raw-file/${tag}/lib/ckfw/builtins/certdata.txt`; |
| 160 | +if (values.verbose) { |
| 161 | + console.log(`Fetching ${certdataURL}`); |
| 162 | +} |
| 163 | +const checkoutDir = join(__filename, '..', '..', '..'); |
| 164 | +const certdata = await fetch(certdataURL); |
| 165 | +const certdataFile = join(checkoutDir, 'tools', 'certdata.txt'); |
| 166 | +if (!certdata.ok) { |
| 167 | + console.error(`Failed to fetch ${certdataURL}: ${certdata.status}: ${certdata.statusText}`); |
| 168 | + process.exit(-1); |
| 169 | +} |
| 170 | +if (values.verbose) { |
| 171 | + console.log(`Writing ${certdataFile}`); |
| 172 | +} |
| 173 | +await pipeline(certdata.body, createWriteStream(certdataFile)); |
| 174 | + |
| 175 | +// Run tools/mk-ca-bundle.pl to generate src/node_root_certs.h. |
| 176 | +if (values.verbose) { |
| 177 | + console.log('Running tools/mk-ca-bundle.pl'); |
| 178 | +} |
| 179 | +const opts = { encoding: 'utf8' }; |
| 180 | +const mkCABundleTool = join(checkoutDir, 'tools', 'mk-ca-bundle.pl'); |
| 181 | +const mkCABundleOut = execFileSync(mkCABundleTool, |
| 182 | + values.verbose ? [ '-v' ] : [], |
| 183 | + opts); |
| 184 | +if (values.verbose) { |
| 185 | + console.log(mkCABundleOut); |
| 186 | +} |
| 187 | + |
| 188 | +// Determine certificates added and/or removed. |
| 189 | +const certHeaderFile = relative(process.cwd(), join(checkoutDir, 'src', 'node_root_certs.h')); |
| 190 | +const diff = execFileSync('git', [ 'diff-files', '-u', '--', certHeaderFile ], opts); |
| 191 | +if (values.verbose) { |
| 192 | + console.log(diff); |
| 193 | +} |
| 194 | +const certsAddedRE = /^\+\/\* (.*) \*\//gm; |
| 195 | +const certsRemovedRE = /^-\/\* (.*) \*\//gm; |
| 196 | +const added = [ ...diff.matchAll(certsAddedRE) ].map((m) => m[1]); |
| 197 | +const removed = [ ...diff.matchAll(certsRemovedRE) ].map((m) => m[1]); |
| 198 | + |
| 199 | +const commitMsg = [ |
| 200 | + `crypto: update root certificates to NSS ${release[kNSSVersion]}`, |
| 201 | + '', |
| 202 | + `This is the certdata.txt[0] from NSS ${release[kNSSVersion]}, released on ${formatDate(release[kNSSDate])}.`, |
| 203 | + '', |
| 204 | + `This is the version of NSS that ${release[kFirefoxDate] < now ? 'shipped' : 'will ship'} in Firefox ${release[kFirefoxVersion]} on`, |
| 205 | + `${formatDate(release[kFirefoxDate])}.`, |
| 206 | + '', |
| 207 | +]; |
| 208 | +if (added.length > 0) { |
| 209 | + commitMsg.push('Certificates added:'); |
| 210 | + commitMsg.push(...added.map((cert) => `- ${cert}`)); |
| 211 | + commitMsg.push(''); |
| 212 | +} |
| 213 | +if (removed.length > 0) { |
| 214 | + commitMsg.push('Certificates removed:'); |
| 215 | + commitMsg.push(...removed.map((cert) => `- ${cert}`)); |
| 216 | + commitMsg.push(''); |
| 217 | +} |
| 218 | +commitMsg.push(`[0] ${certdataURL}`); |
| 219 | +const delimiter = randomUUID(); |
| 220 | +const properties = [ |
| 221 | + `NEW_VERSION=${release[kNSSVersion]}`, |
| 222 | + `COMMIT_MSG<<${delimiter}`, |
| 223 | + ...commitMsg, |
| 224 | + delimiter, |
| 225 | + '', |
| 226 | +].join('\n'); |
| 227 | +if (values.verbose) { |
| 228 | + console.log(properties); |
| 229 | +} |
| 230 | +const propertyFile = values.file; |
| 231 | +if (propertyFile !== undefined) { |
| 232 | + console.log(`Writing to ${propertyFile}`); |
| 233 | + await pipeline(Readable.from(properties), createWriteStream(propertyFile)); |
| 234 | +} |
0 commit comments