webstore-lint 1.0.20

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/src/scan.mjs ADDED
@@ -0,0 +1,705 @@
1
+ // Read an unpacked extension directory into memory, once, so every rule works
2
+ // from the same picture instead of walking the tree again.
3
+ //
4
+ // WHAT IS DELIBERATELY NOT READ: node_modules, .git, and anything over 2 MB.
5
+ // A rule that greps a 40 MB bundled vendor file produces a "finding" in code the
6
+ // developer did not write and cannot fix, which trains people to ignore output.
7
+ // Skipped files are RECORDED and reported, because a silent skip is how a linter
8
+ // says "clean" about a directory it never looked at.
9
+
10
+ import { readdirSync, readFileSync, statSync, existsSync, openSync, readSync, closeSync } from "node:fs";
11
+ import { join, relative, extname } from "node:path";
12
+ import { StringDecoder } from "node:string_decoder";
13
+
14
+ // EVERY JSON FILE IN AN EXTENSION IS READ THROUGH THIS, and it exists for one
15
+ // byte sequence. `readFileSync(f, "utf8")` keeps a leading UTF-8 BOM (U+FEFF) in
16
+ // the string, and `JSON.parse` rejects it - while Chrome accepts it, because
17
+ // Chromium's JSON reader consumes a BOM before parsing. So a file Chrome loads
18
+ // without complaint reads to us as "not valid JSON".
19
+ //
20
+ // That is not hypothetical and it is not rare enough to ignore. In a corpus of
21
+ // 160 public repos, 4 of 2220 `messages.json` files carry a BOM, and two of them
22
+ // belong to extensions published on the Web Store right now: MarvellousSuspender
23
+ // (the maintained fork of The Great Suspender) and SmartProxy. MarvellousSuspender
24
+ // was reported at FAIL - "a localised manifest field does not resolve" - about a
25
+ // file with 374 perfectly good keys in it.
26
+ //
27
+ // THE DAMAGE IS WORSE THAN THE ONE FINDING. That extension's manifest carries
28
+ // `"name": "__MSG_ext_extension_name__"` with `default_locale: en`, so failing to
29
+ // read the locale file means we never learn its name or description at all, and
30
+ // every rule that reads either one is then reading a placeholder. One unparsed
31
+ // byte at offset 0 silently changes the input of four other rules.
32
+ //
33
+ // The manifest reader gets the same treatment. No manifest.json in the corpus
34
+ // carries a BOM today, so that half is a latent trap rather than a measured one -
35
+ // but it is the same one-line mistake at a far worse site, since an unparseable
36
+ // manifest aborts the whole scan and reports a working extension as unreadable.
37
+ const stripBom = (s) => (s.charCodeAt(0) === 0xfeff ? s.slice(1) : s);
38
+ const parseJson = (text) => JSON.parse(stripBom(text));
39
+
40
+ const SKIP_DIRS = new Set(["node_modules", ".git", ".svn", "dist-zip", ".cache"]);
41
+
42
+ // HALF OF ALL EXTENSION PROJECTS HAVE NO MANIFEST TO READ, and telling those
43
+ // developers "no manifest.json in this directory" sends them looking for a file
44
+ // that does not exist anywhere yet. Measured, not guessed: of 160 public repos
45
+ // in research/data/lint-corpus.json, 90 could not be linted as checked in, and
46
+ // of the 84 that were not simply MV2, 45 are a recognisable framework build -
47
+ // wxt 18, plasmo 14, crxjs 8, vite-plugin-web-extension 1, plus 8 on bare
48
+ // webpack or vite. Our README tells a stranger to run the tool on
49
+ // ./my-extension; for these that directory only exists after a build.
50
+ //
51
+ // THE OUTPUT PATHS ARE READ OFF THE CORPUS, not off memory. Counting the paths
52
+ // that appear in .gitignore, README.md and package.json across those repos:
53
+ // `.output/chrome-mv3` 8 (wxt, alongside firefox-mv2/edge-mv3/safari-mv3
54
+ // siblings) and `build/chrome-mv3-prod` 9 against `build/chrome-mv3-dev` 12 -
55
+ // dev is commoner in the corpus but prod is what you submit, so prod is what we
56
+ // name.
57
+ //
58
+ // ORDER MATTERS AND IS NOT ALPHABETICAL: a wxt project also depends on vite, and
59
+ // a plasmo project also looks like a bare node project, so the specific
60
+ // frameworks are tested before the generic bundlers. The generic entries
61
+ // deliberately do NOT name a directory - guessing `dist` for an arbitrary
62
+ // webpack config would be the same misdiagnosis one level down.
63
+ const FRAMEWORKS = [
64
+ { name: "wxt", files: [/^wxt\.config\.[cm]?[jt]s$/], deps: ["wxt"], build: "npx wxt build", out: ".output/chrome-mv3" },
65
+ { name: "Plasmo", deps: ["plasmo"], build: "npm run build", out: "build/chrome-mv3-prod" },
66
+ { name: "CRXJS", deps: ["@crxjs/vite-plugin"], build: "npm run build", out: "dist" },
67
+ { name: "vite-plugin-web-extension", deps: ["vite-plugin-web-extension", "@samrum/vite-plugin-web-extension"], build: "npm run build", out: "dist" },
68
+ { name: "webextension-toolbox", deps: ["webextension-toolbox"], build: "npm run build", out: "dist/chrome" },
69
+ { name: "webpack", deps: ["webpack"], build: "npm run build", out: null },
70
+ { name: "Vite", deps: ["vite"], build: "npm run build", out: null },
71
+ ];
72
+
73
+ // Returns the build hint for a directory that has no manifest.json, or null when
74
+ // nothing recognisable is there. Never throws: an unreadable or invalid
75
+ // package.json means "no hint", because a crash here would replace a usable
76
+ // error message with a stack trace.
77
+ export function buildHint(root, exists = existsSync) {
78
+ let pkg = {};
79
+ try { pkg = parseJson(readFileSync(join(root, "package.json"), "utf8")) || {}; } catch { pkg = {}; }
80
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
81
+ let names = [];
82
+ try { names = readdirSync(root); } catch { names = []; }
83
+
84
+ for (const fw of FRAMEWORKS) {
85
+ const byFile = (fw.files || []).some((rx) => names.some((n) => rx.test(n)));
86
+ const byDep = (fw.deps || []).some((d) => Object.prototype.hasOwnProperty.call(deps, d));
87
+ if (!byFile && !byDep) continue;
88
+ // If the build has already been run, say where it landed rather than telling
89
+ // someone to build again. Only claimed when the manifest is actually there,
90
+ // so this cannot point at a stale empty directory.
91
+ if (fw.out && exists(join(root, fw.out, "manifest.json"))) {
92
+ return `This is a ${fw.name} project and you have already built it. Run: webstore-lint ${fw.out}`;
93
+ }
94
+ return fw.out
95
+ ? `This is a ${fw.name} project, so the manifest is generated by the build and does not exist yet. Run "${fw.build}", then: webstore-lint ${fw.out}`
96
+ : `This looks like a ${fw.name} build, so the manifest is generated and does not exist yet. Run "${fw.build}", then point webstore-lint at your build output directory.`;
97
+ }
98
+ return null;
99
+ }
100
+ const MAX_BYTES = 2 * 1024 * 1024;
101
+ const TEXT_EXT = new Set([
102
+ ".js", ".mjs", ".cjs", ".ts", ".jsx", ".tsx", ".json", ".html", ".htm",
103
+ ".css", ".txt", ".md", ".webmanifest",
104
+ ]);
105
+
106
+ export function scan(root) {
107
+ const files = [];
108
+ const skipped = [];
109
+ // Files too big to hold in memory, kept as a work list rather than only as an
110
+ // apology. `skipped` stays exactly as it was, because every other rule really
111
+ // did not read these and its honesty warning has to keep counting them; a rule
112
+ // that DOES stream one subtracts it from that list itself.
113
+ const oversized = [];
114
+
115
+ const walk = (dir) => {
116
+ let entries;
117
+ try {
118
+ entries = readdirSync(dir, { withFileTypes: true });
119
+ } catch (e) {
120
+ skipped.push({ path: relative(root, dir) || ".", why: `unreadable: ${e.code || e.message}` });
121
+ return;
122
+ }
123
+ for (const e of entries) {
124
+ const full = join(dir, e.name);
125
+ const rel = relative(root, full);
126
+ if (e.isDirectory()) {
127
+ if (SKIP_DIRS.has(e.name)) { skipped.push({ path: rel, why: "not part of a submitted package" }); continue; }
128
+ walk(full);
129
+ continue;
130
+ }
131
+ if (!e.isFile()) continue;
132
+ const ext = extname(e.name).toLowerCase();
133
+ if (!TEXT_EXT.has(ext)) continue; // images, fonts, binaries: nothing to read
134
+ let st;
135
+ try { st = statSync(full); } catch { continue; }
136
+ if (st.size > MAX_BYTES) {
137
+ skipped.push({ path: rel, why: `${(st.size / 1048576).toFixed(1)} MB, over the 2 MB read limit` });
138
+ oversized.push({ path: rel, abs: full, ext, size: st.size });
139
+ continue;
140
+ }
141
+ let text;
142
+ try { text = readFileSync(full, "utf8"); } catch (err) {
143
+ skipped.push({ path: rel, why: `unreadable: ${err.code || err.message}` });
144
+ continue;
145
+ }
146
+ files.push({ path: rel, ext, text, lines: text.split("\n") });
147
+ }
148
+ };
149
+
150
+ walk(root);
151
+
152
+ const manifestPath = join(root, "manifest.json");
153
+ let manifest = null;
154
+ let manifestError = null;
155
+ let manifestHint = null;
156
+ if (!existsSync(manifestPath)) {
157
+ // NAME THE ACTUAL PROBLEM. "no manifest.json in this directory" was printed
158
+ // for a path that is not a directory and for a path that does not exist at
159
+ // all, which sends someone to look for a file in a folder that was never
160
+ // there - the same shape of misdiagnosis as measuring the length of an
161
+ // unresolved __MSG_ placeholder. Pointing at manifest.json itself is the
162
+ // commonest of these and the message now says so outright.
163
+ let st = null;
164
+ try { st = statSync(root); } catch { /* below */ }
165
+ manifestError = !st
166
+ ? `there is nothing at ${root}`
167
+ : st.isDirectory()
168
+ ? "no manifest.json in this directory"
169
+ : /(^|[\\/])manifest\.json$/i.test(root)
170
+ ? `${root} is the manifest itself. Point webstore-lint at the directory that contains it.`
171
+ : `${root} is a file, not an unpacked extension directory`;
172
+ // Only for a real directory that simply has no manifest. A path that does
173
+ // not exist, or a file, is a different mistake and a build hint there would
174
+ // be answering a question nobody asked.
175
+ //
176
+ // THIS GUARD IS BELT-AND-BRACES AND THE TESTS DO NOT PROVE IT LOAD-BEARING:
177
+ // removing it fails nothing, because buildHint already returns null for both
178
+ // of those paths (readdirSync and readFileSync both throw ENOTDIR/ENOENT and
179
+ // are caught). That was found by mutating it, not by reading it, and it is
180
+ // recorded here so a later reader does not mistake the passing controls for
181
+ // evidence about this line. The real protection is buildHint's own
182
+ // behaviour, which IS asserted directly. Kept anyway: it costs one statSync
183
+ // field already in hand, and it keeps the intent legible if buildHint is
184
+ // ever made more eager.
185
+ if (st && st.isDirectory()) manifestHint = buildHint(root);
186
+ } else {
187
+ try {
188
+ manifest = parseJson(readFileSync(manifestPath, "utf8"));
189
+ } catch (e) {
190
+ manifestError = `manifest.json is not valid JSON: ${e.message}`;
191
+ }
192
+ }
193
+
194
+ const { manifest: localized, unresolved } = resolveI18n(manifest, files, skipped);
195
+
196
+ return { root, files, skipped, oversized, manifest: localized, manifestRaw: manifest, i18nUnresolved: unresolved, manifestError, manifestHint };
197
+ }
198
+
199
+ // Chrome's own localisation, resolved before any rule reads the manifest.
200
+ //
201
+ // WHY THIS IS NOT COSMETIC. A localised extension does not put its description in
202
+ // manifest.json at all: it writes "__MSG_extDescription__" there and the real
203
+ // sentence lives in _locales/<default_locale>/messages.json. Three rules read
204
+ // that text - keyword-stuffing, prediction-markets-2026, ai-guardrail-2026 - and
205
+ // listing-metadata measures its length. Reading the placeholder literally meant
206
+ // TWO extensions with the identical description got opposite answers depending on
207
+ // whether they were localised, and the localised one got the wrong answer BOTH
208
+ // ways: a bogus "the description is 22 characters" warning, and total silence
209
+ // about a description reading "prediction market ... sportsbook betting". The
210
+ // silent direction is the dangerous one, because the finding it dropped is the
211
+ // one about a policy that starts being enforced on 1 August 2026.
212
+ //
213
+ // FAIL-SAFE DIRECTION: an unresolvable placeholder is left EXACTLY as written.
214
+ // This function never invents text. What it cannot resolve it reports, and the
215
+ // caller decides - a substituted guess would put words in a developer's listing
216
+ // that they never wrote.
217
+ //
218
+ // Only `name` and `description` are substituted, because those are the fields the
219
+ // rules read. Chrome allows __MSG_ in several other manifest fields; adding one
220
+ // here means checking that no rule reads it structurally first.
221
+ const I18N_FIELDS = ["name", "description"];
222
+
223
+ export function resolveI18n(manifest, files, skipped = []) {
224
+ const unresolved = [];
225
+ if (!manifest) return { manifest, unresolved };
226
+
227
+ const needs = I18N_FIELDS.filter((f) => typeof manifest[f] === "string" && /__MSG_[A-Za-z0-9_@]+__/.test(manifest[f]));
228
+ if (!needs.length) return { manifest, unresolved };
229
+
230
+ const locale = typeof manifest.default_locale === "string" ? manifest.default_locale : null;
231
+ const wanted = locale ? `_locales/${locale}/messages.json` : null;
232
+ const file = wanted ? files.find((f) => f.path.split(/[\\/]/).join("/") === wanted) : null;
233
+ // A locale file the scanner refused to read (over the size limit, unreadable)
234
+ // is NOT evidence that a message is missing. Staying quiet there is the same
235
+ // fail-safe direction as everything else in this file.
236
+ const wasSkipped = wanted ? skipped.some((s) => s.path.split(/[\\/]/).join("/") === wanted) : false;
237
+
238
+ let messages = null;
239
+ if (file) {
240
+ try {
241
+ const parsed = parseJson(file.text);
242
+ // Chrome treats message names as case-insensitive, so the lookup is too.
243
+ messages = new Map(Object.entries(parsed).map(([k, v]) => [k.toLowerCase(), v]));
244
+ } catch (e) {
245
+ unresolved.push({ field: null, placeholder: null, why: `${wanted} is not valid JSON: ${e.message}` });
246
+ }
247
+ }
248
+
249
+ const out = { ...manifest };
250
+ for (const field of needs) {
251
+ out[field] = manifest[field].replace(/__MSG_([A-Za-z0-9_@]+)__/g, (whole, key) => {
252
+ const entry = messages?.get(key.toLowerCase());
253
+ const text = entry && typeof entry.message === "string" ? entry.message : null;
254
+ if (text !== null) return text;
255
+ if (wasSkipped) return whole; // reported by the scanner already; do not double-accuse
256
+ unresolved.push({
257
+ field,
258
+ placeholder: whole,
259
+ why: !locale
260
+ ? `manifest.json uses ${whole} but declares no default_locale`
261
+ : !file
262
+ ? `manifest.json uses ${whole} but the package has no ${wanted}`
263
+ : `${wanted} has no message named ${key}`,
264
+ });
265
+ return whole;
266
+ });
267
+ }
268
+ return { manifest: out, unresolved };
269
+ }
270
+
271
+ // Find every line matching a pattern, with its file and 1-indexed line number.
272
+ // Evidence is what separates a finding from an opinion, so every rule uses this
273
+ // rather than testing whole-file text and reporting the file alone.
274
+ //
275
+ // ONE MATCH PER LINE, AND ON MINIFIED CODE A LINE IS THE WHOLE FILE. That is
276
+ // fine for evidence - one site is enough to make a developer look - but it means
277
+ // WHICHEVER SITE COMES FIRST IN THE FILE IS THE ONLY SITE ANYONE EVER SEES. If
278
+ // that first site is something the rule would rather not report, the effect is
279
+ // not a tidier finding: it is a REAL site, later in the same line, that the tool
280
+ // silently never mentions. Measured on shipped packages (T-0414): screenity's
281
+ // `cloudrecorder.bundle.js` and `contentScript.bundle.js` each begin with
282
+ // webpack's `new Function("return this")` globalThis shim, and each hides a
283
+ // `new Function(scriptText)(window)` further along the same 460 KB line.
284
+ //
285
+ // So `accept` does NOT filter the returned hits. It is consulted per MATCH, and
286
+ // a rejected match makes the scan CONTINUE ALONG THE SAME LINE to the next one.
287
+ // Rejecting a site can therefore only ever reveal another site or leave the line
288
+ // unreported - never mask one. Filtering afterwards would have dropped both
289
+ // screenity files entirely and called that an improvement.
290
+ // THE EVIDENCE MUST CONTAIN THE MATCH, and until s106 nothing required it to.
291
+ // `text` was the first 160 characters of the line, which is correct for ordinary
292
+ // code and actively misleading for the one case that matters most.
293
+ //
294
+ // Found on a real repo in the corpus (s106). A malicious `postcss.config.mjs`
295
+ // ended with the legitimate line `export default config;`, then **507 spaces**,
296
+ // then 8.6 KB of obfuscated loader that resolves a C2 address out of an Ethereum
297
+ // transaction and `eval`s what it downloads. The rule fired correctly at FAIL.
298
+ // The evidence we printed for it was:
299
+ //
300
+ // postcss.config.mjs:9 export default config;
301
+ //
302
+ // - innocent code and whitespace, because the payload starts 529 characters in.
303
+ // A developer reading that dismisses the most serious finding this tool can
304
+ // produce as an obvious false positive. Padding to push code off the right edge
305
+ // is a deliberate hiding technique, so the excerpt has to follow the match.
306
+ //
307
+ // Ordinary lines are byte-identical to before: when the match already falls
308
+ // inside the first EXCERPT characters this returns exactly what it used to, so
309
+ // the change cannot quietly rewrite the evidence on every other finding.
310
+ const EXCERPT = 160;
311
+ export function excerptAround(line, index = 0, matchLen = 0) {
312
+ const lead = line.length - line.trimStart().length;
313
+ const trimmed = line.trim();
314
+ const at = Math.max(0, index - lead);
315
+ if (at + matchLen <= EXCERPT) return trimmed.slice(0, EXCERPT);
316
+ // Keep a little context before the match so the reader can see what it sits in.
317
+ const start = Math.max(0, at - 40);
318
+ return "..." + trimmed.slice(start, start + EXCERPT - 3);
319
+ }
320
+
321
+ export function grep(files, re, filter = () => true, accept = null) {
322
+ const hits = [];
323
+ const push = (f, i, m) =>
324
+ hits.push({
325
+ file: f.path, line: i + 1, match: m[0].slice(0, 120),
326
+ // `col` is carried so a caller that substitutes a DIFFERENT rendering of
327
+ // the same line (rules.mjs swaps the comment-blanked line for the original
328
+ // one) can re-excerpt around the same site instead of falling back to the
329
+ // first 160 characters. Without it that substitution silently undid this.
330
+ col: m.index,
331
+ text: excerptAround(f.lines[i], m.index, m[0].length),
332
+ });
333
+ for (const f of files) {
334
+ if (!filter(f)) continue;
335
+ for (let i = 0; i < f.lines.length; i++) {
336
+ if (!accept) {
337
+ const rx = new RegExp(re.source, re.flags.replace("g", ""));
338
+ const m = rx.exec(f.lines[i]);
339
+ if (m) push(f, i, m);
340
+ continue;
341
+ }
342
+ // `g` is required to advance lastIndex; the caller's regex may not have it.
343
+ const rx = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g");
344
+ let m;
345
+ while ((m = rx.exec(f.lines[i])) !== null) {
346
+ if (m[0].length === 0) { rx.lastIndex++; continue; } // a zero-width match never advances
347
+ if (accept(m, f.lines[i])) { push(f, i, m); break; }
348
+ }
349
+ }
350
+ }
351
+ return hits;
352
+ }
353
+
354
+ // The same search, but over the whole file instead of line by line, still
355
+ // reporting the 1-indexed line the match STARTS on so the evidence is unchanged.
356
+ //
357
+ // WHY BOTH EXIST. grep() above is line-based, and for most rules that is right:
358
+ // the pattern and the evidence are one line. But an HTML attribute is not
359
+ // line-shaped. A long <script src="..."> wrapped across lines - which is exactly
360
+ // what a formatter does to it - was invisible to grep(), so two byte-identical
361
+ // pages differing ONLY in where the line breaks fell got different answers, and
362
+ // the silent one was the formatted one. That was in remote-code, the highest
363
+ // severity rule in the tool and the first trigger Google names for MV3.
364
+ //
365
+ // Use this for any pattern that can legally span a newline. Do NOT use it for
366
+ // patterns anchored to line structure - matching across lines is the whole
367
+ // point, and for a line-anchored rule that is a false positive waiting to happen.
368
+ export function grepAcross(files, re, filter = () => true) {
369
+ const hits = [];
370
+ for (const f of files) {
371
+ if (!filter(f)) continue;
372
+ const rx = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g");
373
+ let m;
374
+ while ((m = rx.exec(f.text)) !== null) {
375
+ // Count the newlines before the match rather than searching the lines
376
+ // array, so a match that spans lines is attributed to the line it opens on.
377
+ const before = f.text.slice(0, m.index);
378
+ const line = before.split("\n").length;
379
+ // Offset of the match WITHIN its opening line, so the excerpt can follow a
380
+ // match that sits far to the right of a padded line - same reason as grep().
381
+ const col = m.index - (before.lastIndexOf("\n") + 1);
382
+ hits.push({
383
+ file: f.path,
384
+ line,
385
+ match: m[0].replace(/\s+/g, " ").slice(0, 120),
386
+ col,
387
+ text: excerptAround(f.lines[line - 1] || "", col, m[0].length),
388
+ });
389
+ // A zero-length match would spin forever.
390
+ if (m.index === rx.lastIndex) rx.lastIndex++;
391
+ }
392
+ }
393
+ return hits;
394
+ }
395
+
396
+ export const isCode = (f) => [".js", ".mjs", ".cjs", ".ts", ".jsx", ".tsx"].includes(f.ext);
397
+ export const isMarkup = (f) => [".html", ".htm"].includes(f.ext);
398
+
399
+ // Blank out comments, keeping every other byte where it was.
400
+ //
401
+ // WHY THIS LIVES HERE rather than in the one rule that first needed it: a
402
+ // commented-out call used to count as a call site, so an extension that DELETED
403
+ // its chrome.bookmarks code and left "// we used to call chrome.bookmarks.getTree
404
+ // here" behind was reported as USING bookmarks. That is Google's Purple Potassium
405
+ // case exactly. Two separate places ask that question - audit.mjs for the ledger
406
+ // and the unused-permissions rule for the verdict - and when they disagreed the
407
+ // tool contradicted itself. The drift between two copies has already been a bug
408
+ // here once (see declaredHosts), so this is shared.
409
+ //
410
+ // Comment bytes become SPACES rather than being removed, so offsets, line counts
411
+ // and therefore every reported line number are unchanged.
412
+ //
413
+ // THE FAIL-SAFE DIRECTION IS DELIBERATE. Blanking real code would invent an
414
+ // "unused" verdict, and that advice deletes a permission the extension needs -
415
+ // the expensive direction. So blanking happens ONLY from a comment opener found
416
+ // in normal state: strings and template literals are tracked and skipped, and an
417
+ // ambiguous slash is resolved toward "not a comment". A missed comment leaves a
418
+ // permission reading "used", which is a miss rather than a false accusation.
419
+ // THERE IS ONE IMPLEMENTATION AND IT IS THE RESUMABLE ONE, on purpose. The
420
+ // streaming scan (grepLarge, below) has to blank comments in a file it never
421
+ // holds whole, and the obvious way to get that is a second copy of this state
422
+ // machine that walks a chunk. The drift between two copies of a shared rule has
423
+ // already been a bug in this file once (see declaredHosts in rules.mjs), and
424
+ // this one decides whether a FAIL-severity site is visible - so instead
425
+ // stripComments() is a single call to the chunk stepper with the whole file as
426
+ // one chunk, and test/lint.test.mjs asserts the two paths AGREE byte for byte
427
+ // on real bundles at several chunk sizes.
428
+ export const initialCommentState = () => ({ mode: "code", quote: '"', cls: false, prev: "" });
429
+
430
+ // Blank the comments in ONE chunk, carrying the parse state across the seam.
431
+ //
432
+ // `atEof` false means another chunk is coming, and then this NEVER consumes the
433
+ // final character of the chunk: every decision here needs at most one character
434
+ // of lookahead (`//` against `/*`, `*/` closing a block, a backslash escaping
435
+ // the next byte), and a seam through the middle of any of those pairs decides it
436
+ // wrong. The unconsumed tail comes back as `held` and the caller prepends it to
437
+ // the next chunk. Holding one character uniformly is cheaper to reason about
438
+ // than three special cases, and it is why there is no `pendingSlash` in the
439
+ // state object.
440
+ //
441
+ // Returns { out, held, state }: `out` is the blanked text for the part actually
442
+ // consumed, so out.length + held.length === text.length and every byte offset in
443
+ // `out` is still the offset it had in the file.
444
+ export function stripCommentsChunk(text, state = initialCommentState(), { atEof = true } = {}) {
445
+ const n = text.length;
446
+ const out = text.split("");
447
+ let { mode, quote, cls, prev } = state;
448
+ // Nothing past `stop` is consumed, so every inner scan is bounded by it too.
449
+ // Bounding only the outer loop would let a backslash skip step over the held
450
+ // character, which is exactly the seam this is protecting.
451
+ const stop = atEof ? n : Math.max(0, n - 1);
452
+ const blank = (a, b) => {
453
+ for (let k = a; k < b && k < n; k++) if (out[k] !== "\n") out[k] = " ";
454
+ };
455
+
456
+ let i = 0;
457
+ while (i < stop) {
458
+ if (mode === "line") {
459
+ let j = i;
460
+ while (j < stop && text[j] !== "\n") j++;
461
+ blank(i, j);
462
+ i = j;
463
+ if (j < stop) mode = "code"; // the newline itself is left for code mode
464
+ continue;
465
+ }
466
+ if (mode === "block") {
467
+ const k = text.indexOf("*/", i);
468
+ if (k !== -1 && k + 2 <= stop) { blank(i, k + 2); i = k + 2; mode = "code"; continue; }
469
+ // Not closed inside this chunk. A trailing `*` has to be HELD, not
470
+ // blanked: blanking it destroys the first half of a `*/` that straddles
471
+ // the seam, and the next chunk then searches for a closer whose asterisk
472
+ // no longer exists - so the block comment swallows the rest of the file.
473
+ // Same failure as the backslash below, and it is why holding one character
474
+ // is not enough on its own.
475
+ let end = stop;
476
+ if (!atEof && end > i && text[end - 1] === "*") end--;
477
+ blank(i, end);
478
+ i = end;
479
+ break;
480
+ }
481
+ if (mode === "str" || mode === "tpl") {
482
+ // A single-quoted or double-quoted string bails at a newline; a template
483
+ // literal does not. Both are STEPPED OVER, never blanked - see the header
484
+ // above and T-0416 for why blanking them is refused.
485
+ const ends = mode === "str" ? (ch) => ch === quote || ch === "\n" : (ch) => ch === "`";
486
+ let j = i;
487
+ // A BACKSLASH IS TWO CHARACTERS AND THE SEAM MUST NOT FALL BETWEEN THEM -
488
+ // handing the escaped character to the next call as an ordinary one ends
489
+ // the string early when it is the closing quote (`\"`, which every
490
+ // embedded JSON blob is full of). No guard is needed for it HERE, and that
491
+ // is a measured claim rather than an assumption: the skip may run j past
492
+ // `stop`, and consuming exactly as far as j - rather than to stop - takes
493
+ // the escaped character with it, which is correct. A version WITH an
494
+ // explicit guard was tried and proved an equivalent mutant, agreeing at
495
+ // every chunk size from 1 upward on the seam fixtures and byte for byte
496
+ // over 36 MB of real bundles. It was removed rather than kept as comfort:
497
+ // a branch that cannot change an answer cannot be tested, and a comment
498
+ // claiming it protects something would be false.
499
+ while (j < stop) {
500
+ if (text[j] === "\\") { j += 2; continue; }
501
+ if (ends(text[j])) break;
502
+ j++;
503
+ }
504
+ if (j < stop && ends(text[j])) { prev = mode === "str" ? quote : "`"; i = j + 1; mode = "code"; continue; }
505
+ i = j;
506
+ break; // holding from j: nothing after it can be decided without the next chunk
507
+
508
+ }
509
+ if (mode === "re") {
510
+ let j = i;
511
+ let done = false;
512
+ while (j < stop) {
513
+ const e = text[j];
514
+ if (e === "\\") { j += 2; continue; } // consuming to j, not to stop, carries the escape
515
+ if (e === "\n") break;
516
+ if (e === "[") cls = true;
517
+ else if (e === "]") cls = false;
518
+ else if (e === "/" && !cls) { done = true; break; }
519
+ j++;
520
+ }
521
+ if (done || (j < stop && text[j] === "\n")) { i = j + 1; mode = "code"; prev = "/"; continue; }
522
+ i = j;
523
+ break; // nothing after j can be decided without the next chunk
524
+
525
+ }
526
+
527
+ const c = text[i];
528
+ const d = text[i + 1];
529
+
530
+ if (c === "/" && d === "/") { blank(i, i + 2); i += 2; mode = "line"; continue; }
531
+ if (c === "/" && d === "*") { blank(i, i + 2); i += 2; mode = "block"; continue; }
532
+ if (c === '"' || c === "'") { quote = c; i++; mode = "str"; continue; }
533
+ if (c === "`") { i++; mode = "tpl"; continue; }
534
+ // A slash here is either division or a regex literal. Only the regex case
535
+ // needs skipping, and guessing wrong that way merely means a comment inside
536
+ // the span is missed - the safe direction. An unescaped // cannot appear
537
+ // inside a regex literal anyway, because it would have closed it.
538
+ if (c === "/" && (prev === "" || "=(,:[!&|?{};+-*%~^<>return".includes(prev))) {
539
+ i++;
540
+ cls = false;
541
+ mode = "re";
542
+ continue;
543
+ }
544
+ if (!/\s/.test(c)) prev = c;
545
+ i++;
546
+ }
547
+
548
+ return {
549
+ out: out.slice(0, i).join(""),
550
+ held: text.slice(i),
551
+ state: { mode, quote, cls, prev },
552
+ };
553
+ }
554
+
555
+ // Blank out comments, keeping every other byte where it was.
556
+ export function stripComments(text) {
557
+ return stripCommentsChunk(text, initialCommentState(), { atEof: true }).out;
558
+ }
559
+
560
+ // Search a file that is too large to hold in memory, in overlapping windows.
561
+ //
562
+ // WHY THIS EXISTS. scan() does not read a file over 2 MB, and the remote-code
563
+ // rule then went silent about it - which a developer reads as "nothing there",
564
+ // on the one rule Google enforces first. Measured on 82 shipped release
565
+ // packages: 23 skip a code file for size and THREE of them hide a real site in
566
+ // one, including `new Function('return (' + source + ');')()` in a 4.0 MB
567
+ // content script.
568
+ //
569
+ // RAISING THE LIMIT IS NOT THE FIX and was rejected: page-assist ships an 8.3 MB
570
+ // chunk, so a bigger number only moves the cliff, and it costs that memory on
571
+ // every scan of every package. This reads a fixed window instead, so a 40 MB
572
+ // file costs the same as a 3 MB one.
573
+ //
574
+ // THE OVERLAP IS THE WHOLE CORRECTNESS ARGUMENT. grepAcross deliberately matches
575
+ // across newlines, so a window boundary can fall through the middle of a match.
576
+ // Every window therefore keeps the last OVERLAP characters of the previous one,
577
+ // and hits are deduplicated by their ABSOLUTE offset in the file - not by their
578
+ // offset in the window, which changes every time the window slides. OVERLAP must
579
+ // stay comfortably longer than the longest pattern any caller passes.
580
+ const WINDOW = 1 << 20; // 1 MB read
581
+ const OVERLAP = 8192;
582
+
583
+ // A match must be reported at a line number and with text a developer can
584
+ // recognise. Neither is free here: the file is never whole, and on a minified
585
+ // bundle "the line" is millions of characters. So the line is counted from the
586
+ // newlines actually passed over, and the evidence is a SNIPPET around the match
587
+ // rather than the line - which is what grep() would have produced anyway once it
588
+ // truncated a 460 KB line to 160 characters.
589
+ // The windowing itself, separated from what any one caller does with it, for the
590
+ // same reason stripComments has exactly one implementation: two rules now read
591
+ // these files and a second copy of the slide-and-overlap arithmetic is a bug
592
+ // waiting to be fixed in one place only.
593
+ //
594
+ // Yields { raw, view, base, lineBase } per window. `raw` is the original text,
595
+ // `view` is the same span with comments blanked when `code` is true, and the two
596
+ // are always the same length - stripComments preserves offsets, which is what
597
+ // lets a match found in `view` be quoted from `raw` and counted in lines.
598
+ export function* largeWindows(f, { code = false } = {}) {
599
+ let fd;
600
+ try { fd = openSync(f.abs, "r"); } catch { return; }
601
+ const decoder = new StringDecoder("utf8");
602
+ const buf = Buffer.allocUnsafe(WINDOW);
603
+ let raw = ""; // window of original text
604
+ let view = ""; // same window, comments blanked when code is true
605
+ let base = 0; // absolute offset of raw[0]
606
+ let lineBase = 1; // 1-indexed line number of raw[0]
607
+ let heldRaw = ""; // characters stripCommentsChunk could not decide yet
608
+ let state = initialCommentState();
609
+ try {
610
+ for (;;) {
611
+ const n = readSync(fd, buf, 0, WINDOW, null);
612
+ const atEof = n === 0;
613
+ const chunk = heldRaw + (atEof ? decoder.end() : decoder.write(buf.subarray(0, n)));
614
+ let add = chunk;
615
+ if (code) {
616
+ const r = stripCommentsChunk(chunk, state, { atEof });
617
+ state = r.state;
618
+ heldRaw = r.held;
619
+ add = r.out;
620
+ raw += chunk.slice(0, r.out.length);
621
+ } else {
622
+ heldRaw = "";
623
+ raw += chunk;
624
+ }
625
+ view += add;
626
+ yield { raw, view, base, lineBase };
627
+ if (atEof) break;
628
+ // Slide, keeping the overlap. Everything dropped is accounted for in base
629
+ // and lineBase so absolute offsets and line numbers keep counting.
630
+ if (view.length > OVERLAP) {
631
+ const drop = view.length - OVERLAP;
632
+ lineBase += countNewlines(raw, 0, drop);
633
+ base += drop;
634
+ view = view.slice(drop);
635
+ raw = raw.slice(drop);
636
+ }
637
+ }
638
+ } finally {
639
+ closeSync(fd);
640
+ }
641
+ }
642
+
643
+ export function grepLarge(oversized, re, { filter = () => true, code = false, accept = null, maxPerFile = 25 } = {}) {
644
+ const hits = [];
645
+ for (const f of oversized || []) {
646
+ if (!filter(f)) continue;
647
+ const emitted = new Set();
648
+ let truncated = false;
649
+
650
+ const search = ({ raw, view, base, lineBase }) => {
651
+ const rx = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g");
652
+ let m;
653
+ while ((m = rx.exec(view)) !== null) {
654
+ if (m[0].length === 0) { rx.lastIndex++; continue; }
655
+ const abs = base + m.index;
656
+ if (emitted.has(abs)) continue;
657
+ // `accept` is consulted per MATCH and a rejection CONTINUES along the
658
+ // window, exactly as in grep(): rejecting a site can only ever reveal
659
+ // another one, never mask it.
660
+ if (accept && !accept(m, view)) continue;
661
+ emitted.add(abs);
662
+ if (emitted.size > maxPerFile) { truncated = true; return; }
663
+ const line = lineBase + countNewlines(raw, 0, m.index);
664
+ const from = Math.max(0, m.index - 40);
665
+ hits.push({
666
+ file: f.path,
667
+ line,
668
+ match: m[0].replace(/\s+/g, " ").slice(0, 120),
669
+ text: raw.slice(from, from + 200).replace(/\s+/g, " ").trim().slice(0, 160),
670
+ });
671
+ }
672
+ };
673
+
674
+ for (const w of largeWindows(f, { code })) {
675
+ search(w);
676
+ if (truncated) break;
677
+ }
678
+
679
+ if (truncated) {
680
+ hits.push({
681
+ file: f.path,
682
+ line: 1,
683
+ match: "",
684
+ text: `more than ${maxPerFile} sites in this file; the rest are not listed`,
685
+ });
686
+ }
687
+ }
688
+ return hits;
689
+ }
690
+
691
+ const countNewlines = (s, from, to) => {
692
+ let c = 0;
693
+ for (let i = from; i < to; i++) if (s.charCodeAt(i) === 10) c++;
694
+ return c;
695
+ };
696
+
697
+ // The same files with comments blanked, computed once. Every call-site question
698
+ // is about code, not prose. Non-code files are passed through untouched.
699
+ export function codeView(files) {
700
+ return files.map((f) => {
701
+ if (!isCode(f)) return f;
702
+ const text = stripComments(f.text);
703
+ return { ...f, text, lines: text.split("\n") };
704
+ });
705
+ }