eslint-plugin-chrome-extensions 2.4.2 → 2.6.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.
Files changed (47) hide show
  1. package/README.md +272 -115
  2. package/package.json +6 -8
  3. package/src/data/apis.js +166 -115
  4. package/src/data/deprecations.js +5 -7
  5. package/src/data/i18n.js +19 -69
  6. package/src/data/manifest.js +61 -57
  7. package/src/data/permissions.js +23 -30
  8. package/src/data/regex.js +10 -0
  9. package/src/index.js +7 -124
  10. package/src/rules/manifest-key-order.js +8 -8
  11. package/src/rules/manifest-spacing.js +5 -5
  12. package/src/rules/no-content-style-leak.js +6 -4
  13. package/src/rules/no-deprecated-apis.js +4 -4
  14. package/src/rules/no-duplicate-manifest-keys.js +3 -4
  15. package/src/rules/no-insecure-messaging.js +4 -4
  16. package/src/rules/no-invalid-locales.js +89 -0
  17. package/src/rules/no-invalid-permissions.js +10 -13
  18. package/src/rules/no-missing-default-locale.js +44 -21
  19. package/src/rules/no-missing-i18n-keys.js +24 -25
  20. package/src/rules/no-missing-permissions.js +7 -6
  21. package/src/rules/no-missing-web-accessible-resources.js +7 -6
  22. package/src/rules/no-remote-code.js +12 -12
  23. package/src/rules/no-sensitive-permissions.js +75 -0
  24. package/src/rules/no-unrestricted-host-permissions.js +35 -19
  25. package/src/rules/no-unused-i18n-keys.js +11 -10
  26. package/src/rules/no-unused-permissions.js +31 -23
  27. package/src/rules/require-license-file.js +4 -5
  28. package/src/rules/require-mv3.js +5 -6
  29. package/src/rules/require-short-name-when-useful.js +4 -3
  30. package/src/rules/valid-filenames.js +3 -10
  31. package/src/rules/valid-license-file.js +18 -11
  32. package/src/rules/valid-manifest-icons.js +35 -6
  33. package/src/rules/valid-mv3-schema.js +22 -22
  34. package/src/rules/valid-service-worker.js +34 -13
  35. package/src/rules/valid-url-fields.js +49 -41
  36. package/src/rules/valid-version-field.js +5 -9
  37. package/src/utils/extension.js +52 -33
  38. package/src/utils/i18n.js +58 -0
  39. package/src/utils/init.js +107 -0
  40. package/src/utils/license.js +56 -0
  41. package/src/utils/node.js +20 -19
  42. package/src/utils/pkg.js +12 -10
  43. package/src/utils/rules.js +6 -4
  44. package/src/utils/text.js +13 -0
  45. package/src/data/license.js +0 -28
  46. package/src/data/text-parser.js +0 -11
  47. package/src/utils/bundle.js +0 -10
@@ -1,18 +1,22 @@
1
- import { createRequire } from 'node:module'
1
+ import license from '../utils/license.js'
2
2
  import path from 'node:path'
3
3
 
4
- import { re } from '../data/license.js'
5
-
6
- const require = createRequire(import.meta.url),
7
- spdxLicenseIDs = require('spdx-license-ids')
4
+ import regex from '../data/regex.js'
8
5
 
9
6
  function toArray(value) { return value == null ? [] : Array.isArray(value) ? value : [value] }
10
7
 
