eslint-plugin-chrome-extensions 2.5.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-chrome-extensions",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "ESLint rules for Chrome extensions",
5
5
  "keywords": [
6
6
  "eslint",
@@ -54,10 +54,8 @@
54
54
  "bump:minor": "bash utils/bump.sh minor",
55
55
  "bump:patch": "bash utils/bump.sh patch",
56
56
  "demo": "node demo/run.js",
57
- "lint": "eslint . --cache && yamllint .",
58
- "lint:all": "eslint . && yamllint .",
59
- "lint:fix": "eslint . --fix --cache && yamllint .",
60
- "lint:fix-all": "eslint . --fix && yamllint .",
57
+ "lint": "pre-commit run",
58
+ "lint:all": "pre-commit run --all-files",
61
59
  "test": "node tests/run.js"
62
60
  },
63
61
  "dependencies": {
@@ -83,8 +81,7 @@
83
81
  "eslint-plugin-package-json": "^1.10.1",
84
82
  "eslint-plugin-promise": "^7.3.0",
85
83
  "eslint-plugin-regexp": "^3.3.1",
86
- "eslint-plugin-security": "^4.2.0",
87
- "yamllint-js": "^0.2.4"
84
+ "eslint-plugin-security": "^4.2.0"
88
85
  },
89
86
  "peerDependencies": {
90
87
  "@eslint/css": ">=0.5.0",
@@ -2,9 +2,7 @@
2
2
 
3
3
  export default {
4
4
  bundles: {
5
- 'no-invalid-permissions': ['recommended'], // 2026-10-5 announced future removal
6
- 'require-mv3': ['recommended'], // 2026-10-5 announced future removal
7
- 'valid-mv3-schema': ['recommended'], // 2026-10-5 announced future removal
8
- 'valid-version-field': ['recommended'] // 2026-10-5 announced future removal
5
+ // <rule-id-a>: [<old-bundle>],
6
+ // <rule-id-b>: [<old-bundle>]
9
7
  }
10
8
  }
package/src/data/i18n.js CHANGED
@@ -1,5 +1,13 @@
1
1
  export default {
2
2
  defaultScanExtensions: ['cjs', 'css', 'htm', 'html', 'js', 'json', 'jsx', 'md', 'mjs', 'svg', 'ts', 'tsx', 'txt'],
3
+ locales: {
4
+ supported: new Set([ // https://developer.chrome.com/docs/extensions/reference/api/i18n
5
+ 'af', 'am', 'ar', 'bg', 'bn', 'ca', 'cs', 'da', 'de', 'el', 'en', 'en_AU', 'en_GB', 'en_IN', 'en_US', 'es',
6
+ 'es_419', 'es_MX', 'es_US', 'et', 'fa', 'fi', 'fil', 'fr', 'fr_CA', 'fr_FR', 'gu', 'he', 'hi', 'hr', 'hu',
7
+ 'id', 'it', 'ja', 'kn', 'ko', 'lt', 'lv', 'ml', 'mr', 'ms', 'nl', 'no', 'pl', 'pt_BR', 'pt_PT', 'ro', 'ru',
8
+ 'sk', 'sl', 'sr', 'sv', 'sw', 'ta', 'te', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW'
9
+ ])
10
+ },
3
11
  re: {
4
12
  getMsg: {
5
13
  literal: /\b(?:browser|chrome)\.i18n\.getMessage\(\s*['"]([^'"]+)['"]/g,
@@ -1,5 +1,3 @@
1
- import regex from './regex.js'
2
-
3
1
  export default {
4
2
  sensitive: new Set([
5
3
  'accessibilityFeatures.modify', 'accessibilityFeatures.read', 'bookmarks', 'browsingData', 'clipboardRead',
@@ -22,10 +20,5 @@ export default {
22
20
  'sessions', 'sidePanel', 'storage', 'system.cpu', 'system.display', 'system.memory', 'system.storage',
23
21
  'tabCapture', 'tabGroups', 'tabs', 'topSites', 'tts', 'ttsEngine', 'unlimitedStorage', 'userScripts',
24
22
  'vpnProvider', 'wallpaper', 'webAuthenticationProxy', 'webNavigation', 'webRequest', 'webRequestBlocking'
25
- ]),
26
- isValid(name, target = 'mv3') {
27
- if (this.valid.has(name)) return true
28
- if (target == 'mv3') return false
29
- return regex.url.test(name) || name.startsWith('<') || name == '*://*/*'
30
- }
23
+ ])
31
24
  }
package/src/data/regex.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  filename: {
3
- cssHTML: /\.(css|htm|html)$/i,
3
+ css: /\.css$/i,
4
4
  license: // eslint-disable-next-line security/detect-unsafe-regex
5
5
  /^(?:[\w.-]*-)?(?:LICEN[SC]E|COPYING)(?:[-.][\w.-]+)?$/i,
6
6
  manifest: /manifest\.json$/,
package/src/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import deprecations from './data/deprecations.js'
2
1
  import init from './utils/init.js'
2
+ import deprecations from './data/deprecations.js'
3
3
 
4
4
  const plugin = init.plugin()
5
5
 
@@ -0,0 +1,89 @@
1
+ /* eslint-disable security/detect-non-literal-fs-filename -- lint-time project inspection only */
2
+
3
+ import extension from '../utils/extension.js'
4
+ import fs from 'node:fs'
5
+ import i18nUtils from '../utils/i18n.js'
6
+ import path from 'node:path'
7
+
8
+ import regex from '../data/regex.js'
9
+
10
+ export default {
11
+ meta: {
12
+ type: 'problem',
13
+ docs: {
14
+ description: 'Disallow invalid Chrome locale codes in `_locales/` folders and `default_locale`',
15
+ category: 'Possible Errors',
16
+ recommended: true,
17
+ manifest: true
18
+ },
19
+ schema: [{
20
+ type: 'object',
21
+ properties: {
22
+ allowedLocales: {
23
+ oneOf: [
24
+ { type: 'string', description: 'Single locale code to allow' },
25
+ {
26
+ type: 'array',
27
+ items: { type: 'string', description: 'Locale code to allow' },
28
+ description: 'List of locale codes to allow'
29
+ }
30
+ ],
31
+ description: 'Additional locale codes to allow (e.g. for Chromium forks)'
32
+ }
33
+ },
34
+ additionalProperties: false
35
+ }],
36
+ defaultOptions: [{ allowedLocales: [] }],
37
+ messages: {
38
+ invalidLocale: '`{{value}}` is not a Chrome-supported locale code.'
39
+ }
40
+ },
41
+
42
+ create(context) {
43
+ const filename = extension.file.getName(context),
44
+ isManifest = regex.filename.manifest.test(filename),
45
+ isScript = regex.filename.script.test(filename)
46
+ if (!isManifest && !isScript) return {}
47
+ const opts = context.options[0] || {},
48
+ allowed = new Set(Array.isArray(opts.allowedLocales) ? opts.allowedLocales
49
+ : opts.allowedLocales ? [opts.allowedLocales] : []),
50
+ invalidFolders = [],
51
+ root = extension.findRoot(context)
52
+ if (root) {
53
+ const localesDir = path.join(root, '_locales')
54
+ try {
55
+ for (const entry of fs.readdirSync(localesDir, { withFileTypes: true }))
56
+ if (entry.isDirectory() && isInvalid(entry.name)) invalidFolders.push(entry.name)
57
+ invalidFolders.sort()
58
+ } catch {} // eslint-disable-line no-empty
59
+ }
60
+ const loc = { line: 1, column: 1 }
61
+ if (!isManifest) {
62
+ const manifest = extension.manifest.read(context)
63
+ if (manifest) {
64
+ const defaultLocale = manifest.default_locale
65
+ if (isInvalid(defaultLocale))
66
+ context.report({ loc, messageId: 'invalidLocale', data: { value: defaultLocale }})
67
+ }
68
+ for (const name of invalidFolders)
69
+ context.report({ loc, messageId: 'invalidLocale', data: { value: name }})
70
+ return {}
71
+ }
72
+ function isInvalid(locale) {
73
+ return typeof locale == 'string' && !i18nUtils.localeIsValid(locale, allowed) }
74
+ return {
75
+ '*'(node) {
76
+ const keyNode = node.name || node.key ; if (!keyNode) return
77
+ const keyName = keyNode.value ?? keyNode.name ; if (keyName != 'default_locale') return
78
+ const valueNode = node.value ; if (valueNode?.type != 'String') return
79
+ if (isInvalid(valueNode.value))
80
+ context.report({ node: valueNode, messageId: 'invalidLocale',
81
+ data: { value: valueNode.value }})
82
+ },
83
+ 'Document:exit'() {
84
+ for (const name of invalidFolders)
85
+ context.report({ loc, messageId: 'invalidLocale', data: { value: name }})
86
+ }
87
+ }
88
+ }
89
+ }
@@ -1,6 +1,4 @@
1
1
  import extension from '../utils/extension.js'
2
-
3
- import permissions from '../data/permissions.js'
4
2
  import regex from '../data/regex.js'
5
3
 
6
4
  export default {
@@ -9,7 +7,7 @@ export default {
9
7
  docs: {
10
8
  description: 'Disallow invalid or unsupported manifest permission names',
11
9
  category: 'Possible Errors',
12
- recommended: false,
10
+ recommended: true,
13
11
  manifest: true
14
12
  },
15
13
  defaultOptions: [{ target: 'mv3', allowedPermissions: [] }],
@@ -46,7 +44,7 @@ export default {
46
44
  : opts.allowedPermissions ? [opts.allowedPermissions] : [])
47
45
  function checkPermission(node, permission) {
48
46
  if (typeof permission != 'string') return
49
- if (allowed.has(permission) || permissions.isValid(permission, target)) return
47
+ if (allowed.has(permission) || extension.manifest.permission.isValid(permission, target)) return
50
48
  context.report({ node, messageId: 'invalidPermission', data: { permission, target }})
51
49
  }
52
50
  if (regex.filename.manifest.test(extension.file.getName(context))) // lint it directly
@@ -67,7 +65,7 @@ export default {
67
65
  return {
68
66
  'Program:exit'() {
69
67
  for (const permission of allPermissions) {
70
- if (allowed.has(permission) || permissions.isValid(permission, target)) continue
68
+ if (allowed.has(permission) || extension.manifest.permission.isValid(permission, target)) continue
71
69
  context.report({ loc: { line: 1, column: 0 },
72
70
  messageId: 'invalidPermission', data: { permission, target }})
73
71
  }
@@ -7,14 +7,7 @@ import path from 'node:path'
7
7
 
8
8
  import regex from '../data/regex.js'
9
9
 
10
- function readMessages(filePath) {
11
- try {
12
- const data = JSON.parse(fs.readFileSync(filePath, 'utf8'))
13
- return (data && typeof data == 'object' && !Array.isArray(data)) ? data : null
14
- } catch { return null }
15
- }
16
-
17
- function readLocaleDirs(localesDir) {
10
+ function readLocaleDirs(localesDir) {
18
11
  try {
19
12
  return fs.readdirSync(localesDir, { withFileTypes: true })
20
13
  .filter(entry => entry.isDirectory())
@@ -22,13 +15,20 @@ function readLocaleDirs(localesDir) {
22
15
  } catch { return [] }
23
16
  }
24
17
 
18
+ function readMessages(filePath) {
19
+ try {
20
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'))
21
+ return (data && typeof data == 'object' && !Array.isArray(data)) ? data : null
22
+ } catch { return null }
23
+ }
24
+
25
25
  export default {
26
26
  meta: {
27
27
  type: 'problem',
28
28
  docs: {
29
29
  description: 'Ensure `default_locale` is valid and all `_locales` keys exist in it',
30
30
  category: 'Possible Errors',
31
- recommended: false,
31
+ recommended: true,
32
32
  manifest: true
33
33
  },
34
34
  schema: [{
@@ -36,23 +36,29 @@ export default {
36
36
  properties: {
37
37
  ignoreKeys: {
38
38
  type: 'array', items: { type: 'string' },
39
- description: 'Keys to skip when checking the default locale'
39
+ description: 'Keys to skip when checking locales'
40
+ },
41
+ checkAllLocales: {
42
+ type: 'boolean',
43
+ description: 'Also flag keys missing from any locale, not just the default'
40
44
  }
41
45
  },
42
46
  additionalProperties: false
43
47
  }],
44
- defaultOptions: [{ ignoreKeys: [] }],
48
+ defaultOptions: [{ ignoreKeys: [], checkAllLocales: false }],
45
49
  messages: {
46
50
  missingDefaultLocale: '`_locales/` exists but `default_locale` is not declared.',
47
51
  unexpectedDefaultLocale: '`default_locale` is declared but no `_locales/` directory exists.',
48
52
  missingDefaultLocaleFile: '`_locales/{{locale}}/messages.json` does not exist.',
49
53
  missingKeyInDefault: '`{{key}}` exists in `_locales/{{locale}}/messages.json`, '
50
- + 'but not in default locale `{{defaultLocale}}`.'
54
+ + 'but not in default locale `{{defaultLocale}}`.',
55
+ missingKeyInLocale: '`{{key}}` is missing from `_locales/{{locale}}/messages.json`.'
51
56
  }
52
57
  },
53
58
 
54
59
  create(context) {
55
- const filename = context.filename ; if (!filename || !regex.filename.manifest.test(filename)) return {}
60
+ const filename = extension.file.getName(context) ; if (!filename) return {}
61
+ if (!regex.filename.manifest.test(filename) && !regex.filename.script.test(filename)) return {}
56
62
  const manifest = extension.manifest.read(context) ; if (!manifest) return {}
57
63
  const root = extension.findRoot(context) ; if (!root) return {}
58
64
  const localesDir = path.join(root, '_locales'),
@@ -60,6 +66,7 @@ export default {
60
66
  defaultLocale = manifest.default_locale,
61
67
  opts = context.options[0] || {},
62
68
  ignored = new Set(opts.ignoreKeys || []),
69
+ checkAllLocales = opts.checkAllLocales ?? false,
63
70
  loc = { line: 1, column: 1 }
64
71
  if (hasLocales && typeof defaultLocale != 'string') {
65
72
  context.report({ loc, messageId: 'missingDefaultLocale' })
@@ -75,7 +82,25 @@ export default {
75
82
  context.report({ loc, messageId: 'missingDefaultLocaleFile', data: { locale: defaultLocale }})
76
83
  return {}
77
84
  }
78
- for (const locale of readLocaleDirs(localesDir)) {
85
+ const localeDirs = readLocaleDirs(localesDir)
86
+ if (checkAllLocales) {
87
+ const allMsgs = new Map([[defaultLocale, defaultMsgs]])
88
+ for (const locale of localeDirs) {
89
+ if (locale == defaultLocale) continue
90
+ const msgs = readMessages(path.join(localesDir, locale, 'messages.json'))
91
+ if (msgs) allMsgs.set(locale, msgs)
92
+ }
93
+ const union = new Set()
94
+ for (const msgs of allMsgs.values())
95
+ for (const key of Object.keys(msgs))
96
+ if (!i18n.isPredefined(key) && !ignored.has(key)) union.add(key)
97
+ for (const [locale, msgs] of allMsgs)
98
+ for (const key of union)
99
+ if (!(key in msgs))
100
+ context.report({ loc, messageId: 'missingKeyInLocale', data: { key, locale }})
101
+ return {}
102
+ }
103
+ for (const locale of localeDirs) {
79
104
  if (locale == defaultLocale) continue
80
105
  const msgs = readMessages(path.join(localesDir, locale, 'messages.json')) ; if (!msgs) continue
81
106
  for (const key of Object.keys(msgs)) {
@@ -13,9 +13,7 @@ export default {
13
13
  category: 'Possible Errors',
14
14
  recommended: true,
15
15
  manifest: true,
16
- css: true,
17
- html: true,
18
- json: true
16
+ css: true
19
17
  },
20
18
  schema: [{
21
19
  type: 'object',
@@ -57,7 +55,7 @@ export default {
57
55
  context.report({ loc, messageId: 'missingKey', data: { key, locale: i18n.locale }})
58
56
  }
59
57
  }
60
- if (!regex.filename.cssHTML.test(filename) && !regex.filename.manifest.test(filename))
58
+ if (!regex.filename.css.test(filename) && !regex.filename.manifest.test(filename))
61
59
  return {}
62
60
  const seenKeys = new Set()
63
61
  function scanPlaceholders() {
@@ -69,6 +67,6 @@ export default {
69
67
  context.report({ loc, messageId: 'missingKey', data: { key, locale: i18n.locale }})
70
68
  }
71
69
  }
72
- return { 'Document': scanPlaceholders, 'Program': scanPlaceholders }
70
+ return { 'Document': scanPlaceholders, 'Program': scanPlaceholders, 'StyleSheet': scanPlaceholders }
73
71
  }
74
72
  }
@@ -7,7 +7,7 @@ export default {
7
7
  meta: {
8
8
  type: 'problem',
9
9
  docs: {
10
- description: 'Flag sensitive permissions that warrant security review',
10
+ description: 'Flag sensitive permissions',
11
11
  category: 'Possible Errors',
12
12
  recommended: true,
13
13
  security: true,
@@ -10,7 +10,8 @@ export default {
10
10
  docs: {
11
11
  description: 'Disallow broad host permissions that grant access to every site',
12
12
  category: 'Possible Errors',
13
- recommended: false,
13
+ recommended: true,
14
+ security: true,
14
15
  manifest: true
15
16
  },
16
17
  defaultOptions: [{ allowedPatterns: [] }],
@@ -32,23 +33,37 @@ export default {
32
33
  },
33
34
 
34
35
  create(context) {
35
- if (!regex.filename.manifest.test(extension.file.getName(context))) return {}
36
36
  const allowedPatterns = new Set(context.options[0]?.allowedPatterns || [])
37
+ function isBroad(pattern) {
38
+ return typeof pattern == 'string'
39
+ && !allowedPatterns.has(pattern)
40
+ && re.broadHost.test(pattern)
41
+ }
42
+ if (regex.filename.manifest.test(extension.file.getName(context))) // lint it directly
43
+ return {
44
+ '*'(node) {
45
+ const keyNode = node.name || node.key ; if (!keyNode) return
46
+ const keyName = keyNode.value ?? keyNode.name ; if (!hostKeys.has(keyName)) return
47
+ const arrNode = node.value ; if (arrNode?.type != 'Array') return
48
+ const nodeElems = arrNode.elements || arrNode.items || []
49
+ for (const elem of nodeElems) {
50
+ const valueNode = elem.value || elem,
51
+ pattern = valueNode?.value
52
+ if (!isBroad(pattern)) continue
53
+ context.report({ node: valueNode, messageId: 'unrestrictedHost', data: { pattern }})
54
+ }
55
+ }
56
+ }
57
+ const manifest = extension.manifest.read(context) ; if (!manifest) return {}
37
58
  return {
38
- '*'(node) {
39
- const keyNode = node.name || node.key ; if (!keyNode) return
40
- const keyName = keyNode.value ?? keyNode.name
41
- if (!hostKeys.has(keyName)) return
42
- const arrNode = node.value ; if (arrNode?.type != 'Array') return
43
- const nodeElems = arrNode.elements || arrNode.items || []
44
- for (const elem of nodeElems) {
45
- const valueNode = elem.value || elem,
46
- pattern = valueNode?.value
47
- if (typeof pattern != 'string'
48
- || allowedPatterns.has(pattern)
49
- || !re.broadHost.test(pattern)
50
- ) continue
51
- context.report({ node: valueNode, messageId: 'unrestrictedHost', data: { pattern }})
59
+ 'Program:exit'() {
60
+ for (const key of hostKeys) {
61
+ const patterns = manifest[key] // eslint-disable-line security/detect-object-injection
62
+ if (!Array.isArray(patterns)) continue
63
+ for (const pattern of patterns)
64
+ if (isBroad(pattern))
65
+ context.report({ loc: { line: 1, column: 1 },
66
+ messageId: 'unrestrictedHost', data: { pattern }})
52
67
  }
53
68
  }
54
69
  }
@@ -33,7 +33,7 @@ export default {
33
33
  docs: {
34
34
  description: 'Flag manifest permissions whose APIs are never used',
35
35
  category: 'Possible Errors',
36
- recommended: false,
36
+ recommended: true,
37
37
  manifest: true
38
38
  },
39
39
  schema: [],
@@ -43,11 +43,11 @@ export default {
43
43
  },
44
44
 
45
45
  create(context) {
46
- if (!regex.filename.manifest.test(extension.file.getName(context))) return {}
46
+ const filename = extension.file.getName(context)
47
+ if (!regex.filename.manifest.test(filename) && !regex.filename.script.test(filename)) return {}
47
48
  const manifest = extension.manifest.read(context) ; if (!manifest) return {}
48
49
  const root = extension.findRoot(context) ; if (!root) return {}
49
- const usedAPIs = collectUsedAPIs(manifest, root),
50
- permissionNodes = new Map()
50
+ const usedAPIs = collectUsedAPIs(manifest, root)
51
51
  function isUsed(permission) {
52
52
  let knownPerm = false
53
53
  for (const [, perm] of apis.permissionMap)
@@ -57,6 +57,15 @@ export default {
57
57
  if (apis.findPermission(api) == permission) return true
58
58
  return false
59
59
  }
60
+ if (!regex.filename.manifest.test(filename)) {
61
+ const loc = { line: 1, column: 1 },
62
+ declared = [...(manifest.permissions || []), ...(manifest.optional_permissions || [])]
63
+ for (const permission of declared)
64
+ if (typeof permission == 'string' && !isUsed(permission))
65
+ context.report({ loc, messageId: 'unusedPermission', data: { permission }})
66
+ return {}
67
+ }
68
+ const permissionNodes = new Map()
60
69
  return {
61
70
  '*'(node) {
62
71
  const keyNode = node.name || node.key ; if (!keyNode) return
@@ -7,7 +7,7 @@ export default {
7
7
  docs: {
8
8
  description: 'Require manifest_version 3',
9
9
  category: 'Possible Errors',
10
- recommended: false,
10
+ recommended: true,
11
11
  manifest: true
12
12
  },
13
13
  schema: [],
@@ -7,13 +7,32 @@ import regex from '../data/regex.js'
7
7
  const re = { svg: /\.svg$/i },
8
8
  sizes = { recommended: ['16', '32', '48', '128'], required: '128' }
9
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
+
10
29
  export default {
11
30
  meta: {
12
31
  type: 'problem',
13
32
  docs: {
14
33
  description: 'Validate manifest icons: sizes, existing files, no SVG',
15
34
  category: 'Possible Errors',
16
- recommended: false,
35
+ recommended: true,
17
36
  manifest: true
18
37
  },
19
38
  defaultOptions: [{
@@ -48,12 +67,21 @@ export default {
48
67
  },
49
68
 
50
69
  create(context) {
51
- if (!regex.filename.manifest.test(extension.file.getName(context))) return {}
70
+ const filename = extension.file.getName(context)
71
+ if (!regex.filename.manifest.test(filename) && !regex.filename.script.test(filename)) return {}
52
72
  const opts = context.options[0] || {},
53
73
  requireRecommended = opts.requireRecommendedSizes !== false,
54
74
  checkFiles = opts.checkIconExists !== false,
55
75
  checkSVG = opts.noSVG !== false,
56
- root = checkFiles ? extension.findRoot(context) : null
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
+ }
57
85
  return {
58
86
  '*'(node) {
59
87
  const keyNode = node.name || node.key ; if (!keyNode) return
@@ -9,7 +9,7 @@ export default {
9
9
  docs: {
10
10
  description: 'Validate manifest against the MV3 schema',
11
11
  category: 'Possible Errors',
12
- recommended: false,
12
+ recommended: true,
13
13
  manifest: true
14
14
  },
15
15
  schema: [],
@@ -22,6 +22,39 @@ function collectStrings(obj, path = [], out = []) {
22
22
 
23
23
  function isURL(value) { return re.absoluteURL.test(value.replace(re.template, 'x')) }
24
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
+
25
58
  export default {
26
59
  meta: {
27
60
  type: 'problem',
@@ -70,53 +103,25 @@ export default {
70
103
  },
71
104
 
72
105
  create(context) {
73
- if (!regex.filename.manifest.test(extension.file.getName(context))) return {}
106
+ const filename = extension.file.getName(context)
107
+ if (!regex.filename.manifest.test(filename) && !regex.filename.script.test(filename)) return {}
74
108
  const opts = context.options[0] || {},
75
109
  checkURLfields = opts.checkURLfields ?? true,
76
110
  checkMatchPatterns = opts.checkMatchPatterns ?? true,
77
111
  allowedSchemes = opts.allowedSchemes ?? ['https', 'file'],
78
112
  allowAllURLs = opts.allowAllURLs ?? false
79
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
+ }
80
120
  return {
81
121
  'Document:exit'() {
82
122
  let manifest
83
123
  try { manifest = JSON.parse(context.sourceCode.text) } catch { return }
84
- for (const { path, value } of collectStrings(manifest)) {
85
- if (checkURLfields && manifestData.paths.url.has(path)) {
86
- if (!isURL(value))
87
- context.report({ loc: { line: 1, column: 1 }, messageId: 'invalidURL',
88
- data: { field: path, value }})
89
- } else if (checkMatchPatterns && manifestData.paths.match.has(path)) {
90
- if (value == '<all_urls>') {
91
- if (!allowAllURLs)
92
- context.report({ loc: { line: 1, column: 1 }, messageId: 'allURLs',
93
- data: { field: path }})
94
- continue
95
- }
96
- const match = value.match(re.match)
97
- if (!match) {
98
- context.report({ loc: { line: 1, column: 1 }, messageId: 'invalidMatchPattern',
99
- data: { field: path, value }})
100
- continue
101
- }
102
- const [, scheme, host] = match
103
- if (scheme != '*' && !allowedSchemes.includes(scheme))
104
- context.report({ loc: { line: 1, column: 1 }, messageId: 'invalidScheme',
105
- data: { field: path, value }})
106
- else if (scheme == 'file' && host != '')
107
- context.report({ loc: { line: 1, column: 1 }, messageId: 'invalidFileHost',
108
- data: { field: path, value }})
109
- else if (path == 'externally_connectable.matches' && host.includes('*'))
110
- context.report({ loc: { line: 1, column: 1 }, messageId: 'invalidExternalHost',
111
- data: { field: path, value }})
112
- else if (host.includes('*') && host != '*' && !host.startsWith('*.'))
113
- context.report({ loc: { line: 1, column: 1 }, messageId: 'invalidHost',
114
- data: { field: path, value }})
115
- else if (path == 'web_accessible_resources.matches' && !value.endsWith('/*'))
116
- context.report({ loc: { line: 1, column: 1 }, messageId: 'invalidWARPath',
117
- data: { field: path, value }})
118
- }
119
- }
124
+ validateManifest(context, manifest, resolved)
120
125
  }
121
126
  }
122
127
  }