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