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.
298 lines
9.3 KiB
Dart
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;
|
|
}
|