eslint-plugin-chrome-extensions 2.2.0 → 2.4.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/README.md +646 -33
- package/docs/LICENSE.md +1 -1
- package/package.json +47 -36
- package/src/data/apis.js +28 -29
- package/src/data/i18n.js +71 -0
- package/src/data/license.js +27 -0
- package/src/data/manifest.js +14 -13
- package/src/data/text-parser.js +11 -0
- package/src/index.js +53 -1
- package/src/rules/manifest-key-order.js +1 -3
- package/src/rules/manifest-spacing.js +12 -4
- package/src/rules/no-content-style-leak.js +5 -2
- package/src/rules/no-invalid-permissions.js +2 -2
- package/src/rules/no-missing-default-locale.js +91 -0
- package/src/rules/no-missing-i18n-keys.js +72 -0
- package/src/rules/no-missing-permissions.js +16 -3
- package/src/rules/no-missing-web-accessible-resources.js +4 -4
- package/src/rules/no-remote-code.js +5 -6
- package/src/rules/no-unrestricted-host-permissions.js +5 -4
- package/src/rules/no-unused-i18n-keys.js +54 -0
- package/src/rules/no-unused-permissions.js +3 -2
- package/src/rules/require-license-file.js +27 -0
- package/src/rules/require-short-name-when-useful.js +71 -0
- package/src/rules/valid-filenames.js +151 -0
- package/src/rules/valid-license-file.js +59 -0
- package/src/rules/valid-mv3-schema.js +2 -1
- package/src/rules/valid-service-worker.js +3 -2
- package/src/rules/valid-url-fields.js +136 -0
- package/src/rules/valid-version-field.js +4 -2
- package/src/utils.js +12 -13
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { getMemberPath, readManifest } from '../utils.js'
|
|
2
|
+
import { findExtensionRoot, isPredefined, readDefaultLocaleMessages, re as i18nRe } from '../data/i18n.js'
|
|
3
|
+
|
|
4
|
+
const re = { file: { js: /\.(cjs|js|jsx|mjs|ts|tsx)$/i, manifest: /manifest\.json$/, text: /\.(css|htm|html)$/i }}
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
meta: {
|
|
8
|
+
type: 'problem',
|
|
9
|
+
docs: {
|
|
10
|
+
description: 'Flag i18n keys referenced in code but missing from `_locales/<default_locale>/messages.json`',
|
|
11
|
+
category: 'Possible Errors',
|
|
12
|
+
recommended: true,
|
|
13
|
+
manifest: true,
|
|
14
|
+
css: true,
|
|
15
|
+
html: true,
|
|
16
|
+
json: true
|
|
17
|
+
},
|
|
18
|
+
schema: [{
|
|
19
|
+
type: 'object',
|
|
20
|
+
properties: {
|
|
21
|
+
ignoreKeys: {
|
|
22
|
+
type: 'array', items: { type: 'string' },
|
|
23
|
+
description: 'Keys to skip (e.g. `["myKey"]`)'
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
additionalProperties: false
|
|
27
|
+
}],
|
|
28
|
+
defaultOptions: [{ ignoreKeys: [] }],
|
|
29
|
+
messages: {
|
|
30
|
+
missingKey: '`{{key}}` is not in `_locales/{{locale}}/messages.json`.'
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
create(context) {
|
|
35
|
+
const filename = context.filename
|
|
36
|
+
if (!filename || filename == '<input>') return {}
|
|
37
|
+
const manifest = readManifest(context) ; if (!manifest?.default_locale) return {}
|
|
38
|
+
const root = findExtensionRoot(filename) ; if (!root) return {}
|
|
39
|
+
const i18n = readDefaultLocaleMessages(manifest, root) ; if (!i18n) return {}
|
|
40
|
+
const opts = context.options[0] || {},
|
|
41
|
+
ignored = new Set(opts.ignoreKeys || []),
|
|
42
|
+
isMissing = key => !isPredefined(key) && !ignored.has(key) && !(key in i18n.messages),
|
|
43
|
+
loc = { line: 1, column: 1 }
|
|
44
|
+
if (re.file.js.test(filename))
|
|
45
|
+
return {
|
|
46
|
+
CallExpression(node) {
|
|
47
|
+
if (node.callee.type != 'MemberExpression') return
|
|
48
|
+
const path = getMemberPath(node.callee)
|
|
49
|
+
if (path != 'chrome.i18n.getMessage' && path != 'browser.i18n.getMessage') return
|
|
50
|
+
const arg = node.arguments[0] ; if (!arg) return
|
|
51
|
+
let key
|
|
52
|
+
if (arg.type == 'Literal' && typeof arg.value == 'string') key = arg.value
|
|
53
|
+
else if (arg.type == 'TemplateLiteral' && arg.expressions.length == 0)
|
|
54
|
+
key = arg.quasis[0].value.cooked
|
|
55
|
+
if (key && isMissing(key))
|
|
56
|
+
context.report({ loc, messageId: 'missingKey', data: { key, locale: i18n.locale }})
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (!re.file.text.test(filename) && !re.file.manifest.test(filename)) return {}
|
|
60
|
+
const seen = new Set()
|
|
61
|
+
const scanPlaceholders = () => {
|
|
62
|
+
for (const match of context.sourceCode.text.matchAll(i18nRe.msgPlaceholder)) {
|
|
63
|
+
const key = match[1]
|
|
64
|
+
if (seen.has(key)) continue
|
|
65
|
+
seen.add(key)
|
|
66
|
+
if (isMissing(key))
|
|
67
|
+
context.report({ loc, messageId: 'missingKey', data: { key, locale: i18n.locale }})
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { 'Document': scanPlaceholders, 'Program': scanPlaceholders }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { findPermission } from '../data/apis.js'
|
|
2
|
-
import {
|
|
2
|
+
import { getMemberPath, readManifest } from '../utils.js'
|
|
3
|
+
|
|
4
|
+
function queriesActiveTab(node) {
|
|
5
|
+
const arg = node.arguments[0] ; if (arg?.type != 'ObjectExpression') return false
|
|
6
|
+
return arg.properties.some(prop => {
|
|
7
|
+
if (prop.type != 'Property') return false
|
|
8
|
+
const key = prop.key?.name ?? prop.key?.value
|
|
9
|
+
return key == 'active' && prop.value?.type == 'Literal' && prop.value.value === true
|
|
10
|
+
})
|
|
11
|
+
}
|
|
3
12
|
|
|
4
13
|
export default {
|
|
5
14
|
meta: {
|
|
@@ -11,7 +20,8 @@ export default {
|
|
|
11
20
|
},
|
|
12
21
|
schema: [],
|
|
13
22
|
messages: {
|
|
14
|
-
missingPermission: '`{{api}}` requires the "{{permission}}" manifest permission.'
|
|
23
|
+
missingPermission: '`{{api}}` requires the "{{permission}}" manifest permission.',
|
|
24
|
+
missingActiveTab: '`{{api}}` requires the "activeTab" manifest permission.'
|
|
15
25
|
}
|
|
16
26
|
},
|
|
17
27
|
|
|
@@ -23,7 +33,10 @@ export default {
|
|
|
23
33
|
if (node.callee.type != 'MemberExpression') return
|
|
24
34
|
const api = getMemberPath(node.callee) ; if (!api) return
|
|
25
35
|
const permission = findPermission(api) ; if (!permission || declared.has(permission)) return
|
|
26
|
-
|
|
36
|
+
const isActiveTabQuery = permission == 'tabs' && queriesActiveTab(node)
|
|
37
|
+
if (isActiveTabQuery && declared.has('activeTab')) return
|
|
38
|
+
const messageId = `missing${ isActiveTabQuery ? 'ActiveTab' : 'Permission' }`
|
|
39
|
+
context.report({ node, messageId, data: { api, permission }})
|
|
27
40
|
}
|
|
28
41
|
}
|
|
29
42
|
}
|
|
@@ -2,7 +2,7 @@ import { getMemberPath, isContentScriptFile, readManifest } from '../utils.js'
|
|
|
2
2
|
|
|
3
3
|
const re = { leadingSlash: /^\//, regexSpecial: /[.+?^${}()|[\]\\]/g }
|
|
4
4
|
|
|
5
|
-
function
|
|
5
|
+
function collectWARpatterns(manifest) {
|
|
6
6
|
const war = manifest.web_accessible_resources
|
|
7
7
|
if (!Array.isArray(war)) return []
|
|
8
8
|
const patterns = []
|
|
@@ -29,14 +29,14 @@ export default {
|
|
|
29
29
|
},
|
|
30
30
|
schema: [],
|
|
31
31
|
messages: {
|
|
32
|
-
|
|
32
|
+
missingWAR: '`{{path}}` is not in `web_accessible_resources`.'
|
|
33
33
|
}
|
|
34
34
|
},
|
|
35
35
|
|
|
36
36
|
create(context) {
|
|
37
37
|
const manifest = readManifest(context) ; if (!manifest) return {}
|
|
38
38
|
if (!isContentScriptFile(context, manifest)) return {}
|
|
39
|
-
const regexes =
|
|
39
|
+
const regexes = collectWARpatterns(manifest).map(patternToRegex)
|
|
40
40
|
return {
|
|
41
41
|
CallExpression(node) {
|
|
42
42
|
if (node.callee.type != 'MemberExpression') return
|
|
@@ -44,7 +44,7 @@ export default {
|
|
|
44
44
|
const arg = node.arguments[0] ; if (arg?.type != 'Literal' || typeof arg.value != 'string') return
|
|
45
45
|
const resourcePath = arg.value.replace(re.leadingSlash, '')
|
|
46
46
|
for (const regex of regexes) if (regex.test(resourcePath)) return
|
|
47
|
-
context.report({ node: arg, messageId: '
|
|
47
|
+
context.report({ node: arg, messageId: 'missingWAR', data: { path: arg.value }})
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { remoteCodePatterns } from '../data/manifest.js'
|
|
2
|
-
|
|
3
2
|
import { getMemberPath } from '../utils.js'
|
|
4
3
|
|
|
5
4
|
const re = { url: /^https?:\/\// }
|
|
@@ -49,16 +48,16 @@ export default {
|
|
|
49
48
|
patterns = new Set(opts.patterns || remoteCodePatterns),
|
|
50
49
|
checkClosures = opts.checkExecuteScriptClosures !== false,
|
|
51
50
|
scriptElems = new Set()
|
|
52
|
-
function isLiteralUrl(node) {
|
|
53
|
-
return node?.type == 'Literal'
|
|
54
|
-
&& typeof node.value == 'string'
|
|
55
|
-
&& re.url.test(node.value)
|
|
56
|
-
}
|
|
57
51
|
function isCreateScript(node) {
|
|
58
52
|
return node?.type == 'CallExpression'
|
|
59
53
|
&& node.callee?.property?.name == 'createElement'
|
|
60
54
|
&& node.arguments[0]?.value == 'script'
|
|
61
55
|
}
|
|
56
|
+
function isLiteralUrl(node) {
|
|
57
|
+
return node?.type == 'Literal'
|
|
58
|
+
&& typeof node.value == 'string'
|
|
59
|
+
&& re.url.test(node.value)
|
|
60
|
+
}
|
|
62
61
|
function isObjectAssign(node) {
|
|
63
62
|
return node?.type == 'CallExpression'
|
|
64
63
|
&& node.callee?.type == 'MemberExpression'
|
|
@@ -25,7 +25,8 @@ export default {
|
|
|
25
25
|
additionalProperties: false
|
|
26
26
|
}],
|
|
27
27
|
messages: {
|
|
28
|
-
unrestrictedHost: 'Host permission "{{pattern}}" grants access to every site.
|
|
28
|
+
unrestrictedHost: 'Host permission "{{pattern}}" grants access to every site. '
|
|
29
|
+
+ 'Narrow to specific origins or allow explicitly instead.'
|
|
29
30
|
}
|
|
30
31
|
},
|
|
31
32
|
|
|
@@ -38,9 +39,9 @@ export default {
|
|
|
38
39
|
const keyName = keyNode.value ?? keyNode.name
|
|
39
40
|
if (!hostKeys.has(keyName)) return
|
|
40
41
|
const arrNode = node.value ; if (arrNode?.type != 'Array') return
|
|
41
|
-
const
|
|
42
|
-
for (const
|
|
43
|
-
const valueNode =
|
|
42
|
+
const elems = arrNode.elements || arrNode.items || []
|
|
43
|
+
for (const elem of elems) {
|
|
44
|
+
const valueNode = elem.value || elem,
|
|
44
45
|
pattern = valueNode?.value
|
|
45
46
|
if (typeof pattern != 'string'
|
|
46
47
|
|| allowed.has(pattern)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readManifest } from '../utils.js'
|
|
2
|
+
import { collectUsedKeys, defaultScanExtensions, findExtensionRoot, readDefaultLocaleMessages } from '../data/i18n.js'
|
|
3
|
+
|
|
4
|
+
const re = { manifestFile: /manifest\.json$/ }
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
meta: {
|
|
8
|
+
type: 'suggestion',
|
|
9
|
+
docs: {
|
|
10
|
+
description: 'Flag i18n keys in `messages.json` that are never referenced',
|
|
11
|
+
category: 'Stylistic Issues',
|
|
12
|
+
recommended: false,
|
|
13
|
+
manifest: true
|
|
14
|
+
},
|
|
15
|
+
schema: [{
|
|
16
|
+
type: 'object',
|
|
17
|
+
properties: {
|
|
18
|
+
ignoreKeys: {
|
|
19
|
+
type: 'array', items: { type: 'string' },
|
|
20
|
+
description: 'Keys to always treat as used (e.g. `["myKey"]`)'
|
|
21
|
+
},
|
|
22
|
+
scanExtensions: {
|
|
23
|
+
type: 'array', items: { type: 'string' },
|
|
24
|
+
description: 'File extensions to scan for references'
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
additionalProperties: false
|
|
28
|
+
}],
|
|
29
|
+
defaultOptions: [{ ignoreKeys: [], scanExtensions: defaultScanExtensions }],
|
|
30
|
+
messages: {
|
|
31
|
+
unusedKey: '`{{key}}` in `_locales/{{locale}}/messages.json` is never referenced.'
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
create(context) {
|
|
36
|
+
const filename = context.filename
|
|
37
|
+
if (!filename || !re.manifestFile.test(filename)) return {}
|
|
38
|
+
const manifest = readManifest(context) ; if (!manifest?.default_locale) return {}
|
|
39
|
+
const root = findExtensionRoot(filename) ; if (!root) return {}
|
|
40
|
+
const i18n = readDefaultLocaleMessages(manifest, root) ; if (!i18n) return {}
|
|
41
|
+
const opts = context.options[0] || {},
|
|
42
|
+
ignored = new Set(opts.ignoreKeys || []),
|
|
43
|
+
scanExtensions = opts.scanExtensions || defaultScanExtensions
|
|
44
|
+
function check() {
|
|
45
|
+
const { used, patterns } = collectUsedKeys(root, scanExtensions)
|
|
46
|
+
for (const key of Object.keys(i18n.messages)) {
|
|
47
|
+
if (ignored.has(key) || used.has(key) || patterns.some(patt => patt.test(key))) continue
|
|
48
|
+
context.report({ loc: { line: 1, column: 1 }, messageId: 'unusedKey',
|
|
49
|
+
data: { key, locale: i18n.locale }})
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { 'Document:exit': check, 'Program:exit': check }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
+
|
|
3
4
|
import { apiPermissionMap, findPermission } from '../data/apis.js'
|
|
4
5
|
import { findExtensionRoot, getFilename, readManifest } from '../utils.js'
|
|
5
6
|
|
|
@@ -19,8 +20,8 @@ function collectUsedAPIs(manifest, root) {
|
|
|
19
20
|
const used = new Set()
|
|
20
21
|
for (const file of files) {
|
|
21
22
|
try {
|
|
22
|
-
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
23
|
-
|
|
23
|
+
const src = // eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
24
|
+
fs.readFileSync(path.resolve(root, file), 'utf8')
|
|
24
25
|
for (const match of src.matchAll(re.chromeAPI)) used.add(match[0])
|
|
25
26
|
} catch {} // eslint-disable-line no-empty
|
|
26
27
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { findLicenseFile } from '../data/license.js'
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
meta: {
|
|
7
|
+
type: 'problem',
|
|
8
|
+
docs: {
|
|
9
|
+
description: 'Requires a license file in the extension',
|
|
10
|
+
recommended: false,
|
|
11
|
+
manifest: true
|
|
12
|
+
},
|
|
13
|
+
schema: [],
|
|
14
|
+
messages: {
|
|
15
|
+
missingFile: 'No license file found. Add one of: LICENSE, LICENSE.md, LICENSE.txt, COPYING '
|
|
16
|
+
+ '(at extension root or in a subdir).'
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
create(context) {
|
|
20
|
+
const { filename } = context,
|
|
21
|
+
check = node => {
|
|
22
|
+
if (!findLicenseFile(path.dirname(filename)))
|
|
23
|
+
context.report({ node, messageId: 'missingFile' })
|
|
24
|
+
}
|
|
25
|
+
return { Program: check, Document: check }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { getFilename } from '../utils.js'
|
|
2
|
+
|
|
3
|
+
const defaults = { maxNameLength: 12 },
|
|
4
|
+
re = { i18nPlaceholder: /^__MSG_.+__$/, manifestFilename: /manifest\.json$/ }
|
|
5
|
+
|
|
6
|
+
function isI18n(value) { return typeof value == 'string' && re.i18nPlaceholder.test(value) }
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
meta: {
|
|
10
|
+
type: 'suggestion',
|
|
11
|
+
docs: {
|
|
12
|
+
description: 'Requires a `short_name` when `name` exceeds 12 chars',
|
|
13
|
+
category: 'Stylistic Issues',
|
|
14
|
+
recommended: false,
|
|
15
|
+
manifest: true
|
|
16
|
+
},
|
|
17
|
+
schema: [{
|
|
18
|
+
type: 'object',
|
|
19
|
+
properties: {
|
|
20
|
+
maxNameLength: {
|
|
21
|
+
type: 'number',
|
|
22
|
+
description: 'Length of `name` above which `short_name` is required (default `12`)'
|
|
23
|
+
},
|
|
24
|
+
always: {
|
|
25
|
+
type: 'boolean',
|
|
26
|
+
description: 'Require `short_name` regardless of `name` length (default `false`)'
|
|
27
|
+
},
|
|
28
|
+
checkRedundant: {
|
|
29
|
+
type: 'boolean',
|
|
30
|
+
description: 'Also flag `short_name` that duplicates `name` (default `false`)'
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
additionalProperties: false
|
|
34
|
+
}],
|
|
35
|
+
defaultOptions: [{
|
|
36
|
+
maxNameLength: defaults.maxNameLength, always: false, checkRedundant: false
|
|
37
|
+
}],
|
|
38
|
+
messages: {
|
|
39
|
+
missingShortName: '`name` exceeds {{max}} chars. Declare `short_name` to control truncation.',
|
|
40
|
+
shortNameTooLong: '`short_name` exceeds the recommended {{max}}-character limit (found {{length}}).',
|
|
41
|
+
redundantShortName: '`short_name` duplicates `name` (Chrome falls back to `name` automatically).'
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
create(context) {
|
|
46
|
+
if (!re.manifestFilename.test(getFilename(context))) return {}
|
|
47
|
+
const opts = context.options[0] || {},
|
|
48
|
+
maxNameLength = opts.maxNameLength ?? defaults.maxNameLength,
|
|
49
|
+
always = opts.always ?? false,
|
|
50
|
+
checkRedundant = opts.checkRedundant ?? false
|
|
51
|
+
let manifest
|
|
52
|
+
try { manifest = JSON.parse(context.sourceCode.text) } catch { return {} }
|
|
53
|
+
if (!manifest || typeof manifest != 'object') return {}
|
|
54
|
+
const name = manifest.name, shortName = manifest.short_name
|
|
55
|
+
if (typeof name != 'string') return {}
|
|
56
|
+
const nameIsI18n = isI18n(name),
|
|
57
|
+
shortNameIsI18n = isI18n(shortName),
|
|
58
|
+
loc = { line: 1, column: 1 }
|
|
59
|
+
if (typeof shortName != 'string') {
|
|
60
|
+
if (always || (!nameIsI18n && name.length > maxNameLength))
|
|
61
|
+
context.report({ loc, messageId: 'missingShortName', data: { max: maxNameLength }})
|
|
62
|
+
} else {
|
|
63
|
+
if (!shortNameIsI18n && shortName.length > maxNameLength)
|
|
64
|
+
context.report({ loc, messageId: 'shortNameTooLong',
|
|
65
|
+
data: { max: maxNameLength, length: shortName.length }})
|
|
66
|
+
if (checkRedundant && shortName == name)
|
|
67
|
+
context.report({ loc, messageId: 'redundantShortName' })
|
|
68
|
+
}
|
|
69
|
+
return {}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/* eslint-disable security/detect-non-literal-fs-filename -- lint-time project inspection only */
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
const re = { invalidChars: /[^\w\-.]/, manifestFilename: /manifest\.json$/, reservedPrefix: /^_/ }
|
|
7
|
+
|
|
8
|
+
const refKeys = [
|
|
9
|
+
['action.default_popup', manifest => manifest.action?.default_popup],
|
|
10
|
+
['background.page', manifest => manifest.background?.page],
|
|
11
|
+
['background.service_worker', manifest => manifest.background?.service_worker],
|
|
12
|
+
['devtools_page', manifest => manifest.devtools_page],
|
|
13
|
+
['options_page', manifest => manifest.options_page],
|
|
14
|
+
['options_ui.page', manifest => manifest.options_ui?.page],
|
|
15
|
+
['side_panel.default_path', manifest => manifest.side_panel?.default_path]
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
function findExtensionRoot(startPath) {
|
|
19
|
+
let dir = path.dirname(startPath)
|
|
20
|
+
while (dir != path.dirname(dir)) {
|
|
21
|
+
if (fs.existsSync(path.join(dir, 'manifest.json'))) return dir
|
|
22
|
+
dir = path.dirname(dir)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function collectRefs(manifest) {
|
|
27
|
+
const refs = []
|
|
28
|
+
for (const [label, get] of refKeys) {
|
|
29
|
+
const val = get(manifest)
|
|
30
|
+
if (typeof val == 'string') refs.push({ label, file: val })
|
|
31
|
+
}
|
|
32
|
+
if (manifest.background?.scripts)
|
|
33
|
+
refs.push(...manifest.background.scripts.map(file => ({ label: 'background.scripts', file })))
|
|
34
|
+
if (manifest.icons)
|
|
35
|
+
refs.push(...Object.values(manifest.icons).map(file => ({ label: 'icons', file })))
|
|
36
|
+
if (manifest.action?.default_icon && typeof manifest.action.default_icon == 'object')
|
|
37
|
+
refs.push(...Object.values(manifest.action.default_icon).map(file => ({ label: 'action.default_icon', file })))
|
|
38
|
+
for (const cs of manifest.content_scripts || []) {
|
|
39
|
+
if (cs.js) refs.push(...cs.js.map(file => ({ label: 'content_scripts.js', file })))
|
|
40
|
+
if (cs.css) refs.push(...cs.css.map(file => ({ label: 'content_scripts.css', file })))
|
|
41
|
+
}
|
|
42
|
+
for (const war of manifest.web_accessible_resources || [])
|
|
43
|
+
if (war.resources) refs.push(...war.resources.map(file => ({ label: 'web_accessible_resources', file })))
|
|
44
|
+
return refs
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveRef(abs) {
|
|
48
|
+
const dir = path.dirname(abs),
|
|
49
|
+
base = path.basename(abs)
|
|
50
|
+
let entries
|
|
51
|
+
try { entries = fs.readdirSync(dir) } catch { return { id: 'fileMissing' }}
|
|
52
|
+
if (entries.includes(base)) return null
|
|
53
|
+
const hit = entries.find(entry => entry.toLowerCase() == base.toLowerCase())
|
|
54
|
+
return hit ? { id: 'caseMismatch', actual: hit } : { id: 'fileMissing' }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default {
|
|
58
|
+
meta: {
|
|
59
|
+
type: 'problem',
|
|
60
|
+
docs: {
|
|
61
|
+
description: 'Enforce valid filenames and paths in Chrome extensions',
|
|
62
|
+
category: 'Possible Errors',
|
|
63
|
+
recommended: true,
|
|
64
|
+
manifest: true,
|
|
65
|
+
html: true,
|
|
66
|
+
json: true
|
|
67
|
+
},
|
|
68
|
+
schema: [{
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {
|
|
71
|
+
noReservedPrefix: {
|
|
72
|
+
type: 'boolean',
|
|
73
|
+
description: 'Disallow filenames/dirs starting w/ `_` (except `_locales`)'
|
|
74
|
+
},
|
|
75
|
+
noSpecialChars: {
|
|
76
|
+
type: 'boolean',
|
|
77
|
+
description: 'Warn on characters outside `a-z`, `0-9`, `-`, `_`, `.`'
|
|
78
|
+
},
|
|
79
|
+
checkFileExists: {
|
|
80
|
+
type: 'boolean',
|
|
81
|
+
description: 'Verify files referenced in `manifest.json` exist on disk'
|
|
82
|
+
},
|
|
83
|
+
enforceCaseSensitivity: {
|
|
84
|
+
type: 'boolean',
|
|
85
|
+
description: 'Verify manifest references match actual file case'
|
|
86
|
+
},
|
|
87
|
+
maxFullPathLength: {
|
|
88
|
+
type: 'number',
|
|
89
|
+
description: 'Max full path length (default 260)'
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
additionalProperties: false
|
|
93
|
+
}],
|
|
94
|
+
defaultOptions: [{
|
|
95
|
+
noReservedPrefix: true, noSpecialChars: true, checkFileExists: true, enforceCaseSensitivity: true,
|
|
96
|
+
maxFullPathLength: 260
|
|
97
|
+
}],
|
|
98
|
+
messages: {
|
|
99
|
+
reservedPrefix: '`{{name}}` starts with reserved prefix `_` (only `_locales` is allowed).',
|
|
100
|
+
specialChars: '`{{name}}` contains characters outside `a-z`, `0-9`, `-`, `_`, and `.`.',
|
|
101
|
+
pathTooLong: 'Full path length ({{length}}) exceeds the {{max}}-character limit (Windows `MAX_PATH`).',
|
|
102
|
+
fileMissing: '`{{label}}` references `{{file}}`, which does not exist.',
|
|
103
|
+
caseMismatch: '`{{label}}` references `{{file}}`, but the actual file is `{{actual}}`.'
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
create(context) {
|
|
108
|
+
const opts = context.options[0] || {},
|
|
109
|
+
noReservedPrefix = opts.noReservedPrefix ?? true,
|
|
110
|
+
noSpecialChars = opts.noSpecialChars ?? true,
|
|
111
|
+
checkFileExists = opts.checkFileExists ?? true,
|
|
112
|
+
enforceCaseSensitivity = opts.enforceCaseSensitivity ?? true,
|
|
113
|
+
maxFullPathLength = opts.maxFullPathLength ?? 260,
|
|
114
|
+
filename = context.filename
|
|
115
|
+
if (!filename || filename == '<input>') return {}
|
|
116
|
+
const basename = path.basename(filename),
|
|
117
|
+
extRoot = findExtensionRoot(filename) || path.dirname(filename),
|
|
118
|
+
segments = path.relative(extRoot, filename).split(path.sep)
|
|
119
|
+
if (noReservedPrefix)
|
|
120
|
+
for (const seg of segments)
|
|
121
|
+
if (re.reservedPrefix.test(seg) && seg != '_locales')
|
|
122
|
+
context.report({ loc: { line: 1, column: 1 }, messageId: 'reservedPrefix', data: { name: seg }})
|
|
123
|
+
if (noSpecialChars)
|
|
124
|
+
for (const seg of segments)
|
|
125
|
+
if (re.invalidChars.test(seg))
|
|
126
|
+
context.report({ loc: { line: 1, column: 1 }, messageId: 'specialChars', data: { name: seg }})
|
|
127
|
+
if (filename.length >= maxFullPathLength)
|
|
128
|
+
context.report({
|
|
129
|
+
loc: { line: 1, column: 1 }, messageId: 'pathTooLong',
|
|
130
|
+
data: { length: filename.length, max: maxFullPathLength }
|
|
131
|
+
})
|
|
132
|
+
if (basename != 'manifest.json' || (!checkFileExists && !enforceCaseSensitivity)) return {}
|
|
133
|
+
const manifestPath = path.join(extRoot, 'manifest.json')
|
|
134
|
+
if (!fs.existsSync(manifestPath)) return {}
|
|
135
|
+
let manifest ; try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) } catch { return {} }
|
|
136
|
+
for (const { label, file } of collectRefs(manifest)) {
|
|
137
|
+
if (file.includes('*')) continue // wildcard refs can't be verified
|
|
138
|
+
const abs = path.resolve(extRoot, file),
|
|
139
|
+
result = resolveRef(abs)
|
|
140
|
+
if (!result
|
|
141
|
+
|| result.id == 'fileMissing' && !checkFileExists
|
|
142
|
+
|| result.id == 'caseMismatch' && !enforceCaseSensitivity
|
|
143
|
+
) continue
|
|
144
|
+
const data = result.id == 'caseMismatch'
|
|
145
|
+
? { label, file, actual: result.actual }
|
|
146
|
+
: { label, file }
|
|
147
|
+
context.report({ loc: { line: 1, column: 1 }, messageId: result.id, data })
|
|
148
|
+
}
|
|
149
|
+
return {}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { re } from '../data/license.js'
|
|
5
|
+
|
|
6
|
+
const require = createRequire(import.meta.url),
|
|
7
|
+
spdxLicenseIDs = require('spdx-license-ids')
|
|
8
|
+
|
|
9
|
+
function toArray(value) { return value == null ? [] : Array.isArray(value) ? value : [value] }
|
|
10
|
+
|
|
11
|
+
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))
|
|
16
|
+
return context.report({ node, messageId: 'invalidSPDX', data: { id }})
|
|
17
|
+
if (allowedLicenses.length && !allowedLicenses.includes(id))
|
|
18
|
+
context.report({ node, messageId: 'disallowedSPDX', data: { id, allowed: allowedLicenses.join(', ') }})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default {
|
|
22
|
+
meta: {
|
|
23
|
+
type: 'problem',
|
|
24
|
+
docs: {
|
|
25
|
+
description: 'Validates SPDX identifiers in license files',
|
|
26
|
+
recommended: false,
|
|
27
|
+
license: true
|
|
28
|
+
},
|
|
29
|
+
defaultOptions: [{ requireSPDX: false, allowedLicenses: [] }],
|
|
30
|
+
schema: [{
|
|
31
|
+
type: 'object',
|
|
32
|
+
properties: {
|
|
33
|
+
requireSPDX: { type: 'boolean', description: 'Require an SPDX-License-Identifier line' },
|
|
34
|
+
allowedLicenses: {
|
|
35
|
+
description: 'SPDX license IDs to restrict to',
|
|
36
|
+
oneOf: [
|
|
37
|
+
{ type: 'string', description: 'Single SPDX license ID' },
|
|
38
|
+
{ type: 'array', description: 'Array of SPDX license IDs', items: { type: 'string' }}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
additionalProperties: false
|
|
43
|
+
}],
|
|
44
|
+
messages: {
|
|
45
|
+
missingSPDX: 'License file missing `SPDX-License-Identifier` line (e.g. `SPDX-License-Identifier: MIT`).',
|
|
46
|
+
invalidSPDX: 'Invalid SPDX identifier `{{id}}` (see https://spdx.org/licenses/).',
|
|
47
|
+
disallowedSPDX: 'License `{{id}}` is not in allowed list: {{allowed}}.'
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
create(context) {
|
|
51
|
+
const opts = context.options[0],
|
|
52
|
+
{ requireSPDX } = opts,
|
|
53
|
+
allowedLicenses = toArray(opts.allowedLicenses),
|
|
54
|
+
{ filename } = context,
|
|
55
|
+
basename = path.basename(filename)
|
|
56
|
+
if (!re.licenseFilename.test(basename)) return {}
|
|
57
|
+
return { Program: node => validateContent(context, node, { requireSPDX, allowedLicenses })}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { getFilename, readManifest } from '../utils.js'
|
|
2
1
|
import { mv2OnlyKeys, forbiddenCSPdirectives, ffMV3bgKeys, ffDataCollectionRequired, ffDataCollectionOptional }
|
|
3
2
|
from '../data/manifest.js'
|
|
4
3
|
|
|
4
|
+
import { getFilename, readManifest } from '../utils.js'
|
|
5
|
+
|
|
5
6
|
const re = { manifestFilename: /manifest\.json$/ }
|
|
6
7
|
|
|
7
8
|
export default {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { domGlobals, serviceWorkerKeepaliveMinMS } from '../data/manifest.js'
|
|
2
|
-
import {
|
|
2
|
+
import { isServiceWorkerFile, isInsideNestedFunction, readManifest } from '../utils.js'
|
|
3
3
|
|
|
4
4
|
export default {
|
|
5
5
|
meta: {
|
|
@@ -11,7 +11,8 @@ export default {
|
|
|
11
11
|
},
|
|
12
12
|
schema: [],
|
|
13
13
|
messages: {
|
|
14
|
-
listenerNotTopLevel: '`chrome.*.addListener()` must be registered at module top level
|
|
14
|
+
listenerNotTopLevel: '`chrome.*.addListener()` must be registered at module top level'
|
|
15
|
+
+ ' so it attaches on service worker wake-up.',
|
|
15
16
|
keepaliveTimer: 'Sub-30s `{{fn}}` will not keep the MV3 service worker alive. Use `chrome.alarms` instead.',
|
|
16
17
|
domInSW: '`{{global}}` is not available in a service worker.'
|
|
17
18
|
}
|