Line data Source code
1 : import 'dart:io';
2 :
3 : import 'package:args/args.dart';
4 : import 'package:args/command_runner.dart';
5 : import 'package:collection/collection.dart';
6 : import 'package:mason/mason.dart';
7 : import 'package:meta/meta.dart';
8 : import 'package:package_config/package_config.dart' as package_config;
9 : // ignore: implementation_imports
10 : import 'package:pana/src/license_detection/license_detector.dart' as detector;
11 : import 'package:path/path.dart' as path;
12 : import 'package:pubspec_lock/pubspec_lock.dart';
13 : import 'package:very_good_cli/src/pub_license/spdx_license.gen.dart';
14 :
15 : /// Overrides the [package_config.findPackageConfig] function for testing.
16 : @visibleForTesting
17 : Future<package_config.PackageConfig?> Function(
18 : Directory directory,
19 : )? findPackageConfigOverride;
20 :
21 : /// Overrides the [detector.detectLicense] function for testing.
22 : @visibleForTesting
23 : Future<detector.Result> Function(String, double)? detectLicenseOverride;
24 :
25 : /// The basename of the pubspec lock file.
26 : @visibleForTesting
27 : const pubspecLockBasename = 'pubspec.lock';
28 :
29 : /// The URI for the pub.dev license page for the given [packageName].
30 1 : @visibleForTesting
31 : Uri pubLicenseUri(String packageName) =>
32 2 : Uri.parse('https://pub.dev/packages/$packageName/license');
33 :
34 : /// The URI for the very_good_cli license documentation page.
35 : @visibleForTesting
36 3 : final licenseDocumentationUri = Uri.parse(
37 : 'https://cli.vgv.dev/docs/commands/check_licenses',
38 : );
39 :
40 : /// Defines a [Map] with dependencies as keys and their licenses as values.
41 : ///
42 : /// If a dependency's license failed to be retrieved its license will be `null`.
43 : typedef _DependencyLicenseMap = Map<String, Set<String>?>;
44 :
45 : /// Defines a [Map] with banned dependencies as keys and their banned licenses
46 : /// as values.
47 : typedef _BannedDependencyLicenseMap = Map<String, Set<String>>;
48 :
49 : /// {@template packages_check_licenses_command}
50 : /// `very_good packages check licenses` command for checking packages licenses.
51 : /// {@endtemplate}
52 : class PackagesCheckLicensesCommand extends Command<int> {
53 : /// {@macro packages_check_licenses_command}
54 15 : PackagesCheckLicensesCommand({
55 : Logger? logger,
56 2 : }) : _logger = logger ?? Logger() {
57 15 : argParser
58 15 : ..addFlag(
59 : 'ignore-retrieval-failures',
60 : help: 'Disregard licenses that failed to be retrieved.',
61 : negatable: false,
62 : )
63 15 : ..addMultiOption(
64 : 'dependency-type',
65 : help: 'The type of dependencies to check licenses for.',
66 15 : allowed: [
67 : 'direct-main',
68 : 'direct-dev',
69 : 'transitive',
70 : ],
71 15 : allowedHelp: {
72 : 'direct-main': 'Check for direct main dependencies.',
73 : 'direct-dev': 'Check for direct dev dependencies.',
74 : 'transitive': 'Check for transitive dependencies.',
75 : },
76 15 : defaultsTo: ['direct-main'],
77 : )
78 15 : ..addMultiOption(
79 : 'allowed',
80 : help: 'Only allow the use of certain licenses.',
81 : )
82 15 : ..addMultiOption(
83 : 'forbidden',
84 : help: 'Deny the use of certain licenses.',
85 : )
86 15 : ..addMultiOption(
87 : 'skip-packages',
88 : help: 'Skip packages from having their licenses checked.',
89 : );
90 : }
91 :
92 : final Logger _logger;
93 :
94 2 : @override
95 : String get description =>
96 : "Check packages' licenses in a Dart or Flutter project.";
97 :
98 15 : @override
99 : String get name => 'licenses';
100 :
101 2 : ArgResults get _argResults => argResults!;
102 :
103 1 : @override
104 : Future<int> run() async {
105 4 : if (_argResults.rest.length > 1) {
106 1 : usageException('Too many arguments');
107 : }
108 :
109 2 : final ignoreFailures = _argResults['ignore-retrieval-failures'] as bool;
110 2 : final dependencyTypes = _argResults['dependency-type'] as List<String>;
111 2 : final allowedLicenses = _argResults['allowed'] as List<String>;
112 2 : final forbiddenLicenses = _argResults['forbidden'] as List<String>;
113 2 : final skippedPackages = _argResults['skip-packages'] as List<String>;
114 : // TODO(alestiago): Add support for threshold.
115 :
116 4 : allowedLicenses.removeWhere((license) => license.trim().isEmpty);
117 4 : forbiddenLicenses.removeWhere((license) => license.trim().isEmpty);
118 :
119 2 : if (allowedLicenses.isNotEmpty && forbiddenLicenses.isNotEmpty) {
120 1 : usageException(
121 3 : '''Cannot specify both ${styleItalic.wrap('allowed')} and ${styleItalic.wrap('forbidden')} options.''',
122 : );
123 : }
124 :
125 2 : final invalidLicenses = _invalidLicenses([
126 : ...allowedLicenses,
127 1 : ...forbiddenLicenses,
128 : ]);
129 1 : if (invalidLicenses.isNotEmpty) {
130 1 : final documentationLink = link(
131 1 : uri: licenseDocumentationUri,
132 : message: 'documentation',
133 : );
134 2 : _logger.warn(
135 2 : '''Some licenses failed to be recognized: ${invalidLicenses.stringify()}. Refer to the $documentationLink for a list of valid licenses.''',
136 : );
137 : }
138 :
139 7 : final target = _argResults.rest.length == 1 ? _argResults.rest[0] : '.';
140 4 : final targetPath = path.normalize(Directory(target).absolute.path);
141 1 : final targetDirectory = Directory(targetPath);
142 1 : if (!targetDirectory.existsSync()) {
143 2 : _logger.err(
144 1 : '''Could not find directory at $targetPath. Specify a valid path to a Dart or Flutter project.''',
145 : );
146 1 : return ExitCode.noInput.code;
147 : }
148 :
149 3 : final progress = _logger.progress('Checking licenses on $targetPath');
150 :
151 2 : final pubspecLockFile = File(path.join(targetPath, pubspecLockBasename));
152 1 : if (!pubspecLockFile.existsSync()) {
153 1 : progress.cancel();
154 3 : _logger.err('Could not find a $pubspecLockBasename in $targetPath');
155 1 : return ExitCode.noInput.code;
156 : }
157 :
158 1 : final pubspecLock = _tryParsePubspecLock(pubspecLockFile);
159 : if (pubspecLock == null) {
160 1 : progress.cancel();
161 3 : _logger.err('Could not parse $pubspecLockBasename in $targetPath');
162 1 : return ExitCode.noInput.code;
163 : }
164 :
165 3 : final filteredDependencies = pubspecLock.packages.where((dependency) {
166 : // ignore: invalid_use_of_protected_member
167 1 : final isPubHosted = dependency.hosted != null;
168 : if (!isPubHosted) return false;
169 :
170 2 : if (skippedPackages.contains(dependency.package())) return false;
171 :
172 1 : final dependencyType = dependency.type();
173 1 : return (dependencyTypes.contains('direct-main') &&
174 1 : dependencyType == DependencyType.direct) ||
175 1 : (dependencyTypes.contains('direct-dev') &&
176 1 : dependencyType == DependencyType.development) ||
177 1 : (dependencyTypes.contains('transitive') &&
178 1 : dependencyType == DependencyType.transitive);
179 : });
180 :
181 1 : if (filteredDependencies.isEmpty) {
182 1 : progress.cancel();
183 2 : _logger.err(
184 2 : '''No hosted dependencies found in $targetPath of type: ${dependencyTypes.stringify()}.''',
185 : );
186 1 : return ExitCode.usage.code;
187 : }
188 :
189 1 : final packageConfig = await _tryFindPackageConfig(targetDirectory);
190 : if (packageConfig == null) {
191 1 : progress.cancel();
192 2 : _logger.err(
193 1 : '''Could not find a valid package config in $targetPath. Run `dart pub get` or `flutter pub get` to generate one.''',
194 : );
195 1 : return ExitCode.noInput.code;
196 : }
197 :
198 1 : final licenses = <String, Set<String>?>{};
199 : final detectLicense = detectLicenseOverride ?? detector.detectLicense;
200 2 : for (final dependency in filteredDependencies) {
201 1 : progress.update(
202 6 : '''Collecting licenses from ${licenses.length + 1} out of ${filteredDependencies.length} ${filteredDependencies.length == 1 ? 'package' : 'packages'}''',
203 : );
204 :
205 1 : final dependencyName = dependency.package();
206 1 : final cachePackageEntry = packageConfig.packages
207 4 : .firstWhereOrNull((package) => package.name == dependencyName);
208 : if (cachePackageEntry == null) {
209 : final errorMessage =
210 1 : '''[$dependencyName] Could not find cached package path.''';
211 : if (!ignoreFailures) {
212 1 : progress.cancel();
213 2 : _logger.err(errorMessage);
214 1 : return ExitCode.noInput.code;
215 : }
216 :
217 3 : _logger.err('\n$errorMessage');
218 2 : licenses[dependencyName] = {SpdxLicense.$unknown.value};
219 : continue;
220 : }
221 :
222 3 : final packagePath = path.normalize(cachePackageEntry.root.path);
223 1 : final packageDirectory = Directory(packagePath);
224 1 : if (!packageDirectory.existsSync()) {
225 : final errorMessage =
226 1 : '''[$dependencyName] Could not find package directory at $packagePath.''';
227 : if (!ignoreFailures) {
228 1 : progress.cancel();
229 2 : _logger.err(errorMessage);
230 1 : return ExitCode.noInput.code;
231 : }
232 :
233 3 : _logger.err('\n$errorMessage');
234 2 : licenses[dependencyName] = {SpdxLicense.$unknown.value};
235 : continue;
236 : }
237 :
238 2 : final licenseFile = File(path.join(packagePath, 'LICENSE'));
239 1 : if (!licenseFile.existsSync()) {
240 2 : licenses[dependencyName] = {SpdxLicense.$unknown.value};
241 : continue;
242 : }
243 :
244 1 : final licenseFileContent = licenseFile.readAsStringSync();
245 :
246 : late final detector.Result detectorResult;
247 : try {
248 1 : detectorResult = await detectLicense(licenseFileContent, 0.9);
249 : } catch (e) {
250 : final errorMessage =
251 1 : '''[$dependencyName] Failed to detect license from $packagePath: $e''';
252 : if (!ignoreFailures) {
253 1 : progress.cancel();
254 2 : _logger.err(errorMessage);
255 1 : return ExitCode.software.code;
256 : }
257 :
258 3 : _logger.err('\n$errorMessage');
259 2 : licenses[dependencyName] = {SpdxLicense.$unknown.value};
260 : continue;
261 : }
262 :
263 1 : final rawLicense = detectorResult.matches
264 : // ignore: invalid_use_of_visible_for_testing_member
265 4 : .map((match) => match.license.identifier)
266 1 : .toSet();
267 1 : licenses[dependencyName] = rawLicense;
268 : }
269 :
270 : late final _BannedDependencyLicenseMap? bannedDependencies;
271 1 : if (allowedLicenses.isNotEmpty) {
272 1 : bannedDependencies = _bannedDependencies(
273 : licenses: licenses,
274 1 : isAllowed: allowedLicenses.contains,
275 : );
276 1 : } else if (forbiddenLicenses.isNotEmpty) {
277 1 : bannedDependencies = _bannedDependencies(
278 : licenses: licenses,
279 2 : isAllowed: (license) => !forbiddenLicenses.contains(license),
280 : );
281 : } else {
282 : bannedDependencies = null;
283 : }
284 :
285 1 : progress.complete(
286 1 : _composeReport(
287 : licenses: licenses,
288 : bannedDependencies: bannedDependencies,
289 : ),
290 : );
291 :
292 : if (bannedDependencies != null) {
293 3 : _logger.err(_composeBannedReport(bannedDependencies));
294 1 : return ExitCode.config.code;
295 : }
296 :
297 1 : return ExitCode.success.code;
298 : }
299 : }
300 :
301 : /// Attempts to parse a [PubspecLock] file in the given [path].
302 : ///
303 : /// If [pubspecLockFile] is not readable or fails to be parsed, `null` is
304 : /// returned.
305 1 : PubspecLock? _tryParsePubspecLock(File pubspecLockFile) {
306 : try {
307 2 : return pubspecLockFile.readAsStringSync().loadPubspecLockFromYaml();
308 : } catch (e) {
309 : return null;
310 : }
311 : }
312 :
313 : /// Attempts to find a [package_config.PackageConfig] using
314 : /// [package_config.findPackageConfig].
315 : ///
316 : /// If [package_config.findPackageConfig] fails to find a package config `null`
317 : /// is returned.
318 1 : Future<package_config.PackageConfig?> _tryFindPackageConfig(
319 : Directory directory,
320 : ) async {
321 : try {
322 : final findPackageConfig =
323 : findPackageConfigOverride ?? package_config.findPackageConfig;
324 1 : return await findPackageConfig(directory);
325 : } catch (error) {
326 : return null;
327 : }
328 : }
329 :
330 : /// Verifies that all [licenses] are valid license inputs.
331 : ///
332 : /// Valid license inputs are:
333 : /// - [SpdxLicense] values.
334 : ///
335 : /// Returns a [List] of invalid licenses, if all licenses are valid the list
336 : /// will be empty.
337 1 : List<String> _invalidLicenses(List<String> licenses) {
338 1 : final invalidLicenses = <String>[];
339 2 : for (final license in licenses) {
340 1 : final parsedLicense = SpdxLicense.tryParse(license);
341 : if (parsedLicense == null) {
342 1 : invalidLicenses.add(license);
343 : }
344 : }
345 :
346 : return invalidLicenses;
347 : }
348 :
349 : /// Returns a [Map] of banned dependencies and their banned licenses.
350 : ///
351 : /// The [Map] is lazily initialized, if no dependencies are banned `null` is
352 : /// returned.
353 1 : _BannedDependencyLicenseMap? _bannedDependencies({
354 : required _DependencyLicenseMap licenses,
355 : required bool Function(String license) isAllowed,
356 : }) {
357 : _BannedDependencyLicenseMap? bannedDependencies;
358 2 : for (final dependency in licenses.entries) {
359 1 : final name = dependency.key;
360 1 : final license = dependency.value;
361 : if (license == null) continue;
362 :
363 2 : for (final licenseType in license) {
364 1 : if (isAllowed(licenseType)) continue;
365 :
366 1 : bannedDependencies ??= <String, Set<String>>{};
367 2 : bannedDependencies.putIfAbsent(name, () => <String>{});
368 2 : bannedDependencies[name]!.add(licenseType);
369 : }
370 : }
371 :
372 : return bannedDependencies;
373 : }
374 :
375 : /// Composes a human friendly [String] to report the result of the retrieved
376 : /// licenses.
377 : ///
378 : /// If [bannedDependencies] is provided those banned licenses will be
379 : /// highlighted in red.
380 1 : String _composeReport({
381 : required _DependencyLicenseMap licenses,
382 : required _BannedDependencyLicenseMap? bannedDependencies,
383 : }) {
384 : final bannedLicenseTypes =
385 3 : bannedDependencies?.values.fold(<String>{}, (previousValue, licenses) {
386 1 : if (licenses.isEmpty) return previousValue;
387 1 : return previousValue..addAll(licenses);
388 : });
389 :
390 : final licenseTypes =
391 4 : licenses.values.fold(<String>[], (previousValue, licenses) {
392 : if (licenses == null) return previousValue;
393 1 : return previousValue..addAll(licenses);
394 : });
395 :
396 1 : final licenseCount = <String, int>{};
397 2 : for (final license in licenseTypes) {
398 4 : licenseCount.update(license, (value) => value + 1, ifAbsent: () => 1);
399 : }
400 1 : final totalLicenseCount = licenseCount.values
401 3 : .fold(0, (previousValue, count) => previousValue + count);
402 :
403 3 : final formattedLicenseTypes = licenseTypes.toSet().map((license) {
404 : final colorWrapper =
405 1 : bannedLicenseTypes != null && bannedLicenseTypes.contains(license)
406 1 : ? red.wrap
407 1 : : green.wrap;
408 :
409 1 : final count = licenseCount[license];
410 2 : final formattedCount = darkGray.wrap('($count)');
411 :
412 2 : return '${colorWrapper(license)} $formattedCount';
413 : });
414 :
415 1 : final licenseWord = totalLicenseCount == 1 ? 'license' : 'licenses';
416 2 : final packageWord = licenses.length == 1 ? 'package' : 'packages';
417 1 : final suffix = formattedLicenseTypes.isEmpty
418 : ? ''
419 3 : : ' of type: ${formattedLicenseTypes.toList().stringify()}';
420 :
421 2 : return '''Retrieved $totalLicenseCount $licenseWord from ${licenses.length} $packageWord$suffix.''';
422 : }
423 :
424 1 : String _composeBannedReport(_BannedDependencyLicenseMap bannedDependencies) {
425 2 : final bannedDependenciesList = bannedDependencies.entries.fold(
426 1 : <String>[],
427 1 : (previousValue, element) {
428 1 : final dependencyName = element.key;
429 1 : final dependencyLicenses = element.value;
430 :
431 1 : final text = '$dependencyName (${link(
432 1 : uri: pubLicenseUri(dependencyName),
433 2 : message: dependencyLicenses.toList().stringify(),
434 1 : )})';
435 1 : return previousValue..add(text);
436 : },
437 : );
438 : final bannedLicenseTypes =
439 3 : bannedDependencies.values.fold(<String>{}, (previousValue, licenses) {
440 1 : if (licenses.isEmpty) return previousValue;
441 1 : return previousValue..addAll(licenses);
442 : });
443 :
444 : final prefix =
445 2 : bannedDependencies.length == 1 ? 'dependency has' : 'dependencies have';
446 : final suffix =
447 2 : bannedLicenseTypes.length == 1 ? 'a banned license' : 'banned licenses';
448 :
449 3 : return '''${bannedDependencies.length} $prefix $suffix: ${bannedDependenciesList.stringify()}.''';
450 : }
451 :
452 : extension on List<Object> {
453 1 : String stringify() {
454 1 : if (isEmpty) return '';
455 4 : if (length == 1) return first.toString();
456 1 : final last = removeLast();
457 2 : return '${join(', ')} and $last';
458 : }
459 : }
|