vite-plugin-sri4 4.0.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +96 -6
- package/dist/index.cjs +540 -192
- package/dist/index.js +540 -192
- package/package.json +20 -15
- package/types/index.d.ts +60 -0
package/dist/index.cjs
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
4
|
|
|
5
5
|
var node_crypto = require('node:crypto');
|
|
6
|
+
var promises = require('node:fs/promises');
|
|
6
7
|
var path = require('node:path');
|
|
7
|
-
var fetch = require('cross-fetch');
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Extended caching mechanism with expiration time
|
|
@@ -67,6 +67,8 @@ class CacheManager {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// Global fetch, stable since Node 18 - the floor the Vite 6.4 peer range
|
|
71
|
+
// already implies. No dependency needed.
|
|
70
72
|
const DEFAULT_TIMEOUT = 5000;
|
|
71
73
|
|
|
72
74
|
/**
|
|
@@ -109,8 +111,21 @@ async function checkResourceSupport(url, urlSupportCache, logger = null, retries
|
|
|
109
111
|
|
|
110
112
|
clearTimeout(timeoutId);
|
|
111
113
|
|
|
114
|
+
// Only `*` can be verified at build time. Injecting integrity also means
|
|
115
|
+
// injecting crossorigin="anonymous"; if the server answers with a
|
|
116
|
+
// concrete origin that does not match wherever the HTML ends up being
|
|
117
|
+
// served from, that turns a working script into a blocked one. Skipping
|
|
118
|
+
// is the safe outcome, but say so at warn level - silence here is what
|
|
119
|
+
// makes an unprotected resource easy to miss.
|
|
112
120
|
const corsHeader = response.headers.get('access-control-allow-origin');
|
|
113
|
-
const isSupported = response.ok &&
|
|
121
|
+
const isSupported = response.ok && corsHeader === '*';
|
|
122
|
+
if (response.ok && corsHeader && corsHeader !== '*' && logger) {
|
|
123
|
+
logger.warn(
|
|
124
|
+
`Skipping SRI for ${url}: Access-Control-Allow-Origin is "${corsHeader}", not "*", ` +
|
|
125
|
+
'so crossorigin="anonymous" cannot be verified at build time. ' +
|
|
126
|
+
'Add the domain to bypassDomains to silence this.'
|
|
127
|
+
);
|
|
128
|
+
}
|
|
114
129
|
urlSupportCache.set(url, isSupported);
|
|
115
130
|
return isSupported
|
|
116
131
|
} catch (error) {
|
|
@@ -182,43 +197,150 @@ async function fetchResource(url, resourceCache, logger = null, retries = 1) {
|
|
|
182
197
|
return null
|
|
183
198
|
}
|
|
184
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Read the hashable source out of a bundle entry (chunk code or asset source)
|
|
202
|
+
*/
|
|
203
|
+
function bundleSource(item) {
|
|
204
|
+
return item.type === 'chunk' ? item.code : item.source
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The only algorithms the SRI spec defines. Browsers reject anything else,
|
|
208
|
+
// which blocks the resource with no build-time error at all.
|
|
209
|
+
const SUPPORTED_HASH_ALGORITHMS = ['sha256', 'sha384', 'sha512'];
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Compute an SRI string for a source that may be a string, Buffer or Uint8Array
|
|
213
|
+
*/
|
|
214
|
+
function sriHash(source, hashAlgorithm) {
|
|
215
|
+
const hash = node_crypto.createHash(hashAlgorithm);
|
|
216
|
+
hash.update(typeof source === 'string' ? source : Buffer.from(source));
|
|
217
|
+
return `${hashAlgorithm}-${hash.digest('base64')}`
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Anything carrying a scheme (data:, blob:, invalid:) is not a path into the
|
|
221
|
+
// bundle. Protocol-relative `//host/path` is excluded here - it is a real HTTP
|
|
222
|
+
// URL and is fetched, not skipped.
|
|
223
|
+
const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
|
224
|
+
const HTTP_RE = /^https?:/i;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The URL to fetch for an external resource, or null if it is not fetchable.
|
|
228
|
+
*/
|
|
229
|
+
function externalUrl(url) {
|
|
230
|
+
if (HTTP_RE.test(url)) return url
|
|
231
|
+
// Protocol-relative: same origin scheme as the page, https at build time
|
|
232
|
+
if (url.startsWith('//')) return `https:${url}`
|
|
233
|
+
return null
|
|
234
|
+
}
|
|
235
|
+
|
|
185
236
|
/**
|
|
186
237
|
* Improved method for getting bundle keys
|
|
187
238
|
*/
|
|
188
239
|
function getBundleKey(htmlPath, url, config) {
|
|
240
|
+
// Bundle keys never carry a query string or fragment
|
|
241
|
+
const cleanUrl = url.replace(/[?#].*$/, '');
|
|
242
|
+
|
|
189
243
|
// Handle absolute path URLs
|
|
190
|
-
if (
|
|
244
|
+
if (cleanUrl.startsWith('/')) {
|
|
191
245
|
// Remove leading slash to match keys in bundle
|
|
192
|
-
return
|
|
246
|
+
return cleanUrl.substring(1)
|
|
193
247
|
}
|
|
194
248
|
|
|
195
|
-
// Handle relative paths (when config.base is relative)
|
|
249
|
+
// Handle relative paths (when config.base is relative). `join`, not
|
|
250
|
+
// `resolve` - bundle keys are relative to the output root, and `resolve`
|
|
251
|
+
// would make the key absolute against the process CWD.
|
|
196
252
|
if (config.base === './' || config.base === '') {
|
|
197
|
-
return path.posix.
|
|
253
|
+
return path.posix.join(path.posix.dirname(htmlPath), cleanUrl)
|
|
198
254
|
}
|
|
199
255
|
|
|
200
256
|
// Handle other cases, remove base prefix from URL
|
|
201
|
-
return
|
|
202
|
-
?
|
|
203
|
-
:
|
|
257
|
+
return cleanUrl.startsWith(config.base)
|
|
258
|
+
? cleanUrl.substring(config.base.length)
|
|
259
|
+
: cleanUrl
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Find a bundle key for a URL that did not match exactly.
|
|
264
|
+
*
|
|
265
|
+
* Matching is anchored on a path separator so `main.js` can never match
|
|
266
|
+
* `assets/vendor-main.js` - a cross-filename match would inject a valid-looking
|
|
267
|
+
* but wrong hash, which the browser rejects with no build-time error.
|
|
268
|
+
*/
|
|
269
|
+
function findBundleKey(bundle, bundleKey, logger = null) {
|
|
270
|
+
const candidates = Object.keys(bundle).filter(key =>
|
|
271
|
+
key === bundleKey ||
|
|
272
|
+
key.endsWith(`/${bundleKey}`) ||
|
|
273
|
+
bundleKey.endsWith(`/${key}`)
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
if (candidates.length > 1 && logger) {
|
|
277
|
+
logger.warn(
|
|
278
|
+
`Ambiguous bundle key for "${bundleKey}": ${candidates.join(', ')} - using ${candidates[0]}`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return candidates[0]
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Read an asset that lives in `publicDir` rather than the bundle.
|
|
287
|
+
*
|
|
288
|
+
* Files copied verbatim from `public/` never appear as bundle entries, so
|
|
289
|
+
* without this a perfectly normal `<script src="/sw.js">` fails the build.
|
|
290
|
+
* Returns null rather than throwing so the caller keeps its own missing-asset
|
|
291
|
+
* policy.
|
|
292
|
+
*/
|
|
293
|
+
async function readPublicAsset(config, bundleKey, logger) {
|
|
294
|
+
const publicDir = config.publicDir;
|
|
295
|
+
if (!publicDir) return null
|
|
296
|
+
|
|
297
|
+
// Bundle keys come from URLs, which may be percent-encoded
|
|
298
|
+
let decoded;
|
|
299
|
+
try {
|
|
300
|
+
decoded = decodeURIComponent(bundleKey);
|
|
301
|
+
} catch {
|
|
302
|
+
decoded = bundleKey;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const filePath = path.resolve(publicDir, decoded);
|
|
306
|
+
|
|
307
|
+
// A URL must never reach outside publicDir, however it is spelled
|
|
308
|
+
const relative = path.relative(publicDir, filePath);
|
|
309
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
310
|
+
if (logger) {
|
|
311
|
+
logger.warn(`Refusing to read outside publicDir: ${bundleKey}`);
|
|
312
|
+
}
|
|
313
|
+
return null
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
const source = await promises.readFile(filePath);
|
|
318
|
+
if (logger) {
|
|
319
|
+
logger.debug(`Resolved from publicDir: ${bundleKey}`);
|
|
320
|
+
}
|
|
321
|
+
return source
|
|
322
|
+
} catch {
|
|
323
|
+
return null
|
|
324
|
+
}
|
|
204
325
|
}
|
|
205
326
|
|
|
206
327
|
/**
|
|
207
328
|
* Calculate SRI integrity hash for a given resource
|
|
208
329
|
*/
|
|
209
330
|
async function calculateIntegrity(
|
|
210
|
-
bundle,
|
|
211
|
-
htmlPath,
|
|
212
|
-
url,
|
|
213
|
-
options,
|
|
214
|
-
config,
|
|
331
|
+
bundle,
|
|
332
|
+
htmlPath,
|
|
333
|
+
url,
|
|
334
|
+
options,
|
|
335
|
+
config,
|
|
215
336
|
cacheManager,
|
|
216
337
|
logger = null
|
|
217
338
|
) {
|
|
218
|
-
const {
|
|
219
|
-
ignoreMissingAsset,
|
|
220
|
-
bypassDomains,
|
|
221
|
-
hashAlgorithm
|
|
339
|
+
const {
|
|
340
|
+
ignoreMissingAsset,
|
|
341
|
+
bypassDomains,
|
|
342
|
+
hashAlgorithm,
|
|
343
|
+
hashedAssets
|
|
222
344
|
} = options;
|
|
223
345
|
|
|
224
346
|
// Skip specified domains
|
|
@@ -226,68 +348,121 @@ async function calculateIntegrity(
|
|
|
226
348
|
return null
|
|
227
349
|
}
|
|
228
350
|
|
|
351
|
+
// With an absolute `base` (assets on a CDN) Vite emits absolute URLs for our
|
|
352
|
+
// own build output. Those must be hashed from the bundle, not fetched - the
|
|
353
|
+
// CDN may not have been deployed yet, and this is the very case SRI exists
|
|
354
|
+
// for. Checked before the network path.
|
|
355
|
+
const base = config.base || '/';
|
|
356
|
+
const ownAsset = (HTTP_RE.test(base) || base.startsWith('//')) && url.startsWith(base);
|
|
357
|
+
const fetchUrl = ownAsset ? null : externalUrl(url);
|
|
358
|
+
|
|
229
359
|
let source;
|
|
230
|
-
|
|
231
|
-
|
|
360
|
+
let bundleFileName = null;
|
|
361
|
+
if (fetchUrl) {
|
|
362
|
+
const isSupported = await checkResourceSupport(fetchUrl, cacheManager.getUrlSupportCache(), logger);
|
|
232
363
|
if (!isSupported) return null
|
|
233
|
-
source = await fetchResource(
|
|
364
|
+
source = await fetchResource(fetchUrl, cacheManager.getResourceCache(), logger);
|
|
234
365
|
if (!source) return null
|
|
366
|
+
} else if (!ownAsset && SCHEME_RE.test(url)) {
|
|
367
|
+
// data:/blob: and unknown schemes cannot be resolved to a bundle asset
|
|
368
|
+
if (logger) {
|
|
369
|
+
logger.debug(`Skipping URL that is not a bundle asset: ${url}`);
|
|
370
|
+
}
|
|
371
|
+
return null
|
|
235
372
|
} else {
|
|
236
373
|
const bundleKey = getBundleKey(htmlPath, url, config);
|
|
237
374
|
const bundleItem = bundle[bundleKey];
|
|
238
375
|
|
|
239
376
|
if (!bundleItem) {
|
|
240
|
-
// Fall back to suffix match
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
const possibleMatch = Object.keys(bundle).find(key =>
|
|
245
|
-
key.endsWith(bundleKey) || bundleKey.endsWith(key)
|
|
246
|
-
);
|
|
377
|
+
// Fall back to a path-anchored suffix match to absorb hashed filenames
|
|
378
|
+
// AND base-prefix mismatches (e.g. URL "/base/main.js" with bare bundle
|
|
379
|
+
// key "main.js").
|
|
380
|
+
const possibleMatch = findBundleKey(bundle, bundleKey, logger);
|
|
247
381
|
|
|
248
382
|
if (possibleMatch) {
|
|
249
383
|
if (logger) {
|
|
250
384
|
logger.debug(`Bundle key fallback: ${bundleKey} -> ${possibleMatch}`);
|
|
251
385
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
: bundle[possibleMatch].source;
|
|
255
|
-
} else if (ignoreMissingAsset) {
|
|
256
|
-
if (logger) {
|
|
257
|
-
logger.warn(`Asset not found in bundle: ${url} (path: ${htmlPath}, key: ${bundleKey})`);
|
|
258
|
-
}
|
|
259
|
-
return null
|
|
386
|
+
bundleFileName = possibleMatch;
|
|
387
|
+
source = bundleSource(bundle[possibleMatch]);
|
|
260
388
|
} else {
|
|
261
|
-
|
|
389
|
+
// Not a build output - it may still be a file copied from publicDir
|
|
390
|
+
source = await readPublicAsset(config, bundleKey, logger);
|
|
391
|
+
|
|
392
|
+
if (!source) {
|
|
393
|
+
if (ignoreMissingAsset) {
|
|
394
|
+
if (logger) {
|
|
395
|
+
logger.warn(
|
|
396
|
+
`Asset not found in bundle or publicDir: ${url} (path: ${htmlPath}, key: ${bundleKey})`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
return null
|
|
400
|
+
}
|
|
401
|
+
throw new Error(
|
|
402
|
+
`Asset ${url} not found in bundle or publicDir (path: ${htmlPath}, key: ${bundleKey})`
|
|
403
|
+
)
|
|
404
|
+
}
|
|
262
405
|
}
|
|
263
406
|
} else {
|
|
264
|
-
|
|
407
|
+
bundleFileName = bundleKey;
|
|
408
|
+
source = bundleSource(bundleItem);
|
|
265
409
|
}
|
|
266
410
|
}
|
|
267
411
|
|
|
268
412
|
// Ensure source is a Uint8Array or string
|
|
269
413
|
if (!source) return null
|
|
270
414
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
415
|
+
const integrity = sriHash(source, hashAlgorithm);
|
|
416
|
+
// Recorded so writeBundle can catch a later plugin rewriting these bytes
|
|
417
|
+
if (bundleFileName && hashedAssets) hashedAssets.set(bundleFileName, integrity);
|
|
418
|
+
return integrity
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Match an attribute regardless of quoting style. Attribute order inside a tag
|
|
423
|
+
* is not significant in HTML, so attributes are read out of the matched tag
|
|
424
|
+
* rather than being baked into the tag regex.
|
|
425
|
+
*/
|
|
426
|
+
function attrPattern(name) {
|
|
427
|
+
// Lookbehind on whitespace, not `\b` - `\b` matches after the hyphen in
|
|
428
|
+
// `data-src`, and getAttr takes the first match, so a decoy attribute would
|
|
429
|
+
// hijack the URL.
|
|
430
|
+
return new RegExp(`(?<=\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, 'i')
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Attributes are always preceded by whitespace inside a tag, so anchoring on
|
|
434
|
+
// it avoids matching `data-integrity`. `crossorigin` is matched with or without
|
|
435
|
+
// a value - Vite emits the valueless form, and duplicating it is invalid HTML.
|
|
436
|
+
const CROSSORIGIN_ATTR_RE = /\scrossorigin(?=[\s=>/]|$)/i;
|
|
437
|
+
const INTEGRITY_ATTR_RE = /\sintegrity\s*=/i;
|
|
438
|
+
|
|
439
|
+
const SRC_RE = attrPattern('src');
|
|
440
|
+
const HREF_RE = attrPattern('href');
|
|
441
|
+
const REL_RE = attrPattern('rel');
|
|
274
442
|
|
|
275
|
-
|
|
443
|
+
// rel values whose tags carry an integrity attribute
|
|
444
|
+
const SRI_LINK_RELS = new Set(['stylesheet', 'modulepreload']);
|
|
445
|
+
|
|
446
|
+
function getAttr(tag, re) {
|
|
447
|
+
const match = tag.match(re);
|
|
448
|
+
if (!match) return null
|
|
449
|
+
return match[1] ?? match[2] ?? match[3] ?? null
|
|
276
450
|
}
|
|
277
451
|
|
|
278
|
-
// Optimized regex patterns for better readability and efficiency
|
|
279
452
|
const HTML_PATTERNS = {
|
|
280
453
|
script: {
|
|
281
|
-
regex: /<script\b[^>]
|
|
282
|
-
endOffset: 10
|
|
283
|
-
|
|
284
|
-
stylesheet: {
|
|
285
|
-
regex: /<link\b[^>]*?\brel\s*=\s*["']stylesheet["'][^>]*?\bhref\s*=\s*["']([^"']+)["'][^>]*>/g,
|
|
286
|
-
endOffset: 1
|
|
454
|
+
regex: /<script\b[^>]*><\/script>/gi,
|
|
455
|
+
endOffset: 10, // length of '></script>'
|
|
456
|
+
getUrl: tag => getAttr(tag, SRC_RE)
|
|
287
457
|
},
|
|
288
|
-
|
|
289
|
-
regex: /<link\b[^>]
|
|
290
|
-
endOffset: 1
|
|
458
|
+
link: {
|
|
459
|
+
regex: /<link\b[^>]*>/gi,
|
|
460
|
+
endOffset: 1, // length of '>'
|
|
461
|
+
getUrl: tag => {
|
|
462
|
+
const rel = getAttr(tag, REL_RE);
|
|
463
|
+
if (!rel || !SRI_LINK_RELS.has(rel.trim().toLowerCase())) return null
|
|
464
|
+
return getAttr(tag, HREF_RE)
|
|
465
|
+
}
|
|
291
466
|
}
|
|
292
467
|
};
|
|
293
468
|
|
|
@@ -303,63 +478,97 @@ function validateHtmlInput(html, htmlPath, logger) {
|
|
|
303
478
|
}
|
|
304
479
|
|
|
305
480
|
/**
|
|
306
|
-
*
|
|
481
|
+
* Offset inside the matched tag where new attributes should go: just before the
|
|
482
|
+
* closing `>`, skipping back over the self-closing slash and any whitespace so
|
|
483
|
+
* `<link ... />` does not become `<link ... / integrity="...">`.
|
|
484
|
+
*/
|
|
485
|
+
function insertOffset(tag, endOffset) {
|
|
486
|
+
let at = tag.length - endOffset;
|
|
487
|
+
while (at > 0 && (tag[at - 1] === '/' || /\s/.test(tag[at - 1]))) at--;
|
|
488
|
+
return at
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Per-tag opt out. `<script skip-sri src="...">` is left alone, and the marker
|
|
493
|
+
* attribute is stripped so it does not ship to the browser. More granular than
|
|
494
|
+
* bypassDomains, which only reaches external hosts.
|
|
495
|
+
*/
|
|
496
|
+
const SKIP_SRI_ATTR_RE = /\s+skip-sri(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>/]+))?/i;
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Process a single match into a text edit: { start, end, content }. An
|
|
500
|
+
* insertion has start === end; a removal has empty content.
|
|
307
501
|
*/
|
|
308
502
|
async function processMatch(
|
|
309
|
-
match,
|
|
310
|
-
|
|
311
|
-
bundle,
|
|
312
|
-
htmlPath,
|
|
313
|
-
options,
|
|
314
|
-
config,
|
|
503
|
+
match,
|
|
504
|
+
pattern,
|
|
505
|
+
bundle,
|
|
506
|
+
htmlPath,
|
|
507
|
+
options,
|
|
508
|
+
config,
|
|
315
509
|
cacheManager,
|
|
316
510
|
logger
|
|
317
511
|
) {
|
|
318
|
-
const
|
|
512
|
+
const tag = match[0];
|
|
513
|
+
|
|
514
|
+
const skip = SKIP_SRI_ATTR_RE.exec(tag);
|
|
515
|
+
if (skip) {
|
|
516
|
+
return {
|
|
517
|
+
start: match.index + skip.index,
|
|
518
|
+
end: match.index + skip.index + skip[0].length,
|
|
519
|
+
content: '',
|
|
520
|
+
url: pattern.getUrl(tag),
|
|
521
|
+
skipped: true
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Nothing to do for a tag that already carries its own hash, and computing
|
|
526
|
+
// one anyway would register it for the writeBundle drift check
|
|
527
|
+
if (INTEGRITY_ATTR_RE.test(tag)) return null
|
|
528
|
+
|
|
529
|
+
const url = pattern.getUrl(tag);
|
|
319
530
|
if (!url) return null
|
|
320
531
|
|
|
321
|
-
const end = match.index + match[0].length;
|
|
322
532
|
const integrity = await calculateIntegrity(
|
|
323
|
-
bundle,
|
|
324
|
-
htmlPath,
|
|
325
|
-
url,
|
|
326
|
-
options,
|
|
327
|
-
config,
|
|
533
|
+
bundle,
|
|
534
|
+
htmlPath,
|
|
535
|
+
url,
|
|
536
|
+
options,
|
|
537
|
+
config,
|
|
328
538
|
cacheManager,
|
|
329
539
|
logger
|
|
330
540
|
);
|
|
331
541
|
|
|
332
|
-
if (integrity)
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
url // For logging
|
|
338
|
-
}
|
|
542
|
+
if (!integrity) return null
|
|
543
|
+
|
|
544
|
+
let content = ` integrity="${integrity}"`;
|
|
545
|
+
if (!CROSSORIGIN_ATTR_RE.test(tag)) {
|
|
546
|
+
content += ` crossorigin="${options.crossorigin}"`;
|
|
339
547
|
}
|
|
340
|
-
|
|
548
|
+
|
|
549
|
+
const at = match.index + insertOffset(tag, pattern.endOffset);
|
|
550
|
+
return { start: at, end: at, content, url }
|
|
341
551
|
}
|
|
342
552
|
|
|
343
553
|
/**
|
|
344
554
|
* Process matches for a specific HTML pattern
|
|
345
555
|
*/
|
|
346
556
|
async function processPatternMatches(
|
|
347
|
-
html,
|
|
348
|
-
pattern,
|
|
349
|
-
bundle,
|
|
350
|
-
htmlPath,
|
|
351
|
-
options,
|
|
352
|
-
config,
|
|
557
|
+
html,
|
|
558
|
+
pattern,
|
|
559
|
+
bundle,
|
|
560
|
+
htmlPath,
|
|
561
|
+
options,
|
|
562
|
+
config,
|
|
353
563
|
cacheManager,
|
|
354
564
|
logger
|
|
355
565
|
) {
|
|
356
|
-
const
|
|
357
|
-
const matches = [...html.matchAll(regex)];
|
|
566
|
+
const matches = [...html.matchAll(pattern.regex)];
|
|
358
567
|
|
|
359
568
|
// Process each match in parallel
|
|
360
569
|
const matchResults = await Promise.all(
|
|
361
|
-
matches.map(match =>
|
|
362
|
-
processMatch(match,
|
|
570
|
+
matches.map(match =>
|
|
571
|
+
processMatch(match, pattern, bundle, htmlPath, options, config, cacheManager, logger)
|
|
363
572
|
)
|
|
364
573
|
);
|
|
365
574
|
|
|
@@ -371,11 +580,11 @@ async function processPatternMatches(
|
|
|
371
580
|
* Collect all integrity changes from HTML patterns
|
|
372
581
|
*/
|
|
373
582
|
async function collectIntegrityChanges(
|
|
374
|
-
html,
|
|
375
|
-
bundle,
|
|
376
|
-
htmlPath,
|
|
377
|
-
options,
|
|
378
|
-
config,
|
|
583
|
+
html,
|
|
584
|
+
bundle,
|
|
585
|
+
htmlPath,
|
|
586
|
+
options,
|
|
587
|
+
config,
|
|
379
588
|
cacheManager,
|
|
380
589
|
logger
|
|
381
590
|
) {
|
|
@@ -385,12 +594,12 @@ async function collectIntegrityChanges(
|
|
|
385
594
|
await Promise.all(
|
|
386
595
|
Object.values(HTML_PATTERNS).map(async pattern => {
|
|
387
596
|
const patternChanges = await processPatternMatches(
|
|
388
|
-
html,
|
|
389
|
-
pattern,
|
|
390
|
-
bundle,
|
|
391
|
-
htmlPath,
|
|
392
|
-
options,
|
|
393
|
-
config,
|
|
597
|
+
html,
|
|
598
|
+
pattern,
|
|
599
|
+
bundle,
|
|
600
|
+
htmlPath,
|
|
601
|
+
options,
|
|
602
|
+
config,
|
|
394
603
|
cacheManager,
|
|
395
604
|
logger
|
|
396
605
|
);
|
|
@@ -401,56 +610,68 @@ async function collectIntegrityChanges(
|
|
|
401
610
|
return changes
|
|
402
611
|
}
|
|
403
612
|
|
|
404
|
-
|
|
405
|
-
|
|
613
|
+
// Attributes are always preceded by whitespace inside a tag, so anchoring on
|
|
614
|
+
// it avoids matching `data-integrity`. `crossorigin` is matched with or without
|
|
615
|
+
// a value - Vite emits the valueless form, and duplicating it is invalid HTML.
|
|
406
616
|
|
|
407
617
|
/**
|
|
408
|
-
*
|
|
618
|
+
* Apply integrity changes to HTML content
|
|
409
619
|
*/
|
|
410
|
-
function
|
|
411
|
-
|
|
412
|
-
|
|
620
|
+
function applyIntegrityChanges(html, changes, logger) {
|
|
621
|
+
// Back to front, so earlier offsets stay valid as the string is edited
|
|
622
|
+
changes.sort((a, b) => b.start - a.start);
|
|
413
623
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
624
|
+
for (const { start, end, content, url, skipped } of changes) {
|
|
625
|
+
html = html.slice(0, start) + content + html.slice(end);
|
|
626
|
+
logger.debug(skipped ? `Skipped (skip-sri): ${url}` : `Added integrity for: ${url}`);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
return html
|
|
419
630
|
}
|
|
420
631
|
|
|
632
|
+
const EXISTING_IMPORTMAP_RE = /<script\b[^>]*\btype\s*=\s*["']importmap["']/i;
|
|
633
|
+
const FIRST_SCRIPT_RE = /<script\b/i;
|
|
634
|
+
const HEAD_CLOSE_RE = /<\/head\s*>/i;
|
|
635
|
+
|
|
421
636
|
/**
|
|
422
|
-
*
|
|
637
|
+
* Inject an import map carrying an `integrity` map.
|
|
638
|
+
*
|
|
639
|
+
* This is the only mechanism that covers modules pulled in at runtime by
|
|
640
|
+
* `import()` / Vite's preload helper, which have no build-time HTML tag to
|
|
641
|
+
* rewrite. Engines without support ignore the key rather than failing.
|
|
423
642
|
*/
|
|
424
|
-
function
|
|
425
|
-
|
|
426
|
-
|
|
643
|
+
function injectImportmapIntegrity(html, integrity, logger) {
|
|
644
|
+
if (!html || typeof html !== 'string' || Object.keys(integrity).length === 0) {
|
|
645
|
+
return html
|
|
646
|
+
}
|
|
427
647
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
}
|
|
648
|
+
if (EXISTING_IMPORTMAP_RE.test(html)) {
|
|
649
|
+
logger.warn('HTML already contains an import map; skipping SRI import map injection');
|
|
650
|
+
return html
|
|
651
|
+
}
|
|
433
652
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
653
|
+
// `<` is escaped so a filename can never close the script element early
|
|
654
|
+
const json = JSON.stringify({ integrity }).replace(/</g, '\\u003c');
|
|
655
|
+
const tag = `<script type="importmap">${json}</script>`;
|
|
656
|
+
|
|
657
|
+
// Must precede every module script, otherwise the map does not apply to them
|
|
658
|
+
for (const re of [FIRST_SCRIPT_RE, HEAD_CLOSE_RE]) {
|
|
659
|
+
const at = html.search(re);
|
|
660
|
+
if (at !== -1) return html.slice(0, at) + tag + html.slice(at)
|
|
440
661
|
}
|
|
441
662
|
|
|
442
|
-
return html
|
|
663
|
+
return html + tag
|
|
443
664
|
}
|
|
444
665
|
|
|
445
666
|
/**
|
|
446
667
|
* Transform HTML by adding SRI integrity attributes
|
|
447
668
|
*/
|
|
448
669
|
async function transformHTML(
|
|
449
|
-
bundle,
|
|
450
|
-
htmlPath,
|
|
451
|
-
html,
|
|
452
|
-
options,
|
|
453
|
-
config,
|
|
670
|
+
bundle,
|
|
671
|
+
htmlPath,
|
|
672
|
+
html,
|
|
673
|
+
options,
|
|
674
|
+
config,
|
|
454
675
|
cacheManager,
|
|
455
676
|
logger
|
|
456
677
|
) {
|
|
@@ -459,11 +680,11 @@ async function transformHTML(
|
|
|
459
680
|
}
|
|
460
681
|
|
|
461
682
|
const changes = await collectIntegrityChanges(
|
|
462
|
-
html,
|
|
463
|
-
bundle,
|
|
464
|
-
htmlPath,
|
|
465
|
-
options,
|
|
466
|
-
config,
|
|
683
|
+
html,
|
|
684
|
+
bundle,
|
|
685
|
+
htmlPath,
|
|
686
|
+
options,
|
|
687
|
+
config,
|
|
467
688
|
cacheManager,
|
|
468
689
|
logger
|
|
469
690
|
);
|
|
@@ -476,9 +697,9 @@ async function transformHTML(
|
|
|
476
697
|
*/
|
|
477
698
|
function createTransformer(options, config, cacheManager, logger) {
|
|
478
699
|
return {
|
|
479
|
-
transformHTML: (bundle, htmlPath, html) =>
|
|
700
|
+
transformHTML: (bundle, htmlPath, html) =>
|
|
480
701
|
transformHTML(bundle, htmlPath, html, options, config, cacheManager, logger),
|
|
481
|
-
calculateIntegrity: (bundle, htmlPath, url) =>
|
|
702
|
+
calculateIntegrity: (bundle, htmlPath, url) =>
|
|
482
703
|
calculateIntegrity(bundle, htmlPath, url, options, config, cacheManager, logger)
|
|
483
704
|
}
|
|
484
705
|
}
|
|
@@ -499,7 +720,9 @@ class Logger {
|
|
|
499
720
|
info: 3,
|
|
500
721
|
debug: 4
|
|
501
722
|
};
|
|
502
|
-
|
|
723
|
+
// `??`, not `||` - levels.silent is 0, which `||` treats as absent and
|
|
724
|
+
// silently downgrades to 'warn', the one level that must suppress output.
|
|
725
|
+
this.currentLevel = this.levels[logLevel] ?? this.levels.warn;
|
|
503
726
|
}
|
|
504
727
|
|
|
505
728
|
/**
|
|
@@ -557,101 +780,226 @@ class Logger {
|
|
|
557
780
|
}
|
|
558
781
|
}
|
|
559
782
|
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
783
|
+
// Vite 6/7 uses `vite:build-import-analysis`; Vite 8 Rolldown native path adds
|
|
784
|
+
// `native:import-analysis-build`.
|
|
785
|
+
//
|
|
786
|
+
// Why we care where these sit: they substitute `__VITE_PRELOAD__` in entry
|
|
787
|
+
// chunks inside their own generateBundle, and Vite places them immediately
|
|
788
|
+
// AFTER `enforce: 'post'` user plugins. Measured on Vite 8.2.2, a post plugin
|
|
789
|
+
// is at index 25 and the analysis plugin at 26 - so by default we would hash
|
|
790
|
+
// an entry chunk still containing `import("./x.js"),__VITE_PRELOAD__)` while
|
|
791
|
+
// the written file contains `import("./x.js"),[])`.
|
|
792
|
+
//
|
|
793
|
+
// The fix is to move THIS plugin one place later, not to rewrite someone
|
|
794
|
+
// else's hook. `config.plugins` is a plain, unfrozen array at configResolved
|
|
795
|
+
// time and Rollup reads it afterwards, so repositioning takes effect.
|
|
796
|
+
// `test/sri.test.js > injects integrity matching the actual emitted bytes` is
|
|
797
|
+
// the pin, and the writeBundle drift check is the second net.
|
|
563
798
|
const VITE_INTERNAL_ANALYSIS_PLUGINS = [
|
|
564
799
|
'vite:build-import-analysis',
|
|
565
800
|
'native:import-analysis-build'
|
|
566
801
|
];
|
|
567
802
|
const DEFAULT_HASH_ALGORITHM = 'sha384';
|
|
803
|
+
const CROSSORIGIN_VALUES = ['anonymous', 'use-credentials'];
|
|
568
804
|
const DEFAULT_PLUGIN_NAME = 'vite-plugin-sri4';
|
|
805
|
+
const MANIFEST_FILE_NAME = 'sri-manifest.json';
|
|
806
|
+
const HTML_RE = /\.html?$/;
|
|
807
|
+
const JS_MODULE_RE = /\.m?js$/;
|
|
808
|
+
|
|
809
|
+
function toText(source) {
|
|
810
|
+
return typeof source === 'string' ? source : Buffer.from(source).toString('utf8')
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function withTrailingSlash(base) {
|
|
814
|
+
if (!base) return '/'
|
|
815
|
+
return base.endsWith('/') ? base : `${base}/`
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Hash every non-HTML output, keyed by bundle file name.
|
|
820
|
+
*/
|
|
821
|
+
function hashBundle(bundle, hashAlgorithm) {
|
|
822
|
+
const hashes = {};
|
|
823
|
+
for (const [fileName, item] of Object.entries(bundle)) {
|
|
824
|
+
if (HTML_RE.test(fileName)) continue
|
|
825
|
+
const source = bundleSource(item);
|
|
826
|
+
if (source) hashes[fileName] = sriHash(source, hashAlgorithm);
|
|
827
|
+
}
|
|
828
|
+
return hashes
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Reject configuration that would build cleanly and then fail in the browser.
|
|
833
|
+
*/
|
|
834
|
+
function validateOptions(hashAlgorithm, crossorigin) {
|
|
835
|
+
if (!SUPPORTED_HASH_ALGORITHMS.includes(hashAlgorithm)) {
|
|
836
|
+
throw new Error(
|
|
837
|
+
`[${DEFAULT_PLUGIN_NAME}] unsupported hashAlgorithm "${hashAlgorithm}". ` +
|
|
838
|
+
`The SRI spec defines ${SUPPORTED_HASH_ALGORITHMS.join(', ')}; browsers reject ` +
|
|
839
|
+
`anything else, so the build would succeed and the resource would be blocked.`
|
|
840
|
+
)
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
if (!CROSSORIGIN_VALUES.includes(crossorigin)) {
|
|
844
|
+
throw new Error(
|
|
845
|
+
`[${DEFAULT_PLUGIN_NAME}] crossorigin must be one of ${CROSSORIGIN_VALUES.join(', ')}, ` +
|
|
846
|
+
`got "${crossorigin}"`
|
|
847
|
+
)
|
|
848
|
+
}
|
|
849
|
+
}
|
|
569
850
|
|
|
570
851
|
function sri(options = {}) {
|
|
571
852
|
const {
|
|
572
853
|
ignoreMissingAsset = false,
|
|
573
854
|
bypassDomains = [],
|
|
574
855
|
hashAlgorithm = DEFAULT_HASH_ALGORITHM,
|
|
575
|
-
|
|
856
|
+
crossorigin = 'anonymous',
|
|
857
|
+
logLevel = 'warn',
|
|
858
|
+
manifest = false,
|
|
859
|
+
importmap = false
|
|
576
860
|
} = options;
|
|
577
861
|
|
|
862
|
+
validateOptions(hashAlgorithm, crossorigin);
|
|
863
|
+
|
|
578
864
|
// Create cache manager and logger instances for this plugin instance
|
|
579
865
|
const cacheManager = new CacheManager();
|
|
580
866
|
const logger = new Logger(logLevel, DEFAULT_PLUGIN_NAME);
|
|
581
867
|
|
|
868
|
+
// bundle fileName -> the integrity we injected, re-checked in writeBundle
|
|
869
|
+
const hashedAssets = new Map();
|
|
870
|
+
|
|
871
|
+
let config;
|
|
872
|
+
let transformer;
|
|
873
|
+
|
|
582
874
|
return {
|
|
583
875
|
name: DEFAULT_PLUGIN_NAME,
|
|
584
876
|
enforce: 'post',
|
|
585
877
|
apply: 'build',
|
|
586
878
|
|
|
587
|
-
//
|
|
588
|
-
|
|
589
|
-
|
|
879
|
+
// Every generateBundle hook has run by now. A plugin that rewrites chunk
|
|
880
|
+
// contents after ours (plugin-legacy, in-place compression) would leave the
|
|
881
|
+
// injected hashes describing bytes that no longer ship - a green build that
|
|
882
|
+
// only fails in the browser. Fail here instead.
|
|
883
|
+
writeBundle(_, bundle) {
|
|
884
|
+
const drifted = [];
|
|
885
|
+
for (const [fileName, integrity] of hashedAssets) {
|
|
886
|
+
const item = bundle[fileName];
|
|
887
|
+
if (!item) continue
|
|
888
|
+
const source = bundleSource(item);
|
|
889
|
+
if (source && sriHash(source, hashAlgorithm) !== integrity) {
|
|
890
|
+
drifted.push(fileName);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
hashedAssets.clear();
|
|
894
|
+
|
|
895
|
+
if (drifted.length > 0) {
|
|
896
|
+
throw new Error(
|
|
897
|
+
`[${DEFAULT_PLUGIN_NAME}] content changed after integrity was computed: ` +
|
|
898
|
+
`${drifted.join(', ')}. A plugin running after this one rewrote these ` +
|
|
899
|
+
`files, so the injected hashes no longer match what ships. Move that ` +
|
|
900
|
+
`plugin before ${DEFAULT_PLUGIN_NAME}, or drop it.`
|
|
901
|
+
)
|
|
902
|
+
}
|
|
903
|
+
},
|
|
904
|
+
|
|
905
|
+
// Cleanup. Note this must not be `buildEnd`, which Rollup runs before the
|
|
906
|
+
// output phase - clearing there would empty the caches before use.
|
|
907
|
+
closeBundle() {
|
|
590
908
|
cacheManager.clearAll();
|
|
909
|
+
hashedAssets.clear();
|
|
591
910
|
},
|
|
592
911
|
|
|
593
|
-
configResolved(
|
|
594
|
-
|
|
912
|
+
configResolved(resolvedConfig) {
|
|
913
|
+
config = resolvedConfig;
|
|
914
|
+
transformer = createTransformer({
|
|
595
915
|
ignoreMissingAsset,
|
|
596
916
|
bypassDomains,
|
|
597
|
-
hashAlgorithm
|
|
917
|
+
hashAlgorithm,
|
|
918
|
+
crossorigin,
|
|
919
|
+
hashedAssets
|
|
598
920
|
}, config, cacheManager, logger);
|
|
599
921
|
|
|
600
|
-
const
|
|
601
|
-
const htmlFiles = Object.entries(bundle).filter(
|
|
602
|
-
([, chunk]) =>
|
|
603
|
-
chunk.type === 'asset' &&
|
|
604
|
-
/\.html?$/.test(chunk.fileName)
|
|
605
|
-
);
|
|
606
|
-
|
|
607
|
-
if (htmlFiles.length === 0) {
|
|
608
|
-
logger.debug('No HTML files found in bundle');
|
|
609
|
-
return
|
|
610
|
-
}
|
|
922
|
+
const plugins = config.plugins;
|
|
611
923
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
chunk.source = await transformer.transformHTML(bundle, name, originalContent);
|
|
618
|
-
|
|
619
|
-
if (originalContent !== chunk.source) {
|
|
620
|
-
logger.debug(`SRI attributes added to ${name}`);
|
|
621
|
-
}
|
|
622
|
-
} catch (error) {
|
|
623
|
-
logger.warn(`Error processing ${name}:`, error);
|
|
624
|
-
// Keep original content on error
|
|
625
|
-
}
|
|
626
|
-
})
|
|
627
|
-
);
|
|
628
|
-
};
|
|
924
|
+
// The last one wins: if both names are present we must follow both
|
|
925
|
+
let target = -1;
|
|
926
|
+
for (let i = 0; i < plugins.length; i++) {
|
|
927
|
+
if (plugins[i] && VITE_INTERNAL_ANALYSIS_PLUGINS.includes(plugins[i].name)) target = i;
|
|
928
|
+
}
|
|
629
929
|
|
|
630
|
-
|
|
631
|
-
p => p && VITE_INTERNAL_ANALYSIS_PLUGINS.includes(p.name)
|
|
632
|
-
);
|
|
633
|
-
if (targets.length === 0) {
|
|
930
|
+
if (target === -1) {
|
|
634
931
|
throw new Error(
|
|
635
|
-
`[${DEFAULT_PLUGIN_NAME}] could not find a Vite import-analysis plugin to
|
|
932
|
+
`[${DEFAULT_PLUGIN_NAME}] could not find a Vite import-analysis plugin to run after ` +
|
|
636
933
|
`(looked for: ${VITE_INTERNAL_ANALYSIS_PLUGINS.join(', ')}). ` +
|
|
637
934
|
`Requires Vite 6.0.0 or higher.`
|
|
638
935
|
)
|
|
639
936
|
}
|
|
640
937
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
938
|
+
const self = plugins.findIndex(p => p && p.name === DEFAULT_PLUGIN_NAME);
|
|
939
|
+
if (self === -1) {
|
|
940
|
+
// Only reachable when the plugin was not registered through Vite, as
|
|
941
|
+
// in a unit test driving the hook directly. Hashes may then be taken a
|
|
942
|
+
// step early, which the writeBundle drift check catches.
|
|
943
|
+
logger.debug('Plugin not present in config.plugins; leaving hook order alone');
|
|
944
|
+
return
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
if (target > self) {
|
|
948
|
+
// Removing ourselves shifts target down one, so inserting at `target`
|
|
949
|
+
// lands immediately after it.
|
|
950
|
+
const [me] = plugins.splice(self, 1);
|
|
951
|
+
plugins.splice(target, 0, me);
|
|
952
|
+
logger.debug(`Repositioned after ${plugins[target - 1].name}`);
|
|
953
|
+
}
|
|
954
|
+
},
|
|
955
|
+
|
|
956
|
+
async generateBundle(_, bundle) {
|
|
957
|
+
// Computed before emitting anything so the manifest never hashes itself
|
|
958
|
+
const hashes = manifest || importmap ? hashBundle(bundle, hashAlgorithm) : null;
|
|
959
|
+
|
|
960
|
+
const htmlFiles = Object.entries(bundle).filter(
|
|
961
|
+
([, chunk]) =>
|
|
962
|
+
chunk.type === 'asset' &&
|
|
963
|
+
HTML_RE.test(chunk.fileName)
|
|
964
|
+
);
|
|
965
|
+
|
|
966
|
+
if (htmlFiles.length === 0) {
|
|
967
|
+
// Normal for SSR / library builds, which render HTML at request time
|
|
968
|
+
// - the manifest below is how those builds get their hashes.
|
|
969
|
+
logger.debug('No HTML files found in bundle');
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Errors are intentionally not caught: a resource that cannot be
|
|
973
|
+
// hashed must fail the build rather than ship without integrity.
|
|
974
|
+
await Promise.all(
|
|
975
|
+
htmlFiles.map(async ([name, chunk]) => {
|
|
976
|
+
const originalContent = toText(chunk.source);
|
|
977
|
+
let html = await transformer.transformHTML(bundle, name, originalContent);
|
|
978
|
+
|
|
979
|
+
if (importmap) {
|
|
980
|
+
const moduleIntegrity = {};
|
|
981
|
+
const base = withTrailingSlash(config.base);
|
|
982
|
+
for (const [fileName, integrity] of Object.entries(hashes)) {
|
|
983
|
+
if (JS_MODULE_RE.test(fileName)) moduleIntegrity[base + fileName] = integrity;
|
|
984
|
+
}
|
|
985
|
+
html = injectImportmapIntegrity(html, moduleIntegrity, logger);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
chunk.source = html;
|
|
989
|
+
|
|
990
|
+
if (originalContent !== chunk.source) {
|
|
991
|
+
logger.debug(`SRI attributes added to ${name}`);
|
|
992
|
+
}
|
|
993
|
+
})
|
|
994
|
+
);
|
|
995
|
+
|
|
996
|
+
if (manifest) {
|
|
997
|
+
this.emitFile({
|
|
998
|
+
type: 'asset',
|
|
999
|
+
fileName: MANIFEST_FILE_NAME,
|
|
1000
|
+
source: JSON.stringify(hashes, null, 2)
|
|
1001
|
+
});
|
|
1002
|
+
logger.debug(`Emitted ${MANIFEST_FILE_NAME} with ${Object.keys(hashes).length} entries`);
|
|
655
1003
|
}
|
|
656
1004
|
}
|
|
657
1005
|
}
|