Skip to content

Commit fdeffcb

Browse files
ruromeroStrum355
andauthored
feat: add support for pnpm (#182)
* feat: add support for pnpm Signed-off-by: Ruben Romero Montes <[email protected]> * feat: add support for pnpm Signed-off-by: Ruben Romero Montes <[email protected]> * chore: use invokeCommand instead of execSync * feat: fix tests Signed-off-by: Ruben Romero Montes <[email protected]> --------- Signed-off-by: Ruben Romero Montes <[email protected]> Co-authored-by: Noah Santschi-Cooney <[email protected]>
1 parent a1bb80b commit fdeffcb

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+11425
-419
lines changed

.eslintrc.json

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,28 @@
11
{
2-
"root": true,
3-
"extends": [
4-
"eslint:recommended",
5-
"plugin:editorconfig/all"
6-
],
7-
"parserOptions": {
8-
"ecmaVersion": 2022,
9-
"sourceType": "module"
10-
},
11-
"env": {
12-
"node": true,
13-
"mocha": true,
14-
"es6": true
15-
},
16-
"plugins": [
17-
"editorconfig"
18-
],
19-
"rules": {
20-
"curly": "warn",
21-
"eqeqeq": "warn",
22-
"no-throw-literal": "warn",
23-
"sort-imports":"warn"
24-
},
25-
"ignorePatterns": [
26-
"integration"
27-
]
28-
}
2+
"root": true,
3+
"extends": [
4+
"eslint:recommended",
5+
"plugin:editorconfig/all"
6+
],
7+
"parserOptions": {
8+
"ecmaVersion": 2022,
9+
"sourceType": "module"
10+
},
11+
"env": {
12+
"node": true,
13+
"mocha": true,
14+
"es6": true
15+
},
16+
"plugins": [
17+
"editorconfig"
18+
],
19+
"rules": {
20+
"curly": "warn",
21+
"eqeqeq": "warn",
22+
"no-throw-literal": "warn",
23+
"sort-imports": "warn"
24+
},
25+
"ignorePatterns": [
26+
"integration"
27+
]
28+
}

.github/workflows/pr.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ jobs:
5151
with:
5252
go-version: '1.20.1'
5353

