@cdxgen/cdxgen 13.5.0 → 13.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/README.md +6 -6
- package/bin/cdxgen.js +4 -0
- package/bin/evinse.js +3 -0
- package/bin/validate.js +6 -0
- package/data/license-aliases.json +24 -2
- package/data/remediations.json +124 -0
- package/data/rules/ai-bom.yaml +1 -1
- package/data/rules/brotli-decoder.yaml +107 -0
- package/data/rules/vscode-extensions.yaml +75 -0
- package/data/spdx-license-list.json +121 -7
- package/data/spdx-licenses.json +69 -0
- package/data/spdx.schema.json +845 -2
- package/lib/cli/bomAssembly.js +92 -3
- package/lib/cli/index.js +108 -1
- package/lib/cli/jsBom.js +153 -64
- package/lib/cli/jvmBom.js +237 -46
- package/lib/cli/managedBom.js +142 -16
- package/lib/cli/nativeBom.js +42 -5
- package/lib/core/boundedFile.js +344 -0
- package/lib/core/brotliProbe.js +182 -0
- package/lib/core/env.js +8 -0
- package/lib/core/fs.js +12 -0
- package/lib/core/httpClient.js +98 -1
- package/lib/core/lockPaths.js +38 -0
- package/lib/core/paths.js +24 -1
- package/lib/core/processTree.js +5 -0
- package/lib/core/toolRegistry.js +170 -0
- package/lib/ecosystems/cppEvidence.js +263 -106
- package/lib/ecosystems/cppIncludeResolver.js +16 -5
- package/lib/ecosystems/ecosystems.js +25 -10
- package/lib/ecosystems/gemHostProbe.js +301 -0
- package/lib/ecosystems/gradleutils.js +828 -224
- package/lib/ecosystems/parsers-bazel.js +107 -6
- package/lib/ecosystems/parsers-js.js +505 -22
- package/lib/ecosystems/parsers-misc.js +209 -20
- package/lib/ecosystems/parsers-python.js +89 -2
- package/lib/ecosystems/pythonutils.js +128 -2
- package/lib/ecosystems/rubyutils.js +538 -31
- package/lib/ecosystems/scalacliutils.js +2 -0
- package/lib/ecosystems/scalasem.js +796 -151
- package/lib/ecosystems/vendoredCode.js +237 -3
- package/lib/ecosystems/venvProbe.js +360 -0
- package/lib/evinser/evinser.js +346 -43
- package/lib/helpers/agenticToolutils.js +413 -93
- package/lib/helpers/vsixutils.js +1574 -315
- package/lib/inventory/atomUtils.js +77 -0
- package/lib/inventory/brotliDecoder.js +142 -0
- package/lib/inventory/buildToolRateLimit.js +1 -1
- package/lib/inventory/cbomutils.js +56 -7
- package/lib/inventory/deps.js +35 -0
- package/lib/inventory/dosai.js +139 -5
- package/lib/inventory/dotnetRuntime.js +217 -0
- package/lib/inventory/envcontext.js +307 -13
- package/lib/inventory/evidenceUtils.js +11 -0
- package/lib/inventory/fetchBatch.js +1 -1
- package/lib/inventory/formulationParsers.js +31 -3
- package/lib/inventory/gtfobins.js +13 -0
- package/lib/inventory/hostRuntimeProbe.js +686 -0
- package/lib/inventory/jvmLocalRepos.js +9 -2
- package/lib/inventory/jvmToolEnv.js +29 -0
- package/lib/inventory/licenseEnhancer.js +12 -0
- package/lib/inventory/mavenCentralMirror.js +534 -0
- package/lib/inventory/nodeBuildInfo.js +218 -0
- package/lib/inventory/nodeHostProbe.js +202 -0
- package/lib/inventory/osPackageResolver.js +182 -0
- package/lib/inventory/phpBuildInfo.js +10 -59
- package/lib/inventory/phpHostProbe.js +32 -488
- package/lib/inventory/rubyBuildInfo.js +972 -0
- package/lib/inventory/rubyHostProbe.js +218 -0
- package/lib/inventory/scalaCoords.js +4 -3
- package/lib/inventory/scalaDedupe.js +440 -0
- package/lib/inventory/source.js +49 -2
- package/lib/managers/binary.js +337 -3
- package/lib/managers/docker.js +79 -0
- package/lib/parsers/largeJson.js +28 -11
- package/lib/parsers/vscodeExtensionId.js +183 -0
- package/lib/server/server.js +12 -1
- package/lib/stages/postgen/ruleEngine.js +13 -5
- package/lib/stages/pregen/envAudit.js +1 -0
- package/lib/stages/pregen/pregen.js +114 -57
- package/lib/validator/bomValidator.js +6 -1
- package/lib/validator/generated/validate-1.6.mjs +1 -1
- package/lib/validator/generated/validate-1.7.mjs +1 -1
- package/lib/validator/generated/validate-2.0.mjs +1 -1
- package/lib/validator/index.js +1 -1
- package/package.json +3 -2
- package/types/lib/cli/bomAssembly.d.ts +13 -1
- package/types/lib/cli/bomAssembly.d.ts.map +1 -1
- package/types/lib/cli/index.d.ts.map +1 -1
- package/types/lib/cli/jsBom.d.ts +1 -17
- package/types/lib/cli/jsBom.d.ts.map +1 -1
- package/types/lib/cli/jvmBom.d.ts.map +1 -1
- package/types/lib/cli/managedBom.d.ts.map +1 -1
- package/types/lib/cli/nativeBom.d.ts.map +1 -1
- package/types/lib/core/boundedFile.d.ts +70 -0
- package/types/lib/core/boundedFile.d.ts.map +1 -0
- package/types/lib/core/brotliProbe.d.ts +37 -0
- package/types/lib/core/brotliProbe.d.ts.map +1 -0
- package/types/lib/core/env.d.ts +4 -0
- package/types/lib/core/env.d.ts.map +1 -1
- package/types/lib/core/fs.d.ts.map +1 -1
- package/types/lib/core/httpClient.d.ts +24 -0
- package/types/lib/core/httpClient.d.ts.map +1 -1
- package/types/lib/core/lockPaths.d.ts +19 -0
- package/types/lib/core/lockPaths.d.ts.map +1 -0
- package/types/lib/core/processTree.d.ts +2 -0
- package/types/lib/core/processTree.d.ts.map +1 -1
- package/types/lib/core/toolRegistry.d.ts +36 -0
- package/types/lib/core/toolRegistry.d.ts.map +1 -0
- package/types/lib/ecosystems/cppEvidence.d.ts +40 -13
- package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
- package/types/lib/ecosystems/cppIncludeResolver.d.ts +2 -1
- package/types/lib/ecosystems/cppIncludeResolver.d.ts.map +1 -1
- package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
- package/types/lib/ecosystems/gemHostProbe.d.ts +28 -0
- package/types/lib/ecosystems/gemHostProbe.d.ts.map +1 -0
- package/types/lib/ecosystems/gradleutils.d.ts +47 -13
- package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-bazel.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-js.d.ts +17 -20
- package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-misc.d.ts +19 -0
- package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-python.d.ts +10 -0
- package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
- package/types/lib/ecosystems/pythonutils.d.ts +20 -2
- package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
- package/types/lib/ecosystems/rubyutils.d.ts +144 -12
- package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
- package/types/lib/ecosystems/scalacliutils.d.ts.map +1 -1
- package/types/lib/ecosystems/vendoredCode.d.ts +26 -0
- package/types/lib/ecosystems/vendoredCode.d.ts.map +1 -1
- package/types/lib/ecosystems/venvProbe.d.ts +40 -0
- package/types/lib/ecosystems/venvProbe.d.ts.map +1 -0
- package/types/lib/evinser/evinser.d.ts +63 -8
- package/types/lib/evinser/evinser.d.ts.map +1 -1
- package/types/lib/inventory/atomUtils.d.ts +19 -0
- package/types/lib/inventory/atomUtils.d.ts.map +1 -1
- package/types/lib/inventory/brotliDecoder.d.ts +58 -0
- package/types/lib/inventory/brotliDecoder.d.ts.map +1 -0
- package/types/lib/inventory/cbomutils.d.ts +17 -0
- package/types/lib/inventory/cbomutils.d.ts.map +1 -1
- package/types/lib/inventory/deps.d.ts.map +1 -1
- package/types/lib/inventory/dosai.d.ts +17 -1
- package/types/lib/inventory/dosai.d.ts.map +1 -1
- package/types/lib/inventory/dotnetRuntime.d.ts +44 -0
- package/types/lib/inventory/dotnetRuntime.d.ts.map +1 -0
- package/types/lib/inventory/envcontext.d.ts +52 -6
- package/types/lib/inventory/envcontext.d.ts.map +1 -1
- package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
- package/types/lib/inventory/formulationParsers.d.ts.map +1 -1
- package/types/lib/inventory/gtfobins.d.ts.map +1 -1
- package/types/lib/inventory/hostRuntimeProbe.d.ts +44 -0
- package/types/lib/inventory/hostRuntimeProbe.d.ts.map +1 -0
- package/types/lib/inventory/jvmToolEnv.d.ts +12 -0
- package/types/lib/inventory/jvmToolEnv.d.ts.map +1 -1
- package/types/lib/inventory/licenseEnhancer.d.ts.map +1 -1
- package/types/lib/inventory/mavenCentralMirror.d.ts +170 -0
- package/types/lib/inventory/mavenCentralMirror.d.ts.map +1 -0
- package/types/lib/inventory/nodeBuildInfo.d.ts +95 -0
- package/types/lib/inventory/nodeBuildInfo.d.ts.map +1 -0
- package/types/lib/inventory/nodeHostProbe.d.ts +15 -0
- package/types/lib/inventory/nodeHostProbe.d.ts.map +1 -0
- package/types/lib/inventory/osPackageResolver.d.ts +38 -0
- package/types/lib/inventory/osPackageResolver.d.ts.map +1 -1
- package/types/lib/inventory/phpBuildInfo.d.ts.map +1 -1
- package/types/lib/inventory/phpHostProbe.d.ts.map +1 -1
- package/types/lib/inventory/rubyBuildInfo.d.ts +271 -0
- package/types/lib/inventory/rubyBuildInfo.d.ts.map +1 -0
- package/types/lib/inventory/rubyHostProbe.d.ts +47 -0
- package/types/lib/inventory/rubyHostProbe.d.ts.map +1 -0
- package/types/lib/inventory/source.d.ts +10 -0
- package/types/lib/inventory/source.d.ts.map +1 -1
- package/types/lib/managers/binary.d.ts.map +1 -1
- package/types/lib/managers/docker.d.ts +12 -0
- package/types/lib/managers/docker.d.ts.map +1 -1
- package/types/lib/parsers/largeJson.d.ts +4 -3
- package/types/lib/parsers/largeJson.d.ts.map +1 -1
- package/types/lib/server/server.d.ts.map +1 -1
- package/types/lib/stages/pregen/envAudit.d.ts.map +1 -1
- package/types/lib/stages/pregen/pregen.d.ts +12 -8
- package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
package/lib/helpers/vsixutils.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
import { readdirSync,
|
|
1
|
+
import { lstatSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { basename, join, resolve } from "node:path";
|
|
3
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
|
|
5
5
|
import { build, Purl } from "@cdxgen/cdx-purl";
|
|
6
6
|
import StreamZip from "node-stream-zip";
|
|
7
7
|
|
|
8
8
|
import { DEBUG_MODE, readEnvironmentVariable } from "../core/activity.js";
|
|
9
|
+
import {
|
|
10
|
+
hashRegularFile,
|
|
11
|
+
isRunningAsRoot,
|
|
12
|
+
readRegularTextFile,
|
|
13
|
+
readUserFileText,
|
|
14
|
+
} from "../core/boundedFile.js";
|
|
9
15
|
import {
|
|
10
16
|
getTmpDir,
|
|
11
17
|
safeExistsSync,
|
|
@@ -13,7 +19,13 @@ import {
|
|
|
13
19
|
safeMkdtempSync,
|
|
14
20
|
safeRmSync,
|
|
15
21
|
} from "../core/fs.js";
|
|
16
|
-
import {
|
|
22
|
+
import { thoughtLog } from "../core/logger.js";
|
|
23
|
+
import { isMac, isWin, redactHomePath } from "../core/paths.js";
|
|
24
|
+
import { sanitizeBomUrl } from "../core/propertySanitizer.js";
|
|
25
|
+
import {
|
|
26
|
+
normalizeVscodeTargetPlatform,
|
|
27
|
+
parseVscodeExtensionDirName,
|
|
28
|
+
} from "../parsers/vscodeExtensionId.js";
|
|
17
29
|
import { xml2js } from "../parsers/xml.js";
|
|
18
30
|
import { toVersRange } from "./versutils.js";
|
|
19
31
|
|
|
@@ -28,225 +40,672 @@ export const VSCODE_EXTENSION_PURL_TYPE = "vscode-extension";
|
|
|
28
40
|
const MANIFEST_ANALYSIS_CONFIDENCE = 0.6;
|
|
29
41
|
|
|
30
42
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
|
|
34
|
-
|
|
43
|
+
* Namespace of the `pkg:generic` purl given to an installed editor, which has
|
|
44
|
+
* no package type of its own.
|
|
45
|
+
*/
|
|
46
|
+
export const IDE_PURL_NAMESPACE = "cdxgen-ide";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* VS Code-family editors and remote servers whose installed extensions cdxgen
|
|
50
|
+
* can inventory.
|
|
51
|
+
*
|
|
52
|
+
* Every editor built from VS Code installs extensions into
|
|
53
|
+
* `<home>/<dataFolderName>/extensions` on Windows, macOS and Linux alike, and
|
|
54
|
+
* its remote server installs them into `<home>/<serverDataFolderName>/extensions`
|
|
55
|
+
* on the remote host. Settings and global storage live elsewhere (for example
|
|
56
|
+
* `~/Library/Application Support/Cursor/User`), and that folder never holds
|
|
57
|
+
* extensions.
|
|
35
58
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
59
|
+
* `apps` lists where the editor itself is installed, relative to the
|
|
60
|
+
* platform's application folders, so its version can be read from
|
|
61
|
+
* `resources/app/product.json` and `package.json`.
|
|
38
62
|
*/
|
|
39
|
-
export
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
name: "VS Code",
|
|
54
|
-
dirs: isWin
|
|
55
|
-
? [join(appData, "Code", "User", "extensions")]
|
|
56
|
-
: isMac
|
|
57
|
-
? [
|
|
58
|
-
join(
|
|
59
|
-
home,
|
|
60
|
-
"Library",
|
|
61
|
-
"Application Support",
|
|
62
|
-
"Code",
|
|
63
|
-
"User",
|
|
64
|
-
"extensions",
|
|
65
|
-
),
|
|
66
|
-
]
|
|
67
|
-
: [join(home, ".vscode", "extensions")],
|
|
63
|
+
export const IDE_PRODUCTS = [
|
|
64
|
+
{
|
|
65
|
+
id: "vscode",
|
|
66
|
+
name: "VS Code",
|
|
67
|
+
dataFolderName: ".vscode",
|
|
68
|
+
serverDataFolderName: ".vscode-server",
|
|
69
|
+
apps: {
|
|
70
|
+
darwin: ["Visual Studio Code.app"],
|
|
71
|
+
linux: [
|
|
72
|
+
"/usr/share/code",
|
|
73
|
+
"/opt/visual-studio-code",
|
|
74
|
+
"/snap/code/current/usr/share/code",
|
|
75
|
+
],
|
|
76
|
+
win32: ["Microsoft VS Code"],
|
|
68
77
|
},
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
),
|
|
83
|
-
]
|
|
84
|
-
: [join(home, ".vscode-insiders", "extensions")],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "vscode-insiders",
|
|
81
|
+
name: "VS Code Insiders",
|
|
82
|
+
dataFolderName: ".vscode-insiders",
|
|
83
|
+
serverDataFolderName: ".vscode-server-insiders",
|
|
84
|
+
apps: {
|
|
85
|
+
darwin: ["Visual Studio Code - Insiders.app"],
|
|
86
|
+
linux: [
|
|
87
|
+
"/usr/share/code-insiders",
|
|
88
|
+
"/snap/code-insiders/current/usr/share/code-insiders",
|
|
89
|
+
],
|
|
90
|
+
win32: ["Microsoft VS Code Insiders"],
|
|
85
91
|
},
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
join(home, ".vscode-oss", "extensions"),
|
|
103
|
-
join(home, ".config", "VSCodium", "User", "extensions"),
|
|
104
|
-
],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
// VSCodium keeps the Code - OSS data folder, so a self-built Code - OSS
|
|
95
|
+
// shares it.
|
|
96
|
+
id: "vscodium",
|
|
97
|
+
name: "VSCodium",
|
|
98
|
+
dataFolderName: ".vscode-oss",
|
|
99
|
+
serverDataFolderName: ".vscodium-server",
|
|
100
|
+
apps: {
|
|
101
|
+
darwin: ["VSCodium.app"],
|
|
102
|
+
linux: [
|
|
103
|
+
"/usr/share/codium",
|
|
104
|
+
"/opt/vscodium-bin",
|
|
105
|
+
"/snap/codium/current/usr/share/codium",
|
|
106
|
+
],
|
|
107
|
+
win32: ["VSCodium"],
|
|
105
108
|
},
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
home,
|
|
117
|
-
"Library",
|
|
118
|
-
"Application Support",
|
|
119
|
-
"Cursor",
|
|
120
|
-
"User",
|
|
121
|
-
"extensions",
|
|
122
|
-
),
|
|
123
|
-
]
|
|
124
|
-
: [join(home, ".cursor", "extensions")],
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: "vscodium-insiders",
|
|
112
|
+
name: "VSCodium Insiders",
|
|
113
|
+
dataFolderName: ".vscodium-insiders",
|
|
114
|
+
serverDataFolderName: ".vscodium-server-insiders",
|
|
115
|
+
apps: {
|
|
116
|
+
darwin: ["VSCodium - Insiders.app"],
|
|
117
|
+
linux: ["/usr/share/codium-insiders"],
|
|
118
|
+
win32: ["VSCodium Insiders"],
|
|
125
119
|
},
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
"Windsurf",
|
|
137
|
-
"User",
|
|
138
|
-
"extensions",
|
|
139
|
-
),
|
|
140
|
-
]
|
|
141
|
-
: [join(home, ".windsurf", "extensions")],
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: "cursor",
|
|
123
|
+
name: "Cursor",
|
|
124
|
+
dataFolderName: ".cursor",
|
|
125
|
+
serverDataFolderName: ".cursor-server",
|
|
126
|
+
apps: {
|
|
127
|
+
darwin: ["Cursor.app"],
|
|
128
|
+
linux: ["/usr/share/cursor", "/opt/Cursor", "/opt/cursor"],
|
|
129
|
+
win32: ["cursor"],
|
|
142
130
|
},
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
"Positron",
|
|
154
|
-
"User",
|
|
155
|
-
"extensions",
|
|
156
|
-
),
|
|
157
|
-
]
|
|
158
|
-
: [join(home, ".positron", "extensions")],
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: "windsurf",
|
|
134
|
+
name: "Windsurf",
|
|
135
|
+
dataFolderName: ".windsurf",
|
|
136
|
+
serverDataFolderName: ".windsurf-server",
|
|
137
|
+
apps: {
|
|
138
|
+
darwin: ["Windsurf.app"],
|
|
139
|
+
linux: ["/usr/share/windsurf", "/opt/Windsurf", "/opt/windsurf"],
|
|
140
|
+
win32: ["Windsurf"],
|
|
159
141
|
},
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
"Theia",
|
|
171
|
-
"extensions",
|
|
172
|
-
),
|
|
173
|
-
]
|
|
174
|
-
: [
|
|
175
|
-
join(home, ".theia", "extensions"),
|
|
176
|
-
join(xdgDataHome, "theia", "extensions"),
|
|
177
|
-
],
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: "windsurf-next",
|
|
145
|
+
name: "Windsurf Next",
|
|
146
|
+
dataFolderName: ".windsurf-next",
|
|
147
|
+
serverDataFolderName: ".windsurf-next-server",
|
|
148
|
+
apps: {
|
|
149
|
+
darwin: ["Windsurf - Next.app"],
|
|
150
|
+
linux: ["/usr/share/windsurf-next"],
|
|
151
|
+
win32: ["Windsurf Next"],
|
|
178
152
|
},
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
// Windsurf was renamed Devin Desktop, and new installs use these folders.
|
|
156
|
+
id: "devin-desktop",
|
|
157
|
+
name: "Devin Desktop",
|
|
158
|
+
dataFolderName: ".devin",
|
|
159
|
+
serverDataFolderName: ".devin-server",
|
|
160
|
+
apps: {
|
|
161
|
+
darwin: ["Devin.app", "Devin Desktop.app"],
|
|
162
|
+
linux: ["/usr/share/devin"],
|
|
163
|
+
win32: ["Devin"],
|
|
183
164
|
},
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: "positron",
|
|
168
|
+
name: "Positron",
|
|
169
|
+
dataFolderName: ".positron",
|
|
170
|
+
serverDataFolderName: ".positron-server",
|
|
171
|
+
apps: {
|
|
172
|
+
darwin: ["Positron.app"],
|
|
173
|
+
linux: ["/usr/share/positron", "/usr/lib/positron"],
|
|
174
|
+
win32: ["Positron"],
|
|
187
175
|
},
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: "kiro",
|
|
179
|
+
name: "Kiro",
|
|
180
|
+
dataFolderName: ".kiro",
|
|
181
|
+
serverDataFolderName: ".kiro-server",
|
|
182
|
+
apps: {
|
|
183
|
+
darwin: ["Kiro.app"],
|
|
184
|
+
linux: ["/usr/share/kiro", "/opt/Kiro"],
|
|
185
|
+
win32: ["Kiro"],
|
|
191
186
|
},
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
"Trae",
|
|
203
|
-
"User",
|
|
204
|
-
"extensions",
|
|
205
|
-
),
|
|
206
|
-
]
|
|
207
|
-
: [join(home, ".trae", "extensions")],
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
id: "trae",
|
|
190
|
+
name: "Trae",
|
|
191
|
+
dataFolderName: ".trae",
|
|
192
|
+
serverDataFolderName: ".trae-server",
|
|
193
|
+
apps: {
|
|
194
|
+
darwin: ["Trae.app"],
|
|
195
|
+
linux: ["/usr/share/trae"],
|
|
196
|
+
win32: ["Trae"],
|
|
208
197
|
},
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
"Augment Code",
|
|
220
|
-
"User",
|
|
221
|
-
"extensions",
|
|
222
|
-
),
|
|
223
|
-
]
|
|
224
|
-
: [join(home, ".augment-code", "extensions")],
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
id: "antigravity",
|
|
201
|
+
name: "Antigravity",
|
|
202
|
+
dataFolderName: ".antigravity",
|
|
203
|
+
serverDataFolderName: ".antigravity-server",
|
|
204
|
+
apps: {
|
|
205
|
+
darwin: ["Antigravity.app"],
|
|
206
|
+
linux: ["/usr/share/antigravity"],
|
|
207
|
+
win32: ["Antigravity"],
|
|
225
208
|
},
|
|
226
|
-
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
id: "antigravity-ide",
|
|
212
|
+
name: "Antigravity IDE",
|
|
213
|
+
dataFolderName: ".antigravity-ide",
|
|
214
|
+
serverDataFolderName: ".antigravity-ide-server",
|
|
215
|
+
apps: {
|
|
216
|
+
darwin: ["Antigravity IDE.app"],
|
|
217
|
+
linux: ["/usr/share/antigravity-ide"],
|
|
218
|
+
win32: ["Antigravity IDE"],
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
id: "qoder",
|
|
223
|
+
name: "Qoder",
|
|
224
|
+
dataFolderName: ".qoder",
|
|
225
|
+
serverDataFolderName: ".qoder-server",
|
|
226
|
+
apps: { darwin: ["Qoder.app"], linux: [], win32: ["Qoder"] },
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
id: "void",
|
|
230
|
+
name: "Void",
|
|
231
|
+
dataFolderName: ".void-editor",
|
|
232
|
+
serverDataFolderName: ".void-server",
|
|
233
|
+
apps: { darwin: ["Void.app"], linux: ["/usr/share/void"], win32: ["Void"] },
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
id: "pearai",
|
|
237
|
+
name: "PearAI",
|
|
238
|
+
dataFolderName: ".pearai",
|
|
239
|
+
serverDataFolderName: ".pearai-server",
|
|
240
|
+
apps: { darwin: ["PearAI.app"], linux: [], win32: ["PearAI"] },
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
id: "theia",
|
|
244
|
+
name: "Theia IDE",
|
|
245
|
+
dataFolderName: ".theia-ide",
|
|
246
|
+
apps: {},
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
id: "theia-legacy",
|
|
250
|
+
name: "Theia",
|
|
251
|
+
dataFolderName: ".theia",
|
|
252
|
+
apps: {},
|
|
253
|
+
},
|
|
254
|
+
];
|
|
227
255
|
|
|
228
|
-
|
|
256
|
+
/**
|
|
257
|
+
* Remote and hosted servers that are not tied to one desktop editor.
|
|
258
|
+
* `appDirs` lists where the server itself is installed, so its version can be
|
|
259
|
+
* read.
|
|
260
|
+
*/
|
|
261
|
+
const REMOTE_EXTENSION_HOSTS = [
|
|
262
|
+
{
|
|
263
|
+
id: "code-server",
|
|
264
|
+
name: "code-server",
|
|
265
|
+
dirs: (_home, xdgDataHome) => [
|
|
266
|
+
join(xdgDataHome, "code-server", "extensions"),
|
|
267
|
+
],
|
|
268
|
+
// The deb and rpm packages install into /usr/lib/code-server, and the
|
|
269
|
+
// standalone installer into ~/.local/lib/code-server-<version>. Its
|
|
270
|
+
// product.json, which carries codeServerVersion, is in lib/vscode.
|
|
271
|
+
appDirs: (home) => [
|
|
272
|
+
join("/usr", "lib", "code-server", "lib", "vscode"),
|
|
273
|
+
...listChildDirs(join(home, ".local", "lib"))
|
|
274
|
+
.filter((name) => name.startsWith("code-server-"))
|
|
275
|
+
.map((name) => join(home, ".local", "lib", name, "lib", "vscode")),
|
|
276
|
+
],
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
id: "openvscode-server",
|
|
280
|
+
name: "OpenVSCode Server",
|
|
281
|
+
dirs: (home) => [join(home, ".openvscode-server", "extensions")],
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
id: "vscode-remote",
|
|
285
|
+
name: "VS Code Remote",
|
|
286
|
+
dirs: (home) => [join(home, ".vscode-remote", "extensions")],
|
|
287
|
+
},
|
|
288
|
+
];
|
|
289
|
+
|
|
290
|
+
// Largest editor metadata file read: product.json, package.json and the
|
|
291
|
+
// .vsixmanifest of one extension, and .obsolete.
|
|
292
|
+
const MAX_METADATA_FILE_BYTES = 8 * 1024 * 1024;
|
|
293
|
+
// extensions.json lists every installed extension and grows with them.
|
|
294
|
+
const MAX_REGISTRY_FILE_BYTES = 32 * 1024 * 1024;
|
|
295
|
+
// Most remote server builds a host keeps, and most extension folders, read
|
|
296
|
+
// from one directory.
|
|
297
|
+
const MAX_SERVER_BUILDS = 64;
|
|
298
|
+
const MAX_EXTENSION_FOLDERS = 10000;
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* List the names of the folders in a directory, or nothing when it cannot be
|
|
302
|
+
* read. A symlink to a folder counts as a folder except when the scan runs as
|
|
303
|
+
* root.
|
|
304
|
+
*
|
|
305
|
+
* @param {string} dir Directory
|
|
306
|
+
* @param {number} [limit] Most names returned
|
|
307
|
+
* @returns {string[]} Folder names
|
|
308
|
+
*/
|
|
309
|
+
function listChildDirs(dir, limit = MAX_EXTENSION_FOLDERS) {
|
|
310
|
+
if (!safeExistsSync(dir)) {
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
let entries;
|
|
314
|
+
try {
|
|
315
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
316
|
+
} catch {
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
const names = [];
|
|
320
|
+
for (const entry of entries) {
|
|
321
|
+
if (names.length >= limit) {
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
if (entry.isDirectory()) {
|
|
325
|
+
names.push(entry.name);
|
|
326
|
+
} else if (entry.isSymbolicLink() && !isRunningAsRoot()) {
|
|
327
|
+
try {
|
|
328
|
+
if (statSync(join(dir, entry.name)).isDirectory()) {
|
|
329
|
+
names.push(entry.name);
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
332
|
+
// dangling link
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return names;
|
|
229
337
|
}
|
|
230
338
|
|
|
231
339
|
/**
|
|
232
|
-
*
|
|
340
|
+
* The XDG data folder for a home. The environment only describes the current
|
|
341
|
+
* user, so another home, such as a fixture or another user's home in a root
|
|
342
|
+
* scan, uses the default.
|
|
233
343
|
*
|
|
234
|
-
* @
|
|
235
|
-
*
|
|
344
|
+
* @param {string} home Home directory
|
|
345
|
+
* @returns {string} Data folder
|
|
236
346
|
*/
|
|
237
|
-
|
|
238
|
-
const
|
|
347
|
+
function xdgDataHomeFor(home) {
|
|
348
|
+
const defaultDir = join(home, ".local", "share");
|
|
349
|
+
if (home !== homedir()) {
|
|
350
|
+
return defaultDir;
|
|
351
|
+
}
|
|
352
|
+
return readEnvironmentVariable("XDG_DATA_HOME") || defaultDir;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Describe where each known editor and remote server installs extensions for
|
|
357
|
+
* the given home directory.
|
|
358
|
+
*
|
|
359
|
+
* Each desktop editor contributes one entry for its own extensions folder and,
|
|
360
|
+
* when it has a remote server, a second entry for the server's folder. For the
|
|
361
|
+
* current user's home, the `VSCODE_EXTENSIONS` environment variable, which VS
|
|
362
|
+
* Code reads in place of the default folder, adds one more entry.
|
|
363
|
+
*
|
|
364
|
+
* @param {string} [home] Home directory to resolve the folders against
|
|
365
|
+
* @returns {Array<{id: string, name: string, kind: string, dirs: string[]}>}
|
|
366
|
+
* One entry per editor or server, with `kind` set to `desktop` or `server`
|
|
367
|
+
*/
|
|
368
|
+
export function getIdeExtensionDirs(home = homedir()) {
|
|
369
|
+
const xdgDataHome = xdgDataHomeFor(home);
|
|
370
|
+
const entries = [];
|
|
371
|
+
for (const product of IDE_PRODUCTS) {
|
|
372
|
+
entries.push({
|
|
373
|
+
id: product.id,
|
|
374
|
+
name: product.name,
|
|
375
|
+
kind: "desktop",
|
|
376
|
+
dirs: [join(home, product.dataFolderName, "extensions")],
|
|
377
|
+
});
|
|
378
|
+
if (product.serverDataFolderName) {
|
|
379
|
+
entries.push({
|
|
380
|
+
id: `${product.id}-server`,
|
|
381
|
+
name: `${product.name} Server`,
|
|
382
|
+
kind: "server",
|
|
383
|
+
dirs: [join(home, product.serverDataFolderName, "extensions")],
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
for (const host of REMOTE_EXTENSION_HOSTS) {
|
|
388
|
+
entries.push({
|
|
389
|
+
id: host.id,
|
|
390
|
+
name: host.name,
|
|
391
|
+
kind: "server",
|
|
392
|
+
dirs: host.dirs(home, xdgDataHome),
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
const overrideDir =
|
|
396
|
+
home === homedir() ? readEnvironmentVariable("VSCODE_EXTENSIONS") : "";
|
|
397
|
+
if (overrideDir) {
|
|
398
|
+
entries.push({
|
|
399
|
+
id: "vscode",
|
|
400
|
+
name: "VS Code",
|
|
401
|
+
kind: "desktop",
|
|
402
|
+
dirs: [resolve(overrideDir)],
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
return entries;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* True when a path, or the folder holding it, is a symlink. A root scan does
|
|
410
|
+
* not follow these, because a user could point them at files only root reads.
|
|
411
|
+
*
|
|
412
|
+
* @param {string} dir Extensions folder
|
|
413
|
+
* @returns {boolean} true when either is a symlink
|
|
414
|
+
*/
|
|
415
|
+
function isLinkedFolder(dir) {
|
|
416
|
+
for (const candidate of [dir, dirname(dir)]) {
|
|
417
|
+
try {
|
|
418
|
+
if (lstatSync(candidate).isSymbolicLink()) {
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
} catch {
|
|
422
|
+
// missing
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Discover the extension folders that exist for the given home directory.
|
|
430
|
+
*
|
|
431
|
+
* @param {string} [home] Home directory to resolve the folders against
|
|
432
|
+
* @returns {Array<{id: string, name: string, kind: string, dir: string, home: string}>}
|
|
433
|
+
* One entry per existing folder
|
|
434
|
+
*/
|
|
435
|
+
export function discoverIdeExtensionDirs(home = homedir()) {
|
|
239
436
|
const found = [];
|
|
240
|
-
|
|
437
|
+
const seenDirs = new Set();
|
|
438
|
+
for (const ide of getIdeExtensionDirs(home)) {
|
|
241
439
|
for (const dir of ide.dirs) {
|
|
242
|
-
if (safeExistsSync(dir)) {
|
|
243
|
-
|
|
440
|
+
if (seenDirs.has(dir) || !safeExistsSync(dir)) {
|
|
441
|
+
continue;
|
|
244
442
|
}
|
|
443
|
+
if (isRunningAsRoot() && isLinkedFolder(dir)) {
|
|
444
|
+
thoughtLog(
|
|
445
|
+
`Skipping ${dir}: a root scan does not follow a symlinked extensions folder.`,
|
|
446
|
+
);
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
seenDirs.add(dir);
|
|
450
|
+
found.push({ id: ide.id, name: ide.name, kind: ide.kind, dir, home });
|
|
245
451
|
}
|
|
246
452
|
}
|
|
247
453
|
return found;
|
|
248
454
|
}
|
|
249
455
|
|
|
456
|
+
/**
|
|
457
|
+
* Find the editor or server an extensions folder belongs to from its path,
|
|
458
|
+
* such as `~/.cursor/extensions` or `/home/dev/.cursor-server/extensions`.
|
|
459
|
+
*
|
|
460
|
+
* @param {string} extensionsDir Path to an extensions folder
|
|
461
|
+
* @returns {{id: string, name: string, kind: string, home: string}|undefined}
|
|
462
|
+
* The match, with the home folder it sits in, or undefined for a folder no
|
|
463
|
+
* known editor uses
|
|
464
|
+
*/
|
|
465
|
+
export function identifyIdeFromExtensionsDir(extensionsDir) {
|
|
466
|
+
const resolved = resolve(extensionsDir);
|
|
467
|
+
const segments = resolved.split(/[/\\]/u).filter((segment) => segment.length);
|
|
468
|
+
if (segments[segments.length - 1] !== "extensions" || segments.length < 2) {
|
|
469
|
+
return undefined;
|
|
470
|
+
}
|
|
471
|
+
const parent = segments[segments.length - 2];
|
|
472
|
+
const dataHome = dirname(dirname(resolved));
|
|
473
|
+
for (const product of IDE_PRODUCTS) {
|
|
474
|
+
if (parent === product.dataFolderName) {
|
|
475
|
+
return {
|
|
476
|
+
id: product.id,
|
|
477
|
+
name: product.name,
|
|
478
|
+
kind: "desktop",
|
|
479
|
+
home: dataHome,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
if (
|
|
483
|
+
product.serverDataFolderName &&
|
|
484
|
+
parent === product.serverDataFolderName
|
|
485
|
+
) {
|
|
486
|
+
return {
|
|
487
|
+
id: `${product.id}-server`,
|
|
488
|
+
name: `${product.name} Server`,
|
|
489
|
+
kind: "server",
|
|
490
|
+
home: dataHome,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (parent === "code-server") {
|
|
495
|
+
// ~/.local/share/code-server/extensions
|
|
496
|
+
return {
|
|
497
|
+
id: "code-server",
|
|
498
|
+
name: "code-server",
|
|
499
|
+
kind: "server",
|
|
500
|
+
home: dirname(dirname(dataHome)),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
if (parent === ".openvscode-server") {
|
|
504
|
+
return {
|
|
505
|
+
id: "openvscode-server",
|
|
506
|
+
name: "OpenVSCode Server",
|
|
507
|
+
kind: "server",
|
|
508
|
+
home: dataHome,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
if (parent === ".vscode-remote") {
|
|
512
|
+
return {
|
|
513
|
+
id: "vscode-remote",
|
|
514
|
+
name: "VS Code Remote",
|
|
515
|
+
kind: "server",
|
|
516
|
+
home: dataHome,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
return undefined;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Candidate `resources/app` folders for an installed desktop editor on the
|
|
524
|
+
* current platform.
|
|
525
|
+
*
|
|
526
|
+
* @param {Object} product Entry from IDE_PRODUCTS
|
|
527
|
+
* @param {string} home Home directory
|
|
528
|
+
* @returns {string[]} Candidate folders, which may not exist
|
|
529
|
+
*/
|
|
530
|
+
function ideAppResourceDirs(product, home) {
|
|
531
|
+
const apps = product.apps || {};
|
|
532
|
+
if (isMac) {
|
|
533
|
+
return (apps.darwin || []).flatMap((bundle) => [
|
|
534
|
+
join("/Applications", bundle, "Contents", "Resources", "app"),
|
|
535
|
+
join(home, "Applications", bundle, "Contents", "Resources", "app"),
|
|
536
|
+
]);
|
|
537
|
+
}
|
|
538
|
+
if (isWin) {
|
|
539
|
+
const ownHome = home === homedir();
|
|
540
|
+
const localAppData =
|
|
541
|
+
(ownHome && readEnvironmentVariable("LOCALAPPDATA")) ||
|
|
542
|
+
join(home, "AppData", "Local");
|
|
543
|
+
const programFiles =
|
|
544
|
+
readEnvironmentVariable("ProgramFiles") || "C:\\Program Files";
|
|
545
|
+
return (apps.win32 || []).flatMap((dirName) => [
|
|
546
|
+
join(localAppData, "Programs", dirName, "resources", "app"),
|
|
547
|
+
join(programFiles, dirName, "resources", "app"),
|
|
548
|
+
]);
|
|
549
|
+
}
|
|
550
|
+
return (apps.linux || []).map((dir) => join(dir, "resources", "app"));
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Candidate `product.json` folders for the remote server an editor installs on
|
|
555
|
+
* a remote host. Newer servers live under `cli/servers/<quality>-<commit>/server`
|
|
556
|
+
* and older ones under `bin/<commit>`.
|
|
557
|
+
*
|
|
558
|
+
* @param {Object} product Entry from IDE_PRODUCTS
|
|
559
|
+
* @param {string} home Home directory
|
|
560
|
+
* @returns {string[]} Existing server folders
|
|
561
|
+
*/
|
|
562
|
+
function ideServerResourceDirs(product, home) {
|
|
563
|
+
if (!product.serverDataFolderName) {
|
|
564
|
+
return [];
|
|
565
|
+
}
|
|
566
|
+
const root = join(home, product.serverDataFolderName);
|
|
567
|
+
return [
|
|
568
|
+
...listChildDirs(join(root, "cli", "servers"), MAX_SERVER_BUILDS).map(
|
|
569
|
+
(name) => join(root, "cli", "servers", name, "server"),
|
|
570
|
+
),
|
|
571
|
+
...listChildDirs(join(root, "bin"), MAX_SERVER_BUILDS).map((name) =>
|
|
572
|
+
join(root, "bin", name),
|
|
573
|
+
),
|
|
574
|
+
];
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function readJsonFile(filePath, maxBytes = MAX_METADATA_FILE_BYTES) {
|
|
578
|
+
try {
|
|
579
|
+
const text = readUserFileText(filePath, maxBytes);
|
|
580
|
+
return text === undefined ? undefined : JSON.parse(text);
|
|
581
|
+
} catch {
|
|
582
|
+
return undefined;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function boundedString(value, maxLength = 256) {
|
|
587
|
+
return typeof value === "string" && value.trim() && value.length <= maxLength
|
|
588
|
+
? value.trim()
|
|
589
|
+
: undefined;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Read an editor's identity from the `product.json` and `package.json` in its
|
|
594
|
+
* `resources/app` folder (or the root of a remote server).
|
|
595
|
+
*
|
|
596
|
+
* Forks report their own release in different fields of `product.json`:
|
|
597
|
+
* `version` (Cursor, Kiro), `windsurfVersion` (Windsurf and Devin Desktop),
|
|
598
|
+
* `positronVersion`, `appVersion` (Trae) and `codeServerVersion`. VS Code
|
|
599
|
+
* itself has none of these and keeps its release in `package.json`, as every
|
|
600
|
+
* fork does for the VS Code release it is based on.
|
|
601
|
+
*
|
|
602
|
+
* @param {string} appDir Folder holding `product.json`
|
|
603
|
+
* @returns {Object|undefined} `{ version, vscodeVersion, commit, quality,
|
|
604
|
+
* nameLong, dataFolderName, serverDataFolderName, appDir }`, or undefined
|
|
605
|
+
* when there is no product.json
|
|
606
|
+
*/
|
|
607
|
+
export function readIdeProductInfo(appDir) {
|
|
608
|
+
const product = readJsonFile(join(appDir, "product.json"));
|
|
609
|
+
if (!product || typeof product !== "object" || Array.isArray(product)) {
|
|
610
|
+
return undefined;
|
|
611
|
+
}
|
|
612
|
+
const pkg = readJsonFile(join(appDir, "package.json")) || {};
|
|
613
|
+
const ownVersion = [
|
|
614
|
+
product.windsurfVersion,
|
|
615
|
+
product.positronVersion,
|
|
616
|
+
product.codeServerVersion,
|
|
617
|
+
product.appVersion,
|
|
618
|
+
product.version,
|
|
619
|
+
]
|
|
620
|
+
.map((value) => boundedString(value, 64))
|
|
621
|
+
.find(Boolean);
|
|
622
|
+
const vscodeVersion = [product.vscodeVersion, pkg.version]
|
|
623
|
+
.map((value) => boundedString(value, 64))
|
|
624
|
+
.find(Boolean);
|
|
625
|
+
return {
|
|
626
|
+
version: ownVersion || boundedString(pkg.version, 64),
|
|
627
|
+
vscodeVersion,
|
|
628
|
+
commit: boundedString(product.commit, 64),
|
|
629
|
+
quality: boundedString(product.quality, 32),
|
|
630
|
+
nameLong: boundedString(product.nameLong, 128),
|
|
631
|
+
applicationName: boundedString(product.applicationName, 64),
|
|
632
|
+
dataFolderName: boundedString(product.dataFolderName, 64),
|
|
633
|
+
serverDataFolderName: boundedString(product.serverDataFolderName, 64),
|
|
634
|
+
appDir,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const productInfoCache = new Map();
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Find the installed editor (or remote server) for an extensions folder and
|
|
642
|
+
* read its identity.
|
|
643
|
+
*
|
|
644
|
+
* A remote server is looked up in the home the folder is in. A desktop editor
|
|
645
|
+
* and code-server are looked up in the application folders of the machine
|
|
646
|
+
* running cdxgen, so that lookup happens only when `probeApps` is true: for a
|
|
647
|
+
* scan of this host, not for a folder copied from another machine.
|
|
648
|
+
*
|
|
649
|
+
* @param {{id: string, kind: string}} ide Entry from discoverIdeExtensionDirs
|
|
650
|
+
* @param {string} [home] Home directory
|
|
651
|
+
* @param {Object} [options] Lookup options
|
|
652
|
+
* @param {boolean} [options.probeApps] Look in this machine's application
|
|
653
|
+
* folders; defaults to true only for the current user's home
|
|
654
|
+
* @param {string[]} [options.appDirs] Folders to read in place of the
|
|
655
|
+
* application folders
|
|
656
|
+
* @returns {Object|undefined} Result of readIdeProductInfo for the newest
|
|
657
|
+
* installation whose data folder matches, or undefined
|
|
658
|
+
*/
|
|
659
|
+
export function findIdeProductInfo(ide, home = homedir(), options = {}) {
|
|
660
|
+
const probeApps = options.probeApps ?? home === homedir();
|
|
661
|
+
const cacheKey = `${ide.kind}\u0000${ide.id}\u0000${home}\u0000${probeApps}\u0000${(options.appDirs || []).join("\u0000")}`;
|
|
662
|
+
if (productInfoCache.has(cacheKey)) {
|
|
663
|
+
return productInfoCache.get(cacheKey);
|
|
664
|
+
}
|
|
665
|
+
const productId =
|
|
666
|
+
ide.kind === "server" ? ide.id.replace(/-server$/u, "") : ide.id;
|
|
667
|
+
const product = IDE_PRODUCTS.find((p) => p.id === productId);
|
|
668
|
+
const host = REMOTE_EXTENSION_HOSTS.find((h) => h.id === ide.id);
|
|
669
|
+
let candidates = [];
|
|
670
|
+
if (options.appDirs) {
|
|
671
|
+
candidates = options.appDirs;
|
|
672
|
+
} else if (product && ide.kind === "server") {
|
|
673
|
+
candidates = ideServerResourceDirs(product, home);
|
|
674
|
+
} else if (product && probeApps) {
|
|
675
|
+
candidates = ideAppResourceDirs(product, home);
|
|
676
|
+
} else if (host?.appDirs && probeApps) {
|
|
677
|
+
candidates = host.appDirs(home);
|
|
678
|
+
}
|
|
679
|
+
const infos = [];
|
|
680
|
+
for (const candidate of candidates) {
|
|
681
|
+
const info = readIdeProductInfo(candidate);
|
|
682
|
+
if (!info) {
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
if (product) {
|
|
686
|
+
const expectedFolder =
|
|
687
|
+
ide.kind === "server"
|
|
688
|
+
? product.serverDataFolderName
|
|
689
|
+
: product.dataFolderName;
|
|
690
|
+
const actualFolder =
|
|
691
|
+
ide.kind === "server" ? info.serverDataFolderName : info.dataFolderName;
|
|
692
|
+
if (actualFolder && actualFolder !== expectedFolder) {
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
infos.push(info);
|
|
697
|
+
}
|
|
698
|
+
// A remote host keeps one server per editor release it has connected from.
|
|
699
|
+
// The newest by version is the one in use.
|
|
700
|
+
infos.sort((a, b) =>
|
|
701
|
+
`${b.version || ""}`.localeCompare(`${a.version || ""}`, undefined, {
|
|
702
|
+
numeric: true,
|
|
703
|
+
}),
|
|
704
|
+
);
|
|
705
|
+
productInfoCache.set(cacheKey, infos[0]);
|
|
706
|
+
return infos[0];
|
|
707
|
+
}
|
|
708
|
+
|
|
250
709
|
/**
|
|
251
710
|
* Parse a `.vsixmanifest` XML string and extract extension metadata.
|
|
252
711
|
*
|
|
@@ -466,8 +925,13 @@ export function parseVsixPackageJson(packageJsonData, srcPath) {
|
|
|
466
925
|
return undefined;
|
|
467
926
|
}
|
|
468
927
|
const externalReferences = [];
|
|
469
|
-
|
|
470
|
-
|
|
928
|
+
const repositoryUrl = boundedString(
|
|
929
|
+
typeof pkg.repository === "string" ? pkg.repository : pkg.repository?.url,
|
|
930
|
+
2048,
|
|
931
|
+
);
|
|
932
|
+
const vcsUrl = repositoryUrl ? sanitizeBomUrl(repositoryUrl) : "";
|
|
933
|
+
if (vcsUrl) {
|
|
934
|
+
externalReferences.push({ type: "vcs", url: vcsUrl });
|
|
471
935
|
}
|
|
472
936
|
return {
|
|
473
937
|
publisher: pkg.publisher || "",
|
|
@@ -494,6 +958,25 @@ export function parseVsixPackageJson(packageJsonData, srcPath) {
|
|
|
494
958
|
}
|
|
495
959
|
}
|
|
496
960
|
|
|
961
|
+
/**
|
|
962
|
+
* Keep the string members of a manifest list, each bounded in length, so a
|
|
963
|
+
* crafted package.json cannot put objects or very long text into a property.
|
|
964
|
+
*
|
|
965
|
+
* @param {unknown} value List from package.json
|
|
966
|
+
* @param {number} [maxItems=200] Most members kept
|
|
967
|
+
* @param {number} [maxLength=256] Longest member kept
|
|
968
|
+
* @returns {string[]} The members
|
|
969
|
+
*/
|
|
970
|
+
function stringList(value, maxItems = 200, maxLength = 256) {
|
|
971
|
+
if (!Array.isArray(value)) {
|
|
972
|
+
return typeof value === "string" ? stringList([value]) : [];
|
|
973
|
+
}
|
|
974
|
+
return value
|
|
975
|
+
.map((item) => boundedString(item, maxLength))
|
|
976
|
+
.filter(Boolean)
|
|
977
|
+
.slice(0, maxItems);
|
|
978
|
+
}
|
|
979
|
+
|
|
497
980
|
/**
|
|
498
981
|
* Extract deep capability and permission information from a VS Code
|
|
499
982
|
* extension package.json.
|
|
@@ -517,23 +1000,27 @@ export function extractExtensionCapabilities(pkg) {
|
|
|
517
1000
|
const capabilities = {};
|
|
518
1001
|
|
|
519
1002
|
// Activation events - security relevant: "*" means the extension activates for every workspace
|
|
520
|
-
|
|
521
|
-
|
|
1003
|
+
const activationEvents = stringList(pkg.activationEvents);
|
|
1004
|
+
if (activationEvents.length) {
|
|
1005
|
+
capabilities.activationEvents = activationEvents;
|
|
522
1006
|
}
|
|
523
1007
|
|
|
524
1008
|
// Extension kind - where the extension runs (ui=local, workspace=remote, both)
|
|
525
|
-
|
|
526
|
-
|
|
1009
|
+
const extensionKind = stringList(pkg.extensionKind);
|
|
1010
|
+
if (extensionKind.length) {
|
|
1011
|
+
capabilities.extensionKind = extensionKind;
|
|
527
1012
|
}
|
|
528
1013
|
|
|
529
1014
|
// Extension dependencies - other extensions this requires
|
|
530
|
-
|
|
531
|
-
|
|
1015
|
+
const extensionDependencies = stringList(pkg.extensionDependencies);
|
|
1016
|
+
if (extensionDependencies.length) {
|
|
1017
|
+
capabilities.extensionDependencies = extensionDependencies;
|
|
532
1018
|
}
|
|
533
1019
|
|
|
534
1020
|
// Extension pack - bundled extensions
|
|
535
|
-
|
|
536
|
-
|
|
1021
|
+
const extensionPack = stringList(pkg.extensionPack);
|
|
1022
|
+
if (extensionPack.length) {
|
|
1023
|
+
capabilities.extensionPack = extensionPack;
|
|
537
1024
|
}
|
|
538
1025
|
|
|
539
1026
|
// Workspace trust configuration
|
|
@@ -593,11 +1080,13 @@ export function extractExtensionCapabilities(pkg) {
|
|
|
593
1080
|
if (contributedFeatures.length) {
|
|
594
1081
|
capabilities.contributes = contributedFeatures;
|
|
595
1082
|
}
|
|
596
|
-
|
|
597
|
-
|
|
1083
|
+
const main = boundedString(pkg.main, 512);
|
|
1084
|
+
if (main) {
|
|
1085
|
+
capabilities.main = main;
|
|
598
1086
|
}
|
|
599
|
-
|
|
600
|
-
|
|
1087
|
+
const browser = boundedString(pkg.browser, 512);
|
|
1088
|
+
if (browser) {
|
|
1089
|
+
capabilities.browser = browser;
|
|
601
1090
|
}
|
|
602
1091
|
const scripts = pkg.scripts || {};
|
|
603
1092
|
const lifecycleScripts = [];
|
|
@@ -621,6 +1110,22 @@ export function extractExtensionCapabilities(pkg) {
|
|
|
621
1110
|
return capabilities;
|
|
622
1111
|
}
|
|
623
1112
|
|
|
1113
|
+
/**
|
|
1114
|
+
* Read a workspace support declaration, which package.json gives as a boolean,
|
|
1115
|
+
* a string such as `limited`, or an object with a `supported` field.
|
|
1116
|
+
*
|
|
1117
|
+
* @param {unknown} value Declaration from package.json capabilities
|
|
1118
|
+
* @returns {string|undefined} `true`, `false`, `limited`, or undefined
|
|
1119
|
+
*/
|
|
1120
|
+
function workspaceSupportValue(value) {
|
|
1121
|
+
const supported =
|
|
1122
|
+
value && typeof value === "object" ? value.supported : value;
|
|
1123
|
+
if (typeof supported === "boolean") {
|
|
1124
|
+
return String(supported);
|
|
1125
|
+
}
|
|
1126
|
+
return boundedString(supported, 32);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
624
1129
|
/**
|
|
625
1130
|
* Convert parsed extension metadata into a CycloneDX component object.
|
|
626
1131
|
*
|
|
@@ -701,24 +1206,18 @@ export function toComponent(extInfo, ideName) {
|
|
|
701
1206
|
value: extensionPack.join(", "),
|
|
702
1207
|
});
|
|
703
1208
|
}
|
|
704
|
-
|
|
705
|
-
|
|
1209
|
+
const uwsValue = workspaceSupportValue(caps.untrustedWorkspaces);
|
|
1210
|
+
if (uwsValue) {
|
|
706
1211
|
props.push({
|
|
707
1212
|
name: "cdx:vscode-extension:untrustedWorkspaces",
|
|
708
|
-
value:
|
|
709
|
-
typeof uws === "object" && uws.supported !== undefined
|
|
710
|
-
? String(uws.supported)
|
|
711
|
-
: String(uws),
|
|
1213
|
+
value: uwsValue,
|
|
712
1214
|
});
|
|
713
1215
|
}
|
|
714
|
-
|
|
715
|
-
|
|
1216
|
+
const vwsValue = workspaceSupportValue(caps.virtualWorkspaces);
|
|
1217
|
+
if (vwsValue) {
|
|
716
1218
|
props.push({
|
|
717
1219
|
name: "cdx:vscode-extension:virtualWorkspaces",
|
|
718
|
-
value:
|
|
719
|
-
typeof vws === "object" && vws.supported !== undefined
|
|
720
|
-
? String(vws.supported)
|
|
721
|
-
: String(vws),
|
|
1220
|
+
value: vwsValue,
|
|
722
1221
|
});
|
|
723
1222
|
}
|
|
724
1223
|
if (caps.contributes?.length) {
|
|
@@ -761,20 +1260,23 @@ export function toComponent(extInfo, ideName) {
|
|
|
761
1260
|
externalRefs.push(...extInfo.externalReferences);
|
|
762
1261
|
}
|
|
763
1262
|
if (extInfo.links) {
|
|
764
|
-
|
|
765
|
-
const
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
externalRefs.push({ type: "issue-tracker", url: extInfo.links.Support });
|
|
772
|
-
}
|
|
773
|
-
if (extInfo.links.Learn) {
|
|
774
|
-
externalRefs.push({ type: "documentation", url: extInfo.links.Learn });
|
|
1263
|
+
const link = (value) => {
|
|
1264
|
+
const url = boundedString(value, 2048);
|
|
1265
|
+
return url ? sanitizeBomUrl(url) : "";
|
|
1266
|
+
};
|
|
1267
|
+
const vcsUrl = link(extInfo.links.Source) || link(extInfo.links.GitHub);
|
|
1268
|
+
if (vcsUrl && !externalRefs.some((r) => r.type === "vcs")) {
|
|
1269
|
+
externalRefs.push({ type: "vcs", url: vcsUrl });
|
|
775
1270
|
}
|
|
776
|
-
|
|
777
|
-
|
|
1271
|
+
for (const [key, type] of [
|
|
1272
|
+
["Support", "issue-tracker"],
|
|
1273
|
+
["Learn", "documentation"],
|
|
1274
|
+
["Getstarted", "website"],
|
|
1275
|
+
]) {
|
|
1276
|
+
const url = link(extInfo.links[key]);
|
|
1277
|
+
if (url) {
|
|
1278
|
+
externalRefs.push({ type, url });
|
|
1279
|
+
}
|
|
778
1280
|
}
|
|
779
1281
|
}
|
|
780
1282
|
if (externalRefs.length) {
|
|
@@ -796,6 +1298,36 @@ export function toComponent(extInfo, ideName) {
|
|
|
796
1298
|
return component;
|
|
797
1299
|
}
|
|
798
1300
|
|
|
1301
|
+
/** Largest .vsix whose SHA-256 is computed. */
|
|
1302
|
+
const MAX_VSIX_BYTES = 2 * 1024 * 1024 * 1024;
|
|
1303
|
+
|
|
1304
|
+
function realpathOrSelf(filePath) {
|
|
1305
|
+
try {
|
|
1306
|
+
return realpathSync(filePath);
|
|
1307
|
+
} catch {
|
|
1308
|
+
return filePath;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/**
|
|
1313
|
+
* True when a .vsix path is a regular file. A FIFO or a device named `*.vsix`
|
|
1314
|
+
* would block or never end, and a root scan does not follow a symlink.
|
|
1315
|
+
*
|
|
1316
|
+
* @param {string} vsixFile Path to the .vsix
|
|
1317
|
+
* @returns {boolean} true when the archive can be opened
|
|
1318
|
+
*/
|
|
1319
|
+
export function isReadableVsix(vsixFile) {
|
|
1320
|
+
try {
|
|
1321
|
+
const link = lstatSync(vsixFile);
|
|
1322
|
+
if (link.isSymbolicLink()) {
|
|
1323
|
+
return !isRunningAsRoot() && statSync(vsixFile).isFile();
|
|
1324
|
+
}
|
|
1325
|
+
return link.isFile();
|
|
1326
|
+
} catch {
|
|
1327
|
+
return false;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
799
1331
|
/**
|
|
800
1332
|
* Extract a `.vsix` file (ZIP archive) to a temporary directory for deep
|
|
801
1333
|
* analysis. The caller is responsible for cleaning up the temp directory.
|
|
@@ -804,6 +1336,9 @@ export function toComponent(extInfo, ideName) {
|
|
|
804
1336
|
* @returns {Promise<string|undefined>} Path to the extracted temp directory, or undefined on failure
|
|
805
1337
|
*/
|
|
806
1338
|
export async function extractVsixToTempDir(vsixFile) {
|
|
1339
|
+
if (!isReadableVsix(vsixFile)) {
|
|
1340
|
+
return undefined;
|
|
1341
|
+
}
|
|
807
1342
|
let tempDir;
|
|
808
1343
|
let zip;
|
|
809
1344
|
try {
|
|
@@ -876,6 +1411,9 @@ export function cleanupTempDir(tempDir) {
|
|
|
876
1411
|
* @returns {Promise<Object|undefined>} CycloneDX component object or undefined
|
|
877
1412
|
*/
|
|
878
1413
|
export async function parseVsixFile(vsixFile) {
|
|
1414
|
+
if (!isReadableVsix(vsixFile)) {
|
|
1415
|
+
return undefined;
|
|
1416
|
+
}
|
|
879
1417
|
let zip;
|
|
880
1418
|
try {
|
|
881
1419
|
zip = new StreamZip.async({ file: vsixFile });
|
|
@@ -922,7 +1460,17 @@ export async function parseVsixFile(vsixFile) {
|
|
|
922
1460
|
}
|
|
923
1461
|
|
|
924
1462
|
if (extInfo) {
|
|
925
|
-
|
|
1463
|
+
const component = toComponent(extInfo);
|
|
1464
|
+
// Marketplaces and security notices publish the SHA-256 of the .vsix
|
|
1465
|
+
// itself, so that is the hash that identifies this build.
|
|
1466
|
+
const digest = hashRegularFile(
|
|
1467
|
+
isRunningAsRoot() ? vsixFile : realpathOrSelf(vsixFile),
|
|
1468
|
+
MAX_VSIX_BYTES,
|
|
1469
|
+
);
|
|
1470
|
+
if (digest) {
|
|
1471
|
+
component.hashes = [{ alg: "SHA-256", content: digest }];
|
|
1472
|
+
}
|
|
1473
|
+
return component;
|
|
926
1474
|
}
|
|
927
1475
|
return undefined;
|
|
928
1476
|
} catch (e) {
|
|
@@ -942,94 +1490,695 @@ export async function parseVsixFile(vsixFile) {
|
|
|
942
1490
|
}
|
|
943
1491
|
|
|
944
1492
|
/**
|
|
945
|
-
*
|
|
946
|
-
* Looks for `package.json` (preferred) and `.vsixmanifest`.
|
|
1493
|
+
* Read the install registry an editor keeps beside its extensions.
|
|
947
1494
|
*
|
|
948
|
-
*
|
|
949
|
-
*
|
|
950
|
-
*
|
|
1495
|
+
* `extensions.json` lists every installed extension with where it came from
|
|
1496
|
+
* and when it was installed. `.obsolete` names the folders of extensions that
|
|
1497
|
+
* were uninstalled or replaced by an update but are still on disk until the
|
|
1498
|
+
* editor next starts. Folder names from either file are only compared with the
|
|
1499
|
+
* names in the folder, never turned into paths.
|
|
1500
|
+
*
|
|
1501
|
+
* @param {string} extensionsDir Extensions folder
|
|
1502
|
+
* @returns {{records: Map<string, Object>, obsolete: Set<string>, hasRegistry: boolean}}
|
|
1503
|
+
* Install records keyed by folder name, the obsolete folder names, and
|
|
1504
|
+
* whether an extensions.json was read
|
|
951
1505
|
*/
|
|
952
|
-
export function
|
|
953
|
-
|
|
954
|
-
const
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1506
|
+
export function readInstalledExtensionsRegistry(extensionsDir) {
|
|
1507
|
+
const records = new Map();
|
|
1508
|
+
const obsolete = new Set();
|
|
1509
|
+
const registry = readJsonFile(
|
|
1510
|
+
join(extensionsDir, "extensions.json"),
|
|
1511
|
+
MAX_REGISTRY_FILE_BYTES,
|
|
1512
|
+
);
|
|
1513
|
+
if (Array.isArray(registry)) {
|
|
1514
|
+
for (const record of registry) {
|
|
1515
|
+
if (!record || typeof record !== "object") {
|
|
1516
|
+
continue;
|
|
1517
|
+
}
|
|
1518
|
+
const location = record.location || {};
|
|
1519
|
+
const folder =
|
|
1520
|
+
boundedString(record.relativeLocation, 512) ||
|
|
1521
|
+
basename(
|
|
1522
|
+
`${boundedString(location.path || location.fsPath, 4096) || ""}`,
|
|
1523
|
+
);
|
|
1524
|
+
if (folder) {
|
|
1525
|
+
records.set(folder, record);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
const obsoleteMap = readJsonFile(join(extensionsDir, ".obsolete"));
|
|
1530
|
+
if (
|
|
1531
|
+
obsoleteMap &&
|
|
1532
|
+
typeof obsoleteMap === "object" &&
|
|
1533
|
+
!Array.isArray(obsoleteMap)
|
|
1534
|
+
) {
|
|
1535
|
+
for (const [folder, flag] of Object.entries(obsoleteMap)) {
|
|
1536
|
+
if (flag === true) {
|
|
1537
|
+
obsolete.add(folder);
|
|
961
1538
|
}
|
|
962
|
-
} catch (_e) {
|
|
963
|
-
// Fall through to vsixmanifest
|
|
964
1539
|
}
|
|
965
1540
|
}
|
|
1541
|
+
return { records, obsolete, hasRegistry: Array.isArray(registry) };
|
|
1542
|
+
}
|
|
966
1543
|
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
1544
|
+
/** Largest extension entry file whose SHA-256 is computed. */
|
|
1545
|
+
const MAX_HASHED_ENTRY_FILE_BYTES = 64 * 1024 * 1024;
|
|
1546
|
+
|
|
1547
|
+
/**
|
|
1548
|
+
* Hash the `main` and `browser` entry files of an installed extension the way
|
|
1549
|
+
* the extension host resolves them: the path as written, with a `.js` suffix,
|
|
1550
|
+
* or as a folder holding `index.js`. Only regular files that stay inside the
|
|
1551
|
+
* extension folder are read; a symlink, a FIFO or a path that leaves the
|
|
1552
|
+
* folder is not.
|
|
1553
|
+
*
|
|
1554
|
+
* @param {string} extDir Installed extension folder
|
|
1555
|
+
* @param {{main?: string, browser?: string}} entries Entry points from
|
|
1556
|
+
* package.json
|
|
1557
|
+
* @returns {Array<{role: string, name: string, digest: string, path: string}>}
|
|
1558
|
+
* One entry per hashed file, with its path relative to the folder
|
|
1559
|
+
*/
|
|
1560
|
+
export function hashExtensionEntryFiles(extDir, entries) {
|
|
1561
|
+
const files = [];
|
|
1562
|
+
const seen = new Set();
|
|
1563
|
+
const root = resolve(extDir);
|
|
1564
|
+
for (const role of ["main", "browser"]) {
|
|
1565
|
+
const entry = boundedString(entries?.[role], 512);
|
|
1566
|
+
if (!entry) {
|
|
1567
|
+
continue;
|
|
1568
|
+
}
|
|
1569
|
+
for (const candidate of [entry, `${entry}.js`, join(entry, "index.js")]) {
|
|
1570
|
+
const filePath = resolve(root, candidate);
|
|
1571
|
+
if (!filePath.startsWith(`${root}${sep}`)) {
|
|
1572
|
+
break;
|
|
976
1573
|
}
|
|
977
|
-
|
|
978
|
-
|
|
1574
|
+
const digest = hashRegularFile(
|
|
1575
|
+
filePath,
|
|
1576
|
+
MAX_HASHED_ENTRY_FILE_BYTES,
|
|
1577
|
+
root,
|
|
1578
|
+
);
|
|
1579
|
+
if (!digest) {
|
|
1580
|
+
continue;
|
|
1581
|
+
}
|
|
1582
|
+
if (!seen.has(filePath)) {
|
|
1583
|
+
seen.add(filePath);
|
|
1584
|
+
files.push({
|
|
1585
|
+
role,
|
|
1586
|
+
name: relative(root, filePath).replaceAll("\\", "/"),
|
|
1587
|
+
digest,
|
|
1588
|
+
path: filePath,
|
|
1589
|
+
});
|
|
1590
|
+
}
|
|
1591
|
+
break;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
return files;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
/**
|
|
1598
|
+
* Describe the code an installed extension runs as `file` components with
|
|
1599
|
+
* their SHA-256, so the extension can be matched against a published hash of
|
|
1600
|
+
* a known good or known bad build.
|
|
1601
|
+
*
|
|
1602
|
+
* @param {string} extDir Installed extension folder
|
|
1603
|
+
* @param {Object} capabilities Capabilities from extractExtensionCapabilities
|
|
1604
|
+
* @param {string} parentRef bom-ref of the extension component
|
|
1605
|
+
* @returns {Object[]} File components, empty when no entry file is found
|
|
1606
|
+
*/
|
|
1607
|
+
export function describeExtensionEntryFiles(extDir, capabilities, parentRef) {
|
|
1608
|
+
return hashExtensionEntryFiles(extDir, capabilities).map((file) =>
|
|
1609
|
+
toEntryFileComponent(parentRef, file, false),
|
|
1610
|
+
);
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
function toEntryFileComponent(parentRef, file, qualifyRef) {
|
|
1614
|
+
return {
|
|
1615
|
+
type: "file",
|
|
1616
|
+
name: file.name,
|
|
1617
|
+
"bom-ref": qualifyRef
|
|
1618
|
+
? `${parentRef}#${file.name}@sha256:${file.digest}`
|
|
1619
|
+
: `${parentRef}#${file.name}`,
|
|
1620
|
+
hashes: [{ alg: "SHA-256", content: file.digest }],
|
|
1621
|
+
properties: [
|
|
1622
|
+
{ name: "cdx:vscode-extension:entryPoint", value: file.role },
|
|
1623
|
+
{ name: "internal:SrcFile", value: file.path },
|
|
1624
|
+
],
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
const INSTALL_SOURCES = new Set(["gallery", "vsix", "resource"]);
|
|
1629
|
+
// The largest millisecond value a JavaScript Date holds.
|
|
1630
|
+
const MAX_DATE_MS = 8.64e15;
|
|
1631
|
+
|
|
1632
|
+
function isUuid(value) {
|
|
1633
|
+
if (typeof value !== "string") {
|
|
1634
|
+
return false;
|
|
1635
|
+
}
|
|
1636
|
+
const groups = value.split("-");
|
|
1637
|
+
const lengths = [8, 4, 4, 4, 12];
|
|
1638
|
+
return (
|
|
1639
|
+
groups.length === lengths.length &&
|
|
1640
|
+
groups.every(
|
|
1641
|
+
(group, i) =>
|
|
1642
|
+
group.length === lengths[i] &&
|
|
1643
|
+
[...group].every((c) => "0123456789abcdefABCDEF".includes(c)),
|
|
1644
|
+
)
|
|
1645
|
+
);
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
/**
|
|
1649
|
+
* Read what an editor's install registry says about one folder, keeping only
|
|
1650
|
+
* enumerated values, a UUID and a valid timestamp.
|
|
1651
|
+
*
|
|
1652
|
+
* @param {Object} [record] Entry from extensions.json
|
|
1653
|
+
* @returns {Object} Install state
|
|
1654
|
+
*/
|
|
1655
|
+
function installStateFromRecord(record) {
|
|
1656
|
+
const metadata =
|
|
1657
|
+
record?.metadata && typeof record.metadata === "object"
|
|
1658
|
+
? record.metadata
|
|
1659
|
+
: {};
|
|
1660
|
+
const state = {};
|
|
1661
|
+
if (INSTALL_SOURCES.has(metadata.source)) {
|
|
1662
|
+
state.source = metadata.source;
|
|
1663
|
+
}
|
|
1664
|
+
const installedAt = Number(metadata.installedTimestamp);
|
|
1665
|
+
if (
|
|
1666
|
+
Number.isFinite(installedAt) &&
|
|
1667
|
+
installedAt > 0 &&
|
|
1668
|
+
installedAt <= MAX_DATE_MS
|
|
1669
|
+
) {
|
|
1670
|
+
state.installedAt = installedAt;
|
|
1671
|
+
}
|
|
1672
|
+
state.preRelease =
|
|
1673
|
+
metadata.isPreReleaseVersion === true || metadata.preRelease === true;
|
|
1674
|
+
state.pinned = metadata.pinned === true;
|
|
1675
|
+
state.builtin = metadata.isBuiltin === true;
|
|
1676
|
+
state.updated = metadata.updated === true;
|
|
1677
|
+
if (isUuid(metadata.id)) {
|
|
1678
|
+
state.galleryId = metadata.id.toLowerCase();
|
|
1679
|
+
} else if (isUuid(record?.identifier?.uuid)) {
|
|
1680
|
+
state.galleryId = record.identifier.uuid.toLowerCase();
|
|
1681
|
+
}
|
|
1682
|
+
return state;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
/**
|
|
1686
|
+
* Record on an extension component what the editors' install registries say
|
|
1687
|
+
* about every folder it was found in.
|
|
1688
|
+
*
|
|
1689
|
+
* Each value describes the whole host: the install sources are all listed, the
|
|
1690
|
+
* install time is the earliest, a flag such as `pinned` is set when any copy
|
|
1691
|
+
* has it, an extension is pending removal only when every copy is, and it is
|
|
1692
|
+
* unregistered when any copy is missing from a registry that exists.
|
|
1693
|
+
*
|
|
1694
|
+
* @param {Object} component Extension component
|
|
1695
|
+
* @param {Object[]} states Install state of each folder
|
|
1696
|
+
*/
|
|
1697
|
+
function applyInstallStates(component, states) {
|
|
1698
|
+
const props = component.properties || [];
|
|
1699
|
+
const addProp = (name, value) => {
|
|
1700
|
+
if (value === undefined || value === null || value === "") {
|
|
1701
|
+
return;
|
|
979
1702
|
}
|
|
1703
|
+
if (!props.some((p) => p.name === name)) {
|
|
1704
|
+
props.push({ name, value: String(value) });
|
|
1705
|
+
}
|
|
1706
|
+
};
|
|
1707
|
+
const sources = [...new Set(states.map((s) => s.source).filter(Boolean))];
|
|
1708
|
+
addProp("cdx:vscode-extension:installSource", sources.sort().join(", "));
|
|
1709
|
+
const times = states.map((s) => s.installedAt).filter(Boolean);
|
|
1710
|
+
if (times.length) {
|
|
1711
|
+
addProp(
|
|
1712
|
+
"cdx:vscode-extension:installedAt",
|
|
1713
|
+
new Date(Math.min(...times)).toISOString(),
|
|
1714
|
+
);
|
|
1715
|
+
}
|
|
1716
|
+
for (const [key, name] of [
|
|
1717
|
+
["preRelease", "cdx:vscode-extension:preRelease"],
|
|
1718
|
+
["pinned", "cdx:vscode-extension:pinned"],
|
|
1719
|
+
["builtin", "cdx:vscode-extension:builtin"],
|
|
1720
|
+
["updated", "cdx:vscode-extension:updated"],
|
|
1721
|
+
["symlink", "cdx:vscode-extension:symlink"],
|
|
1722
|
+
]) {
|
|
1723
|
+
if (states.some((s) => s[key])) {
|
|
1724
|
+
addProp(name, "true");
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
addProp(
|
|
1728
|
+
"cdx:vscode-extension:galleryId",
|
|
1729
|
+
states.map((s) => s.galleryId).find(Boolean),
|
|
1730
|
+
);
|
|
1731
|
+
if (states.length && states.every((s) => s.obsolete)) {
|
|
1732
|
+
addProp("cdx:vscode-extension:pendingRemoval", "true");
|
|
1733
|
+
}
|
|
1734
|
+
if (states.some((s) => s.unregistered)) {
|
|
1735
|
+
addProp("cdx:vscode-extension:registered", "false");
|
|
980
1736
|
}
|
|
1737
|
+
if (props.length) {
|
|
1738
|
+
component.properties = props;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
981
1741
|
|
|
982
|
-
|
|
983
|
-
|
|
1742
|
+
/**
|
|
1743
|
+
* Read one installed extension folder as JSON text, only from a regular file
|
|
1744
|
+
* inside that folder.
|
|
1745
|
+
*
|
|
1746
|
+
* @param {string} extDir Extension folder
|
|
1747
|
+
* @param {string} fileName File in the folder
|
|
1748
|
+
* @returns {string|undefined} File text
|
|
1749
|
+
*/
|
|
1750
|
+
function readExtensionFileText(extDir, fileName) {
|
|
1751
|
+
return readRegularTextFile(
|
|
1752
|
+
join(extDir, fileName),
|
|
1753
|
+
MAX_METADATA_FILE_BYTES,
|
|
1754
|
+
extDir,
|
|
1755
|
+
);
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
/**
|
|
1759
|
+
* Parse a single installed extension folder.
|
|
1760
|
+
*
|
|
1761
|
+
* `package.json` gives the identity and capabilities. The `.vsixmanifest` the
|
|
1762
|
+
* editor keeps beside it adds the target platform, links and manifest
|
|
1763
|
+
* properties, and the folder name supplies the platform when neither does.
|
|
1764
|
+
*
|
|
1765
|
+
* @param {string} extDir Absolute path to the extension folder (e.g. `~/.cursor/extensions/anthropic.claude-code-2.1.313-darwin-arm64`)
|
|
1766
|
+
* @param {string} [ideName] Name of the editor the folder belongs to
|
|
1767
|
+
* @param {Object} [installRecord] Entry for the folder from extensions.json
|
|
1768
|
+
* @param {Object} [options] Parse options
|
|
1769
|
+
* @param {boolean} [options.nameOnly] Read only the folder name, as a root
|
|
1770
|
+
* scan does for a symlinked folder
|
|
1771
|
+
* @returns {Object|undefined} CycloneDX component object or undefined
|
|
1772
|
+
*/
|
|
1773
|
+
export function parseInstalledExtensionDir(
|
|
1774
|
+
extDir,
|
|
1775
|
+
ideName,
|
|
1776
|
+
installRecord,
|
|
1777
|
+
options = {},
|
|
1778
|
+
) {
|
|
1779
|
+
const dirParts = parseVscodeExtensionDirName(basename(extDir));
|
|
1780
|
+
let extInfo;
|
|
1781
|
+
let manifestInfo;
|
|
1782
|
+
if (!options.nameOnly) {
|
|
1783
|
+
const packageJson = readExtensionFileText(extDir, "package.json");
|
|
1784
|
+
if (packageJson !== undefined) {
|
|
1785
|
+
const parsed = parseVsixPackageJson(packageJson, extDir);
|
|
1786
|
+
if (parsed?.name) {
|
|
1787
|
+
extInfo = parsed;
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
const manifest = readExtensionFileText(extDir, ".vsixmanifest");
|
|
1791
|
+
if (manifest !== undefined) {
|
|
1792
|
+
manifestInfo = parseVsixManifest(manifest);
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
if (!extInfo && manifestInfo) {
|
|
1796
|
+
extInfo = { ...manifestInfo, srcPath: extDir };
|
|
1797
|
+
} else if (extInfo && manifestInfo) {
|
|
1798
|
+
for (const key of [
|
|
1799
|
+
"vscodeEngine",
|
|
1800
|
+
"executesCode",
|
|
1801
|
+
"links",
|
|
1802
|
+
"extensionKind",
|
|
1803
|
+
"extensionDependencies",
|
|
1804
|
+
"extensionPack",
|
|
1805
|
+
]) {
|
|
1806
|
+
if (extInfo[key] === undefined && manifestInfo[key] !== undefined) {
|
|
1807
|
+
extInfo[key] = manifestInfo[key];
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
extInfo.platform = manifestInfo.platform || "";
|
|
1811
|
+
}
|
|
1812
|
+
if (!extInfo && dirParts) {
|
|
1813
|
+
extInfo = {
|
|
1814
|
+
publisher: dirParts.publisher,
|
|
1815
|
+
name: dirParts.name,
|
|
1816
|
+
version: dirParts.version,
|
|
1817
|
+
displayName: "",
|
|
1818
|
+
description: "",
|
|
1819
|
+
platform: "",
|
|
1820
|
+
srcPath: extDir,
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
if (!extInfo) {
|
|
1824
|
+
return undefined;
|
|
1825
|
+
}
|
|
1826
|
+
extInfo.platform =
|
|
1827
|
+
normalizeVscodeTargetPlatform(extInfo.platform) ||
|
|
1828
|
+
normalizeVscodeTargetPlatform(installRecord?.metadata?.targetPlatform) ||
|
|
1829
|
+
dirParts?.platform ||
|
|
1830
|
+
"";
|
|
1831
|
+
return toComponent(extInfo, ideName);
|
|
984
1832
|
}
|
|
985
1833
|
|
|
986
1834
|
/**
|
|
987
1835
|
* Attempt to extract extension metadata from a directory name following the
|
|
988
|
-
* pattern `publisher.name-version`.
|
|
1836
|
+
* pattern `publisher.name-version` or `publisher.name-version-platform`.
|
|
989
1837
|
*
|
|
990
1838
|
* @param {string} extDir Absolute path to extension directory
|
|
991
1839
|
* @param {string} [ideName] IDE name
|
|
992
1840
|
* @returns {Object|undefined} CycloneDX component or undefined
|
|
993
1841
|
*/
|
|
994
1842
|
export function parseExtensionDirName(extDir, ideName) {
|
|
995
|
-
const dirName = extDir
|
|
996
|
-
|
|
1843
|
+
const dirName = extDir
|
|
1844
|
+
.split(/[/\\]/)
|
|
1845
|
+
.filter((segment) => segment.length)
|
|
1846
|
+
.pop();
|
|
1847
|
+
const parts = parseVscodeExtensionDirName(dirName);
|
|
1848
|
+
if (!parts) {
|
|
997
1849
|
return undefined;
|
|
998
1850
|
}
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1851
|
+
return toComponent(
|
|
1852
|
+
{
|
|
1853
|
+
publisher: parts.publisher,
|
|
1854
|
+
name: parts.name,
|
|
1855
|
+
version: parts.version,
|
|
1856
|
+
displayName: "",
|
|
1857
|
+
description: "",
|
|
1858
|
+
platform: parts.platform,
|
|
1859
|
+
srcPath: extDir,
|
|
1860
|
+
},
|
|
1861
|
+
ideName,
|
|
1862
|
+
);
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
/**
|
|
1866
|
+
* Decide whether a folder is an editor's extensions folder: one at a path a
|
|
1867
|
+
* known editor uses, or one holding the `extensions.json` or `.obsolete` an
|
|
1868
|
+
* editor writes. A source repository whose subfolders happen to include an
|
|
1869
|
+
* extension is not one.
|
|
1870
|
+
*
|
|
1871
|
+
* @param {string} dir Folder to check
|
|
1872
|
+
* @returns {boolean} True when the folder is an extensions folder
|
|
1873
|
+
*/
|
|
1874
|
+
export function isInstalledExtensionsDir(dir) {
|
|
1875
|
+
if (identifyIdeFromExtensionsDir(dir)) {
|
|
1876
|
+
return safeExistsSync(dir);
|
|
1877
|
+
}
|
|
1878
|
+
return (
|
|
1879
|
+
safeExistsSync(join(dir, "extensions.json")) ||
|
|
1880
|
+
safeExistsSync(join(dir, ".obsolete"))
|
|
1881
|
+
);
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
/**
|
|
1885
|
+
* Decide whether a folder is one installed extension: a `.vsixmanifest`, or a
|
|
1886
|
+
* package.json with a publisher and a `vscode` engine.
|
|
1887
|
+
*
|
|
1888
|
+
* @param {string} dir Folder to check
|
|
1889
|
+
* @returns {boolean} True for an extension folder
|
|
1890
|
+
*/
|
|
1891
|
+
export function isInstalledExtensionDir(dir) {
|
|
1892
|
+
if (readExtensionFileText(dir, ".vsixmanifest") !== undefined) {
|
|
1893
|
+
return true;
|
|
1894
|
+
}
|
|
1895
|
+
let pkg;
|
|
1896
|
+
try {
|
|
1897
|
+
pkg = JSON.parse(readExtensionFileText(dir, "package.json") ?? "");
|
|
1898
|
+
} catch {
|
|
1899
|
+
return false;
|
|
1900
|
+
}
|
|
1901
|
+
return Boolean(
|
|
1902
|
+
typeof pkg?.publisher === "string" && pkg?.engines?.vscode !== undefined,
|
|
1903
|
+
);
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
/**
|
|
1907
|
+
* Build a CycloneDX component for an installed editor or remote server.
|
|
1908
|
+
*
|
|
1909
|
+
* @param {{id: string, name: string, kind: string, dirs?: string[], dir?: string, home?: string}} ide
|
|
1910
|
+
* Discovered extensions folder, or folders, of one editor
|
|
1911
|
+
* @param {Object} [productInfo] Result of findIdeProductInfo
|
|
1912
|
+
* @param {number} extensionCount Number of extension builds found
|
|
1913
|
+
* @returns {Object} CycloneDX application component
|
|
1914
|
+
*/
|
|
1915
|
+
export function toIdeComponent(ide, productInfo, extensionCount) {
|
|
1916
|
+
const version = productInfo?.version;
|
|
1917
|
+
let purl;
|
|
1918
|
+
try {
|
|
1919
|
+
purl = build({
|
|
1920
|
+
type: "generic",
|
|
1921
|
+
namespace: IDE_PURL_NAMESPACE,
|
|
1922
|
+
name: ide.id,
|
|
1923
|
+
version: version || null,
|
|
1924
|
+
});
|
|
1925
|
+
} catch {
|
|
1926
|
+
purl = undefined;
|
|
1004
1927
|
}
|
|
1005
|
-
const
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1928
|
+
const dirs = ide.dirs || [ide.dir];
|
|
1929
|
+
const properties = [
|
|
1930
|
+
{ name: "cdx:ide:id", value: ide.id },
|
|
1931
|
+
{ name: "cdx:ide:kind", value: ide.kind },
|
|
1932
|
+
{
|
|
1933
|
+
name: "cdx:ide:extensionsDir",
|
|
1934
|
+
value: [...new Set(dirs.map((d) => redactHomePath(d, ide.home)))].join(
|
|
1935
|
+
", ",
|
|
1936
|
+
),
|
|
1937
|
+
},
|
|
1938
|
+
{ name: "cdx:ide:extensionCount", value: String(extensionCount) },
|
|
1939
|
+
];
|
|
1940
|
+
if (productInfo?.vscodeVersion && productInfo.vscodeVersion !== version) {
|
|
1941
|
+
properties.push({
|
|
1942
|
+
name: "cdx:ide:vscodeVersion",
|
|
1943
|
+
value: productInfo.vscodeVersion,
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1946
|
+
if (productInfo?.commit) {
|
|
1947
|
+
properties.push({ name: "cdx:ide:commit", value: productInfo.commit });
|
|
1948
|
+
}
|
|
1949
|
+
if (productInfo?.quality) {
|
|
1950
|
+
properties.push({ name: "cdx:ide:quality", value: productInfo.quality });
|
|
1951
|
+
}
|
|
1952
|
+
if (productInfo?.appDir) {
|
|
1953
|
+
properties.push({
|
|
1954
|
+
name: "cdx:ide:installDir",
|
|
1955
|
+
value: redactHomePath(productInfo.appDir, ide.home),
|
|
1956
|
+
});
|
|
1957
|
+
}
|
|
1958
|
+
const component = {
|
|
1959
|
+
type: "application",
|
|
1960
|
+
group: IDE_PURL_NAMESPACE,
|
|
1961
|
+
name: ide.name,
|
|
1962
|
+
version: version || "",
|
|
1963
|
+
description: productInfo?.nameLong || ide.name,
|
|
1964
|
+
"bom-ref": purl ? decodeURIComponent(purl) : `urn:ide:${ide.id}`,
|
|
1965
|
+
properties,
|
|
1966
|
+
tags: ["ide", ide.kind === "server" ? "ide-server" : "ide-desktop"],
|
|
1967
|
+
};
|
|
1968
|
+
if (purl) {
|
|
1969
|
+
component.purl = purl;
|
|
1970
|
+
}
|
|
1971
|
+
return component;
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
/**
|
|
1975
|
+
* Collect the installed extensions of each editor folder, plus a component for
|
|
1976
|
+
* each editor and the dependency edges from an editor to its extensions.
|
|
1977
|
+
*
|
|
1978
|
+
* One extension installed in two editors is one component: the editors are
|
|
1979
|
+
* listed in `cdx:vscode-extension:ide`, every folder it was found in is an
|
|
1980
|
+
* occurrence, and every copy's entry files are hashed. When two copies of the
|
|
1981
|
+
* same build have different entry-file bytes, each digest becomes its own file
|
|
1982
|
+
* component and the extension is marked `cdx:vscode-extension:entryHashMismatch`.
|
|
1983
|
+
*
|
|
1984
|
+
* @param {Array<{id?: string, name?: string, kind?: string, dir: string, home?: string, only?: string, probeApps?: boolean}>} ideDirs
|
|
1985
|
+
* Extensions folders, as returned by discoverIdeExtensionDirs. `only` limits
|
|
1986
|
+
* a folder to one extension; `home` and `probeApps` say where the editor's
|
|
1987
|
+
* own version is looked up (see findIdeProductInfo)
|
|
1988
|
+
* @param {Object} [options] Collection options
|
|
1989
|
+
* @param {boolean} [options.includeIdeComponents=true] Emit a component for
|
|
1990
|
+
* each editor and link it to its extensions
|
|
1991
|
+
* @param {string} [options.home] Home directory used when a folder names none
|
|
1992
|
+
* @param {boolean} [options.probeApps] Default for folders that do not say
|
|
1993
|
+
* @returns {{components: Object[], dependencies: Object[]}} Inventory
|
|
1994
|
+
*/
|
|
1995
|
+
export function collectIdeInventory(ideDirs, options = {}) {
|
|
1996
|
+
const includeIdeComponents = options.includeIdeComponents !== false;
|
|
1997
|
+
const extensions = new Map();
|
|
1998
|
+
const ideGroups = new Map();
|
|
1999
|
+
for (const ide of ideDirs) {
|
|
2000
|
+
try {
|
|
2001
|
+
collectIdeFolder(ide, options, extensions, ideGroups);
|
|
2002
|
+
} catch (err) {
|
|
2003
|
+
thoughtLog(`Unable to read the extensions in ${ide.dir}: ${err.message}`);
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
const components = [];
|
|
2007
|
+
const dependencies = [];
|
|
2008
|
+
if (includeIdeComponents) {
|
|
2009
|
+
for (const group of ideGroups.values()) {
|
|
2010
|
+
const home = group.home || options.home || homedir();
|
|
2011
|
+
const probeApps = group.probeApps ?? options.probeApps;
|
|
2012
|
+
const ideComponent = toIdeComponent(
|
|
2013
|
+
group,
|
|
2014
|
+
findIdeProductInfo(group, home, { probeApps }),
|
|
2015
|
+
group.refs.size,
|
|
2016
|
+
);
|
|
2017
|
+
components.push(ideComponent);
|
|
2018
|
+
if (group.refs.size) {
|
|
2019
|
+
dependencies.push({
|
|
2020
|
+
ref: ideComponent["bom-ref"],
|
|
2021
|
+
dependsOn: [...group.refs].sort(),
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
for (const entry of extensions.values()) {
|
|
2027
|
+
components.push(finishExtension(entry));
|
|
2028
|
+
}
|
|
2029
|
+
return { components, dependencies };
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
function collectIdeFolder(ide, options, extensions, ideGroups) {
|
|
2033
|
+
const { name: ideName, dir } = ide;
|
|
2034
|
+
const { records, obsolete, hasRegistry } =
|
|
2035
|
+
readInstalledExtensionsRegistry(dir);
|
|
2036
|
+
let group;
|
|
2037
|
+
if (ide.id) {
|
|
2038
|
+
const home = ide.home || options.home;
|
|
2039
|
+
const key = `${ide.kind || "desktop"}\u0000${ide.id}\u0000${home || ""}`;
|
|
2040
|
+
group = ideGroups.get(key);
|
|
2041
|
+
if (!group) {
|
|
2042
|
+
group = {
|
|
2043
|
+
id: ide.id,
|
|
2044
|
+
name: ide.name,
|
|
2045
|
+
kind: ide.kind || "desktop",
|
|
2046
|
+
home,
|
|
2047
|
+
probeApps: ide.probeApps,
|
|
2048
|
+
dirs: [],
|
|
2049
|
+
refs: new Set(),
|
|
2050
|
+
};
|
|
2051
|
+
ideGroups.set(key, group);
|
|
2052
|
+
}
|
|
2053
|
+
group.dirs.push(dir);
|
|
2054
|
+
}
|
|
2055
|
+
let entries;
|
|
2056
|
+
try {
|
|
2057
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
2058
|
+
} catch {
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
let seenFolders = 0;
|
|
2062
|
+
for (const entry of entries) {
|
|
2063
|
+
if (seenFolders >= MAX_EXTENSION_FOLDERS) {
|
|
2064
|
+
thoughtLog(
|
|
2065
|
+
`Stopped reading ${dir} after ${MAX_EXTENSION_FOLDERS} extension folders.`,
|
|
2066
|
+
);
|
|
1012
2067
|
break;
|
|
1013
2068
|
}
|
|
2069
|
+
if (entry.name.startsWith(".") || (ide.only && entry.name !== ide.only)) {
|
|
2070
|
+
continue;
|
|
2071
|
+
}
|
|
2072
|
+
const extDir = join(dir, entry.name);
|
|
2073
|
+
let isLink = false;
|
|
2074
|
+
if (entry.isSymbolicLink()) {
|
|
2075
|
+
isLink = true;
|
|
2076
|
+
try {
|
|
2077
|
+
if (!statSync(extDir).isDirectory()) {
|
|
2078
|
+
continue;
|
|
2079
|
+
}
|
|
2080
|
+
} catch {
|
|
2081
|
+
continue;
|
|
2082
|
+
}
|
|
2083
|
+
} else if (!entry.isDirectory()) {
|
|
2084
|
+
continue;
|
|
2085
|
+
}
|
|
2086
|
+
// A root scan reads only the name of a symlinked folder, which a user
|
|
2087
|
+
// could point anywhere.
|
|
2088
|
+
const nameOnly = isLink && isRunningAsRoot();
|
|
2089
|
+
if (
|
|
2090
|
+
!parseVscodeExtensionDirName(entry.name) &&
|
|
2091
|
+
(nameOnly || !isInstalledExtensionDir(extDir))
|
|
2092
|
+
) {
|
|
2093
|
+
continue;
|
|
2094
|
+
}
|
|
2095
|
+
seenFolders++;
|
|
2096
|
+
const record = records.get(entry.name);
|
|
2097
|
+
const component = parseInstalledExtensionDir(extDir, ideName, record, {
|
|
2098
|
+
nameOnly,
|
|
2099
|
+
});
|
|
2100
|
+
if (!component) {
|
|
2101
|
+
continue;
|
|
2102
|
+
}
|
|
2103
|
+
const ref = component["bom-ref"];
|
|
2104
|
+
group?.refs.add(ref);
|
|
2105
|
+
let target = extensions.get(ref);
|
|
2106
|
+
if (!target) {
|
|
2107
|
+
target = {
|
|
2108
|
+
component,
|
|
2109
|
+
ideNames: [],
|
|
2110
|
+
occurrences: [],
|
|
2111
|
+
states: [],
|
|
2112
|
+
files: new Map(),
|
|
2113
|
+
};
|
|
2114
|
+
extensions.set(ref, target);
|
|
2115
|
+
}
|
|
2116
|
+
if (ideName && !target.ideNames.includes(ideName)) {
|
|
2117
|
+
target.ideNames.push(ideName);
|
|
2118
|
+
}
|
|
2119
|
+
target.occurrences.push(extDir);
|
|
2120
|
+
const state = installStateFromRecord(record);
|
|
2121
|
+
state.obsolete = obsolete.has(entry.name);
|
|
2122
|
+
state.unregistered = hasRegistry && !record && !state.obsolete;
|
|
2123
|
+
state.symlink = isLink;
|
|
2124
|
+
target.states.push(state);
|
|
2125
|
+
if (!nameOnly) {
|
|
2126
|
+
const props = component.properties || [];
|
|
2127
|
+
for (const file of hashExtensionEntryFiles(extDir, {
|
|
2128
|
+
main: props.find((p) => p.name === "cdx:vscode-extension:main")?.value,
|
|
2129
|
+
browser: props.find((p) => p.name === "cdx:vscode-extension:browser")
|
|
2130
|
+
?.value,
|
|
2131
|
+
})) {
|
|
2132
|
+
const digests = target.files.get(file.name) || new Map();
|
|
2133
|
+
if (!digests.has(file.digest)) {
|
|
2134
|
+
digests.set(file.digest, file);
|
|
2135
|
+
}
|
|
2136
|
+
target.files.set(file.name, digests);
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
1014
2139
|
}
|
|
1015
|
-
|
|
1016
|
-
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
function finishExtension(entry) {
|
|
2143
|
+
const { component } = entry;
|
|
2144
|
+
const ref = component["bom-ref"];
|
|
2145
|
+
component.properties = (component.properties || []).filter(
|
|
2146
|
+
(p) => p.name !== "cdx:vscode-extension:ide",
|
|
2147
|
+
);
|
|
2148
|
+
if (entry.ideNames.length) {
|
|
2149
|
+
component.properties.unshift({
|
|
2150
|
+
name: "cdx:vscode-extension:ide",
|
|
2151
|
+
value: entry.ideNames.join(", "),
|
|
2152
|
+
});
|
|
1017
2153
|
}
|
|
1018
|
-
|
|
1019
|
-
const
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
srcPath: extDir,
|
|
1029
|
-
};
|
|
1030
|
-
return toComponent(extInfo, ideName);
|
|
2154
|
+
applyInstallStates(component, entry.states);
|
|
2155
|
+
const files = [];
|
|
2156
|
+
let mismatch = false;
|
|
2157
|
+
for (const digests of entry.files.values()) {
|
|
2158
|
+
if (digests.size > 1) {
|
|
2159
|
+
mismatch = true;
|
|
2160
|
+
}
|
|
2161
|
+
for (const file of digests.values()) {
|
|
2162
|
+
files.push(toEntryFileComponent(ref, file, digests.size > 1));
|
|
2163
|
+
}
|
|
1031
2164
|
}
|
|
1032
|
-
|
|
2165
|
+
if (mismatch) {
|
|
2166
|
+
component.properties.push({
|
|
2167
|
+
name: "cdx:vscode-extension:entryHashMismatch",
|
|
2168
|
+
value: "true",
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2171
|
+
if (files.length) {
|
|
2172
|
+
component.components = files;
|
|
2173
|
+
}
|
|
2174
|
+
component.evidence = {
|
|
2175
|
+
...(component.evidence || {}),
|
|
2176
|
+
occurrences: entry.occurrences.map((location) => ({ location })),
|
|
2177
|
+
};
|
|
2178
|
+
if (!component.properties.length) {
|
|
2179
|
+
delete component.properties;
|
|
2180
|
+
}
|
|
2181
|
+
return component;
|
|
1033
2182
|
}
|
|
1034
2183
|
|
|
1035
2184
|
/**
|
|
@@ -1039,31 +2188,141 @@ export function parseExtensionDirName(extDir, ideName) {
|
|
|
1039
2188
|
* @returns {Object[]} Array of CycloneDX component objects
|
|
1040
2189
|
*/
|
|
1041
2190
|
export function collectInstalledExtensions(ideDirs) {
|
|
1042
|
-
|
|
1043
|
-
|
|
2191
|
+
return collectIdeInventory(ideDirs, { includeIdeComponents: false })
|
|
2192
|
+
.components;
|
|
2193
|
+
}
|
|
1044
2194
|
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
2195
|
+
/**
|
|
2196
|
+
* Merge two descriptions of the same extension build, such as a `.vsix` file
|
|
2197
|
+
* and the folder it was installed into, so neither loses its hashes,
|
|
2198
|
+
* occurrences, entry files or properties.
|
|
2199
|
+
*
|
|
2200
|
+
* @param {Object[]} components Components, some of which share a bom-ref
|
|
2201
|
+
* @returns {Object[]} Components with one entry per extension bom-ref
|
|
2202
|
+
*/
|
|
2203
|
+
export function mergeExtensionComponents(components) {
|
|
2204
|
+
const merged = [];
|
|
2205
|
+
const byRef = new Map();
|
|
2206
|
+
for (const component of components) {
|
|
2207
|
+
const ref = component?.["bom-ref"];
|
|
2208
|
+
if (!ref || !component.purl?.startsWith("pkg:vscode-extension/")) {
|
|
2209
|
+
merged.push(component);
|
|
1050
2210
|
continue;
|
|
1051
2211
|
}
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
2212
|
+
const known = byRef.get(ref);
|
|
2213
|
+
if (!known) {
|
|
2214
|
+
byRef.set(ref, component);
|
|
2215
|
+
merged.push(component);
|
|
2216
|
+
continue;
|
|
2217
|
+
}
|
|
2218
|
+
mergeInto(known, component);
|
|
2219
|
+
}
|
|
2220
|
+
return merged;
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
function mergeInto(known, component) {
|
|
2224
|
+
for (const hash of component.hashes || []) {
|
|
2225
|
+
known.hashes = known.hashes || [];
|
|
2226
|
+
if (
|
|
2227
|
+
!known.hashes.some(
|
|
2228
|
+
(h) => h.alg === hash.alg && h.content === hash.content,
|
|
2229
|
+
)
|
|
2230
|
+
) {
|
|
2231
|
+
known.hashes.push(hash);
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
known.properties = known.properties || [];
|
|
2235
|
+
for (const prop of component.properties || []) {
|
|
2236
|
+
const existing = known.properties.find((p) => p.name === prop.name);
|
|
2237
|
+
if (!existing) {
|
|
2238
|
+
known.properties.push(prop);
|
|
2239
|
+
} else if (prop.name === "cdx:vscode-extension:ide") {
|
|
2240
|
+
const names = existing.value.split(", ");
|
|
2241
|
+
for (const name of prop.value.split(", ")) {
|
|
2242
|
+
if (!names.includes(name)) {
|
|
2243
|
+
names.push(name);
|
|
2244
|
+
}
|
|
1065
2245
|
}
|
|
2246
|
+
existing.value = names.join(", ");
|
|
1066
2247
|
}
|
|
1067
2248
|
}
|
|
1068
|
-
|
|
2249
|
+
const occurrences = known.evidence?.occurrences || [];
|
|
2250
|
+
for (const occurrence of component.evidence?.occurrences || []) {
|
|
2251
|
+
if (!occurrences.some((o) => o.location === occurrence.location)) {
|
|
2252
|
+
occurrences.push(occurrence);
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
if (occurrences.length) {
|
|
2256
|
+
known.evidence = { ...(known.evidence || {}), occurrences };
|
|
2257
|
+
}
|
|
2258
|
+
for (const child of component.components || []) {
|
|
2259
|
+
known.components = known.components || [];
|
|
2260
|
+
if (!known.components.some((c) => c["bom-ref"] === child["bom-ref"])) {
|
|
2261
|
+
known.components.push(child);
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
/**
|
|
2267
|
+
* Add the host's editors and the extensions osquery could not see to an OBOM
|
|
2268
|
+
* component list.
|
|
2269
|
+
*
|
|
2270
|
+
* osquery's `vscode_extensions` table reads `extensions.json` for a fixed set
|
|
2271
|
+
* of editors, so extensions of other editors, and folders an editor never
|
|
2272
|
+
* registered, are missing from its rows. The editor folders of each home are
|
|
2273
|
+
* read here: an extension osquery already reported keeps its row and gains the
|
|
2274
|
+
* editor names, the occurrences and the hashed entry files, and the rest are
|
|
2275
|
+
* added. Every editor found also becomes a component. No dependency edges are
|
|
2276
|
+
* added, because an OBOM has no dependency graph.
|
|
2277
|
+
*
|
|
2278
|
+
* @param {Object[]} pkgList OBOM components, which are updated in place
|
|
2279
|
+
* @param {Object} [options] Collection options
|
|
2280
|
+
* @param {string[]} [options.homes] Home directories to read
|
|
2281
|
+
* @param {string} [options.home] One home directory to read, used when
|
|
2282
|
+
* `homes` is not given; defaults to the current user's
|
|
2283
|
+
* @param {Array<Object>} [options.ideDirs] Extensions folders to read in place
|
|
2284
|
+
* of discovering them
|
|
2285
|
+
* @returns {Object[]} Components to append to the OBOM
|
|
2286
|
+
*/
|
|
2287
|
+
export function collectObomIdeInventory(pkgList, options = {}) {
|
|
2288
|
+
const homes = options.homes?.length
|
|
2289
|
+
? options.homes
|
|
2290
|
+
: [options.home || homedir()];
|
|
2291
|
+
const ideDirs =
|
|
2292
|
+
options.ideDirs ||
|
|
2293
|
+
homes.flatMap((home) =>
|
|
2294
|
+
discoverIdeExtensionDirs(home).map((ide) => ({
|
|
2295
|
+
...ide,
|
|
2296
|
+
probeApps: true,
|
|
2297
|
+
})),
|
|
2298
|
+
);
|
|
2299
|
+
if (!ideDirs.length) {
|
|
2300
|
+
return [];
|
|
2301
|
+
}
|
|
2302
|
+
const inventory = collectIdeInventory(ideDirs, {
|
|
2303
|
+
home: homes[0],
|
|
2304
|
+
probeApps: true,
|
|
2305
|
+
});
|
|
2306
|
+
const existing = new Map(
|
|
2307
|
+
pkgList
|
|
2308
|
+
.filter((c) => c?.["bom-ref"])
|
|
2309
|
+
.map((component) => [component["bom-ref"], component]),
|
|
2310
|
+
);
|
|
2311
|
+
const additions = [];
|
|
2312
|
+
for (const component of inventory.components) {
|
|
2313
|
+
const known = existing.get(component["bom-ref"]);
|
|
2314
|
+
if (!known) {
|
|
2315
|
+
additions.push(component);
|
|
2316
|
+
continue;
|
|
2317
|
+
}
|
|
2318
|
+
const scanned = {
|
|
2319
|
+
...component,
|
|
2320
|
+
properties: (component.properties || []).filter((p) =>
|
|
2321
|
+
p.name.startsWith("cdx:vscode-extension:"),
|
|
2322
|
+
),
|
|
2323
|
+
hashes: undefined,
|
|
2324
|
+
};
|
|
2325
|
+
mergeInto(known, scanned);
|
|
2326
|
+
}
|
|
2327
|
+
return additions;
|
|
1069
2328
|
}
|