eslint-plugin-chrome-extensions 2.4.1 → 2.5.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 +101 -23
- package/package.json +4 -3
- package/src/data/apis.js +166 -115
- package/src/data/deprecations.js +7 -7
- package/src/data/i18n.js +11 -69
- package/src/data/manifest.js +61 -57
- package/src/data/permissions.js +29 -29
- package/src/data/regex.js +10 -0
- package/src/index.js +7 -123
- package/src/rules/manifest-key-order.js +8 -8
- package/src/rules/manifest-spacing.js +5 -5
- package/src/rules/no-content-style-leak.js +6 -4
- package/src/rules/no-deprecated-apis.js +4 -4
- package/src/rules/no-duplicate-manifest-keys.js +3 -4
- package/src/rules/no-insecure-messaging.js +4 -4
- package/src/rules/no-invalid-permissions.js +10 -11
- package/src/rules/no-missing-default-locale.js +7 -9
- package/src/rules/no-missing-i18n-keys.js +22 -20
- package/src/rules/no-missing-permissions.js +7 -5
- package/src/rules/no-missing-web-accessible-resources.js +7 -5
- package/src/rules/no-remote-code.js +12 -12
- package/src/rules/no-sensitive-permissions.js +75 -0
- package/src/rules/no-unrestricted-host-permissions.js +8 -7
- package/src/rules/no-unused-i18n-keys.js +11 -10
- package/src/rules/no-unused-permissions.js +22 -23
- package/src/rules/require-license-file.js +9 -8
- package/src/rules/require-mv3.js +4 -5
- package/src/rules/require-short-name-when-useful.js +4 -3
- package/src/rules/valid-filenames.js +3 -10
- package/src/rules/valid-license-file.js +18 -11
- package/src/rules/valid-manifest-icons.js +6 -5
- package/src/rules/valid-mv3-schema.js +21 -21
- package/src/rules/valid-service-worker.js +36 -13
- package/src/rules/valid-url-fields.js +10 -7
- package/src/rules/valid-version-field.js +4 -8
- package/src/utils/extension.js +51 -0
- package/src/utils/i18n.js +55 -0
- package/src/utils/init.js +105 -0
- package/src/utils/license.js +55 -0
- package/src/utils/node.js +23 -0
- package/src/utils/pkg.js +21 -0
- package/src/utils/rules.js +7 -0
- package/src/utils/text.js +13 -0
- package/src/data/license.js +0 -27
- package/src/data/text-parser.js +0 -11
- package/src/utils.js +0 -95
|
@@ -1,9 +1,10 @@
|
|
|
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
|
|
5
|
+
import regex from '../data/regex.js'
|
|
5
6
|
|
|
6
|
-
const re = {
|
|
7
|
+
const re = { svg: /\.svg$/i },
|
|
7
8
|
sizes = { recommended: ['16', '32', '48', '128'], required: '128' }
|
|
8
9
|
|
|
9
10
|
export default {
|
|
@@ -47,12 +48,12 @@ export default {
|
|
|
47
48
|
},
|
|
48
49
|
|
|
49
50
|
create(context) {
|
|
50
|
-
if (!
|
|
51
|
+
if (!regex.filename.manifest.test(extension.file.getName(context))) return {}
|
|
51
52
|
const opts = context.options[0] || {},
|
|
52
53
|
requireRecommended = opts.requireRecommendedSizes !== false,
|
|
53
54
|
checkFiles = opts.checkIconExists !== false,
|
|
54
|
-
checkSVG = opts.noSVG !== false
|
|
55
|
-
|
|
55
|
+
checkSVG = opts.noSVG !== false,
|
|
56
|
+
root = checkFiles ? extension.findRoot(context) : null
|
|
56
57
|
return {
|
|
57
58
|
'*'(node) {
|
|
58
59
|
const keyNode = node.name || node.key ; if (!keyNode) return
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
from '../data/manifest.js'
|
|
1
|
+
import extension from '../utils/extension.js'
|
|
3
2
|
|
|
4
|
-
import
|
|
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: {
|
|
@@ -24,7 +22,7 @@ export default {
|
|
|
24
22
|
},
|
|
25
23
|
|
|
26
24
|
create(context) {
|
|
27
|
-
if (
|
|
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 &&
|
|
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 &&
|
|
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
|
|
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
|
|
71
|
-
|
|
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 =
|
|
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 (
|
|
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
|
|
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
|
|
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 [
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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,5 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import extension from '../utils/extension.js'
|
|
2
|
+
import nodeUtils from '../utils/node.js'
|
|
3
|
+
|
|
4
|
+
import manifestData from '../data/manifest.js'
|
|
3
5
|
|
|
4
6
|
export default {
|
|
5
7
|
meta: {
|
|
@@ -9,7 +11,23 @@ export default {
|
|
|
9
11
|
category: 'Possible Errors',
|
|
10
12
|
recommended: true
|
|
11
13
|
},
|
|
12
|
-
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
|
+
}],
|
|
13
31
|
messages: {
|
|
14
32
|
listenerNotTopLevel: '`chrome.*.addListener()` must be registered at module top level'
|
|
15
33
|
+ ' so it attaches on service worker wake-up.',
|
|
@@ -19,29 +37,34 @@ export default {
|
|
|
19
37
|
},
|
|
20
38
|
|
|
21
39
|
create(context) {
|
|
22
|
-
const
|
|
40
|
+
const { checkListenerTopLevel, checkKeepaliveTimer, checkDOMglobals } = context.options[0],
|
|
41
|
+
manifest = extension.manifest.read(context)
|
|
42
|
+
if (!manifest || !extension.file.isServiceWorker(context, manifest)) return {}
|
|
23
43
|
return {
|
|
24
44
|
CallExpression(node) {
|
|
25
|
-
if (
|
|
45
|
+
if (checkListenerTopLevel
|
|
46
|
+
&& node.callee.type == 'MemberExpression'
|
|
26
47
|
&& node.callee.property.name == 'addListener'
|
|
27
|
-
&& isInsideNestedFunction(node)
|
|
48
|
+
&& nodeUtils.isInsideNestedFunction(node)
|
|
28
49
|
) context.report({ node, messageId: 'listenerNotTopLevel' })
|
|
29
|
-
if (
|
|
50
|
+
if (checkKeepaliveTimer
|
|
51
|
+
&& node.callee.type == 'Identifier'
|
|
30
52
|
&& ['setTimeout', 'setInterval'].includes(node.callee.name)
|
|
31
53
|
&& node.arguments[1]?.type == 'Literal'
|
|
32
54
|
&& typeof node.arguments[1].value == 'number'
|
|
33
|
-
&& node.arguments[1].value <
|
|
34
|
-
&& !isInsideNestedFunction(node)
|
|
55
|
+
&& node.arguments[1].value < 30000
|
|
56
|
+
&& !nodeUtils.isInsideNestedFunction(node)
|
|
35
57
|
) context.report({ node, messageId: 'keepaliveTimer', data: { fn: node.callee.name }})
|
|
36
58
|
},
|
|
37
59
|
Identifier(node) {
|
|
60
|
+
if (!checkDOMglobals) return
|
|
38
61
|
if (node.parent?.type == 'MemberExpression' && node.parent.property === node
|
|
39
62
|
|| node.parent?.type == 'Property' && node.parent.key === node && !node.parent.computed
|
|
40
|
-
|| !
|
|
63
|
+
|| !manifestData.globals.dom.has(node.name)
|
|
41
64
|
) return
|
|
42
|
-
for (let scope = context.sourceCode.getScope(node)
|
|
43
|
-
scope && scope.type != 'global'
|
|
44
|
-
scope = scope.upper
|
|
65
|
+
for (let scope = context.sourceCode.getScope(node)
|
|
66
|
+
; scope && scope.type != 'global'
|
|
67
|
+
; scope = scope.upper
|
|
45
68
|
) if (scope.set.has(node.name)) return // shadowed by a local binding
|
|
46
69
|
context.report({ node, messageId: 'domInSW', data: { global: node.name }})
|
|
47
70
|
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
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,
|
|
6
|
-
match: /^(\*|https?|file|ftp):\/\/([^/]*)(\/.*)$/,
|
|
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 = []) {
|
|
@@ -67,7 +70,7 @@ export default {
|
|
|
67
70
|
},
|
|
68
71
|
|
|
69
72
|
create(context) {
|
|
70
|
-
if (!
|
|
73
|
+
if (!regex.filename.manifest.test(extension.file.getName(context))) return {}
|
|
71
74
|
const opts = context.options[0] || {},
|
|
72
75
|
checkURLfields = opts.checkURLfields ?? true,
|
|
73
76
|
checkMatchPatterns = opts.checkMatchPatterns ?? true,
|
|
@@ -79,11 +82,11 @@ export default {
|
|
|
79
82
|
let manifest
|
|
80
83
|
try { manifest = JSON.parse(context.sourceCode.text) } catch { return }
|
|
81
84
|
for (const { path, value } of collectStrings(manifest)) {
|
|
82
|
-
if (checkURLfields &&
|
|
85
|
+
if (checkURLfields && manifestData.paths.url.has(path)) {
|
|
83
86
|
if (!isURL(value))
|
|
84
87
|
context.report({ loc: { line: 1, column: 1 }, messageId: 'invalidURL',
|
|
85
88
|
data: { field: path, value }})
|
|
86
|
-
} else if (checkMatchPatterns &&
|
|
89
|
+
} else if (checkMatchPatterns && manifestData.paths.match.has(path)) {
|
|
87
90
|
if (value == '<all_urls>') {
|
|
88
91
|
if (!allowAllURLs)
|
|
89
92
|
context.report({ loc: { line: 1, column: 1 }, messageId: 'allURLs',
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import
|
|
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
|
|
|
@@ -79,7 +79,7 @@ export default {
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
-
if (
|
|
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
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
const manifestCache = new Map()
|
|
7
|
+
|
|
8
|
+
const utils = {
|
|
9
|
+
file: {
|
|
10
|
+
getName(context) { return context.filename || context.getFilename() },
|
|
11
|
+
|
|
12
|
+
isContentScript(context, manifest) {
|
|
13
|
+
const scripts = (manifest?.content_scripts || []).flatMap(cs => cs.js || [])
|
|
14
|
+
if (!scripts.length) return false
|
|
15
|
+
const root = utils.findRoot(context) ; if (!root) return false
|
|
16
|
+
const filename = path.resolve(utils.file.getName(context))
|
|
17
|
+
return scripts.some(script => filename == path.resolve(root, script))
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
isServiceWorker(context, manifest) {
|
|
21
|
+
const worker = manifest?.background?.service_worker ; if (!worker) return false
|
|
22
|
+
const root = utils.findRoot(context) ; if (!root) return false
|
|
23
|
+
return path.resolve(utils.file.getName(context)) == path.resolve(root, worker)
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
findRoot(context) {
|
|
28
|
+
let dir = path.dirname(utils.file.getName(context))
|
|
29
|
+
while (dir) {
|
|
30
|
+
if (fs.existsSync(path.join(dir, 'manifest.json'))) return dir
|
|
31
|
+
const parent = path.dirname(dir)
|
|
32
|
+
if (parent == dir) break // reached fs root
|
|
33
|
+
dir = parent
|
|
34
|
+
}
|
|
35
|
+
return null
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
manifest: {
|
|
39
|
+
read(context) {
|
|
40
|
+
const root = utils.findRoot(context) ; if (!root) return null
|
|
41
|
+
if (manifestCache.has(root)) return manifestCache.get(root)
|
|
42
|
+
let parsed
|
|
43
|
+
try { parsed = JSON.parse(fs.readFileSync(path.join(root, 'manifest.json'), 'utf8')) }
|
|
44
|
+
catch { parsed = null }
|
|
45
|
+
manifestCache.set(root, parsed)
|
|
46
|
+
return parsed
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default utils
|
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
readDefaultLocaleMessages(manifest, root) {
|
|
47
|
+
const locale = manifest?.default_locale
|
|
48
|
+
if (typeof locale != 'string') return null
|
|
49
|
+
const filePath = path.join(root, '_locales', locale, 'messages.json')
|
|
50
|
+
let messages
|
|
51
|
+
try { messages = JSON.parse(fs.readFileSync(filePath, 'utf8')) } catch { return null }
|
|
52
|
+
if (!messages || typeof messages != 'object' || Array.isArray(messages)) return null
|
|
53
|
+
return { locale, messages }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import css from '@eslint/css'
|
|
2
|
+
import html from '@html-eslint/eslint-plugin'
|
|
3
|
+
import json from '@eslint/json'
|
|
4
|
+
import rulesUtils from './rules.js'
|
|
5
|
+
import text from './text.js'
|
|
6
|
+
|
|
7
|
+
import manifestKeyOrder from '../rules/manifest-key-order.js'
|
|
8
|
+
import manifestSpacing from '../rules/manifest-spacing.js'
|
|
9
|
+
import noContentStyleLeak from '../rules/no-content-style-leak.js'
|
|
10
|
+
import noDeprecatedAPIs from '../rules/no-deprecated-apis.js'
|
|
11
|
+
import noDuplicateManifestKeys from '../rules/no-duplicate-manifest-keys.js'
|
|
12
|
+
import noInsecureFetch from '../rules/no-insecure-fetch.js'
|
|
13
|
+
import noInsecureMessaging from '../rules/no-insecure-messaging.js'
|
|
14
|
+
import noInvalidPermissions from '../rules/no-invalid-permissions.js'
|
|
15
|
+
import noMissingDefaultLocale from '../rules/no-missing-default-locale.js'
|
|
16
|
+
import noMissingI18nKeys from '../rules/no-missing-i18n-keys.js'
|
|
17
|
+
import noMissingPermissions from '../rules/no-missing-permissions.js'
|
|
18
|
+
import noMissingWebAccessibleResources from '../rules/no-missing-web-accessible-resources.js'
|
|
19
|
+
import noRemoteCode from '../rules/no-remote-code.js'
|
|
20
|
+
import noSensitivePermissions from '../rules/no-sensitive-permissions.js'
|
|
21
|
+
import noUnrestrictedHostPermissions from '../rules/no-unrestricted-host-permissions.js'
|
|
22
|
+
import noUnusedI18nKeys from '../rules/no-unused-i18n-keys.js'
|
|
23
|
+
import noUnusedPermissions from '../rules/no-unused-permissions.js'
|
|
24
|
+
import requireLicenseFile from '../rules/require-license-file.js'
|
|
25
|
+
import requireMV3 from '../rules/require-mv3.js'
|
|
26
|
+
import requireShortNameWhenUseful from '../rules/require-short-name-when-useful.js'
|
|
27
|
+
import validFilenames from '../rules/valid-filenames.js'
|
|
28
|
+
import validLicenseFile from '../rules/valid-license-file.js'
|
|
29
|
+
import validManifestIcons from '../rules/valid-manifest-icons.js'
|
|
30
|
+
import validMV3schema from '../rules/valid-mv3-schema.js'
|
|
31
|
+
import validServiceWorker from '../rules/valid-service-worker.js'
|
|
32
|
+
import validURLfields from '../rules/valid-url-fields.js'
|
|
33
|
+
import validVersionField from '../rules/valid-version-field.js'
|
|
34
|
+
|
|
35
|
+
const namespace = 'chrome-extensions'
|
|
36
|
+
|
|
37
|
+
export default {
|
|
38
|
+
bundleDeprecation(plugin, [ruleId, bundleNames], pluginName) { /* eslint-disable security/detect-object-injection */
|
|
39
|
+
for (const bundleName of bundleNames)
|
|
40
|
+
if (plugin.configs[bundleName])
|
|
41
|
+
plugin.configs[bundleName].rules[`${pluginName}/${ruleId}`] = 'error'
|
|
42
|
+
}, /* eslint-enable security/detect-object-injection */
|
|
43
|
+
|
|
44
|
+
configs(plugin) {
|
|
45
|
+
const plugins = { [namespace]: plugin },
|
|
46
|
+
rules = plugin.rules,
|
|
47
|
+
createConfig = predicate => ({ plugins, rules: rulesUtils.bundle(rules, predicate, namespace) })
|
|
48
|
+
plugin.configs = {
|
|
49
|
+
recommended: createConfig(rule => rule.meta.docs.recommended),
|
|
50
|
+
strict: createConfig(() => true),
|
|
51
|
+
problem: createConfig(rule => rule.meta.type == 'problem'),
|
|
52
|
+
security: createConfig(rule => rule.meta.docs.security),
|
|
53
|
+
manifest: {
|
|
54
|
+
...createConfig(rule => rule.meta.docs.manifest), language: 'json/json', plugins: { ...plugins, json }},
|
|
55
|
+
license: {
|
|
56
|
+
...createConfig(rule => rule.meta.docs.license), languageOptions: { parser: text.parser }},
|
|
57
|
+
css: {
|
|
58
|
+
...createConfig(rule => rule.meta.docs.css), language: 'css/css', plugins: { ...plugins, css }},
|
|
59
|
+
html: {
|
|
60
|
+
...createConfig(rule => rule.meta.docs.html), language: 'html/html', plugins: { ...plugins, html }},
|
|
61
|
+
json: {
|
|
62
|
+
...createConfig(rule => rule.meta.docs.json), language: 'json/json', plugins: { ...plugins, json }},
|
|
63
|
+
json5: {
|
|
64
|
+
...createConfig(rule => rule.meta.docs.json), language: 'json/json5', plugins: { ...plugins, json }},
|
|
65
|
+
jsonc: {
|
|
66
|
+
...createConfig(rule => rule.meta.docs.json), language: 'json/jsonc', plugins: { ...plugins, json }}
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
plugin() { return { rules: {}, configs: {}}},
|
|
71
|
+
|
|
72
|
+
rules(plugin) {
|
|
73
|
+
plugin.rules = {
|
|
74
|
+
'manifest-key-order': manifestKeyOrder,
|
|
75
|
+
'manifest-spacing': manifestSpacing,
|
|
76
|
+
'no-content-style-leak': noContentStyleLeak,
|
|
77
|
+
'no-deprecated-apis': noDeprecatedAPIs,
|
|
78
|
+
'no-duplicate-manifest-keys': noDuplicateManifestKeys,
|
|
79
|
+
'no-insecure-fetch': noInsecureFetch,
|
|
80
|
+
'no-insecure-messaging': noInsecureMessaging,
|
|
81
|
+
'no-invalid-permissions': noInvalidPermissions,
|
|
82
|
+
'no-missing-default-locale': noMissingDefaultLocale,
|
|
83
|
+
'no-missing-i18n-keys': noMissingI18nKeys,
|
|
84
|
+
'no-missing-permissions': noMissingPermissions,
|
|
85
|
+
'no-missing-web-accessible-resources': noMissingWebAccessibleResources,
|
|
86
|
+
'no-remote-code': noRemoteCode,
|
|
87
|
+
'no-sensitive-permissions': noSensitivePermissions,
|
|
88
|
+
'no-unrestricted-host-permissions': noUnrestrictedHostPermissions,
|
|
89
|
+
'no-unused-i18n-keys': noUnusedI18nKeys,
|
|
90
|
+
'no-unused-permissions': noUnusedPermissions,
|
|
91
|
+
'require-mv3': requireMV3,
|
|
92
|
+
'require-license-file': requireLicenseFile,
|
|
93
|
+
'require-short-name-when-useful': requireShortNameWhenUseful,
|
|
94
|
+
'valid-filenames': validFilenames,
|
|
95
|
+
'valid-license-file': validLicenseFile,
|
|
96
|
+
'valid-manifest-icons': validManifestIcons,
|
|
97
|
+
'valid-mv3-schema': validMV3schema,
|
|
98
|
+
'valid-service-worker': validServiceWorker,
|
|
99
|
+
'valid-url-fields': validURLfields,
|
|
100
|
+
'valid-version-field': validVersionField
|
|
101
|
+
}
|
|
102
|
+
for (const [ruleId, rule] of Object.entries(plugin.rules))
|
|
103
|
+
rule.meta.docs.url = `https://codeberg.org/adamlui/eslint-plugin-${namespace}/#${ruleId}`
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/* eslint-disable security/detect-non-literal-fs-filename -- lint-time test fixtures only */
|
|
2
|
+
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
import regex from '../data/regex.js'
|
|
8
|
+
|
|
9
|
+
const re = { spdxTag: /SPDX-License-Identifier:\s*([\w.-]+)/i },
|
|
10
|
+
require = createRequire(import.meta.url),
|
|
11
|
+
skipDirs = new Set(['node_modules']),
|
|
12
|
+
spdxIDs = new Set(require('spdx-license-ids')),
|
|
13
|
+
spdxNames = Object.entries(require('spdx-license-list'))
|
|
14
|
+
.map(([id, { name }]) => ({ id, name }))
|
|
15
|
+
.sort((a, b) => b.name.length - a.name.length)
|
|
16
|
+
|
|
17
|
+
function tryReaddir(dir) {
|
|
18
|
+
try { return fs.readdirSync(dir, { withFileTypes: true }) } catch { return null }}
|
|
19
|
+
|
|
20
|
+
export default {
|
|
21
|
+
find(root) {
|
|
22
|
+
const queue = [root]
|
|
23
|
+
while (queue.length) {
|
|
24
|
+
const dir = queue.shift(), entries = tryReaddir(dir)
|
|
25
|
+
if (!entries) continue
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
if (entry.name.startsWith('.') || skipDirs.has(entry.name)) continue
|
|
28
|
+
if (entry.isFile() && regex.filename.license.test(entry.name)) return path.join(dir, entry.name)
|
|
29
|
+
if (entry.isDirectory()) queue.push(path.join(dir, entry.name))
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
spdx: {
|
|
36
|
+
appendTag(fixer, text, id) {
|
|
37
|
+
const nl = text.includes('\r\n') ? '\r\n' : '\n',
|
|
38
|
+
trailer = `${text.endsWith(nl) ? '' : nl}${nl}SPDX-License-Identifier: ${id}${nl}`
|
|
39
|
+
return fixer.insertTextAfterRange([0, text.length], trailer)
|
|
40
|
+
},
|
|
41
|
+
find(text) {
|
|
42
|
+
const normalizedTxt = text.replace(/\s+/g, ' ')
|
|
43
|
+
let best = null
|
|
44
|
+
for (const { id, name } of spdxNames) {
|
|
45
|
+
const idx = normalizedTxt.indexOf(name)
|
|
46
|
+
if (idx == -1) continue
|
|
47
|
+
if (!best || idx < best.idx || (idx == best.idx && name.length > best.name.length))
|
|
48
|
+
best = { id, idx, name }
|
|
49
|
+
}
|
|
50
|
+
return best?.id ?? null
|
|
51
|
+
},
|
|
52
|
+
isValid(id) { return spdxIDs.has(id) },
|
|
53
|
+
parse(text) { return text.match(re.spdxTag)?.[1] ?? null }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
getMemberPath(node) {
|
|
3
|
+
const parts = [] ; let currentNode = node
|
|
4
|
+
while (currentNode?.type == 'MemberExpression') {
|
|
5
|
+
if (currentNode.computed || currentNode.property.type != 'Identifier') return null
|
|
6
|
+
parts.unshift(currentNode.property.name)
|
|
7
|
+
currentNode = currentNode.object
|
|
8
|
+
}
|
|
9
|
+
if (currentNode?.type != 'Identifier') return null
|
|
10
|
+
parts.unshift(currentNode.name)
|
|
11
|
+
return parts.join('.')
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
isInsideNestedFunction(node) {
|
|
15
|
+
let ancestor = node.parent
|
|
16
|
+
while (ancestor) {
|
|
17
|
+
if (['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'].includes(ancestor.type))
|
|
18
|
+
return true
|
|
19
|
+
ancestor = ancestor.parent
|
|
20
|
+
}
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/utils/pkg.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
findRoot(dir) {
|
|
6
|
+
let current = path.resolve(dir)
|
|
7
|
+
while (true) {
|
|
8
|
+
if (current.split(path.sep).includes('node_modules')) {
|
|
9
|
+
const parent = path.dirname(current)
|
|
10
|
+
if (parent === current) return null
|
|
11
|
+
current = parent
|
|
12
|
+
continue
|
|
13
|
+
}
|
|
14
|
+
if ( // eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
15
|
+
fs.existsSync(path.join(current, 'package.json'))) return current
|
|
16
|
+
const parent = path.dirname(current)
|
|
17
|
+
if (parent === current) return null
|
|
18
|
+
current = parent
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/data/license.js
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs'
|
|
2
|
-
import path from 'node:path'
|
|
3
|
-
|
|
4
|
-
export const re = {
|
|
5
|
-
licenseFilename: // e.g. LICENSE.md, LICENSE-MIT, MIT-LICENSE.md, COPYING.LESSER, LICENSE-2.0.txt
|
|
6
|
-
/^(?:[\w.-]*-)?(?:LICEN[SC]E|COPYING)(?:[-.][\w.-]+)?$/i, // eslint-disable-line security/detect-unsafe-regex
|
|
7
|
-
spdxTag: /SPDX-License-Identifier:\s*([\w.+-]+)/i
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export const skipDirs = new Set(['node_modules'])
|
|
11
|
-
|
|
12
|
-
export function findLicenseFile(root) {
|
|
13
|
-
const queue = [root]
|
|
14
|
-
while (queue.length) {
|
|
15
|
-
const dir = queue.shift(), entries = tryReaddir(dir)
|
|
16
|
-
if (!entries) continue
|
|
17
|
-
for (const entry of entries) {
|
|
18
|
-
if (entry.name.startsWith('.') || skipDirs.has(entry.name)) continue
|
|
19
|
-
if (entry.isFile() && re.licenseFilename.test(entry.name)) return path.join(dir, entry.name)
|
|
20
|
-
if (entry.isDirectory()) queue.push(path.join(dir, entry.name))
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
return null
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function tryReaddir(dir) { // eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
27
|
-
try { return fs.readdirSync(dir, { withFileTypes: true }) } catch { return null }}
|