54+
- name: Install pnpm
55+
run: npm install -g pnpm
56+
5457
- name: Setup Gradle
5558
uses: gradle/actions/setup-gradle@v4
5659

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ $ exhort-javascript-api component /path/to/pom.xml
152152
<ul>
153153
<li><a href="https://www.java.com/">Java</a> - <a href="https://maven.apache.org/">Maven</a></li>
154154
<li><a href="https://www.javascript.com/">JavaScript</a> - <a href="https://www.npmjs.com/">Npm</a></li>
155+
<li><a href="https://www.javascript.com/">JavaScript</a> - <a href="https://pnpm.io/">pnpm</a></li>
155156
<li><a href="https://go.dev/">Golang</a> - <a href="https://go.dev/blog/using-go-modules/">Go Modules</a></li>
156157
<li><a href="https://www.python.org/">Python</a> - <a href="https://pypi.org/project/pip/">pip Installer</a></li>
157158
<li><a href="https://gradle.org/">Gradle (Groovy and Kotlin DSL)</a> - <a href="https://gradle.org/install/">Gradle Installation</a></li>
@@ -298,6 +299,7 @@ import fs from 'node:fs'
298299
let options = {
299300
'EXHORT_MVN_PATH': '/path/to/my/mvn',
300301
'EXHORT_NPM_PATH': '/path/to/npm',
302+
'EXHORT_PNPM_PATH': '/path/to/pnpm',
301303
'EXHORT_GO_PATH': '/path/to/go',
302304
//python - python3, pip3 take precedence if python version > 3 installed
303305
'EXHORT_PYTHON3_PATH' : '/path/to/python3',
@@ -343,6 +345,11 @@ following keys for setting custom paths for the said executables.
343345
<td>EXHORT_NPM_PATH</td>
344346
</tr>
345347
<tr>
348+
<td><a href="https://pnpm.io/">PNPM</a></td>
349+
<td><em>pnpm</em></td>
350+
<td>EXHORT_PNPM_PATH</td>
351+
</tr>
352+
<tr>
346353
<td><a href="https://go.dev/blog/using-go-modules/">Go Modules</a></td>
347354
<td><em>go</em></td>
348355
<td>EXHORT_GO_PATH</td>

src/provider.js

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
1+
import path from 'node:path'
2+
13
import golangGomodulesProvider from './providers/golang_gomodules.js'
24
import Java_gradle_groovy from "./providers/java_gradle_groovy.js";
35
import Java_gradle_kotlin from "./providers/java_gradle_kotlin.js";
46
import Java_maven from "./providers/java_maven.js";
5-
import javascriptNpmProvider from './providers/javascript_npm.js'
6-
import path from 'node:path'
77
import pythonPipProvider from './providers/python_pip.js'
8+
import Javascript_npm from './providers/javascript_npm.js';
9+
import Javascript_pnpm from './providers/javascript_pnpm.js';
810

911
/** @typedef {{ecosystem: string, contentType: string, content: string}} Provided */
10-
/** @typedef {{isSupported: function(string): boolean, validateLockFile: function(): void, provideComponent: function(string, {}): Provided, provideStack: function(string, {}): Provided}} Provider */
12+
/** @typedef {{isSupported: function(string): boolean, validateLockFile: function(string): void, provideComponent: function(string, {}): Provided, provideStack: function(string, {}): Provided}} Provider */
1113

1214
/**
1315
* MUST include all providers here.
1416
* @type {[Provider]}
1517
*/
16-
export const availableProviders = [new Java_maven(), new Java_gradle_groovy(), new Java_gradle_kotlin(), javascriptNpmProvider, golangGomodulesProvider, pythonPipProvider]
18+
export const availableProviders = [new Java_maven(), new Java_gradle_groovy(), new Java_gradle_kotlin(), new Javascript_npm(), new Javascript_pnpm(), golangGomodulesProvider, pythonPipProvider]
1719

1820
/**
1921
* Match a provider from a list or providers based on file type.
@@ -26,15 +28,14 @@ export const availableProviders = [new Java_maven(), new Java_gradle_groovy(), n
2628
* @throws {Error} when the manifest is not supported and no provider was matched
2729
*/
2830
export function match(manifest, providers) {
29-
let manifestPath = path.parse(manifest)
30-
let provider = providers.find(prov => prov.isSupported(manifestPath.base))
31-
if (!provider) {
31+
const manifestPath = path.parse(manifest)
32+
const supported = providers.filter(prov => prov.isSupported(manifestPath.base))
33+
if (supported.length === 0) {
3234
throw new Error(`${manifestPath.base} is not supported`)
3335
}
34-
35-
if (provider) {
36-
provider.validateLockFile(manifestPath.dir);
36+
const provider = supported.find(prov => prov.validateLockFile(manifestPath.dir))
37+
if(!provider) {
38+
throw new Error(`${manifestPath.base} requires a lock file. Use your preferred package manager to generate the lock file.`);
3739
}
38-
3940
return provider
4041
}

src/providers/base_java.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { PackageURL } from 'packageurl-js'
2+
23
import { invokeCommand } from "../tools.js"
34

45

@@ -44,7 +45,7 @@ export default class Base_Java {
4445
let matchedScope = target.match(/:compile|:provided|:runtime|:test|:system|:import/g)
4546
let matchedScopeSrc = src.match(/:compile|:provided|:runtime|:test|:system|:import/g)
4647
// only add dependency to sbom if it's not with test scope or if it's root
47-
if ((matchedScope && matchedScope[0] !== ":test" && (matchedScopeSrc && matchedScopeSrc[0] !== ":test")) || (srcDepth == 0 && matchedScope && matchedScope[0] !== ":test")) {
48+
if ((matchedScope && matchedScope[0] !== ":test" && (matchedScopeSrc && matchedScopeSrc[0] !== ":test")) || (srcDepth === 0 && matchedScope && matchedScope[0] !== ":test")) {
4849
sbom.addDependency(sbom.purlToComponent(from), to)
4950
}
5051
} else {

src/providers/base_javascript.js

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import fs from 'node:fs'
2+
import os from "node:os";
3+
import path from 'node:path'
4+
import { PackageURL } from 'packageurl-js'
5+
6+
import { getCustomPath, invokeCommand } from "../tools.js";
7+
import Sbom from '../sbom.js'
8+
9+
/** @typedef {import('../provider.js').Provider} */
10+
11+
/** @typedef {import('../provider.js').Provided} Provided */
12+
13+
const ecosystem = 'npm'
14+
const defaultVersion = 'v0.0.0'
15+
16+
export default class Base_javascript {
17+
18+
// Resolved cmd to use
19+
#cmd;
20+
21+
/**
22+
* @returns {string} the name of the lock file name for the specific implementation
23+
*/
24+
_lockFileName() {
25+
throw new TypeError("_lockFileName must be implemented");
26+
}
27+
28+
/**
29+
* @returns {string} the command name to use for the specific JS package manager
30+
*/
31+
_cmdName() {
32+
throw new TypeError("_cmdName must be implemented");
33+
}
34+
35+
/**
36+
* @returns {Array<string>}
37+
*/
38+
_listCmdArgs() {
39+
throw new TypeError("_listCmdArgs must be implemented");
40+
}
41+
42+
/**
43+
* @returns {Array<string>}
44+
*/
45+
_updateLockFileCmdArgs() {
46+
throw new TypeError("_updateLockFileCmdArgs must be implemented");
47+
}
48+
49+
/**
50+
* @param {string} manifestName - the subject manifest name-type
51+
* @returns {boolean} - return true if `pom.xml` is the manifest name-type
52+
*/
53+
isSupported(manifestName) {
54+
return 'package.json' === manifestName;
55+
}
56+
57+
/**
58+
* Checks if a required lock file exists in the same path as the manifest
59+
*
60+
* @param {string} manifestDir - The base directory where the manifest is located
61+
* @returns {boolean} - True if the lock file exists
62+
*/
63+
validateLockFile(manifestDir) {
64+
const lock = path.join(manifestDir, this._lockFileName());
65+
return fs.existsSync(lock);
66+
}
67+
68+
/**
69+
* Provide content and content type for maven-maven stack analysis.
70+
* @param {string} manifest - the manifest path or name
71+
* @param {{}} [opts={}] - optional various options to pass along the application
72+
* @returns {Provided}
73+
*/
74+
provideStack(manifest, opts = {}) {
75+
return {
76+
ecosystem,
77+
content: this.#getSBOM(manifest, opts, true),
78+
contentType: 'application/vnd.cyclonedx+json'
79+
}
80+
}
81+
82+
/**
83+
* Provide content and content type for maven-maven component analysis.
84+
* @param {string} manifest - path to pom.xml for component report
85+
* @param {{}} [opts={}] - optional various options to pass along the application
86+
* @returns {Provided}
87+
*/
88+
provideComponent(manifest, opts = {}) {
89+
return {
90+
ecosystem,
91+
content: this.#getSBOM(manifest, opts, false),
92+
contentType: 'application/vnd.cyclonedx+json'
93+
}
94+
}
95+
96+
/**
97+
* Utility function for creating Purl String
98+
* @param name the name of the artifact, can include a namespace(group) or not - namespace/artifactName.
99+
* @param version the version of the artifact
100+
* @returns {PackageURL|null} PackageUrl Object ready to be used in SBOM
101+
*/
102+
#toPurl(name, version) {
103+
let parts = name.split("/");
104+
var purlNs, purlName;
105+
if (parts.length === 2) {
106+
purlNs = parts[0];
107+
purlName = parts[1];
108+
} else {
109+
purlName = parts[0];
110+
}
111+
return new PackageURL('npm', purlNs, purlName, version, undefined, undefined);
112+
}
113+
114+
_buildDependencyTree(includeTransitive, manifest) {
115+
this.#version();
116+
let manifestDir = path.dirname(manifest)
117+
this.#createLockFile(manifestDir);
118+
119+
let npmOutput = this.#executeListCmd(includeTransitive, manifestDir);
120+
return JSON.parse(npmOutput);
121+
}
122+
123+
/**
124+
* Create SBOM json string for npm Package.
125+
* @param {string} manifest - path for package.json
126+
* @param {{}} [opts={}] - optional various options to pass along the application
127+
* @returns {string} the SBOM json content
128+
* @private
129+
*/
130+
#getSBOM(manifest, opts = {}, includeTransitive) {
131+
this.#cmd = getCustomPath(this._cmdName(), opts);
132+
const depsObject = this._buildDependencyTree(includeTransitive, manifest, opts);
133+
let rootName = depsObject["name"]
134+
let rootVersion = depsObject["version"]
135+
if (!rootVersion) {
136+
rootVersion = defaultVersion
137+
}
138+
let mainComponent = this.#toPurl(rootName, rootVersion);
139+
140+
let sbom = new Sbom();
141+
sbom.addRoot(mainComponent)
142+
143+
let dependencies = depsObject["dependencies"] || {};
144+
this.#addAllDependencies(sbom, sbom.getRoot(), dependencies)
145+
let packageJson = fs.readFileSync(manifest).toString()
146+
let packageJsonObject = JSON.parse(packageJson);
147+
if (packageJsonObject.exhortignore !== undefined) {
148+
let ignoredDeps = Array.from(packageJsonObject.exhortignore);
149+
sbom.filterIgnoredDeps(ignoredDeps)
150+
}
151+
return sbom.getAsJsonString(opts)
152+
}
153+
154+
/**
155+
* This function recursively build the Sbom from the JSON that npm listing returns
156+
* @param sbom this is the sbom object
157+
* @param from this is the current component in bom (Should start with root/main component of SBOM) for which we want to add all its dependencies.
158+
* @param dependencies the current dependency list (initially it's the list of the root component)
159+
* @private
160+
*/
161+
#addAllDependencies(sbom, from, dependencies) {
162+
Object.entries(dependencies)
163+
.filter(entry => entry[1].version !== undefined)
164+
.forEach(entry => {
165+
let [name, artifact] = entry;
166+
let purl = this.#toPurl(name, artifact.version);
167+
sbom.addDependency(from, purl)
168+
let transitiveDeps = artifact.dependencies
169+
if (transitiveDeps !== undefined) {
170+
this.#addAllDependencies(sbom, sbom.purlToComponent(purl), transitiveDeps)
171+
}
172+
});
173+
}
174+
175+
#executeListCmd(includeTransitive, manifestDir) {
176+
const listArgs = this._listCmdArgs(includeTransitive, manifestDir);
177+
return this.#invokeCommand(listArgs);
178+
}
179+
180+
#version() {
181+
this.#invokeCommand(['--version'], { stdio: 'ignore' });
182+
}
183+
184+
#createLockFile(manifestDir) {
185+
// in windows os, --prefix flag doesn't work, it behaves really weird , instead of installing the package.json fromm the prefix folder,
186+
// it's installing package.json (placed in current working directory of process) into prefix directory, so
187+
let originalDir = process.cwd()
188+
if (os.platform() === 'win32') {
189+
process.chdir(manifestDir)
190+
}
191+
const args = this._updateLockFileCmdArgs(manifestDir);
192+
try {
193+
this.#invokeCommand(args);
194+
} finally {
195+
if (os.platform() === 'win32') {
196+
process.chdir(originalDir)
197+
}
198+
}
199+
}
200+
201+
#invokeCommand(args, opts = {}) {
202+
try {
203+
return invokeCommand(this.#cmd, args, opts);
204+
} catch (error) {
205+
if (error.code === 'ENOENT') {
206+
throw new Error(`${this.#cmd} is not accessible`);
207+
}
208+
throw new Error(`failed to execute ${this.#cmd} ${args}`, { cause: error })
209+
}
210+
}
211+
}
212+

src/providers/golang_gomodules.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ function isSupported(manifestName) {
3232
/**
3333
* @param {string} manifestDir - the directory where the manifest lies
3434
*/
35-
function validateLockFile() {}
35+
function validateLockFile() { return true; }
3636

3737
/**
3838
* Provide content and content type for maven-maven stack analysis.

src/providers/java_gradle.js

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export default class Java_gradle extends Base_java {
113113
/**
114114
* @param {string} manifestDir - the directory where the manifest lies
115115
*/
116-
validateLockFile() {}
116+
validateLockFile() { return true; }
117117

118118
/**
119119
* Provide content and content type for stack analysis.
@@ -131,10 +131,6 @@ export default class Java_gradle extends Base_java {
131131
}
132132
}
133133

134-
testMeNow() {
135-
return {hello: "there"}
136-
}
137-
138134
/**
139135
* Provide content and content type for maven-maven component analysis.
140136
* @param {string} manifest - path to pom.xml for component report

0 commit comments

Comments
 (0)