// Pure-Dart license inventory generator. Parses pubspec.lock and // pulls every dependency's LICENSE file from the local pub cache, // emitting docs/security/flutter-license-inventory.md. // // Run via tools/dump_flutter_licenses.sh from the repo root. import 'dart:convert'; import 'dart:io'; Future main(List argv) async { if (argv.length != 2) { stderr.writeln( 'usage: dump_flutter_licenses.dart ', ); return 64; } final lockPath = argv[0]; final outPath = argv[1]; final lockText = await File(lockPath).readAsString(); final packages = _parsePubspecLockPackages(lockText); final cacheRoot = _pubCacheRoot(); final rows = <_LicenseRow>[]; for (final pkg in packages) { String? licenseText; String? cacheDir; final dir = _resolvePackageDir(cacheRoot, pkg); cacheDir = dir?.path; if (dir != null) { final lic = File('${dir.path}/LICENSE'); if (await lic.exists()) { licenseText = await lic.readAsString(); } else { await for (final entry in dir.list()) { if (entry is File && entry.uri.pathSegments.last.toUpperCase().startsWith('LICENSE')) { licenseText = await entry.readAsString(); break; } } } } else if (pkg.source == 'sdk') { // Flutter SDK packages do not ship a LICENSE in the pub // cache. The Flutter framework itself is BSD-3-Clause; the // canonical text lives in the Flutter SDK at // bin/cache/flutter_tools/LICENSE. Resolve it if FLUTTER_ROOT // is set. final flutterRoot = _flutterRoot(); if (flutterRoot != null) { final candidates = [ '$flutterRoot/LICENSE', '$flutterRoot/packages/${pkg.name}/LICENSE', '$flutterRoot/packages/flutter/LICENSE', ]; for (final path in candidates) { final f = File(path); if (await f.exists()) { licenseText = await f.readAsString(); cacheDir = f.parent.path; break; } } } licenseText ??= '(Flutter SDK component — covered by the Flutter framework license, ' 'BSD-3-Clause; see https://github.com/flutter/flutter/blob/master/LICENSE)'; } rows.add( _LicenseRow( name: pkg.name, version: pkg.version, source: pkg.source, licenseText: licenseText, cacheDir: cacheDir, ), ); } rows.sort((a, b) => a.name.compareTo(b.name)); final missing = rows.where((r) => r.licenseText == null).toList(); final buf = StringBuffer() ..writeln('# Chanora — Flutter / Dart license inventory') ..writeln() ..writeln('Generated by `tools/dump_flutter_licenses.sh` from') ..writeln('`apps/chanora_flutter/pubspec.lock`. The script walks') ..writeln('every resolved dependency in the lockfile, pulls each') ..writeln('package\'s LICENSE file out of the local pub cache,') ..writeln('and writes the result here. CI checks that the') ..writeln('committed copy matches the regenerated output.') ..writeln() ..writeln('Chanora itself is dual-licensed under') ..writeln('[Apache License 2.0](../../LICENSE-APACHE) or the') ..writeln('[MIT License](../../LICENSE-MIT) at the recipient\'s') ..writeln('option (DEC-020). The packages listed below carry') ..writeln('their own licenses and are redistributed under those') ..writeln('terms.') ..writeln() ..writeln('| Package | Version | Source | License found |') ..writeln('|---------|---------|--------|---------------|'); for (final r in rows) { buf.writeln('| `${r.name}` | ${r.version} | ${r.source} | ' '${r.licenseText == null ? '—' : 'yes'} |'); } if (missing.isNotEmpty) { buf ..writeln() ..writeln('## Packages without a LICENSE file in pub cache') ..writeln() ..writeln('The following packages did not ship a LICENSE file at') ..writeln('the top level of their pub-cache directory. The legal') ..writeln('review must confirm each one\'s license posture by') ..writeln('hand before promoting this RC to GA:') ..writeln(); for (final r in missing) { buf.writeln('* `${r.name}` ${r.version} — ' 'source `${r.source}`, cache `${r.cacheDir ?? '(unresolved)'}`'); } } buf ..writeln() ..writeln('## Full license texts') ..writeln(); for (final r in rows) { buf ..writeln('### ${r.name} ${r.version}') ..writeln(); if (r.licenseText == null) { buf.writeln('_No LICENSE file found in pub cache._'); } else { buf ..writeln('```') ..writeln(r.licenseText!.trimRight()) ..writeln('```'); } buf.writeln(); } final out = File(outPath); await out.parent.create(recursive: true); await out.writeAsString(buf.toString()); stderr.writeln( 'wrote $outPath ' '(${rows.length} packages, ${missing.length} without LICENSE)', ); return 0; } class _PackageEntry { _PackageEntry({ required this.name, required this.version, required this.source, required this.description, }); final String name; final String version; final String source; final dynamic description; } class _LicenseRow { _LicenseRow({ required this.name, required this.version, required this.source, required this.licenseText, required this.cacheDir, }); final String name; final String version; final String source; final String? licenseText; final String? cacheDir; } List<_PackageEntry> _parsePubspecLockPackages(String text) { // Minimal YAML reader for pubspec.lock's structure. We don't pull // in a YAML dependency to keep the script self-contained. final lines = LineSplitter.split(text).toList(); final out = <_PackageEntry>[]; var i = 0; // Find the "packages:" top-level mapping. while (i < lines.length && !lines[i].startsWith('packages:')) { i++; } if (i == lines.length) return out; i++; while (i < lines.length) { final line = lines[i]; if (line.isEmpty || line.startsWith('#')) { i++; continue; } // Names are indented two spaces and end with a colon. Anything // less indented terminates the packages: block. if (!line.startsWith(' ') || line.startsWith(' ')) { // Two-space indent only — a deeper indent is a child of the // previous entry; a top-level key ends the packages section. } if (line.length >= 2 && !line.startsWith(' ')) { break; } final trimmed = line.trimLeft(); if (line.startsWith(' ') && !line.startsWith(' ') && trimmed.endsWith(':')) { final name = trimmed.substring(0, trimmed.length - 1); String? version; String source = 'unknown'; String descLine = ''; i++; while (i < lines.length && lines[i].startsWith(' ')) { final child = lines[i].substring(4); if (child.startsWith('version: ')) { version = child.substring('version: '.length).trim(); version = _stripQuotes(version); } else if (child.startsWith('source: ')) { source = child.substring('source: '.length).trim(); } else if (child.startsWith('description:')) { descLine = child; } i++; } if (version != null) { out.add(_PackageEntry( name: name, version: version, source: source, description: descLine, )); } continue; } i++; } return out; } String _stripQuotes(String s) { if (s.length >= 2 && ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")))) { return s.substring(1, s.length - 1); } return s; } String _pubCacheRoot() { final env = Platform.environment['PUB_CACHE']; if (env != null && env.isNotEmpty) return env; if (Platform.isWindows) { final appData = Platform.environment['APPDATA']; if (appData != null) return '$appData/Pub/Cache'; } final home = Platform.environment['HOME'] ?? ''; return '$home/.pub-cache'; } String? _flutterRoot() { final env = Platform.environment['FLUTTER_ROOT']; if (env != null && env.isNotEmpty) return env; final home = Platform.environment['HOME'] ?? ''; final candidates = [ '$home/sdks/flutter', '$home/flutter', '/opt/flutter', ]; for (final c in candidates) { if (Directory(c).existsSync()) return c; } return null; } Directory? _resolvePackageDir(String cacheRoot, _PackageEntry pkg) { if (pkg.source == 'hosted') { final base = Directory('$cacheRoot/hosted/pub.dev/${pkg.name}-${pkg.version}'); if (base.existsSync()) return base; // Older pub layouts use the host directory as a directory name. final alt = Directory('$cacheRoot/hosted/pub.dartlang.org/${pkg.name}-${pkg.version}'); if (alt.existsSync()) return alt; return null; } if (pkg.source == 'git') { // The git directory is content-addressed by commit; we don't // map back to it without re-parsing description. Skip; the // missing-LICENSE table flags these for manual review. return null; } if (pkg.source == 'sdk') { // The Flutter SDK ships its license text bundled into the // framework's LicenseRegistry; not on disk in a discoverable // way for this script. The legal review knows the Flutter // framework is BSD-3-Clause. return null; } return null; }