licenseguard-cli 2.0.0 → 2.1.1
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/CHANGELOG.md +210 -0
- package/README.md +111 -9
- package/bin/licenseguard.js +26 -0
- package/lib/commands/init-fast.js +2 -10
- package/lib/commands/init.js +3 -11
- package/lib/commands/scan.js +122 -0
- package/lib/scanner/color-mapper.js +87 -0
- package/lib/scanner/compat-checker.js +369 -50
- package/lib/scanner/index.js +75 -117
- package/lib/scanner/license-compatibility-matrix.json +338 -0
- package/lib/scanner/license-detector.js +847 -0
- package/lib/scanner/license-normalizer.js +357 -0
- package/lib/scanner/plugins/cpp.js +267 -0
- package/lib/scanner/plugins/go.js +420 -0
- package/lib/scanner/plugins/node.js +149 -0
- package/lib/scanner/plugins/python-license-scanner.py +173 -0
- package/lib/scanner/plugins/python.js +336 -0
- package/lib/scanner/plugins/rust.js +196 -0
- package/lib/utils/license-mapper.js +28 -0
- package/lib/utils/update-notifier.js +141 -0
- package/package.json +2 -2
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Python License Scanner - Native Python implementation
|
|
4
|
+
Scans installed packages and extracts license information using importlib.metadata
|
|
5
|
+
|
|
6
|
+
This script is called by Node.js and returns JSON output.
|
|
7
|
+
Handles all worst-case scenarios:
|
|
8
|
+
1. Python version compatibility (3.7+)
|
|
9
|
+
2. Read-only filesystems (runs via python -c)
|
|
10
|
+
3. Encoding issues (force UTF-8)
|
|
11
|
+
4. Virtualenv detection
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
# MITIGATION #3: Force UTF-8 encoding (Windows CP1252 fix)
|
|
19
|
+
if sys.version_info >= (3, 7):
|
|
20
|
+
sys.stdout.reconfigure(encoding='utf-8')
|
|
21
|
+
|
|
22
|
+
# MITIGATION #1: Python version compatibility
|
|
23
|
+
# Try importlib.metadata (Python 3.8+), fallback to pkg_resources (Python 3.7)
|
|
24
|
+
try:
|
|
25
|
+
import importlib.metadata as metadata
|
|
26
|
+
USING_IMPORTLIB = True
|
|
27
|
+
except ImportError:
|
|
28
|
+
try:
|
|
29
|
+
import pkg_resources
|
|
30
|
+
USING_IMPORTLIB = False
|
|
31
|
+
except ImportError:
|
|
32
|
+
print(json.dumps({"error": "Neither importlib.metadata nor pkg_resources available"}), file=sys.stderr)
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def extract_license_from_classifier(classifier):
|
|
37
|
+
"""
|
|
38
|
+
Extract license name from Trove Classifier
|
|
39
|
+
Example: "License :: OSI Approved :: MIT License" -> "MIT License"
|
|
40
|
+
"""
|
|
41
|
+
parts = classifier.split(' :: ')
|
|
42
|
+
last = parts[-1].strip()
|
|
43
|
+
return last if last != 'OSI Approved' else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_license_via_importlib(package_name):
|
|
47
|
+
"""
|
|
48
|
+
Get license using importlib.metadata (Python 3.8+)
|
|
49
|
+
Implements pip-licenses priority: License-Expression > Classifier > License
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
dist = metadata.distribution(package_name)
|
|
53
|
+
|
|
54
|
+
# Priority 1: License-Expression (PEP 639 - modern packages)
|
|
55
|
+
license_expr = dist.metadata.get('License-Expression')
|
|
56
|
+
if license_expr and license_expr.strip() and license_expr.strip().lower() != 'none':
|
|
57
|
+
return license_expr.strip()
|
|
58
|
+
|
|
59
|
+
# Priority 2: Trove Classifiers
|
|
60
|
+
classifiers = dist.metadata.get_all('Classifier') or []
|
|
61
|
+
license_classifiers = [c for c in classifiers if c.startswith('License ::')]
|
|
62
|
+
if license_classifiers:
|
|
63
|
+
license_from_classifier = extract_license_from_classifier(license_classifiers[0])
|
|
64
|
+
if license_from_classifier:
|
|
65
|
+
return license_from_classifier
|
|
66
|
+
|
|
67
|
+
# Priority 3: License field (legacy)
|
|
68
|
+
license_field = dist.metadata.get('License')
|
|
69
|
+
if license_field and license_field.strip() and license_field.strip().lower() != 'none':
|
|
70
|
+
return license_field.strip()
|
|
71
|
+
|
|
72
|
+
return 'UNKNOWN'
|
|
73
|
+
|
|
74
|
+
except Exception:
|
|
75
|
+
return 'UNKNOWN'
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_license_via_pkg_resources(package_name):
|
|
79
|
+
"""
|
|
80
|
+
Get license using pkg_resources (Python 3.7 fallback)
|
|
81
|
+
"""
|
|
82
|
+
try:
|
|
83
|
+
dist = pkg_resources.get_distribution(package_name)
|
|
84
|
+
|
|
85
|
+
if dist.has_metadata('METADATA'):
|
|
86
|
+
metadata_content = dist.get_metadata('METADATA')
|
|
87
|
+
lines = metadata_content.split('\n')
|
|
88
|
+
|
|
89
|
+
license_expr = None
|
|
90
|
+
license_field = None
|
|
91
|
+
classifiers = []
|
|
92
|
+
|
|
93
|
+
for line in lines:
|
|
94
|
+
if line.startswith('License-Expression:'):
|
|
95
|
+
license_expr = line.split(':', 1)[1].strip()
|
|
96
|
+
elif line.startswith('License:'):
|
|
97
|
+
license_field = line.split(':', 1)[1].strip()
|
|
98
|
+
elif line.startswith('Classifier:') and 'License ::' in line:
|
|
99
|
+
classifiers.append(line.split(':', 1)[1].strip())
|
|
100
|
+
|
|
101
|
+
# Priority: License-Expression > Classifier > License
|
|
102
|
+
if license_expr and license_expr.lower() != 'none':
|
|
103
|
+
return license_expr
|
|
104
|
+
|
|
105
|
+
if classifiers:
|
|
106
|
+
license_from_classifier = extract_license_from_classifier(classifiers[0])
|
|
107
|
+
if license_from_classifier:
|
|
108
|
+
return license_from_classifier
|
|
109
|
+
|
|
110
|
+
if license_field and license_field.lower() != 'none':
|
|
111
|
+
return license_field
|
|
112
|
+
|
|
113
|
+
return 'UNKNOWN'
|
|
114
|
+
|
|
115
|
+
except Exception:
|
|
116
|
+
return 'UNKNOWN'
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def scan_packages(package_names):
|
|
120
|
+
"""
|
|
121
|
+
Scan licenses for a list of packages
|
|
122
|
+
Returns: dict {package_name: license_string}
|
|
123
|
+
"""
|
|
124
|
+
results = {}
|
|
125
|
+
get_license = get_license_via_importlib if USING_IMPORTLIB else get_license_via_pkg_resources
|
|
126
|
+
|
|
127
|
+
for pkg in package_names:
|
|
128
|
+
# Try exact name first
|
|
129
|
+
license_info = get_license(pkg)
|
|
130
|
+
|
|
131
|
+
# If UNKNOWN, try with normalized name (dashes to underscores)
|
|
132
|
+
if license_info == 'UNKNOWN' and '-' in pkg:
|
|
133
|
+
normalized = pkg.replace('-', '_')
|
|
134
|
+
license_info = get_license(normalized)
|
|
135
|
+
|
|
136
|
+
# If still UNKNOWN, try with underscores to dashes
|
|
137
|
+
if license_info == 'UNKNOWN' and '_' in pkg:
|
|
138
|
+
normalized = pkg.replace('_', '-')
|
|
139
|
+
license_info = get_license(normalized)
|
|
140
|
+
|
|
141
|
+
results[pkg.lower()] = license_info
|
|
142
|
+
|
|
143
|
+
return results
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def main():
|
|
147
|
+
"""
|
|
148
|
+
Main entry point
|
|
149
|
+
Expects: JSON array of package names via stdin
|
|
150
|
+
Returns: JSON object {package: license} via stdout
|
|
151
|
+
"""
|
|
152
|
+
try:
|
|
153
|
+
# Read package list from stdin
|
|
154
|
+
input_data = sys.stdin.read()
|
|
155
|
+
package_names = json.loads(input_data)
|
|
156
|
+
|
|
157
|
+
if not isinstance(package_names, list):
|
|
158
|
+
raise ValueError("Expected JSON array of package names")
|
|
159
|
+
|
|
160
|
+
# Scan licenses
|
|
161
|
+
results = scan_packages(package_names)
|
|
162
|
+
|
|
163
|
+
# Output JSON to stdout
|
|
164
|
+
print(json.dumps(results, ensure_ascii=False))
|
|
165
|
+
|
|
166
|
+
except Exception as e:
|
|
167
|
+
error_output = {"error": str(e)}
|
|
168
|
+
print(json.dumps(error_output), file=sys.stderr)
|
|
169
|
+
sys.exit(1)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == '__main__':
|
|
173
|
+
main()
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python Ecosystem Plugin (pip/pipenv/poetry)
|
|
3
|
+
* Scans Python dependencies for license information
|
|
4
|
+
* Supports three package managers with priority: poetry > pipenv > pip
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs')
|
|
8
|
+
const { execSync } = require('child_process')
|
|
9
|
+
const { checkCompatibility } = require('../compat-checker')
|
|
10
|
+
const { showProgress } = require('../progress')
|
|
11
|
+
const { normalize } = require('../license-normalizer')
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Detect if this is a Python project and return package manager
|
|
15
|
+
* Priority order: poetry > pipenv > pip
|
|
16
|
+
* @returns {string|boolean} 'poetry' | 'pipenv' | 'pip' | false
|
|
17
|
+
*/
|
|
18
|
+
function detect() {
|
|
19
|
+
const hasPoetry = fs.existsSync('pyproject.toml')
|
|
20
|
+
const hasPipenv = fs.existsSync('Pipfile')
|
|
21
|
+
const hasPip = fs.existsSync('requirements.txt')
|
|
22
|
+
|
|
23
|
+
// Priority: poetry > pipenv > pip
|
|
24
|
+
if (hasPoetry) return 'poetry'
|
|
25
|
+
if (hasPipenv) return 'pipenv'
|
|
26
|
+
if (hasPip) return 'pip'
|
|
27
|
+
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse requirements.txt to get dependency list
|
|
33
|
+
* @returns {string[]} Array of package names
|
|
34
|
+
*/
|
|
35
|
+
function parseRequirementsTxt() {
|
|
36
|
+
if (!fs.existsSync('requirements.txt')) {
|
|
37
|
+
return []
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const content = fs.readFileSync('requirements.txt', 'utf8')
|
|
41
|
+
const packages = []
|
|
42
|
+
|
|
43
|
+
for (const line of content.split('\n')) {
|
|
44
|
+
const trimmed = line.trim()
|
|
45
|
+
|
|
46
|
+
// Skip empty lines and comments
|
|
47
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Extract package name from various formats:
|
|
52
|
+
// requests==2.31.0
|
|
53
|
+
// numpy>=1.24.0
|
|
54
|
+
// pandas~=2.0.0
|
|
55
|
+
// flask[async]>=2.0.0
|
|
56
|
+
// git+https://... (skip)
|
|
57
|
+
// -r requirements-dev.txt (skip)
|
|
58
|
+
// -e . (skip editable installs)
|
|
59
|
+
|
|
60
|
+
if (trimmed.startsWith('-') || trimmed.startsWith('git+') || trimmed.startsWith('http')) {
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Extract package name before version specifier or extras
|
|
65
|
+
const match = trimmed.match(/^([a-zA-Z0-9_-]+)/)
|
|
66
|
+
if (match) {
|
|
67
|
+
packages.push(match[1])
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return packages
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse Pipfile to get dependency list
|
|
76
|
+
* @returns {string[]} Array of package names
|
|
77
|
+
*/
|
|
78
|
+
function parsePipfile() {
|
|
79
|
+
if (!fs.existsSync('Pipfile')) {
|
|
80
|
+
return []
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const content = fs.readFileSync('Pipfile', 'utf8')
|
|
84
|
+
const packages = []
|
|
85
|
+
let inPackages = false
|
|
86
|
+
|
|
87
|
+
for (const line of content.split('\n')) {
|
|
88
|
+
const trimmed = line.trim()
|
|
89
|
+
|
|
90
|
+
// Check for [packages] section
|
|
91
|
+
if (trimmed === '[packages]') {
|
|
92
|
+
inPackages = true
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Check for other sections (end of packages)
|
|
97
|
+
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
98
|
+
inPackages = false
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Parse package in packages section
|
|
103
|
+
// Format: package = "version" or package = {version = "1.0.0"}
|
|
104
|
+
if (inPackages && trimmed && !trimmed.startsWith('#')) {
|
|
105
|
+
const match = trimmed.match(/^([a-zA-Z0-9_-]+)\s*=/)
|
|
106
|
+
if (match) {
|
|
107
|
+
packages.push(match[1])
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return packages
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Parse pyproject.toml to get dependency list (Poetry format)
|
|
117
|
+
* @returns {string[]} Array of package names
|
|
118
|
+
*/
|
|
119
|
+
function parsePyprojectToml() {
|
|
120
|
+
if (!fs.existsSync('pyproject.toml')) {
|
|
121
|
+
return []
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const content = fs.readFileSync('pyproject.toml', 'utf8')
|
|
125
|
+
const packages = []
|
|
126
|
+
let inDependencies = false
|
|
127
|
+
|
|
128
|
+
for (const line of content.split('\n')) {
|
|
129
|
+
const trimmed = line.trim()
|
|
130
|
+
|
|
131
|
+
// Check for [tool.poetry.dependencies] section
|
|
132
|
+
if (trimmed === '[tool.poetry.dependencies]') {
|
|
133
|
+
inDependencies = true
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Check for other sections (end of dependencies)
|
|
138
|
+
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
139
|
+
inDependencies = false
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Parse package in dependencies section
|
|
144
|
+
// Format: package = "^1.0.0" or package = {version = "^1.0.0"}
|
|
145
|
+
if (inDependencies && trimmed && !trimmed.startsWith('#')) {
|
|
146
|
+
// Skip python version specification
|
|
147
|
+
if (trimmed.startsWith('python =')) {
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const match = trimmed.match(/^([a-zA-Z0-9_-]+)\s*=/)
|
|
152
|
+
if (match) {
|
|
153
|
+
packages.push(match[1])
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return packages
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Batch fetch licenses using native Python script
|
|
163
|
+
* Uses importlib.metadata directly in Python for maximum reliability
|
|
164
|
+
* Handles: version compatibility, encoding, virtualenv detection
|
|
165
|
+
*
|
|
166
|
+
* @param {string[]} packageNames - Array of package names
|
|
167
|
+
* @param {string} manager - Package manager (for error messages)
|
|
168
|
+
* @returns {Object} Map of { packageName: licenseString }
|
|
169
|
+
*/
|
|
170
|
+
function getLicensesBatch(packageNames, _manager) {
|
|
171
|
+
if (packageNames.length === 0) return {}
|
|
172
|
+
|
|
173
|
+
const CHUNK_SIZE = 100 // Process in chunks to avoid stdin size limits
|
|
174
|
+
const licenseMap = {}
|
|
175
|
+
const totalChunks = Math.ceil(packageNames.length / CHUNK_SIZE)
|
|
176
|
+
|
|
177
|
+
// Path to Python scanner script
|
|
178
|
+
const scriptPath = `${__dirname}/python-license-scanner.py`
|
|
179
|
+
|
|
180
|
+
for (let i = 0; i < packageNames.length; i += CHUNK_SIZE) {
|
|
181
|
+
const currentChunk = Math.floor(i / CHUNK_SIZE) + 1
|
|
182
|
+
showProgress(currentChunk, totalChunks)
|
|
183
|
+
|
|
184
|
+
const chunk = packageNames.slice(i, i + CHUNK_SIZE)
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
// Call Python scanner script with package list via stdin
|
|
188
|
+
const input = JSON.stringify(chunk)
|
|
189
|
+
const output = execSync(`python "${scriptPath}"`, {
|
|
190
|
+
input: input,
|
|
191
|
+
encoding: 'utf8',
|
|
192
|
+
timeout: 60000, // 60 seconds for large chunks
|
|
193
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// Parse JSON response
|
|
197
|
+
const results = JSON.parse(output)
|
|
198
|
+
|
|
199
|
+
// Normalize licenses before adding to map (using shared normalizer)
|
|
200
|
+
for (const [pkg, license] of Object.entries(results)) {
|
|
201
|
+
const normalized = normalize(license)
|
|
202
|
+
licenseMap[pkg.toLowerCase()] = normalized
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
} catch (error) {
|
|
206
|
+
const stderr = error.stderr || ''
|
|
207
|
+
const message = error.message || ''
|
|
208
|
+
|
|
209
|
+
// 1. FATAL: Python not available
|
|
210
|
+
if (message.includes('command not found') ||
|
|
211
|
+
message.includes('not recognized') ||
|
|
212
|
+
message.includes('ENOENT')) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
'Python not installed.\n\n' +
|
|
215
|
+
'Install Python 3.7+ from: https://www.python.org/downloads/\n' +
|
|
216
|
+
'Or skip scanning: licenseguard init --noscan'
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 2. Try to parse error output for diagnostics
|
|
221
|
+
try {
|
|
222
|
+
const errorData = JSON.parse(stderr)
|
|
223
|
+
if (errorData.error) {
|
|
224
|
+
throw new Error(`Python scanner error: ${errorData.error}`)
|
|
225
|
+
}
|
|
226
|
+
} catch (parseErr) {
|
|
227
|
+
// stderr is not JSON, show raw error
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 3. FATAL: System errors
|
|
231
|
+
const fatalErrors = [
|
|
232
|
+
'Permission denied',
|
|
233
|
+
'No space left on device',
|
|
234
|
+
'Disk quota exceeded',
|
|
235
|
+
'Read-only file system'
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
const isFatal = fatalErrors.some(err =>
|
|
239
|
+
stderr.includes(err) || message.includes(err)
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if (isFatal) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
'Python scanner failed with system error:\n\n' +
|
|
245
|
+
(stderr || message)
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 4. UNKNOWN: Mark all packages in this chunk as UNKNOWN
|
|
250
|
+
for (const pkg of chunk) {
|
|
251
|
+
licenseMap[pkg.toLowerCase()] = 'UNKNOWN'
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return licenseMap
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Scan all Python dependencies for license compatibility
|
|
261
|
+
* Uses batch processing for 30x performance improvement on large projects
|
|
262
|
+
* @param {string} projectLicense - The project's license (SPDX format)
|
|
263
|
+
* @returns {Promise<Object>} Scan results
|
|
264
|
+
*/
|
|
265
|
+
async function scanDependencies(projectLicense) {
|
|
266
|
+
const manager = detect()
|
|
267
|
+
|
|
268
|
+
if (!manager) {
|
|
269
|
+
throw new Error('No Python package manager detected (requirements.txt, Pipfile, or pyproject.toml)')
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Get dependency list based on manager
|
|
273
|
+
let packages = []
|
|
274
|
+
if (manager === 'poetry') {
|
|
275
|
+
packages = parsePyprojectToml()
|
|
276
|
+
} else if (manager === 'pipenv') {
|
|
277
|
+
packages = parsePipfile()
|
|
278
|
+
} else {
|
|
279
|
+
packages = parseRequirementsTxt()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Normalize package names to lowercase for consistent lookup
|
|
283
|
+
packages = packages.map(pkg => pkg.toLowerCase())
|
|
284
|
+
|
|
285
|
+
// Batch fetch licenses (O(n/50) instead of O(n) execSync calls)
|
|
286
|
+
const licenseMap = getLicensesBatch(packages, manager)
|
|
287
|
+
|
|
288
|
+
const results = {
|
|
289
|
+
timestamp: new Date().toISOString(),
|
|
290
|
+
totalDependencies: packages.length,
|
|
291
|
+
compatible: 0,
|
|
292
|
+
incompatible: 0,
|
|
293
|
+
unknown: 0,
|
|
294
|
+
issues: []
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Process results (fast in-memory loop)
|
|
298
|
+
for (const packageName of packages) {
|
|
299
|
+
const license = licenseMap[packageName] || 'UNKNOWN'
|
|
300
|
+
|
|
301
|
+
// Check compatibility using universal compat-checker
|
|
302
|
+
const compatResult = checkCompatibility(projectLicense, license)
|
|
303
|
+
|
|
304
|
+
if (!compatResult.compatible) {
|
|
305
|
+
const isUnknown = license === 'UNKNOWN'
|
|
306
|
+
|
|
307
|
+
if (isUnknown) {
|
|
308
|
+
results.unknown++
|
|
309
|
+
} else {
|
|
310
|
+
results.incompatible++
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
results.issues.push({
|
|
314
|
+
package: packageName,
|
|
315
|
+
license: license,
|
|
316
|
+
type: isUnknown ? 'warning' : 'conflict',
|
|
317
|
+
reason: compatResult.reason,
|
|
318
|
+
location: 'Python site-packages'
|
|
319
|
+
})
|
|
320
|
+
} else {
|
|
321
|
+
results.compatible++
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return results
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
module.exports = {
|
|
329
|
+
detect,
|
|
330
|
+
scanDependencies,
|
|
331
|
+
// Export internal functions for testing
|
|
332
|
+
parseRequirementsTxt,
|
|
333
|
+
parsePipfile,
|
|
334
|
+
parsePyprojectToml,
|
|
335
|
+
getLicensesBatch
|
|
336
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rust Ecosystem Plugin (Cargo)
|
|
3
|
+
* Scans Cargo dependencies for license information
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs')
|
|
7
|
+
const { execSync } = require('child_process')
|
|
8
|
+
const { checkCompatibility } = require('../compat-checker')
|
|
9
|
+
const { showProgress } = require('../progress')
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Detect if this is a Rust Cargo project
|
|
13
|
+
* @returns {boolean} True if Cargo.toml exists
|
|
14
|
+
*/
|
|
15
|
+
function detect() {
|
|
16
|
+
return fs.existsSync('Cargo.toml')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get Cargo metadata via CLI
|
|
21
|
+
* @returns {Object} Cargo metadata JSON
|
|
22
|
+
*/
|
|
23
|
+
function getCargoMetadata() {
|
|
24
|
+
try {
|
|
25
|
+
const output = execSync('cargo metadata --format-version 1', {
|
|
26
|
+
encoding: 'utf8',
|
|
27
|
+
timeout: 30000, // 30 second timeout for large projects
|
|
28
|
+
cwd: process.cwd(),
|
|
29
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
30
|
+
})
|
|
31
|
+
return JSON.parse(output)
|
|
32
|
+
} catch (error) {
|
|
33
|
+
// Check if Cargo is not installed
|
|
34
|
+
if (error.message.includes('command not found') ||
|
|
35
|
+
error.message.includes('not recognized') ||
|
|
36
|
+
error.message.includes('ENOENT')) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
'Cargo not installed.\n\n' +
|
|
39
|
+
'Install Rust/Cargo: https://rustup.rs\n' +
|
|
40
|
+
'Or skip scanning: licenseguard init --noscan'
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check for project not found / no Cargo.toml
|
|
45
|
+
if (error.message.includes('could not find') ||
|
|
46
|
+
error.message.includes('no Cargo.toml')) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
'No Cargo.toml found in current directory.\n\n' +
|
|
49
|
+
'Make sure you are in a Rust project directory.'
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
throw new Error('Failed to get Cargo metadata: ' + error.message)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Normalize license string from Rust ecosystem conventions to SPDX format
|
|
59
|
+
* Many older Rust crates use "/" instead of " OR " for dual-licensing
|
|
60
|
+
* e.g., "MIT/Apache-2.0" → "MIT OR Apache-2.0"
|
|
61
|
+
*
|
|
62
|
+
* @param {string} license - Raw license string from Cargo metadata
|
|
63
|
+
* @returns {string} Normalized SPDX-compliant license string
|
|
64
|
+
*/
|
|
65
|
+
function normalizeLicense(license) {
|
|
66
|
+
if (!license) return license
|
|
67
|
+
|
|
68
|
+
// Rust ecosystem convention: "/" means OR (dual-licensing)
|
|
69
|
+
// Convert "MIT/Apache-2.0" → "MIT OR Apache-2.0"
|
|
70
|
+
// Handle both "MIT/Apache-2.0" and "Apache-2.0 / MIT" (with spaces)
|
|
71
|
+
if (license.includes('/') && !license.includes(' OR ')) {
|
|
72
|
+
return license
|
|
73
|
+
.split('/')
|
|
74
|
+
.map(part => part.trim())
|
|
75
|
+
.join(' OR ')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return license
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Extract license from Cargo package metadata
|
|
83
|
+
* @param {Object} pkg - Package object from cargo metadata
|
|
84
|
+
* @returns {string} License identifier or 'UNKNOWN'
|
|
85
|
+
*/
|
|
86
|
+
function extractCrateLicense(pkg) {
|
|
87
|
+
// Cargo metadata includes license field (SPDX format)
|
|
88
|
+
// e.g., "MIT", "Apache-2.0", "MIT OR Apache-2.0"
|
|
89
|
+
if (pkg.license) {
|
|
90
|
+
// Normalize legacy Rust license format to SPDX
|
|
91
|
+
return normalizeLicense(pkg.license)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Some crates use license_file instead
|
|
95
|
+
if (pkg.license_file) {
|
|
96
|
+
return 'UNKNOWN' // Would need to parse license file content
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return 'UNKNOWN'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Filter packages to only include actual dependencies (not root package)
|
|
104
|
+
* @param {Object} metadata - Cargo metadata object
|
|
105
|
+
* @returns {Array} Array of dependency packages
|
|
106
|
+
*/
|
|
107
|
+
function filterDependencies(metadata) {
|
|
108
|
+
const packages = metadata.packages || []
|
|
109
|
+
const rootPackageId = metadata.resolve?.root
|
|
110
|
+
|
|
111
|
+
// Filter out the root package (the project itself)
|
|
112
|
+
return packages.filter(pkg => {
|
|
113
|
+
// Skip if this is the root package
|
|
114
|
+
if (rootPackageId && pkg.id === rootPackageId) {
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Skip packages that are path dependencies from the workspace root
|
|
119
|
+
// These are typically the project's own packages
|
|
120
|
+
if (pkg.source === null && pkg.manifest_path) {
|
|
121
|
+
const manifestPath = pkg.manifest_path
|
|
122
|
+
// If manifest is in the current directory or a subdirectory, it's part of the project
|
|
123
|
+
const cwd = process.cwd()
|
|
124
|
+
if (manifestPath.startsWith(cwd)) {
|
|
125
|
+
return false
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return true
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Scan all Cargo dependencies for license compatibility
|
|
135
|
+
* @param {string} projectLicense - The project's license (SPDX format)
|
|
136
|
+
* @returns {Promise<Object>} Scan results
|
|
137
|
+
*/
|
|
138
|
+
async function scanDependencies(projectLicense) {
|
|
139
|
+
// Get metadata via Cargo CLI
|
|
140
|
+
const metadata = getCargoMetadata()
|
|
141
|
+
|
|
142
|
+
// Filter to only actual dependencies
|
|
143
|
+
const packages = filterDependencies(metadata)
|
|
144
|
+
|
|
145
|
+
const results = {
|
|
146
|
+
timestamp: new Date().toISOString(),
|
|
147
|
+
totalDependencies: packages.length,
|
|
148
|
+
compatible: 0,
|
|
149
|
+
incompatible: 0,
|
|
150
|
+
unknown: 0,
|
|
151
|
+
issues: []
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Scan each dependency
|
|
155
|
+
for (let i = 0; i < packages.length; i++) {
|
|
156
|
+
showProgress(i + 1, packages.length)
|
|
157
|
+
|
|
158
|
+
const pkg = packages[i]
|
|
159
|
+
const license = extractCrateLicense(pkg)
|
|
160
|
+
|
|
161
|
+
// Check compatibility using universal compat-checker
|
|
162
|
+
const compatResult = checkCompatibility(projectLicense, license)
|
|
163
|
+
|
|
164
|
+
if (!compatResult.compatible) {
|
|
165
|
+
const isUnknown = license === 'UNKNOWN'
|
|
166
|
+
|
|
167
|
+
if (isUnknown) {
|
|
168
|
+
results.unknown++
|
|
169
|
+
} else {
|
|
170
|
+
results.incompatible++
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
results.issues.push({
|
|
174
|
+
package: `${pkg.name}@${pkg.version}`,
|
|
175
|
+
license: license,
|
|
176
|
+
type: isUnknown ? 'warning' : 'conflict',
|
|
177
|
+
reason: compatResult.reason,
|
|
178
|
+
location: pkg.manifest_path || 'crates.io'
|
|
179
|
+
})
|
|
180
|
+
} else {
|
|
181
|
+
results.compatible++
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return results
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = {
|
|
189
|
+
detect,
|
|
190
|
+
scanDependencies,
|
|
191
|
+
// Export internal functions for testing
|
|
192
|
+
getCargoMetadata,
|
|
193
|
+
extractCrateLicense,
|
|
194
|
+
filterDependencies,
|
|
195
|
+
normalizeLicense
|
|
196
|
+
}
|