diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 848b058..e95de04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,7 @@ on: branches: - main - dev + - 'staging/**' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -41,14 +42,54 @@ jobs: with: channel: stable - uses: dtolnay/rust-toolchain@stable - - run: cargo install flutter_rust_bridge_codegen@2.11.1 + - run: cargo install flutter_rust_bridge_codegen@2.12.0 - run: flutter pub get - run: flutter_rust_bridge_codegen generate - run: dart run build_runner build --delete-conflicting-outputs - - run: dart analyze lib/ integration_test/ + - run: dart analyze lib/ integration_test/ test/ tool/ example/ + - run: flutter test test/ tool/ + + packaged-consumer: + name: Packaged consumer (Linux) + needs: [rust, dart] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: stable + - uses: dtolnay/rust-toolchain@stable + - run: sudo apt-get update && sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev xvfb + - run: flutter pub get + - name: Assemble the publish payload + run: dart run tool/publication.dart --out "$RUNNER_TEMP/payload" + - name: Run the containment subset from a clean consumer + run: > + xvfb-run -a dart run tool/packaged_consumer.dart + --payload "$RUNNER_TEMP/payload" + --out "$RUNNER_TEMP/consumer" + --device linux + --min-tests 20 + --report "$RUNNER_TEMP/payload.consumer.json" + - name: Retain the payload and consumer evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: packaged-consumer-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/payload.manifest.txt + ${{ runner.temp }}/payload.report.json + ${{ runner.temp }}/payload.consumer.json + ${{ runner.temp }}/payload.tar + if-no-files-found: warn build-android: name: Android build + # The staging lane runs the checks that gate a single item. The platform + # builds are the expensive part and wait for the promotion into dev, where + # they see every item at once. github.base_ref is empty on push, so this + # only ever narrows a pull request. + if: ${{ !startsWith(github.base_ref, 'staging/') }} needs: [rust, dart] runs-on: ubuntu-latest steps: @@ -62,7 +103,7 @@ jobs: - uses: nttld/setup-ndk@v1 with: ndk-version: r27c - - run: cargo install flutter_rust_bridge_codegen@2.11.1 + - run: cargo install flutter_rust_bridge_codegen@2.12.0 - run: flutter pub get - run: flutter_rust_bridge_codegen generate - run: dart run build_runner build --delete-conflicting-outputs @@ -70,6 +111,11 @@ jobs: build-ios: name: Apple builds + # The staging lane runs the checks that gate a single item. The platform + # builds are the expensive part and wait for the promotion into dev, where + # they see every item at once. github.base_ref is empty on push, so this + # only ever narrows a pull request. + if: ${{ !startsWith(github.base_ref, 'staging/') }} needs: [rust, dart] runs-on: macos-latest steps: @@ -84,7 +130,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: targets: aarch64-apple-ios,aarch64-apple-ios-sim - - run: cargo install flutter_rust_bridge_codegen@2.11.1 + - run: cargo install flutter_rust_bridge_codegen@2.12.0 - run: flutter pub get - run: flutter_rust_bridge_codegen generate - run: dart run build_runner build --delete-conflicting-outputs @@ -100,6 +146,11 @@ jobs: build-linux: name: Linux build + # The staging lane runs the checks that gate a single item. The platform + # builds are the expensive part and wait for the promotion into dev, where + # they see every item at once. github.base_ref is empty on push, so this + # only ever narrows a pull request. + if: ${{ !startsWith(github.base_ref, 'staging/') }} needs: [rust, dart] runs-on: ubuntu-latest steps: @@ -109,7 +160,7 @@ jobs: channel: stable - uses: dtolnay/rust-toolchain@stable - run: sudo apt-get update && sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev - - run: cargo install flutter_rust_bridge_codegen@2.11.1 + - run: cargo install flutter_rust_bridge_codegen@2.12.0 - run: flutter pub get - run: flutter_rust_bridge_codegen generate - run: dart run build_runner build --delete-conflicting-outputs diff --git a/.pubignore b/.pubignore index 273958a..b2c1ae9 100644 --- a/.pubignore +++ b/.pubignore @@ -2,6 +2,10 @@ # FRB-generated Dart bindings must be included in the published package # so consumers don't need to run codegen themselves. +# This file replaces the root .gitignore for publishing rather than adding to +# it, so anything the root .gitignore keeps out of the package has to be +# repeated here or it lands in the payload. + # Exclude development/CI files .github/ .vscode/ @@ -9,10 +13,41 @@ *.iml *.ipr *.iws +tool/ # Exclude Rust source and build artifacts (compiled via cargokit at build time) rust/target/ +# Exclude local build output. Whatever the last `flutter test` left in build/ +# would otherwise ship, which made the payload a function of the machine. +build/ +coverage/ +**/Podfile.lock +**/doc/api/ +migrate_working_dir/ +*.log +*.class +*.pyc +*.swp +package-lock.json + +# Code generation writes these into rust/ and they belong to no consumer. +rust/android/ +rust/example/ +rust/integration_test/ +rust/lib/ +rust/assets/ + +# Kotlin DSL duplicates of the tracked Groovy originals, and a local Android +# SDK install. +android/build.gradle.kts +android/settings.gradle.kts +build-tools/ +ndk/ +platform-tools/ +platforms/ +licenses/ + # Exclude example build artifacts example/build/ example/.dart_tool/ diff --git a/example/integration_test/containment_test.dart b/example/integration_test/containment_test.dart new file mode 100644 index 0000000..9e619a5 --- /dev/null +++ b/example/integration_test/containment_test.dart @@ -0,0 +1,467 @@ +/// The subset a packaged consumer runs against the native library. +/// +/// Everything here goes through `package:m_security/m_security.dart` and +/// nothing else, so the same file runs from this example against the checkout +/// and from a throwaway app that depends only on the assembled publish payload. +/// Adding a `package:m_security/src/...` import would quietly break the second +/// one, which is the run that matters. +library; + +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:m_security/m_security.dart'; + +/// A released mobile-preset hash of 'preset_vector'. +const String releasedMobileVector = + r'$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$rUYVKsKrcBrgqxhUdNkDIkzdd3Df9gC3RP6cEdFyM8k'; + +/// The same hash with memory raised past the verification ceiling. +const String overLimitVector = + r'$argon2id$v=19$m=1048576,t=3,p=4$c29tZXNhbHQ$rUYVKsKrcBrgqxhUdNkDIkzdd3Df9gC3RP6cEdFyM8k'; + +/// The error's variant name, e.g. `CryptoError.unsafeLegacyFormatDenied`. +/// +/// The package barrel exports the calls but not the error type, so a consumer +/// that imports only the barrel cannot write `isA()`. The +/// generated `toString` starts with the variant, which is as close as this +/// vantage point gets. +String kindOf(Object error) => error.toString().split('(').first; + +Future errorFrom(Future call) async { + try { + await call; + } catch (error) { + return error; + } + return null; +} + +/// Drain a progress stream, keeping the values and the errors apart. +Future<({List values, List errors})> drain( + Stream progress, +) async { + final values = []; + final errors = []; + final done = Completer(); + + progress.listen( + values.add, + onError: errors.add, + onDone: done.complete, + cancelOnError: false, + ); + await done.future; + + return (values: values, errors: errors); +} + +/// The `.lock`, `.wal` and rotation files a vault path can grow. +List sidecars(String path) => [ + path, + '$path.lock', + '$path.wal', + '$path.rotating', + '$path.defrag', +].where((candidate) => File(candidate).existsSync()).toList(); + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() async => await RustLib.init()); + + late Directory tempDir; + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('m_security_containment'); + }); + tearDown(() async => await tempDir.delete(recursive: true)); + + group('one-shot ciphers', () { + test('AES-256-GCM round trips through the native library', () async { + final cipher = await createAes256Gcm(key: await generateAes256GcmKey()); + final plaintext = Uint8List.fromList( + List.generate(4096, (i) => (i * 31) % 256), + ); + final aad = Uint8List.fromList([9, 8, 7]); + + final ciphertext = await encrypt( + cipher: cipher, + plaintext: plaintext, + aad: aad, + ); + + expect(ciphertext, isNot(plaintext)); + expect( + await decrypt(cipher: cipher, ciphertext: ciphertext, aad: aad), + plaintext, + ); + }); + + test('AES-256-GCM rejects a wrong AAD', () async { + final cipher = await createAes256Gcm(key: await generateAes256GcmKey()); + final ciphertext = await encrypt( + cipher: cipher, + plaintext: Uint8List.fromList([1, 2, 3]), + aad: Uint8List.fromList([4]), + ); + + final error = await errorFrom( + decrypt( + cipher: cipher, + ciphertext: ciphertext, + aad: Uint8List.fromList([5]), + ), + ); + + expect(kindOf(error!), 'CryptoError.authenticationFailed'); + }); + + test('ChaCha20-Poly1305 round trips through the native library', () async { + final cipher = await createChacha20Poly1305( + key: await generateChacha20Poly1305Key(), + ); + final plaintext = Uint8List.fromList( + List.generate(4096, (i) => (i * 17) % 256), + ); + final aad = Uint8List(0); + + final ciphertext = await encrypt( + cipher: cipher, + plaintext: plaintext, + aad: aad, + ); + + expect(ciphertext, isNot(plaintext)); + expect( + await decrypt(cipher: cipher, ciphertext: ciphertext, aad: aad), + plaintext, + ); + }); + + test('an empty plaintext still round trips', () async { + final cipher = await createAes256Gcm(key: await generateAes256GcmKey()); + final ciphertext = await encrypt( + cipher: cipher, + plaintext: Uint8List(0), + aad: Uint8List(0), + ); + + expect(ciphertext, isNotEmpty); + expect( + await decrypt( + cipher: cipher, + ciphertext: ciphertext, + aad: Uint8List(0), + ), + isEmpty, + ); + }); + }); + + group('Argon2id verification limits', () { + test('the released mobile vector verifies', () async { + await argon2IdVerify( + phcHash: releasedMobileVector, + password: 'preset_vector', + ); + }); + + test('a wrong password is an authentication failure', () async { + final error = await errorFrom( + argon2IdVerify(phcHash: releasedMobileVector, password: 'wrong'), + ); + + expect(kindOf(error!), 'CryptoError.authenticationFailed'); + }); + + test('work factors above the ceiling are a policy violation', () async { + final error = await errorFrom( + argon2IdVerify(phcHash: overLimitVector, password: 'preset_vector'), + ); + + expect(kindOf(error!), 'CryptoError.argon2PolicyViolation'); + }); + + // This one stops in the Dart wrapper, before the bridge. The native + // boundary enforces the same ceiling and the Rust suite covers it there. + test('a password over the ceiling is a policy violation', () async { + final error = await errorFrom( + argon2IdVerify( + phcHash: releasedMobileVector, + password: 'a' * (maxArgon2VerifyPasswordBytes + 1), + ), + ); + + expect(kindOf(error!), 'CryptoError.argon2PolicyViolation'); + }); + + test('a password at the ceiling reaches the verifier', () async { + final error = await errorFrom( + argon2IdVerify( + phcHash: releasedMobileVector, + password: 'a' * maxArgon2VerifyPasswordBytes, + ), + ); + + expect(kindOf(error!), 'CryptoError.authenticationFailed'); + }); + + test('overlapping verifications are refused, never queued', () async { + // The bridge dispatches all four before the first finishes, so three come + // back refused rather than waiting their turn. Measured at two, four and + // eight callers: one success every time and the rest refused. + final outcomes = await Future.wait([ + for (var i = 0; i < 4; i++) + errorFrom( + argon2IdVerify( + phcHash: releasedMobileVector, + password: 'preset_vector', + ), + ), + ]); + final kinds = outcomes.map((o) => o == null ? 'ok' : kindOf(o)).toList(); + + expect(kinds, contains('ok')); + expect(kinds, contains('CryptoError.argon2VerificationBusy')); + expect( + kinds.where((k) => k != 'ok' && k != 'CryptoError.argon2VerificationBusy'), + isEmpty, + reason: 'overlap produced an outcome other than success or busy', + ); + + // The permit comes back, so a caller arriving afterwards is not stuck + // with the refusal. + await argon2IdVerify( + phcHash: releasedMobileVector, + password: 'preset_vector', + ); + }); + + test('hashing still produces a verifiable PHC string', () async { + final hash = await argon2IdHash( + password: 'fresh_password', + preset: Argon2Preset.mobile, + ); + + expect(hash, startsWith(r'$argon2id$v=19$m=65536,t=3,p=4$')); + await argon2IdVerify(phcHash: hash, password: 'fresh_password'); + }); + }); + + group('vault format policy', () { + test('creation is denied and writes nothing', () async { + final path = '${tempDir.path}/denied.vault'; + + final error = await errorFrom( + VaultService.create( + path: path, + key: await generateAes256GcmKey(), + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + ), + ); + + expect(kindOf(error!), 'CryptoError.unsafeLegacyFormatDenied'); + expect(sidecars(path), isEmpty); + expect(tempDir.listSync(), isEmpty); + }); + + test('the explicit opt-in round trips a segment', () async { + final path = '${tempDir.path}/optin.vault'; + final key = await generateAes256GcmKey(); + final payload = Uint8List.fromList(List.generate(2048, (i) => i % 256)); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + unsafeLegacyPolicy: UnsafeLegacyEvfsPolicy.allowUnauthenticatedV1V2, + ); + await VaultService.write( + handle: handle, + name: 'payload.bin', + data: payload, + ); + await VaultService.close(handle: handle); + + final reopened = await VaultService.open( + path: path, + key: key, + unsafeLegacyPolicy: UnsafeLegacyEvfsPolicy.allowUnauthenticatedV1V2, + ); + final read = await VaultService.read( + handle: reopened, + name: 'payload.bin', + ); + expect(read.data, payload); + await VaultService.close(handle: reopened); + }); + + test('an earlier opt-in does not authorize the next default open', () async { + final path = '${tempDir.path}/reopen.vault'; + final key = await generateAes256GcmKey(); + + final handle = await VaultService.create( + path: path, + key: key, + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + unsafeLegacyPolicy: UnsafeLegacyEvfsPolicy.allowUnauthenticatedV1V2, + ); + await VaultService.close(handle: handle); + + final before = await File(path).readAsBytes(); + final error = await errorFrom(VaultService.open(path: path, key: key)); + + expect(kindOf(error!), 'CryptoError.unsafeLegacyFormatDenied'); + expect(await File(path).readAsBytes(), before); + }); + + test('denial does not depend on the arguments being usable', () async { + final path = '${tempDir.path}/bad_args.vault'; + + // An empty key and an unknown algorithm are both rejected further in, so + // the denial arriving first means the check ran before either. + final error = await errorFrom( + VaultService.create( + path: path, + key: Uint8List(0), + algorithm: 'not-an-algorithm', + capacityBytes: 1024 * 1024, + ), + ); + + expect(kindOf(error!), 'CryptoError.unsafeLegacyFormatDenied'); + expect(sidecars(path), isEmpty); + }); + }); + + // These six stubs refuse in Dart and never reach the bridge, so what a + // packaged consumer gets to check is the shape of the refusal, not an + // ordering against filesystem access. The Rust suite owns that. + group('disabled formats', () { + test('archive export refuses before reading the vault', () async { + final out = '${tempDir.path}/out.mvex'; + final handle = await VaultService.create( + path: '${tempDir.path}/source.vault', + key: await generateAes256GcmKey(), + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + unsafeLegacyPolicy: UnsafeLegacyEvfsPolicy.allowUnauthenticatedV1V2, + ); + addTearDown(() => VaultService.close(handle: handle)); + + final error = await errorFrom( + VaultService.export( + handle: handle, + wrappingKey: Uint8List(32), + exportPath: out, + ), + ); + + expect(kindOf(error!), 'CryptoError.disabledFormat'); + expect(File(out).existsSync(), isFalse); + }); + + test('archive import refuses before touching either path', () async { + final dest = '${tempDir.path}/imported.vault'; + + final error = await errorFrom( + VaultService.importVault( + archivePath: '${tempDir.path}/absent.mvex', + wrappingKey: Uint8List(32), + destPath: dest, + newMasterKey: await generateAes256GcmKey(), + algorithm: 'aes-256-gcm', + capacityBytes: 1024 * 1024, + ), + ); + + expect(kindOf(error!), 'CryptoError.disabledFormat'); + expect(File(dest).existsSync(), isFalse); + }); + + test('each encrypted stream method emits exactly one refusal', () async { + final input = File('${tempDir.path}/input.bin'); + await input.writeAsBytes( + Uint8List.fromList(List.generate(65536, (i) => i % 256)), + ); + final output = '${tempDir.path}/nested/deeper/out.bin'; + final cipher = await createAes256Gcm(key: await generateAes256GcmKey()); + + final calls = Function()>{ + 'encryptFile': () => StreamingService.encryptFile( + inputPath: input.path, + outputPath: output, + cipher: cipher, + ), + 'decryptFile': () => StreamingService.decryptFile( + inputPath: input.path, + outputPath: output, + cipher: cipher, + ), + 'compressAndEncryptFile': () => + CompressionService.compressAndEncryptFile( + inputPath: input.path, + outputPath: output, + cipher: cipher, + config: const CompressionConfig( + algorithm: CompressionAlgorithm.zstd, + ), + ), + 'decryptAndDecompressFile': () => + CompressionService.decryptAndDecompressFile( + inputPath: input.path, + outputPath: output, + cipher: cipher, + ), + }; + + for (final entry in calls.entries) { + final outcome = await drain(entry.value()); + + expect(outcome.values, isEmpty, reason: entry.key); + expect(outcome.errors, hasLength(1), reason: entry.key); + expect( + kindOf(outcome.errors.single), + 'CryptoError.disabledFormat', + reason: entry.key, + ); + } + + expect(Directory('${tempDir.path}/nested').existsSync(), isFalse); + }); + }); + + group('stream hashing', () { + test('streaming BLAKE3 matches the one-shot digest', () async { + final file = File('${tempDir.path}/hash_me.bin'); + final data = Uint8List.fromList(List.generate(200000, (i) => i % 256)); + await file.writeAsBytes(data); + + final streamed = await StreamingService.hashFile( + filePath: file.path, + hasher: await createBlake3(), + ); + + expect(streamed, await blake3Hash(data: data)); + expect(streamed, hasLength(32)); + }); + + test('an empty file hashes without reading past it', () async { + final file = File('${tempDir.path}/empty.bin'); + await file.writeAsBytes(Uint8List(0)); + + expect( + await StreamingService.hashFile( + filePath: file.path, + hasher: await createBlake3(), + ), + await blake3Hash(data: Uint8List(0)), + ); + }); + }); +} diff --git a/example/pubspec.lock b/example/pubspec.lock index 0cfd965..a4efc7c 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -103,10 +103,10 @@ packages: dependency: transitive description: name: flutter_rust_bridge - sha256: "37ef40bc6f863652e865f0b2563ea07f0d3c58d8efad803cc01933a4b2ee067e" + sha256: e87d6b9ee934dcd24a128ccb2bd91905d2d5fe5c06245d6a8f5477d4907a437a url: "https://pub.dev" source: hosted - version: "2.11.1" + version: "2.12.0" flutter_test: dependency: "direct dev" description: flutter diff --git a/lib/src/rust/api/compression.dart b/lib/src/rust/api/compression.dart index ae72082..1ac1db0 100644 --- a/lib/src/rust/api/compression.dart +++ b/lib/src/rust/api/compression.dart @@ -1,5 +1,5 @@ // This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.11.1. +// @generated by `flutter_rust_bridge`@ 2.12.0. // ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import diff --git a/lib/src/rust/api/encryption.dart b/lib/src/rust/api/encryption.dart index 34f3ee8..34a9e76 100644 --- a/lib/src/rust/api/encryption.dart +++ b/lib/src/rust/api/encryption.dart @@ -1,5 +1,5 @@ // This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.11.1. +// @generated by `flutter_rust_bridge`@ 2.12.0. // ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import diff --git a/lib/src/rust/api/encryption/aes_gcm.dart b/lib/src/rust/api/encryption/aes_gcm.dart index 1ac0ac0..da29fc8 100644 --- a/lib/src/rust/api/encryption/aes_gcm.dart +++ b/lib/src/rust/api/encryption/aes_gcm.dart @@ -1,5 +1,5 @@ // This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.11.1. +// @generated by `flutter_rust_bridge`@ 2.12.0. // ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import diff --git a/lib/src/rust/api/encryption/chacha20.dart b/lib/src/rust/api/encryption/chacha20.dart index aa3fd7a..ad77567 100644 --- a/lib/src/rust/api/encryption/chacha20.dart +++ b/lib/src/rust/api/encryption/chacha20.dart @@ -1,5 +1,5 @@ // This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.11.1. +// @generated by `flutter_rust_bridge`@ 2.12.0. // ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import diff --git a/lib/src/rust/api/evfs.dart b/lib/src/rust/api/evfs.dart index a58fed0..d5ac369 100644 --- a/lib/src/rust/api/evfs.dart +++ b/lib/src/rust/api/evfs.dart @@ -1,5 +1,5 @@ // This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.11.1. +// @generated by `flutter_rust_bridge`@ 2.12.0. // ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import diff --git a/lib/src/rust/api/evfs/types.dart b/lib/src/rust/api/evfs/types.dart index 99299bd..63bf2a4 100644 --- a/lib/src/rust/api/evfs/types.dart +++ b/lib/src/rust/api/evfs/types.dart @@ -1,5 +1,5 @@ // This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.11.1. +// @generated by `flutter_rust_bridge`@ 2.12.0. // ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import diff --git a/lib/src/rust/api/hashing.dart b/lib/src/rust/api/hashing.dart index e4427ef..348a1a6 100644 --- a/lib/src/rust/api/hashing.dart +++ b/lib/src/rust/api/hashing.dart @@ -1,5 +1,5 @@ // This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.11.1. +// @generated by `flutter_rust_bridge`@ 2.12.0. // ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import diff --git a/lib/src/rust/api/hashing/argon2.dart b/lib/src/rust/api/hashing/argon2.dart index d47ba3c..7e3dba5 100644 --- a/lib/src/rust/api/hashing/argon2.dart +++ b/lib/src/rust/api/hashing/argon2.dart @@ -1,5 +1,5 @@ // This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.11.1. +// @generated by `flutter_rust_bridge`@ 2.12.0. // ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import diff --git a/lib/src/rust/api/kdf/hkdf.dart b/lib/src/rust/api/kdf/hkdf.dart index 21a3e13..9c08410 100644 --- a/lib/src/rust/api/kdf/hkdf.dart +++ b/lib/src/rust/api/kdf/hkdf.dart @@ -1,5 +1,5 @@ // This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.11.1. +// @generated by `flutter_rust_bridge`@ 2.12.0. // ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import diff --git a/lib/src/rust/api/streaming.dart b/lib/src/rust/api/streaming.dart index 81cf9e0..e904d22 100644 --- a/lib/src/rust/api/streaming.dart +++ b/lib/src/rust/api/streaming.dart @@ -1,5 +1,5 @@ // This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.11.1. +// @generated by `flutter_rust_bridge`@ 2.12.0. // ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import diff --git a/lib/src/rust/core/error.dart b/lib/src/rust/core/error.dart index 344f927..7355424 100644 --- a/lib/src/rust/core/error.dart +++ b/lib/src/rust/core/error.dart @@ -1,5 +1,5 @@ // This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.11.1. +// @generated by `flutter_rust_bridge`@ 2.12.0. // ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import diff --git a/lib/src/rust/frb_generated.dart b/lib/src/rust/frb_generated.dart index 7ddcb7e..a46eeb3 100644 --- a/lib/src/rust/frb_generated.dart +++ b/lib/src/rust/frb_generated.dart @@ -1,5 +1,5 @@ // This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.11.1. +// @generated by `flutter_rust_bridge`@ 2.12.0. // ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field @@ -71,7 +71,7 @@ class RustLib extends BaseEntrypoint { kDefaultExternalLibraryLoaderConfig; @override - String get codegenVersion => '2.11.1'; + String get codegenVersion => '2.12.0'; @override int get rustContentHash => -1545310340; @@ -81,6 +81,7 @@ class RustLib extends BaseEntrypoint { stem: 'm_security', ioDirectory: 'rust/target/release/', webPrefix: 'pkg/', + wasmBindgenName: 'wasm_bindgen', ); } diff --git a/lib/src/rust/frb_generated.io.dart b/lib/src/rust/frb_generated.io.dart index 8fbcbd3..b61ad94 100644 --- a/lib/src/rust/frb_generated.io.dart +++ b/lib/src/rust/frb_generated.io.dart @@ -1,5 +1,5 @@ // This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.11.1. +// @generated by `flutter_rust_bridge`@ 2.12.0. // ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field diff --git a/lib/src/rust/frb_generated.web.dart b/lib/src/rust/frb_generated.web.dart index 20025b6..0196c89 100644 --- a/lib/src/rust/frb_generated.web.dart +++ b/lib/src/rust/frb_generated.web.dart @@ -1,5 +1,5 @@ // This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.11.1. +// @generated by `flutter_rust_bridge`@ 2.12.0. // ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field diff --git a/pubspec.yaml b/pubspec.yaml index ea2143c..1b2f10c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,11 +21,16 @@ dependencies: collection: ^1.18.0 flutter: sdk: flutter - flutter_rust_bridge: ^2.11.1 + # Exact, not a caret range. The bridge compares the version stamped into the + # committed bindings against its own with string equality and refuses to + # initialise on a mismatch, so any range at all ships a package that a + # consumer can install and not start. + flutter_rust_bridge: 2.12.0 freezed_annotation: ^3.1.0 plugin_platform_interface: ^2.0.2 dev_dependencies: + crypto: ^3.0.7 flutter_test: sdk: flutter flutter_lints: ^6.0.0 diff --git a/rust/.gitignore b/rust/.gitignore index bb1d313..ea8c4bf 100644 --- a/rust/.gitignore +++ b/rust/.gitignore @@ -1,2 +1 @@ /target -src/frb_generated.rs \ No newline at end of file diff --git a/rust/.pubignore b/rust/.pubignore index 6903333..91f9d9b 100644 --- a/rust/.pubignore +++ b/rust/.pubignore @@ -1,3 +1,3 @@ -# Override rust/.gitignore for pub.dev publishing. -# frb_generated.rs must be included so the Rust crate compiles for consumers. +# Replaces rust/.gitignore for pub.dev publishing. src/frb_generated.rs is +# tracked and has to ship, so neither file may list it. /target diff --git a/rust/Cargo.lock b/rust/Cargo.lock index abea3f7..05f8c97 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -466,9 +466,9 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flutter_rust_bridge" -version = "2.11.1" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dde126295b2acc5f0a712e265e91b6fdc0ed38767496483e592ae7134db83725" +checksum = "a0884853aae8a6517b5b58cf36f55da487f2fe110e1686938eb29b6640aae4a5" dependencies = [ "allo-isolate", "android_logger", @@ -495,9 +495,9 @@ dependencies = [ [[package]] name = "flutter_rust_bridge_macros" -version = "2.11.1" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5f0420326b13675321b194928bb7830043b68cf8b810e1c651285c747abb080" +checksum = "6b5ce32f35f710ced8c5aa557f023f1a624e737b5460cee2b70fcd3a8df09e1b" dependencies = [ "hex", "md-5", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 796584c..5807331 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -8,7 +8,7 @@ crate-type = ["cdylib", "staticlib"] [dependencies] # FFI bridge -flutter_rust_bridge = "=2.11.1" +flutter_rust_bridge = "=2.12.0" # Error handling thiserror = "2.0" diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index b3ba6f1..f7ddc6a 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -1,5 +1,5 @@ // This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.11.1. +// @generated by `flutter_rust_bridge`@ 2.12.0. #![allow( non_camel_case_types, @@ -20,6 +20,7 @@ clippy::deref_addrof, clippy::explicit_auto_deref, clippy::borrow_deref_ref, + clippy::uninlined_format_args, clippy::needless_borrow )] @@ -39,7 +40,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_opaque = RustOpaqueNom, default_rust_auto_opaque = RustAutoOpaqueNom, ); -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.11.1"; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1545310340; // Section: executor @@ -1926,7 +1927,7 @@ impl SseDecode for Vec { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { let mut len_ = ::sse_decode(deserializer); - let mut ans_ = vec![]; + let mut ans_ = Vec::with_capacity(len_ as usize); for idx_ in 0..len_ { ans_.push(::sse_decode(deserializer)); } @@ -1938,7 +1939,7 @@ impl SseDecode for Vec { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { let mut len_ = ::sse_decode(deserializer); - let mut ans_ = vec![]; + let mut ans_ = Vec::with_capacity(len_ as usize); for idx_ in 0..len_ { ans_.push(::sse_decode(deserializer)); } @@ -1950,7 +1951,7 @@ impl SseDecode for Vec<(String, String)> { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { let mut len_ = ::sse_decode(deserializer); - let mut ans_ = vec![]; + let mut ans_ = Vec::with_capacity(len_ as usize); for idx_ in 0..len_ { ans_.push(<(String, String)>::sse_decode(deserializer)); } @@ -1962,7 +1963,7 @@ impl SseDecode for Vec { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { let mut len_ = ::sse_decode(deserializer); - let mut ans_ = vec![]; + let mut ans_ = Vec::with_capacity(len_ as usize); for idx_ in 0..len_ { ans_.push(::sse_decode( deserializer, @@ -2965,7 +2966,7 @@ impl SseEncode for crate::api::evfs::types::VaultHealthInfo { #[cfg(not(target_family = "wasm"))] mod io { // This file is automatically generated, so please do not edit it. - // @generated by `flutter_rust_bridge`@ 2.11.1. + // @generated by `flutter_rust_bridge`@ 2.12.0. // Section: imports @@ -4195,7 +4196,7 @@ pub use io::*; #[cfg(target_family = "wasm")] mod web { // This file is automatically generated, so please do not edit it. - // @generated by `flutter_rust_bridge`@ 2.11.1. + // @generated by `flutter_rust_bridge`@ 2.12.0. // Section: imports diff --git a/tool/packaged_consumer.dart b/tool/packaged_consumer.dart new file mode 100644 index 0000000..0959939 --- /dev/null +++ b/tool/packaged_consumer.dart @@ -0,0 +1,575 @@ +// Build a throwaway app against an assembled publish payload and run the +// containment subset on it. +// +// dart run tool/packaged_consumer.dart \ +// --payload --out --device linux --min-tests 20 +// +// The point is that nothing here reaches back into the checkout. The consumer +// lives outside the repository, depends on the payload by path, takes its test +// file out of the payload, and links the native library the payload's own Rust +// sources produce. What it proves is what a `pub get` of this release would +// give somebody. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; + +const String _usage = + 'usage: dart run tool/packaged_consumer.dart --payload --out ' + '[--device linux] [--min-tests 1]'; + +const String _projectName = 'm_security_consumer'; +const String _entrypoint = 'example/integration_test/containment_test.dart'; + +/// Native entries this release removed. None may appear in the built library. +const List _removedSymbols = [ + 'create_noop_encryption', + 'vault_export', + 'vault_import', + 'stream_encrypt_file', + 'stream_decrypt_file', + 'stream_compress_encrypt_file', + 'stream_decrypt_decompress_file', +]; + +/// Entries that must survive, so an empty or unreadable symbol table cannot +/// pass the check above by accident. +const List _keptSymbols = [ + 'create_aes256_gcm', + 'create_chacha20_poly1305', + 'vault_create', + 'stream_hash_file', +]; + +Future main(List args) async { + final options = _Options.parse(args); + + final entrypoint = File('${options.payload}/$_entrypoint'); + if (!entrypoint.existsSync()) { + _fail('the payload has no $_entrypoint'); + } + if (!File('${options.payload}/pubspec.yaml').existsSync()) { + _fail('${options.payload} does not look like a package payload'); + } + + final consumer = Directory(options.out); + if (consumer.existsSync()) consumer.deleteSync(recursive: true); + consumer.createSync(recursive: true); + + await _run('flutter', [ + 'create', + '--template=app', + '--platforms=${options.device}', + '--project-name', + _projectName, + consumer.path, + ], consumer.parent.path); + + File('${consumer.path}/pubspec.yaml').writeAsStringSync( + _consumerPubspec(options.payload), + ); + // The generated options file includes a lint package the pubspec above + // drops, and an unresolved include is an analysis error in its own right. + final generatedOptions = File('${consumer.path}/analysis_options.yaml'); + if (generatedOptions.existsSync()) generatedOptions.deleteSync(); + Directory('${consumer.path}/integration_test').createSync(recursive: true); + File( + '${consumer.path}/integration_test/containment_test.dart', + ).writeAsBytesSync(entrypoint.readAsBytesSync()); + + await _run('flutter', ['pub', 'get'], consumer.path); + await _checkRemovedSurfaceIsUnresolvable(consumer.path); + + final report = await _runContainmentTests(consumer.path, options); + final problems = _verdict(report, options.minTests); + + // The symbol scan and the report are what a failing run most needs, so they + // happen before the verdict is acted on rather than after. + final native = _findNativeLibrary(Directory('${consumer.path}/build')); + final symbols = native == null + ? {'error': 'no built m_security library'} + : await _checkSymbols(native); + + final payloadReport = File('${options.payload}.report.json'); + final result = { + 'payload_dir': options.payload, + 'payload_report': payloadReport.existsSync() ? payloadReport.path : null, + 'consumer_dir': consumer.path, + 'device': options.device, + 'command': + 'flutter test integration_test/containment_test.dart ' + '-d ${options.device} --machine', + 'flutter': (await _capture( + 'flutter', + ['--version'], + consumer.path, + )).split('\n').first, + 'dart': await _capture('dart', ['--version'], consumer.path), + 'rustc': await _capture('rustc', ['--version'], consumer.path), + 'native_library': native?.path, + 'native_digest': native == null + ? null + : sha256.convert(native.readAsBytesSync()).toString(), + 'symbol_scan': symbols, + 'tests_executed': report.executed, + 'tests_failed': report.failed, + 'tests_skipped': report.skipped, + 'problems': problems, + }; + if (options.report != null) { + File(options.report!).writeAsStringSync( + '${const JsonEncoder.withIndent(' ').convert(result)}\n', + ); + } + + stdout + ..writeln('tests executed ${report.executed}') + ..writeln('tests failed ${report.failed}') + ..writeln('tests skipped ${report.skipped}') + ..writeln('native library ${native?.path}') + ..writeln('native digest ${result['native_digest']}') + ..writeln('symbol scan $symbols'); + + if (native == null) { + problems.add('no built m_security library under ${consumer.path}/build'); + } else if (symbols['ok'] != true) { + problems.add('the symbol scan did not pass: ${symbols['error']}'); + } + if (problems.isNotEmpty) { + _fail('the packaged run did not pass:\n' + '${problems.map((p) => ' $p').join('\n')}'); + } +} + +class _Options { + const _Options({ + required this.payload, + required this.out, + required this.device, + required this.minTests, + required this.report, + }); + + final String payload; + final String out; + final String device; + final int minTests; + final String? report; + + static _Options parse(List args) { + String? payload; + String? out; + var device = 'linux'; + var minTests = 1; + String? report; + + for (var i = 0; i < args.length; i++) { + final next = i + 1 < args.length ? args[i + 1] : null; + switch (args[i]) { + case '--payload' when next != null: + payload = args[++i]; + case '--out' when next != null: + out = args[++i]; + case '--device' when next != null: + device = args[++i]; + case '--min-tests' when next != null: + minTests = int.parse(args[++i]); + case '--report' when next != null: + report = args[++i]; + default: + _fail('unrecognized argument ${args[i]}\n$_usage'); + } + } + if (payload == null || out == null) _fail(_usage); + if (minTests < 1) _fail('--min-tests must be at least 1'); + + // A trailing slash would put the sibling report under a dot name. + String trim(String path) => path.replaceFirst(RegExp(r'/+$'), ''); + + return _Options( + payload: Directory(trim(payload)).absolute.path, + out: Directory(trim(out)).absolute.path, + device: device, + minTests: minTests, + report: report == null ? null : File(report).absolute.path, + ); + } +} + +String _consumerPubspec(String payloadPath) => + ''' +name: $_projectName +description: Runs the containment subset against an assembled publish payload. +publish_to: none +version: 0.0.0 + +environment: + sdk: ^3.10.8 + +dependencies: + flutter: + sdk: flutter + m_security: + path: $payloadPath + +dev_dependencies: + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + +flutter: + uses-material-design: true +'''; + +/// Write a file that calls the entries this release removed, prove it does not +/// analyze, then delete it and prove the rest of the consumer does. +/// +/// This is the case a symbol scan cannot make: a consumer that reaches past the +/// package barrel straight at the generated bindings still has nothing to call. +/// +/// The trap to avoid is the fixture failing for the wrong reason. A payload +/// missing `lib/src/rust/` entirely would produce the same undefined-function +/// errors, so an unresolved import is treated as a failure of the check rather +/// than a pass, and a positive fixture runs first to establish that the +/// package's own libraries resolve at all. +Future _checkRemovedSurfaceIsUnresolvable(String consumerDir) async { + final positive = File('$consumerDir/lib/present_surface_fixture.dart'); + positive.writeAsStringSync(''' +// Temporary. Everything here survives this release, so this file must analyze. +import 'package:m_security/m_security.dart'; +import 'package:m_security/src/rust/api/encryption.dart'; +import 'package:m_security/src/rust/api/evfs.dart'; +import 'package:m_security/src/rust/api/streaming.dart'; + +Future reachKeptEntries() async { + await RustLib.init(); + final cipher = await createAes256Gcm(key: await generateAes256GcmKey()); + await createChacha20Poly1305(key: await generateChacha20Poly1305Key()); + await vaultCreate( + path: 'v', + key: await generateAes256GcmKey(), + algorithm: 'aes-256-gcm', + capacityBytes: BigInt.one, + unsafeLegacyPolicy: UnsafeLegacyEvfsPolicy.deny, + ); + streamHashFile(hasher: await createBlake3(), filePath: 'f'); + cipher.dispose(); +} +'''); + + final withPositive = await Process.run( + 'dart', + ['analyze', '--no-fatal-warnings', positive.path], + workingDirectory: consumerDir, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ); + final positiveOutput = '${withPositive.stdout}${withPositive.stderr}'; + positive.deleteSync(); + if (withPositive.exitCode != 0) { + _fail( + 'the payload\'s own libraries do not resolve from a clean consumer, so ' + 'the removed-entry check below would pass for the wrong reason:\n' + '$positiveOutput', + ); + } + + final fixture = File('$consumerDir/lib/removed_surface_fixture.dart'); + fixture.writeAsStringSync(''' +// Temporary. Every call below names an entry this release removed, so this +// file must not analyze. +import 'dart:typed_data'; + +import 'package:m_security/src/rust/api/encryption.dart'; +import 'package:m_security/src/rust/api/evfs.dart'; +import 'package:m_security/src/rust/api/streaming.dart'; + +Future reachRemovedEntries(CipherHandle cipher, Uint8List key) async { + await createNoopEncryption(); + await vaultExport(handle: cipher, wrappingKey: key, exportPath: 'out.mvex'); + await vaultImport(archivePath: 'in.mvex', wrappingKey: key); + streamEncryptFile(cipher: cipher, inputPath: 'a', outputPath: 'b'); + streamDecryptFile(cipher: cipher, inputPath: 'a', outputPath: 'b'); + streamCompressEncryptFile(cipher: cipher, inputPath: 'a', outputPath: 'b'); + streamDecryptDecompressFile(cipher: cipher, inputPath: 'a', outputPath: 'b'); +} +'''); + + final withFixture = await Process.run( + 'dart', + ['analyze', '--no-fatal-warnings', fixture.path], + workingDirectory: consumerDir, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ); + final output = '${withFixture.stdout}${withFixture.stderr}'; + fixture.deleteSync(); + + if (withFixture.exitCode == 0) { + _fail('a consumer can still resolve the removed entries:\n$output'); + } + if (output.contains("Target of URI doesn't exist")) { + _fail( + 'the negative fixture failed on an import rather than on the removed ' + 'entries:\n$output', + ); + } + for (final name in [ + 'createNoopEncryption', + 'vaultExport', + 'vaultImport', + 'streamEncryptFile', + 'streamDecryptFile', + 'streamCompressEncryptFile', + 'streamDecryptDecompressFile', + ]) { + if (!output.contains(name)) { + _fail( + 'the negative fixture failed without naming $name, so the failure may ' + 'not be the one it is testing for:\n$output', + ); + } + } + + // Without the fixture the same consumer has to be clean, otherwise the + // failure above proves nothing about the removed entries. + final clean = await Process.run( + 'dart', + ['analyze', 'lib'], + workingDirectory: consumerDir, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ); + if (clean.exitCode != 0) { + _fail('the consumer does not analyze on its own:\n' + '${clean.stdout}${clean.stderr}'); + } +} + +class _TestReport { + const _TestReport({ + required this.executed, + required this.failed, + required this.skipped, + required this.exitCode, + required this.errors, + }); + + final int executed; + final int failed; + final int skipped; + final int exitCode; + final List errors; +} + +Future<_TestReport> _runContainmentTests( + String consumerDir, + _Options options, +) async { + final process = await Process.start('flutter', [ + 'test', + 'integration_test/containment_test.dart', + '-d', + options.device, + '--machine', + ], workingDirectory: consumerDir); + + // Forwarded as it arrives. A build failure shows up here and nowhere in the + // JSON events, which say only that loading the test failed. + final drainedStderr = process.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .forEach(stderr.writeln); + + var executed = 0; + var failed = 0; + var skipped = 0; + final failures = []; + final names = {}; + + await for (final line in process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter())) { + Object? decoded; + try { + decoded = jsonDecode(line); + } on FormatException { + stdout.writeln(line); // Flutter's own progress output, not an event. + continue; + } + if (decoded is! Map) continue; + + switch (decoded['type']) { + case 'testStart': + final test = decoded['test'] as Map; + names[test['id'] as int] = test['name'] as String; + case 'testDone': + if (decoded['hidden'] == true) continue; + // A skipped test reports success, so counting it as executed would let + // a `skip:` on a group satisfy the floor with nothing run. + if (decoded['skipped'] == true) { + skipped++; + continue; + } + executed++; + if (decoded['result'] != 'success') { + failed++; + failures.add(names[decoded['testID'] as int] ?? 'test'); + } + case 'error': + failures.add('${decoded['error']}'); + } + } + final code = await process.exitCode; + await drainedStderr; + + stdout.writeln( + 'executed $executed test(s), $failed failed, $skipped skipped', + ); + for (final failure in failures) { + stdout.writeln(' failure: $failure'); + } + return _TestReport( + executed: executed, + failed: failed, + skipped: skipped, + exitCode: code, + errors: failures, + ); +} + +/// Everything that makes a run unacceptable, as text, so the caller can write +/// its report before stopping. +List _verdict(_TestReport report, int minTests) => [ + if (report.executed == 0) + 'no containment test ran, so nothing about the payload was checked', + if (report.executed > 0 && report.executed < minTests) + 'only ${report.executed} of at least $minTests tests ran', + if (report.skipped > 0) '${report.skipped} test(s) were skipped', + if (report.failed > 0) '${report.failed} test(s) failed', + // Errors arrive without a failing test attached, so they would otherwise + // leave the counts at zero. + for (final error in report.errors) 'reported error: $error', + if (report.exitCode != 0) 'flutter test exited ${report.exitCode}', +]; + +/// The shared library the consumer's build produced. +/// +/// A build tree can hold more than one match, so the candidates are sorted and +/// the shared object is preferred over a framework binary. Whichever is used +/// ends up in the report by path, so there is no guessing after the fact. +File? _findNativeLibrary(Directory buildDir) { + if (!buildDir.existsSync()) return null; + final candidates = []; + for (final entity in buildDir.listSync(recursive: true, followLinks: false)) { + if (entity is! File) continue; + final name = entity.uri.pathSegments.last; + if (name == 'libm_security.so' || + name == 'libm_security.dylib' || + (name == 'm_security' && entity.path.contains('.framework/'))) { + candidates.add(entity); + } + } + if (candidates.isEmpty) return null; + + int rank(File file) { + final name = file.uri.pathSegments.last; + if (name == 'libm_security.so') return 0; + if (name == 'libm_security.dylib') return 1; + return 2; + } + + candidates.sort((a, b) { + final byRank = rank(a).compareTo(rank(b)); + return byRank != 0 ? byRank : a.path.compareTo(b.path); + }); + return candidates.first; +} + +/// Read the built library's exports and check the removed entries are gone. +/// +/// The dynamic table is the one a consumer can reach, and on ELF targets the +/// crate narrows it with a version script, so it is asked for first. Mach-O +/// has no separate dynamic table, so there the global table is what there is. +Future> _checkSymbols(File native) async { + var mode = '-D'; + var result = await Process.run( + 'nm', + ['-D', '--defined-only', native.path], + stdoutEncoding: utf8, + stderrEncoding: utf8, + ); + if (result.exitCode != 0 || (result.stdout as String).trim().isEmpty) { + mode = '-g'; + result = await Process.run( + 'nm', + ['-g', '--defined-only', native.path], + stdoutEncoding: utf8, + stderrEncoding: utf8, + ); + } + if (result.exitCode != 0) { + return {'ok': false, 'error': 'nm could not read the library'}; + } + final table = (result.stdout as String).toLowerCase(); + + final missing = _keptSymbols.where((s) => !table.contains(s)).toList(); + final present = _removedSymbols.where(table.contains).toList(); + return { + 'ok': missing.isEmpty && present.isEmpty, + 'nm_mode': mode, + 'kept_missing': missing, + 'removed_present': present, + if (missing.isNotEmpty) + 'error': 'the library does not export ${missing.join(', ')}, so an ' + 'absence check against it would prove nothing', + if (present.isNotEmpty) + 'error': 'the library still exports ${present.join(', ')}', + }; +} + +Future _run( + String executable, + List args, + String workingDirectory, +) async { + final result = await Process.run( + executable, + args, + workingDirectory: workingDirectory, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ); + if (result.exitCode != 0) { + _fail( + '$executable ${args.join(' ')} exited ${result.exitCode}:\n' + '${result.stdout}${result.stderr}', + ); + } +} + +Future _capture( + String executable, + List args, + String workingDirectory, +) async { + final result = await Process.run( + executable, + args, + workingDirectory: workingDirectory, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ); + if (result.exitCode != 0) { + _fail('$executable ${args.join(' ')} exited ${result.exitCode}'); + } + return (result.stdout as String).trim(); +} + +Never _fail(String message) { + stderr.writeln('packaged_consumer: $message'); + exit(1); +} diff --git a/tool/publication.dart b/tool/publication.dart new file mode 100644 index 0000000..9823e44 --- /dev/null +++ b/tool/publication.dart @@ -0,0 +1,426 @@ +// Assemble the exact publish payload and record what it contains. +// +// dart run tool/publication.dart --out +// +// The inclusion set comes from `flutter pub publish --dry-run`, which is the +// only thing that knows what pub would upload. Every entry has to be a tracked +// file, so the payload is a function of the revision rather than of whatever a +// local build left behind. The output is a directory of byte-identical copies, +// a sorted manifest, a normalized archive and a JSON report of the digests. +// +// One limit worth stating: pub's printed tree omits every dot-prefixed entry, +// so the parse below cannot see one. This pub does not publish them either, +// which was checked by writing 32 MiB of random bytes into `ios/Assets/.gitkeep` +// and into `rust_builder/android/.gradle/8.9/gc.properties`, both tracked, and +// into an untracked `.probe/blob.bin` and `lib/.probe/blob.bin`, and watching +// the reported archive size hold at 620 KB each time while the same bytes in a +// non-dot directory took it to 46 MB. A directory whose only children are +// dot-prefixed still prints, with nothing under it. That is a measurement, not +// something this program re-proves on every run. + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +const String _usage = 'usage: dart run tool/publication.dart --out '; + +/// Dry-run complaints that are not payload defects. +/// +/// A dirty worktree is what working on the package looks like. The bridge +/// constraint is deliberately a single version: the committed bindings are +/// generated by one exact bridge release and refuse to initialise against any +/// other, so pub's advice to widen it would ship a package that cannot start. +const List _toleratedComplaints = [ + 'modified in git', + '"flutter_rust_bridge" should allow more than one version', +]; + +Future main(List args) async { + final out = _parseArgs(args); + final repoRoot = Directory.current.absolute.path; + + final dryRun = await _dryRun(repoRoot); + final inclusion = parseInclusionSet(dryRun); + if (inclusion.isEmpty) { + _fail('the dry run reported no files; its output was:\n$dryRun'); + } + + // A file git ignores is one this machine happens to have: the last local + // test run's output, a Podfile.lock from a pod install. Those are what made + // the payload vary by machine, so they end the run. + final ignored = await _ignoredFiles(repoRoot, inclusion); + if (ignored.isNotEmpty) { + _fail( + 'the publish payload would carry ${ignored.length} git-ignored file(s), ' + 'so it is not a function of the revision:\n' + '${ignored.map((path) => ' $path').join('\n')}', + ); + } + + // Not yet tracked is a different thing: it is what an unfinished change + // looks like, and CI checks out a tree that has none. Worth saying, not + // worth stopping for. + final tracked = await _trackedFiles(repoRoot); + final uncommitted = inclusion + .where((path) => !tracked.contains(path)) + .toList(); + for (final path in uncommitted) { + stderr.writeln('publication: not committed yet: $path'); + } + + final payloadDir = Directory(out); + if (payloadDir.existsSync()) payloadDir.deleteSync(recursive: true); + payloadDir.createSync(recursive: true); + + final entries = <_Entry>[]; + for (final path in inclusion) { + final source = File('$repoRoot/$path'); + final bytes = source.readAsBytesSync(); + final target = File('${payloadDir.path}/$path'); + target.parent.createSync(recursive: true); + target.writeAsBytesSync(bytes); + + // pub keeps the executable bit, and `cargokit/build_pod.sh` needs it to run + // at all, so a payload that flattened every mode to 0644 would not build on + // Apple. Nothing here needs a finer mode than executable or not. + final executable = source.statSync().mode & 0x49 != 0; + final mode = executable ? 493 : 420; // 0755 or 0644 + if (executable) await _capture('chmod', ['0755', target.path], repoRoot); + + final digest = sha256.convert(bytes).toString(); + if (sha256.convert(target.readAsBytesSync()).toString() != digest) { + _fail('the copy of $path does not match the source'); + } + entries.add(_Entry(path, bytes.length, mode, digest)); + } + + final manifest = File('$out.manifest.txt'); + manifest.writeAsStringSync( + '${entries.map((e) => '${e.digest} ${e.modeString} ${e.size} ${e.path}').join('\n')}\n', + ); + final payloadDigest = sha256.convert(manifest.readAsBytesSync()).toString(); + + final archive = File('$out.tar'); + archive.writeAsBytesSync(_buildTar(entries, payloadDir.path)); + final archiveDigest = sha256.convert(archive.readAsBytesSync()).toString(); + await _verifyArchive(archive, entries); + + final report = { + 'revision': await _capture('git', ['rev-parse', 'HEAD'], repoRoot), + 'flutter': (await _capture( + 'flutter', + ['--version'], + repoRoot, + )).split('\n').first, + 'dart': await _capture('dart', ['--version'], repoRoot), + 'rustc': await _capture('rustc', ['--version'], repoRoot), + // The revision above names a commit; these two say how far the bytes that + // were hashed have drifted from it. + 'worktree_clean': (await _capture( + 'git', + ['status', '--porcelain'], + repoRoot, + )).isEmpty, + 'file_count': entries.length, + 'uncommitted_count': uncommitted.length, + 'total_bytes': entries.fold(0, (sum, e) => sum + e.size), + 'payload_digest': payloadDigest, + 'archive_digest': archiveDigest, + 'payload_dir': payloadDir.path, + 'manifest_path': manifest.path, + 'archive_path': archive.path, + }; + final reportFile = File('$out.report.json'); + reportFile.writeAsStringSync( + '${const JsonEncoder.withIndent(' ').convert(report)}\n', + ); + + stdout + ..writeln('payload files ${entries.length}') + ..writeln('payload digest $payloadDigest') + ..writeln('archive digest $archiveDigest') + ..writeln('payload dir ${payloadDir.path}') + ..writeln('report ${reportFile.path}'); +} + +class _Entry { + const _Entry(this.path, this.size, this.mode, this.digest); + + final String path; + final int size; + final int mode; + final String digest; + + String get modeString => mode.toRadixString(8).padLeft(4, '0'); +} + +String _parseArgs(List args) { + String? out; + for (var i = 0; i < args.length; i++) { + if (args[i] == '--out' && i + 1 < args.length) { + out = args[++i]; + } else { + _fail('unrecognized argument ${args[i]}\n$_usage'); + } + } + if (out == null) _fail(_usage); + // A trailing slash would turn the sibling files into dotfiles. + return File(out.replaceFirst(RegExp(r'/+$'), '')).absolute.path; +} + +/// Run the dry run and refuse anything it complains about except a dirty +/// worktree. +/// +/// pub prints hints, warnings and errors with the same `* ` prefix but only +/// leaves the exit code nonzero for the last two, so a zero exit is taken at +/// its word. On a nonzero exit each complaint is read against +/// [_toleratedComplaints], because 65 covers both a tracked file that a +/// `.gitignore` hides, which is a payload defect, and things that are the +/// intended state of the package. +Future _dryRun(String repoRoot) async { + final result = await Process.run( + 'flutter', + ['pub', 'publish', '--dry-run'], + workingDirectory: repoRoot, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ); + final output = '${result.stdout}${result.stderr}'; + if (result.exitCode == 0) return output; + + final complaints = const LineSplitter() + .convert(output) + .where((line) => line.startsWith('* ')) + .toList(); + final unexpected = complaints + .where((line) => !_toleratedComplaints.any(line.contains)) + .toList(); + + if (unexpected.isNotEmpty || complaints.isEmpty) { + _fail( + 'flutter pub publish --dry-run exited ${result.exitCode}:\n$output', + ); + } + for (final complaint in complaints) { + stderr.writeln('publication: tolerated: $complaint'); + } + return output; +} + +/// Turn pub's printed tree into the sorted list of files it would upload. +/// +/// Lines look like `│ ├── frb_generated.rs (202 KB)`. A trailing size marks a +/// file; without one the entry is a directory, and a directory can be a leaf +/// when everything below it is dot-prefixed and therefore not printed. +List parseInclusionSet(String dryRun) { + final lines = const LineSplitter().convert(dryRun); + final start = lines.indexWhere((line) => line.startsWith('Publishing ')); + if (start < 0) return const []; + var end = lines.indexWhere( + (line) => line.startsWith('Total compressed archive size'), + start, + ); + if (end < 0) end = lines.length; + + final entry = RegExp(r'^((?:(?:│ )|(?: ))*)(?:├── |└── )(.*)$'); + final size = RegExp(r'^(.*) \((?:<1 [KMG]?B|\d+(?:\.\d+)? ?[KMG]?B)\)$'); + + final stack = []; + final files = []; + for (final line in lines.getRange(start + 1, end)) { + if (line.trim().isEmpty) continue; + final match = entry.firstMatch(line); + if (match == null) { + _fail('could not parse a line of the dry-run tree: $line'); + } + final depth = match.group(1)!.length ~/ 4; + var name = match.group(2)!; + final sized = size.firstMatch(name); + if (sized != null) name = sized.group(1)!; + + stack.removeRange(depth.clamp(0, stack.length), stack.length); + stack.add(name); + if (sized != null) files.add(stack.join('/')); + } + files.sort(); + return files; +} + +/// The subset of [paths] that git's ignore rules exclude. +/// +/// `--no-index` is what makes this useful: without it a tracked file is never +/// reported, and a tracked file that a `.gitignore` also names is exactly the +/// case worth catching. +Future> _ignoredFiles(String repoRoot, List paths) async { + final process = await Process.start('git', [ + 'check-ignore', + '--no-index', + '--stdin', + ], workingDirectory: repoRoot); + + // Both pipes are being read before anything is written, so a long list of + // matches cannot fill git's stdout buffer and stall the write below. + final stdoutDone = process.stdout.transform(utf8.decoder).join(); + final stderrDone = process.stderr.transform(utf8.decoder).join(); + process.stdin.write(paths.join('\n')); + await process.stdin.close(); + + final out = await stdoutDone; + final err = await stderrDone; + final code = await process.exitCode; + // 0 means some path matched, 1 means none did. Anything else is a failure. + if (code != 0 && code != 1) { + _fail('git check-ignore exited $code: $err'); + } + return const LineSplitter() + .convert(out) + .where((line) => line.isNotEmpty) + .toList(); +} + +Future> _trackedFiles(String repoRoot) async { + final result = await Process.run( + 'git', + ['ls-files', '-z'], + workingDirectory: repoRoot, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ); + if (result.exitCode != 0) _fail('git ls-files failed: ${result.stderr}'); + return (result.stdout as String) + .split('\u0000') + .where((path) => path.isNotEmpty) + .toSet(); +} + +/// A minimal deterministic ustar writer. +/// +/// Writing it here rather than shelling out to `tar` is what makes the archive +/// digest comparable between a developer's machine and CI: GNU tar and bsdtar +/// disagree on padding and on which optional fields they fill, so the same +/// payload hashes differently under each. Every variable field is pinned here: +/// uid and gid 0, empty owner names, mtime 0, entries in manifest order. Mode +/// is the one thing carried over from the source, and only as executable or +/// not. +List _buildTar(List<_Entry> entries, String payloadDir) { + final out = BytesBuilder(); + for (final entry in entries) { + final bytes = File('$payloadDir/${entry.path}').readAsBytesSync(); + out.add(tarHeader(entry.path, bytes.length, entry.mode)); + out.add(bytes); + out.add(List.filled((512 - bytes.length % 512) % 512, 0)); + } + out.add(List.filled(1024, 0)); + return out.takeBytes(); +} + +/// One 512-byte ustar header. Exposed so the prefix split, which no path in +/// this package is long enough to reach, still has a test. +List tarHeader(String path, int size, int mode) { + var name = path; + var prefix = ''; + if (utf8.encode(name).length > 100) { + final cut = name.lastIndexOf('/'); + if (cut <= 0) _fail('path too long for ustar: $path'); + prefix = name.substring(0, cut); + name = name.substring(cut + 1); + if (utf8.encode(name).length > 100 || utf8.encode(prefix).length > 155) { + _fail('path too long for ustar: $path'); + } + } + + final header = List.filled(512, 0); + void put(int offset, List value) => + header.setRange(offset, offset + value.length, value); + + // Octal, left-padded with zeros, one byte short of the field so the trailing + // NUL terminates it. + String octal(int value, int width) => + value.toRadixString(8).padLeft(width - 1, '0'); + + // Plain ustar has no room for a larger size, and silently writing over the + // next field would produce an archive that extracts wrong. + if (size >= 1 << 33) _fail('file too large for ustar: $path'); + + put(0, utf8.encode(name)); + put(100, utf8.encode(octal(mode, 8))); + put(108, utf8.encode(octal(0, 8))); // uid + put(116, utf8.encode(octal(0, 8))); // gid + put(124, utf8.encode(octal(size, 12))); + put(136, utf8.encode(octal(0, 12))); // mtime + put(148, utf8.encode(' ')); // the checksum counts as spaces + header[156] = 0x30; // typeflag '0', a regular file + put(257, utf8.encode('ustar')); + put(263, utf8.encode('00')); + put(345, utf8.encode(prefix)); + + final checksum = header.fold(0, (sum, byte) => sum + byte); + put(148, utf8.encode(octal(checksum, 7))); + header[154] = 0; // six octal digits, then NUL and space + header[155] = 0x20; + return header; +} + +/// Extract the archive with the system `tar` and compare it back to the +/// manifest, so a bug in the writer above fails here instead of shipping a +/// decorative artifact. +Future _verifyArchive(File archive, List<_Entry> entries) async { + final scratch = Directory.systemTemp.createTempSync('m_security_tar'); + // `_fail` calls `exit`, which does not unwind, so the scratch copy has to go + // before the message rather than in a `finally`. + Never fail(String message) { + scratch.deleteSync(recursive: true); + _fail(message); + } + + final result = await Process.run( + 'tar', + ['-xf', archive.path, '-C', scratch.path], + stdoutEncoding: utf8, + stderrEncoding: utf8, + ); + if (result.exitCode != 0) { + fail('tar could not read the archive: ${result.stderr}'); + } + for (final entry in entries) { + final extracted = File('${scratch.path}/${entry.path}'); + if (!extracted.existsSync()) fail('the archive is missing ${entry.path}'); + if (sha256.convert(extracted.readAsBytesSync()).toString() != + entry.digest) { + fail('the archive holds a different ${entry.path}'); + } + if (extracted.statSync().mode & 0x1FF != entry.mode) { + fail('the archive holds ${entry.path} with the wrong mode'); + } + } + final count = scratch.listSync(recursive: true).whereType().length; + if (count != entries.length) { + fail('the archive holds $count files, the manifest ${entries.length}'); + } + scratch.deleteSync(recursive: true); +} + +Future _capture( + String executable, + List args, + String workingDirectory, +) async { + final result = await Process.run( + executable, + args, + workingDirectory: workingDirectory, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ); + if (result.exitCode != 0) { + _fail('$executable ${args.join(' ')} exited ${result.exitCode}'); + } + return (result.stdout as String).trim(); +} + +Never _fail(String message) { + stderr.writeln('publication: $message'); + exit(1); +} diff --git a/tool/publication_test.dart b/tool/publication_test.dart new file mode 100644 index 0000000..b76868f --- /dev/null +++ b/tool/publication_test.dart @@ -0,0 +1,120 @@ +// The tree parse is the one place in the assembler that can be wrong quietly: +// a size suffix it does not recognize turns a file into a directory and the +// file leaves the payload without anything complaining. These cases pin the +// shapes pub actually prints. + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'publication.dart'; + +const String _tree = ''' +Resolving dependencies... +Got dependencies! +Publishing m_security 0.3.5 to https://pub.dev: +├── CHANGELOG.md (12 KB) +├── android +│ ├── build.gradle (1 KB) +│ ├── gradle +│ │ └── wrapper +│ │ ├── gradle-wrapper.jar (42 KB) +│ │ └── gradle-wrapper.properties (<1 KB) +│ └── settings.gradle (<1 KB) +├── ios +│ └── Assets +├── rust +│ └── src +│ ├── frb_generated.rs (202 KB) +│ └── lib.rs (<1 KB) +└── windows + └── CMakeLists.txt (<1 KB) + +Total compressed archive size: 14 MB. +Validating package... +'''; + +void main() { + test('every printed file becomes a full path, in sorted order', () { + expect(parseInclusionSet(_tree), [ + 'CHANGELOG.md', + 'android/build.gradle', + 'android/gradle/wrapper/gradle-wrapper.jar', + 'android/gradle/wrapper/gradle-wrapper.properties', + 'android/settings.gradle', + 'rust/src/frb_generated.rs', + 'rust/src/lib.rs', + 'windows/CMakeLists.txt', + ]); + }); + + test('a childless directory contributes no file', () { + expect(parseInclusionSet(_tree), isNot(contains('ios/Assets'))); + expect(parseInclusionSet(_tree), isNot(contains('ios'))); + }); + + test('sizes pub prints are all recognized as sizes', () { + // The four shapes pub's own `_readableFileSize` can produce. + const sizes = ['<1 KB', '1 KB', '202 KB', '14 MB', '3 GB']; + for (final size in sizes) { + final tree = + 'Publishing p 1.0.0 to https://pub.dev:\n' + '└── file.bin ($size)\n' + 'Total compressed archive size: 1 MB.\n'; + expect(parseInclusionSet(tree), ['file.bin'], reason: size); + } + }); + + test('a tree with no files parses to nothing rather than to a directory', () { + const tree = + 'Publishing p 1.0.0 to https://pub.dev:\n' + '└── empty_dir\n' + 'Total compressed archive size: 1 MB.\n'; + expect(parseInclusionSet(tree), isEmpty); + }); + + test('output without a tree parses to nothing', () { + expect(parseInclusionSet('Resolving dependencies...\nGot it!\n'), isEmpty); + }); + + // No path in this package is long enough to reach the prefix split, and + // assembling the payload is the only other thing that exercises the header, + // so the branch would otherwise ship unrun. + group('ustar headers', () { + String field(List header, int offset, int length) => + utf8.decode(header.sublist(offset, offset + length)).split('\u0000')[0]; + + test('a short path stays in the name field', () { + final header = tarHeader('rust/src/lib.rs', 265, 420); + + expect(field(header, 0, 100), 'rust/src/lib.rs'); + expect(field(header, 345, 155), ''); + expect(field(header, 100, 8), '0000644'); + expect(field(header, 124, 12), '00000000411'); + expect(field(header, 257, 6), 'ustar'); + expect(header[156], 0x30); + }); + + test('a long path splits across prefix and name', () { + final long = '${List.filled(12, 'directory').join('/')}/file.dart'; + expect(long.length, greaterThan(100)); + + final header = tarHeader(long, 1, 493); + + expect(field(header, 0, 100), 'file.dart'); + expect(field(header, 345, 155), List.filled(12, 'directory').join('/')); + expect(field(header, 100, 8), '0000755'); + }); + + test('the checksum covers the header with its own field blanked', () { + final header = tarHeader('a.txt', 3, 420); + final blanked = [...header]; + blanked.setRange(148, 156, utf8.encode(' ')); + + final sum = blanked.fold(0, (total, byte) => total + byte); + expect(field(header, 148, 6), sum.toRadixString(8).padLeft(6, '0')); + expect(header[154], 0); + expect(header[155], 0x20); + }); + }); +}