11
8
  function validateContent(context, node, { requireSPDX, allowedLicenses }) {
12
- const match = context.sourceCode.text.match(re.spdxTag)
13
- if (!match) return void (requireSPDX && context.report({ node, messageId: 'missingSPDX' }))
14
- const id = match[1]
15
- if (!spdxLicenseIDs.includes(id))
9
+ const text = context.sourceCode.text,
10
+ id = license.spdx.parse(text)
11
+ if (!id) {
12
+ if (!requireSPDX) return
13
+ const inferredID = license.spdx.find(text),
14
+ report = { node, messageId: 'missingSPDX' }
15
+ if (inferredID && (!allowedLicenses.length || allowedLicenses.includes(inferredID)))
16
+ report.fix = fixer => license.spdx.appendTag(fixer, text, inferredID)
17
+ return context.report(report)
18
+ }
19
+ if (!license.spdx.isValid(id))
16
20
  return context.report({ node, messageId: 'invalidSPDX', data: { id }})
17
21
  if (allowedLicenses.length && !allowedLicenses.includes(id))
18
22
  context.report({ node, messageId: 'disallowedSPDX', data: { id, allowed: allowedLicenses.join(', ') }})
@@ -21,6 +25,7 @@ function validateContent(context, node, { requireSPDX, allowedLicenses }) {
21
25
  export default {
22
26
  meta: {
23
27
  type: 'problem',
28
+ fixable: 'code',
24
29
  docs: {
25
30
  description: 'Validates SPDX identifiers in license files',
26
31
  recommended: false,
@@ -47,13 +52,15 @@ export default {
47
52
  disallowedSPDX: 'License `{{id}}` is not in allowed list: {{allowed}}.'
48
53
  }
49
54
  },
55
+
50
56
  create(context) {
51
57
  const opts = context.options[0],
52
- { requireSPDX } = opts,
58
+ { requireSPDX } = opts,
53
59
  allowedLicenses = toArray(opts.allowedLicenses),
54
60
  { filename } = context,
55
61
  basename = path.basename(filename)
56
- if (!re.licenseFilename.test(basename)) return {}
62
+ if (!regex.filename.license.test(basename) || regex.filename.script.test(basename))
63
+ return {}
57
64
  return { Program: node => validateContent(context, node, { requireSPDX, allowedLicenses })}
58
65
  }
59
66
  }
@@ -1,18 +1,38 @@
1
+ import extension from '../utils/extension.js'
1
2
  import fs from 'node:fs'
2
3
  import path from 'node:path'
3
4
 
4
- import { findRoot as findExtensionRoot, file } from '../utils/extension.js'
5
+ import regex from '../data/regex.js'
5
6
 
6
- const re = { manifestFilename: /manifest\.json$/, svg: /\.svg$/i },
7
+ const re = { svg: /\.svg$/i },
7
8
  sizes = { recommended: ['16', '32', '48', '128'], required: '128' }
8
9
 
10
+ function validateIcons(context, icons, root, opts, loc) {
11
+ const { requireRecommended, checkFiles, checkSVG } = opts,
12
+ declaredSizes = new Set(Object.keys(icons))
13
+ if (requireRecommended) {
14
+ for (const size of sizes.recommended)
15
+ if (!declaredSizes.has(size))
16
+ context.report({ loc, messageId: 'missingSize', data: { size }})
17
+ } else if (!declaredSizes.has(sizes.required))
18
+ context.report({ loc, messageId: 'missingRequiredSize' })
19
+ for (const file of Object.values(icons)) {
20
+ if (typeof file != 'string') continue
21
+ if (checkSVG && re.svg.test(file))
22
+ context.report({ loc, messageId: 'svgNotSupported' })
23
+ else if (checkFiles && root // eslint-disable-next-line security/detect-non-literal-fs-filename
24
+ && !fs.existsSync(path.resolve(root, file))
25
+ ) context.report({ loc, messageId: 'missingFile', data: { file }})
26
+ }
27
+ }
28
+
9
29
  export default {
10
30
  meta: {
11
31
  type: 'problem',
12
32
  docs: {
13
33
  description: 'Validate manifest icons: sizes, existing files, no SVG',
14
34
  category: 'Possible Errors',
15
- recommended: false,
35
+ recommended: true,
16
36
  manifest: true
17
37
  },
18
38
  defaultOptions: [{
@@ -47,12 +67,21 @@ export default {
47
67
  },
48
68
 
49
69
  create(context) {
50
- if (!re.manifestFilename.test(file.getName(context))) return {}
70
+ const filename = extension.file.getName(context)
71
+ if (!regex.filename.manifest.test(filename) && !regex.filename.script.test(filename)) return {}
51
72
  const opts = context.options[0] || {},
52
73
  requireRecommended = opts.requireRecommendedSizes !== false,
53
74
  checkFiles = opts.checkIconExists !== false,
54
- checkSVG = opts.noSVG !== false
55
- const root = checkFiles ? findExtensionRoot(context) : null
75
+ checkSVG = opts.noSVG !== false,
76
+ root = checkFiles ? extension.findRoot(context) : null,
77
+ resolved = { requireRecommended, checkFiles, checkSVG }
78
+ if (!regex.filename.manifest.test(filename)) {
79
+ const manifest = extension.manifest.read(context) ; if (!manifest) return {}
80
+ const icons = manifest.icons
81
+ if (!icons || typeof icons != 'object' || Array.isArray(icons)) return {}
82
+ validateIcons(context, icons, root, resolved, { line: 1, column: 1 })
83
+ return {}
84
+ }
56
85
  return {
57
86
  '*'(node) {
58
87
  const keyNode = node.name || node.key ; if (!keyNode) return
@@ -1,9 +1,7 @@
1
- import { mv2OnlyKeys, forbiddenCSPdirectives, ffMV3bgKeys, ffDataCollectionRequired, ffDataCollectionOptional }
2
- from '../data/manifest.js'
1
+ import extension from '../utils/extension.js'
3
2
 
4
- import { file, readManifest } from '../utils/extension.js'
5
-
6
- const re = { manifestFilename: /manifest\.json$/ }
3
+ import manifestData from '../data/manifest.js'
4
+ import regex from '../data/regex.js'
7
5
 
8
6
  export default {
9
7
  meta: {
@@ -11,7 +9,7 @@ export default {
11
9
  docs: {
12
10
  description: 'Validate manifest against the MV3 schema',
13
11
  category: 'Possible Errors',
14
- recommended: false,
12
+ recommended: true,
15
13
  manifest: true
16
14
  },
17
15
  schema: [],
@@ -24,7 +22,7 @@ export default {
24
22
  },
25
23
 
26
24
  create(context) {
27
- if (re.manifestFilename.test(file.getName(context))) { // lint it directly
25
+ if (regex.filename.manifest.test(extension.file.getName(context))) { // lint it directly
28
26
  const ancestors = [], pendingReports = [], deferredMV2 = []
29
27
  let isMV3 = false, isFF = false
30
28
  function keyOf(node) {
@@ -44,10 +42,10 @@ export default {
44
42
  keyOf(ancestor) == 'data_collection_permissions')
45
43
  if (keyName == 'manifest_version' && node.value?.value == 3) isMV3 = true
46
44
  if (isTopLevel && keyName == 'browser_specific_settings') isFF = true
47
- if (isTopLevel && mv2OnlyKeys.has(keyName))
45
+ if (isTopLevel && manifestData.keys.mv2Only.has(keyName))
48
46
  pendingReports.push({ node, messageId: 'mv2Key',
49
47
  data: { key: keyName, reason: 'MV2-only key' }})
50
- if (inBackground && ffMV3bgKeys.has(keyName))
48
+ if (inBackground && manifestData.keys.ff.mv3BG.has(keyName))
51
49
  deferredMV2.push({ node, messageId: 'mv2Key',
52
50
  data: { key: `background.${keyName}`,
53
51
  reason: 'Use background.service_worker instead' }})
@@ -58,7 +56,7 @@ export default {
58
56
  if (isTopLevel && keyName == 'content_security_policy' && node.value?.type == 'String')
59
57
  pendingReports.push({ node: node.value, messageId: 'wrongCSPType' })
60
58
  if (inCSP && keyName == 'extension_pages' && typeof node.value?.value == 'string')
61
- for (const directive of forbiddenCSPdirectives)
59
+ for (const directive of manifestData.csp.forbiddenDirectives)
62
60
  if (node.value.value.includes(directive))
63
61
  pendingReports.push({ node: node.value, messageId: 'unsafeCSP',
64
62
  data: { directive }})
@@ -67,8 +65,9 @@ export default {
67
65
  ) for (const elem of node.value.elements) {
68
66
  const value = typeof elem.value == 'string' ? elem.value : elem.value?.value
69
67
  if (typeof value == 'string') {
70
- const set = keyName == 'required' ? ffDataCollectionRequired : ffDataCollectionOptional
71
- if (!set.has(value))
68
+ const allowedVals = manifestData.keys.ff[
69
+ `dataCollection${ keyName == 'required' ? 'Required' : 'Optional' }`]
70
+ if (!allowedVals.has(value))
72
71
  pendingReports.push({ node: elem, messageId: 'invalidDataCollectionValue',
73
72
  data: { value, kind: keyName }})
74
73
  }
@@ -86,14 +85,14 @@ export default {
86
85
  }
87
86
  }
88
87
  }
89
- const manifest = readManifest(context) ; if (!manifest || manifest.manifest_version != 3) return {}
88
+ const manifest = extension.manifest.read(context) ; if (!manifest || manifest.manifest_version != 3) return {}
90
89
  const isFF = manifest.browser_specific_settings != null
91
90
  return {
92
91
  Program(node) { /* eslint-disable security/detect-object-injection */
93
92
  for (const key of Object.keys(manifest))
94
- if (mv2OnlyKeys.has(key))
93
+ if (manifestData.keys.mv2Only.has(key))
95
94
  context.report({ node, messageId: 'mv2Key', data: { key, reason: 'MV2-only key' }})
96
- for (const key of ffMV3bgKeys)
95
+ for (const key of manifestData.keys.ff.mv3BG)
97
96
  if (manifest.background?.[key] && !isFF)
98
97
  context.report({ node, messageId: 'mv2Key',
99
98
  data: { key: `background.${key}`, reason: 'Use background.service_worker instead' }})
@@ -105,17 +104,18 @@ export default {
105
104
  context.report({ node, messageId: 'wrongCSPType' })
106
105
  const cspPages = csp?.extension_pages
107
106
  if (typeof cspPages == 'string')
108
- for (const directive of forbiddenCSPdirectives)
107
+ for (const directive of manifestData.csp.forbiddenDirectives)
109
108
  if (cspPages.includes(directive))
110
109
  context.report({ node, messageId: 'unsafeCSP', data: { directive }})
111
110
  if (isFF) { // validate data collection permissions
112
111
  const dcp = manifest.browser_specific_settings?.gecko?.data_collection_permissions
113
- for (const [kind, set] of [['required', ffDataCollectionRequired], ['optional', ffDataCollectionOptional]])
114
- if (Array.isArray(dcp?.[kind]))
115
- for (const value of dcp[kind])
116
- if (!set.has(value))
117
- context.report({ node, messageId: 'invalidDataCollectionValue',
118
- data: { value, kind }})
112
+ for (const [kind, set] of [
113
+ ['required', manifestData.keys.ff.dataCollectionRequired],
114
+ ['optional', manifestData.keys.ff.dataCollectionOptional]
115
+ ]) if (Array.isArray(dcp?.[kind]))
116
+ for (const value of dcp[kind])
117
+ if (!set.has(value))
118
+ context.report({ node, messageId: 'invalidDataCollectionValue', data: { value, kind }})
119
119
  }
120
120
  } /* eslint-enable security/detect-object-injection */
121
121
  }
@@ -1,7 +1,7 @@
1
- import { domGlobals, serviceWorkerKeepaliveMinMS } from '../data/manifest.js'
1
+ import extension from '../utils/extension.js'
2
+ import nodeUtils from '../utils/node.js'
2
3
 
3
- import { isInsideNestedFunction } from '../utils/node.js'
4
- import { file, readManifest } from '../utils/extension.js'
4
+ import manifestData from '../data/manifest.js'
5
5
 
6
6
  export default {
7
7
  meta: {
@@ -11,7 +11,23 @@ export default {
11
11
  category: 'Possible Errors',
12
12
  recommended: true
13
13
  },
14
- schema: [],
14
+ schema: [{
15
+ type: 'object',
16
+ properties: {
17
+ checkListenerTopLevel: { type: 'boolean',
18
+ description: 'Flag `chrome.*.addListener()` calls not registered at module top level' },
19
+ checkKeepaliveTimer: { type: 'boolean',
20
+ description: 'Flag sub-30s `setTimeout`/`setInterval` calls used as a keepalive workaround' },
21
+ checkDOMglobals: { type: 'boolean',
22
+ description: 'Flag DOM globals (`window`, `document`, `localStorage`) inside the service worker' }
23
+ },
24
+ additionalProperties: false
25
+ }],
26
+ defaultOptions: [{
27
+ checkListenerTopLevel: true,
28
+ checkKeepaliveTimer: true,
29
+ checkDOMglobals: true
30
+ }],
15
31
  messages: {
16
32
  listenerNotTopLevel: '`chrome.*.addListener()` must be registered at module top level'
17
33
  + ' so it attaches on service worker wake-up.',
@@ -21,29 +37,34 @@ export default {
21
37
  },
22
38
 
23
39
  create(context) {
24
- const manifest = readManifest(context) ; if (!manifest || !file.isServiceWorker(context, manifest)) return {}
40
+ const { checkListenerTopLevel, checkKeepaliveTimer, checkDOMglobals } = context.options[0],
41
+ manifest = extension.manifest.read(context)
42
+ if (!manifest || !extension.file.isServiceWorker(context, manifest)) return {}
25
43
  return {
26
44
  CallExpression(node) {
27
- if (node.callee.type == 'MemberExpression'
45
+ if (checkListenerTopLevel
46
+ && node.callee.type == 'MemberExpression'
28
47
  && node.callee.property.name == 'addListener'
29
- && isInsideNestedFunction(node)
48
+ && nodeUtils.isInsideNestedFunction(node)
30
49
  ) context.report({ node, messageId: 'listenerNotTopLevel' })
31
- if (node.callee.type == 'Identifier'
50
+ if (checkKeepaliveTimer
51
+ && node.callee.type == 'Identifier'
32
52
  && ['setTimeout', 'setInterval'].includes(node.callee.name)
33
53
  && node.arguments[1]?.type == 'Literal'
34
54
  && typeof node.arguments[1].value == 'number'
35
- && node.arguments[1].value < serviceWorkerKeepaliveMinMS
36
- && !isInsideNestedFunction(node)
55
+ && node.arguments[1].value < 30000
56
+ && !nodeUtils.isInsideNestedFunction(node)
37
57
  ) context.report({ node, messageId: 'keepaliveTimer', data: { fn: node.callee.name }})
38
58
  },
39
59
  Identifier(node) {
60
+ if (!checkDOMglobals) return
40
61
  if (node.parent?.type == 'MemberExpression' && node.parent.property === node
41
62
  || node.parent?.type == 'Property' && node.parent.key === node && !node.parent.computed
42
- || !domGlobals.has(node.name)
63
+ || !manifestData.globals.dom.has(node.name)
43
64
  ) return
44
65
  for (let scope = context.sourceCode.getScope(node)
45
- ;scope && scope.type != 'global'
46
- ;scope = scope.upper
66
+ ; scope && scope.type != 'global'
67
+ ; scope = scope.upper
47
68
  ) if (scope.set.has(node.name)) return // shadowed by a local binding
48
69
  context.report({ node, messageId: 'domInSW', data: { global: node.name }})
49
70
  }
@@ -1,9 +1,12 @@
1
- import { matchPaths, urlPaths } from '../data/manifest.js'
2
- import { file } from '../utils/extension.js'
1
+ import extension from '../utils/extension.js'
2
+
3
+ import manifestData from '../data/manifest.js'
4
+ import regex from '../data/regex.js'
3
5
 
4
6
  const re = {
5
- absoluteURL: /^[a-z][a-z0-9+.-]*:\/\/[^\s/]+/i, manifestFilename: /manifest\.json$/,
6
- match: /^(\*|https?|file|ftp):\/\/([^/]*)(\/.*)$/, template: /\{[^}]+\}/g
7
+ absoluteURL: /^[a-z][a-z0-9+.-]*:\/\/[^\s/]+/i,
8
+ match: /^(\*|https?|file|ftp):\/\/([^/]*)(\/.*)$/,
9
+ template: /\{[^}]+\}/g
7
10
  }
8
11
 
9
12
  function collectStrings(obj, path = [], out = []) {
@@ -19,6 +22,39 @@ function collectStrings(obj, path = [], out = []) {
19
22
 
20
23
  function isURL(value) { return re.absoluteURL.test(value.replace(re.template, 'x')) }
21
24
 
25
+ function validateManifest(context, manifest, opts) {
26
+ const { checkURLfields, checkMatchPatterns, allowedSchemes, allowAllURLs } = opts,
27
+ loc = { line: 1, column: 1 }
28
+ for (const { path, value } of collectStrings(manifest)) {
29
+ if (checkURLfields && manifestData.paths.url.has(path)) {
30
+ if (!isURL(value))
31
+ context.report({ loc, messageId: 'invalidURL', data: { field: path, value }})
32
+ } else if (checkMatchPatterns && manifestData.paths.match.has(path)) {
33
+ if (value == '<all_urls>') {
34
+ if (!allowAllURLs)
35
+ context.report({ loc, messageId: 'allURLs', data: { field: path }})
36
+ continue
37
+ }
38
+ const match = value.match(re.match)
39
+ if (!match) {
40
+ context.report({ loc, messageId: 'invalidMatchPattern', data: { field: path, value }})
41
+ continue
42
+ }
43
+ const [, scheme, host] = match
44
+ if (scheme != '*' && !allowedSchemes.includes(scheme))
45
+ context.report({ loc, messageId: 'invalidScheme', data: { field: path, value }})
46
+ else if (scheme == 'file' && host != '')
47
+ context.report({ loc, messageId: 'invalidFileHost', data: { field: path, value }})
48
+ else if (path == 'externally_connectable.matches' && host.includes('*'))
49
+ context.report({ loc, messageId: 'invalidExternalHost', data: { field: path, value }})
50
+ else if (host.includes('*') && host != '*' && !host.startsWith('*.'))
51
+ context.report({ loc, messageId: 'invalidHost', data: { field: path, value }})
52
+ else if (path == 'web_accessible_resources.matches' && !value.endsWith('/*'))
53
+ context.report({ loc, messageId: 'invalidWARPath', data: { field: path, value }})
54
+ }
55
+ }
56
+ }
57
+
22
58
  export default {
23
59
  meta: {
24
60
  type: 'problem',
@@ -67,53 +103,25 @@ export default {
67
103
  },
68
104
 
69
105
  create(context) {
70
- if (!re.manifestFilename.test(file.getName(context))) return {}
106
+ const filename = extension.file.getName(context)
107
+ if (!regex.filename.manifest.test(filename) && !regex.filename.script.test(filename)) return {}
71
108
  const opts = context.options[0] || {},
72
109
  checkURLfields = opts.checkURLfields ?? true,
73
110
  checkMatchPatterns = opts.checkMatchPatterns ?? true,
74
111
  allowedSchemes = opts.allowedSchemes ?? ['https', 'file'],
75
112
  allowAllURLs = opts.allowAllURLs ?? false
76
113
  if (!checkURLfields && !checkMatchPatterns) return {}
114
+ const resolved = { checkURLfields, checkMatchPatterns, allowedSchemes, allowAllURLs }
115
+ if (!regex.filename.manifest.test(filename)) {
116
+ const manifest = extension.manifest.read(context) ; if (!manifest) return {}
117
+ validateManifest(context, manifest, resolved)
118
+ return {}
119
+ }
77
120
  return {
78
121
  'Document:exit'() {
79
122
  let manifest
80
123
  try { manifest = JSON.parse(context.sourceCode.text) } catch { return }
81
- for (const { path, value } of collectStrings(manifest)) {
82
- if (checkURLfields && urlPaths.has(path)) {
83
- if (!isURL(value))
84
- context.report({ loc: { line: 1, column: 1 }, messageId: 'invalidURL',
85
- data: { field: path, value }})
86
- } else if (checkMatchPatterns && matchPaths.has(path)) {
87
- if (value == '<all_urls>') {
88
- if (!allowAllURLs)
89
- context.report({ loc: { line: 1, column: 1 }, messageId: 'allURLs',
90
- data: { field: path }})
91
- continue
92
- }
93
- const match = value.match(re.match)
94
- if (!match) {
95
- context.report({ loc: { line: 1, column: 1 }, messageId: 'invalidMatchPattern',
96
- data: { field: path, value }})
97
- continue
98
- }
99
- const [, scheme, host] = match
100
- if (scheme != '*' && !allowedSchemes.includes(scheme))
101
- context.report({ loc: { line: 1, column: 1 }, messageId: 'invalidScheme',
102
- data: { field: path, value }})
103
- else if (scheme == 'file' && host != '')
104
- context.report({ loc: { line: 1, column: 1 }, messageId: 'invalidFileHost',
105
- data: { field: path, value }})
106
- else if (path == 'externally_connectable.matches' && host.includes('*'))
107
- context.report({ loc: { line: 1, column: 1 }, messageId: 'invalidExternalHost',
108
- data: { field: path, value }})
109
- else if (host.includes('*') && host != '*' && !host.startsWith('*.'))
110
- context.report({ loc: { line: 1, column: 1 }, messageId: 'invalidHost',
111
- data: { field: path, value }})
112
- else if (path == 'web_accessible_resources.matches' && !value.endsWith('/*'))
113
- context.report({ loc: { line: 1, column: 1 }, messageId: 'invalidWARPath',
114
- data: { field: path, value }})
115
- }
116
- }
124
+ validateManifest(context, manifest, resolved)
117
125
  }
118
126
  }
119
127
  }
@@ -1,9 +1,9 @@
1
- import { file, readManifest } from '../utils/extension.js'
1
+ import extension from '../utils/extension.js'
2
+ import regex from '../data/regex.js'
2
3
 
3
4
  const re = { /* eslint-disable security/detect-unsafe-regex */
4
5
  chromeParts: /^\d+(?:\.\d+){0,3}$/,
5
6
  date: /^\d{4}\.(?:0?[1-9]|1[0-2])\.(?:0?[1-9]|[12]\d|3[01])(?:\.\d+)?$/,
6
- manifestFilename: /manifest\.json$/,
7
7
  semver: /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/
8
8
  } /* eslint-enable security/detect-unsafe-regex */
9
9
 
@@ -13,7 +13,7 @@ export default {
13
13
  docs: {
14
14
  description: 'Validate the manifest version field format',
15
15
  category: 'Possible Errors',
16
- recommended: false,
16
+ recommended: true,
17
17
  manifest: true
18
18
  },
19
19
  schema: [{
@@ -79,7 +79,7 @@ export default {
79
79
  }
80
80
  }
81
81
  }
82
- if (re.manifestFilename.test(file.getName(context))) // lint it directly
82
+ if (regex.filename.manifest.test(extension.file.getName(context))) // lint it directly
83
83
  return {
84
84
  '*'(node) {
85
85
  const keyNode = node.name || node.key ; if (!keyNode) return
@@ -88,10 +88,6 @@ export default {
88
88
  validate(valueNode, valueNode.value)
89
89
  }
90
90
  }
91
- return {
92
- Program(node) {
93
- validate(node, readManifest(context)?.version)
94
- }
95
- }
91
+ return { Program(node) { validate(node, extension.manifest.read(context)?.version) }}
96
92
  }
97
93
  }
@@ -1,45 +1,64 @@
1
+ /* eslint-disable security/detect-non-literal-fs-filename -- lint-time test fixtures only */
2
+
1
3
  import fs from 'node:fs'
2
4
  import path from 'node:path'
3
5
 
6
+ import permissions from '../data/permissions.js'
7
+ import regex from '../data/regex.js'
8
+
4
9
  const manifestCache = new Map()
5
10
 
6
- export function findRoot(context) {
7
- let dir = path.dirname(file.getName(context))
8
- while (dir) {
9
- // eslint-disable-next-line security/detect-non-literal-fs-filename
10
- if (fs.existsSync(path.join(dir, 'manifest.json'))) return dir
11
- const parent = path.dirname(dir)
12
- if (parent == dir) break // reached fs root
13
- dir = parent
14
- }
15
- return null
16
- }
11
+ const utils = {
12
+ file: {
13
+ getName(context) { return context.filename || context.getFilename() },
17
14
 
18
- export const file = {
19
- getName(context) { return context.filename || context.getFilename() },
15
+ isContentScript(context, manifest) {
16
+ const scripts = (manifest?.content_scripts || []).flatMap(cs => cs.js || [])
17
+ if (!scripts.length) return false
18
+ const root = utils.findRoot(context) ; if (!root) return false
19
+ const filename = path.resolve(utils.file.getName(context))
20
+ return scripts.some(script => filename == path.resolve(root, script))
21
+ },
20
22
 
21
- isContentScript(context, manifest) {
22
- const scripts = (manifest?.content_scripts || []).flatMap(cs => cs.js || []) ; if (!scripts.length) return false
23
- const root = findRoot(context) ; if (!root) return false
24
- const filename = path.resolve(file.getName(context))
25
- return scripts.some(script => filename == path.resolve(root, script))
23
+ isServiceWorker(context, manifest) {
24
+ const worker = manifest?.background?.service_worker ; if (!worker) return false
25
+ const root = utils.findRoot(context) ; if (!root) return false
26
+ return path.resolve(utils.file.getName(context)) == path.resolve(root, worker)
27
+ }
26
28
  },
27
29
 
28
- isServiceWorker(context, manifest) {
29
- const worker = manifest?.background?.service_worker ; if (!worker) return false
30
- const root = findRoot(context) ; if (!root) return false
31
- return path.resolve(file.getName(context)) == path.resolve(root, worker)
30
+ findRoot(context) {
31
+ let dir = path.dirname(utils.file.getName(context))
32
+ while (dir) {
33
+ if (fs.existsSync(path.join(dir, 'manifest.json'))) return dir
34
+ const parent = path.dirname(dir)
35
+ if (parent == dir) break // reached fs root
36
+ dir = parent
37
+ }
38
+ return null
39
+ },
40
+
41
+ manifest: {
42
+ permission: {
43
+ valid: permissions.valid,
44
+ sensitive: permissions.sensitive,
45
+ isValid(name, target = 'mv3') {
46
+ if (permissions.valid.has(name)) return true
47
+ if (target == 'mv3') return false
48
+ return regex.url.test(name) || name.startsWith('<') || name == '*://*/*'
49
+ }
50
+ },
51
+
52
+ read(context) {
53
+ const root = utils.findRoot(context) ; if (!root) return null
54
+ if (manifestCache.has(root)) return manifestCache.get(root)
55
+ let parsed
56
+ try { parsed = JSON.parse(fs.readFileSync(path.join(root, 'manifest.json'), 'utf8')) }
57
+ catch { parsed = null }
58
+ manifestCache.set(root, parsed)
59
+ return parsed
60
+ }
32
61
  }
33
62
  }
34
63
 
35
- export function readManifest(context) {
36
- const root = findRoot(context)
37
- if (!root) return null
38
- if (manifestCache.has(root)) return manifestCache.get(root)
39
- let parsed
40
- try { // eslint-disable-next-line security/detect-non-literal-fs-filename
41
- parsed = JSON.parse(fs.readFileSync(path.join(root, 'manifest.json'), 'utf8'))
42
- } catch { parsed = null }
43
- manifestCache.set(root, parsed)
44
- return parsed
45
- }
64
+ export default utils
@@ -0,0 +1,58 @@
1
+ /* eslint-disable security/detect-non-literal-fs-filename -- lint-time test fixtures only */
2
+
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+
6
+ import i18n from '../data/i18n.js'
7
+
8
+ function templateToPattern(body) {
9
+ const parts = body.split(i18n.re.templateExpr).map(part => part.replace(i18n.re.specialChars, '\\$&'))
10
+ return new RegExp(`^${parts.join('.*')}$`) // eslint-disable-line security/detect-non-literal-regexp
11
+ }
12
+
13
+ export default {
14
+ collectUsedKeys(root, scanExtensions) {
15
+ const used = new Set(),
16
+ patterns = [],
17
+ exts = new Set(scanExtensions),
18
+ stack = [root]
19
+ while (stack.length) {
20
+ const dir = stack.pop() ; let entries
21
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { continue }
22
+ for (const entry of entries) {
23
+ if (entry.name.startsWith('.') || entry.name == 'node_modules') continue
24
+ const fullPath = path.join(dir, entry.name)
25
+ if (entry.isDirectory()) { stack.push(fullPath) ; continue }
26
+ const ext = path.extname(entry.name).slice(1).toLowerCase()
27
+ if (!exts.has(ext)) continue
28
+ let text
29
+ try { text = fs.readFileSync(fullPath, 'utf8') } catch { continue }
30
+ for (const match of text.matchAll(i18n.re.msgPlaceholder))
31
+ if (!this.isPredefined(match[1])) used.add(match[1])
32
+ for (const match of text.matchAll(i18n.re.getMsg.literal))
33
+ used.add(match[1])
34
+ for (const match of text.matchAll(i18n.re.getMsg.template)) {
35
+ const body = match[1]
36
+ if (body.includes('${')) patterns.push(templateToPattern(body))
37
+ else used.add(body)
38
+ }
39
+ }
40
+ }
41
+ return { used, patterns }
42
+ },
43
+
44
+ isPredefined(key) { return key.startsWith('@@') },
45
+
46
+ localeIsValid(code, allowed = new Set()) {
47
+ return typeof code == 'string' && (allowed.has(code) || i18n.locales.supported.has(code)) },
48
+
49
+ readDefaultLocaleMessages(manifest, root) {
50
+ const locale = manifest?.default_locale
51
+ if (typeof locale != 'string') return null
52
+ const filePath = path.join(root, '_locales', locale, 'messages.json')
53
+ let messages
54
+ try { messages = JSON.parse(fs.readFileSync(filePath, 'utf8')) } catch { return null }
55
+ if (!messages || typeof messages != 'object' || Array.isArray(messages)) return null
56
+ return { locale, messages }
57
+ }
58
+ }