Files
chanora/tools/dump_flutter_licenses.dart
T
EdisonJwa b932dc1405 feat(legal): land cargo-about + cargo-deny + Flutter license inventory
Closes engineering deliverables 1–3 from the open-work table in
`docs/governance/legal-review-readiness.md` so the DEC-012 legal
review can actually run. With this commit, the only remaining
engineering item blocking sign-off is signed Windows / macOS / iOS
build artefacts, deferrable per the DEC-002 staged release plan.

Tooling
-------

* `about.toml` + `about.hbs` + `about-md.hbs` configure cargo-about
  with the DEC-020 license posture and the five-target matrix
  (Linux, Android, Windows, macOS, iOS). One per-crate clarification
  for `allo-isolate` (`flutter_rust_bridge` transitive that ships
  Apache-2.0 via `license-file` rather than an SPDX `license`
  field). `cargo about generate` runs with zero warnings.
* `deny.toml` mirrors the cargo-about allow-list and adds minimal
  bans / sources / advisories config. `cargo deny check` reports
  `advisories ok, bans ok, licenses ok, sources ok` for the
  workspace; multiple-versions of `windows_x86_64_msvc` produce
  advisory `warn` (no fail) because three windows-targets versions
  reach the graph via `jni`, `cpal`, and `keyring` respectively.
* `tools/dump_flutter_licenses.sh` + `tools/dump_flutter_licenses.dart`
  walk `apps/chanora_flutter/pubspec.lock`, resolve each dependency
  to its local pub-cache directory, read the LICENSE file, and emit
  `docs/security/flutter-license-inventory.md`. SDK-sourced
  packages (`flutter`, `flutter_localizations`, `flutter_test`,
  `flutter_web_plugins`, `sky_engine`) resolve to the Flutter
  framework BSD-3-Clause LICENSE under `$FLUTTER_ROOT` (or
  `$HOME/sdks/flutter`).

Artefacts
---------

* `docs/security/license-inventory.md` — 364 transitive Rust
  crates with full license texts. Apache-2.0 (276), MIT (55),
  Unicode-3.0 (19), BSD-3-Clause (7), ISC (7). Zero copyleft.
* `docs/security/license-inventory.html` — same data rendered as
  styled HTML for reviewer convenience.
* `docs/security/flutter-license-inventory.md` — 94 Dart / Flutter
  packages with their LICENSE texts. Zero packages without a
  resolvable LICENSE in this RC.

CI
--

* New `supply-chain` job runs `cargo deny check --workspace
  --all-features` via `EmbarkStudios/cargo-deny-action@v2`. Fails
  the build on any GPL / LGPL / AGPL / commercial-source license
  surfacing transitively.
* New `license-inventory` job installs `cargo-about --features cli`
  and regenerates `docs/security/license-inventory.md`; diffs
  against the committed copy and fails on drift. Forces
  contributors who touch the Cargo.lock to refresh the inventory.
* New `flutter-license-inventory` job runs
  `tools/dump_flutter_licenses.sh` against the just-resolved pub
  cache; same diff-on-drift semantics.

Governance
----------

* `docs/governance/legal-review-readiness.md` §5 cross-links the
  three new artefacts in a "Reviewer artefacts" subsection.
* The open-work table at the bottom of the doc is rewritten as a
  status grid: items 1–3 now read **Done**; item 4 (signed iOS /
  macOS builds) remains the only open engineering blocker, with a
  pointer back to `staged-release-plan.md`.

Verification
------------

* `CHANORA_DISABLE_KEYRING=1 cargo test --workspace`: all 49 unit
  + integration tests green (unchanged from v1.0.0-rc.1).
* `cargo deny check`: advisories ok, bans ok, licenses ok,
  sources ok.
* `cargo about generate --output-file …`: zero warnings.
* `tools/dump_flutter_licenses.sh`: 94 packages, 0 without LICENSE.
* `flutter analyze`: clean.

No code changes touch the runtime; this is governance-tooling only.
2026-05-15 14:00:16 +08:00

298 lines
9.3 KiB
Dart

// 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<int> main(List<String> argv) async {
if (argv.length != 2) {
stderr.writeln(
'usage: dump_flutter_licenses.dart <pubspec.lock> <output.md>',
);
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 = <String>[
'$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 = <String>[
'$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;
}