From b1d17f0339dd8b26301fb69c0359f54ca6ef4508 Mon Sep 17 00:00:00 2001 From: Tony Date: Tue, 23 Jun 2026 13:01:48 +0800 Subject: [PATCH] feat(open_aot_patch_tools): add compact AOT patch artifact tools with AES-GCM encryption - Introduced a new package `open_aot_patch_tools` for creating compact, flavor-aware AOT patch artifacts. - Implemented runtime model for AOT patches, ensuring compatibility with iOS constraints. - Added delivery security using AES-256-GCM for encrypted patch artifacts. - Created compact payload formats including empty, binary diff, and full snapshot. - Developed linking and encryption functionalities for patch artifacts. - Added tests for verifying encryption, metadata validation, and artifact reconstruction. - Included documentation for the design and usage of the patch tools. --- .../bin/open_aot_patch_tools.dart | 324 +++++++++ .../doc/open_aot_patch_design.md | 153 ++++ .../lib/open_aot_patch_tools.dart | 4 + .../lib/src/patch_artifact.dart | 688 ++++++++++++++++++ packages/open_aot_patch_tools/pubspec.yaml | 21 + .../test/patch_artifact_test.dart | 397 ++++++++++ pubspec.yaml | 1 + 7 files changed, 1588 insertions(+) create mode 100644 packages/open_aot_patch_tools/bin/open_aot_patch_tools.dart create mode 100644 packages/open_aot_patch_tools/doc/open_aot_patch_design.md create mode 100644 packages/open_aot_patch_tools/lib/open_aot_patch_tools.dart create mode 100644 packages/open_aot_patch_tools/lib/src/patch_artifact.dart create mode 100644 packages/open_aot_patch_tools/pubspec.yaml create mode 100644 packages/open_aot_patch_tools/test/patch_artifact_test.dart diff --git a/packages/open_aot_patch_tools/bin/open_aot_patch_tools.dart b/packages/open_aot_patch_tools/bin/open_aot_patch_tools.dart new file mode 100644 index 00000000..ad334036 --- /dev/null +++ b/packages/open_aot_patch_tools/bin/open_aot_patch_tools.dart @@ -0,0 +1,324 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:open_aot_patch_tools/open_aot_patch_tools.dart'; + +void main(List args) { + if (args.isEmpty || args.first == '--help' || args.first == '-h') { + _usage(); + return; + } + + final command = args.first; + final options = _parse(args.skip(1).toList()); + switch (command) { + case 'link': + _link(options); + case 'encrypt': + _encrypt(options); + case 'verify': + _verify(options); + case 'dump-blobs': + _dumpBlobs(options); + case 'compile-patch': + _compilePatch(options); + default: + stderr.writeln('Unknown command: $command'); + _usage(); + exitCode = 64; + } +} + +void _link(Map options) { + final base = File(_required(options, 'base')).readAsBytesSync(); + final patch = File(_required(options, 'patch')).readAsBytesSync(); + final metadata = PatchMetadata( + appId: _required(options, 'app-id'), + appBuildId: _required(options, 'app-build-id'), + baseFlavorId: options['base-flavor-id'], + baseLicenseType: options['base-license-type'], + flavorId: _required(options, 'flavor-id'), + licenseType: _required(options, 'license-type'), + sdkHash: _required(options, 'sdk-hash'), + baseSnapshotHash: sha256Hex(base), + patchSnapshotHash: sha256Hex(patch), + targetOs: _required(options, 'target-os'), + targetArch: _required(options, 'target-arch'), + obfuscationMapHash: options['obfuscation-map-hash'], + ); + final artifact = linkArtifacts( + baseSnapshot: base, + patchSnapshot: patch, + metadata: metadata, + forceFullSnapshot: _boolOption(options, 'full-snapshot'), + ); + File(_required(options, 'output')).writeAsStringSync( + const JsonEncoder.withIndent(' ').convert(artifact.toJson()), + ); +} + +void _encrypt(Map options) { + final artifact = PatchArtifact.fromJson( + readJsonFile(File(_required(options, 'input'))), + ); + final encrypted = encryptArtifact( + artifact: artifact, + keyId: _required(options, 'key-id'), + key: readKey(_required(options, 'key-hex')), + nonce: readNonce(_required(options, 'nonce-hex')), + ); + File(_required(options, 'output')).writeAsStringSync( + const JsonEncoder.withIndent(' ').convert(encrypted.toJson()), + ); +} + +void _verify(Map options) { + final input = File(_required(options, 'input')); + final inputBytes = input.readAsBytesSync(); + _verifyArtifactHash(inputBytes, options['artifact-sha256']); + final encrypted = EncryptedPatchArtifact.fromJson( + (jsonDecode(utf8.decode(inputBytes)) as Map).cast(), + ); + verifyMetadata( + encrypted.metadata, + baseFlavorId: options['base-flavor-id'], + baseLicenseType: options['base-license-type'], + flavorId: _required(options, 'flavor-id'), + licenseType: _required(options, 'license-type'), + ); + final decrypted = encrypted.decrypt(readKey(_required(options, 'key-hex'))); + final basePath = options['base']; + if (basePath != null) { + final reconstructed = decrypted.reconstruct( + File(basePath).readAsBytesSync(), + ); + final reconstructedHash = sha256Hex(reconstructed); + if (reconstructedHash != decrypted.metadata.patchSnapshotHash) { + throw StateError( + 'Reconstructed patch hash $reconstructedHash does not match metadata ' + '${decrypted.metadata.patchSnapshotHash}.', + ); + } + } else if (decrypted.payloadKind == payloadKindBinaryDiff) { + stderr.writeln( + 'warning: compact binary diff decrypted, but --base was not provided; ' + 'skipping reconstructed patch hash verification.', + ); + } +} + +void _verifyArtifactHash(List bytes, String? expectedHash) { + if (expectedHash == null) { + return; + } + final actualHash = sha256Hex(bytes); + if (actualHash != expectedHash) { + throw StateError( + 'Artifact hash $actualHash does not match expected $expectedHash.', + ); + } +} + +void _dumpBlobs(Map options) { + final input = File(_required(options, 'input')); + final bytes = input.readAsBytesSync(); + Map report; + try { + final json = (jsonDecode(utf8.decode(bytes)) as Map) + .cast(); + final format = json['format']; + if (format == artifactFormat) { + final artifact = PatchArtifact.fromJson(json); + final reconstructed = _maybeReconstruct( + artifact, + basePath: options['base'], + outputPath: options['output'], + ); + report = { + 'path': input.path, + 'format': artifactFormat, + 'artifact_sha256': sha256Hex(bytes), + 'metadata': artifact.metadata.toJson(), + 'payload_kind': artifact.payloadKind, + 'payload_size': artifact.payload.length, + 'payload_sha256': sha256Hex(artifact.payload), + if (artifact.reconstructedSize != null) + 'reconstructed_size': artifact.reconstructedSize, + if (reconstructed != null) + 'reconstructed_patch_size': reconstructed.length, + if (reconstructed != null) + 'reconstructed_patch_sha256': sha256Hex(reconstructed), + if (options['output'] != null) 'output': options['output'], + }; + } else if (format == encryptedArtifactFormat) { + final encrypted = EncryptedPatchArtifact.fromJson(json); + report = { + 'path': input.path, + 'format': encryptedArtifactFormat, + 'artifact_sha256': sha256Hex(bytes), + 'metadata': encrypted.metadata.toJson(), + 'payload_kind': encrypted.payloadKind, + 'encrypted_payload_size': encrypted.encryptedPayload.length, + 'payload_sha256': encrypted.payloadSha256, + 'key_id': encrypted.keyId, + if (encrypted.reconstructedSize != null) + 'reconstructed_size': encrypted.reconstructedSize, + }; + final keyHex = options['key-hex']; + if (keyHex != null) { + final decrypted = encrypted.decrypt(readKey(keyHex)); + final reconstructed = _maybeReconstruct( + decrypted, + basePath: options['base'], + outputPath: options['output'], + ); + report = { + ...report, + 'decrypted_payload_size': decrypted.payload.length, + 'decrypted_payload_sha256': sha256Hex(decrypted.payload), + if (reconstructed != null) + 'reconstructed_patch_size': reconstructed.length, + if (reconstructed != null) + 'reconstructed_patch_sha256': sha256Hex(reconstructed), + if (options['output'] != null) 'output': options['output'], + }; + } else if (options['output'] != null) { + throw const FormatException( + 'Encrypted artifacts require --key-hex when --output is used.', + ); + } + } else { + throw const FormatException('not an open AOT patch artifact'); + } + } on Object { + report = { + 'path': input.path, + 'format': 'raw', + 'size': bytes.length, + 'sha256': sha256Hex(bytes), + }; + } + stdout.writeln(const JsonEncoder.withIndent(' ').convert(report)); +} + +List? _maybeReconstruct( + PatchArtifact artifact, { + required String? basePath, + required String? outputPath, +}) { + if (basePath == null && artifact.payloadKind != payloadKindFullSnapshot) { + if (outputPath != null) { + throw const FormatException( + 'Compact artifacts require --base when --output is used.', + ); + } + return null; + } + + final base = basePath == null + ? const [] + : File(basePath).readAsBytesSync(); + final reconstructed = artifact.reconstruct(base); + if (sha256Hex(reconstructed) != artifact.metadata.patchSnapshotHash) { + throw StateError( + 'Reconstructed patch hash does not match metadata ' + '${artifact.metadata.patchSnapshotHash}.', + ); + } + if (outputPath != null) { + File(outputPath).writeAsBytesSync(reconstructed); + } + return reconstructed; +} + +void _compilePatch(Map options) { + final genSnapshot = _required(options, 'gen-snapshot'); + final kernel = _required(options, 'kernel'); + final output = _required(options, 'output'); + final snapshotKind = options['snapshot-kind'] ?? 'app-aot-elf'; + final args = ['--snapshot-kind=$snapshotKind']; + switch (snapshotKind) { + case 'app-aot-elf': + args.add('--elf=$output'); + case 'app-aot-macho-dylib': + args.add('--macho=$output'); + final machoObject = options['macho-object']; + if (machoObject != null) { + args.add('--macho-object=$machoObject'); + } + case 'app-aot-assembly': + args.add('--assembly=$output'); + default: + throw ArgumentError('Unsupported --snapshot-kind=$snapshotKind'); + } + if (_boolOption(options, 'strip', defaultValue: true)) { + args.add('--strip'); + } + final loadObfuscationMap = options['load-obfuscation-map']; + final saveObfuscationMap = options['save-obfuscation-map']; + if (_boolOption(options, 'obfuscate') || + loadObfuscationMap != null || + saveObfuscationMap != null) { + args.add('--obfuscate'); + } + if (loadObfuscationMap != null) { + args.add('--load-obfuscation-map=$loadObfuscationMap'); + } + if (saveObfuscationMap != null) { + args.add('--save-obfuscation-map=$saveObfuscationMap'); + } + args.add(kernel); + + final result = Process.runSync(genSnapshot, args); + stdout.write(result.stdout); + stderr.write(result.stderr); + if (result.exitCode != 0) { + throw ProcessException( + genSnapshot, + args, + 'gen_snapshot failed', + result.exitCode, + ); + } +} + +Map _parse(List args) { + final parsed = {}; + for (final arg in args) { + if (!arg.startsWith('--') || !arg.contains('=')) { + throw ArgumentError('Expected --name=value, got $arg'); + } + final separator = arg.indexOf('='); + parsed[arg.substring(2, separator)] = arg.substring(separator + 1); + } + return parsed; +} + +String _required(Map options, String name) { + final value = options[name]; + if (value == null || value.isEmpty) { + throw ArgumentError('Missing --$name'); + } + return value; +} + +bool _boolOption( + Map options, + String name, { + bool defaultValue = false, +}) { + final value = options[name]; + if (value == null) return defaultValue; + return value == 'true' || value == '1' || value == 'yes'; +} + +void _usage() { + stdout.writeln(''' +Usage: + open_aot_patch_tools dump-blobs --input= [--key-hex=<64 hex chars>] [--base=] [--output=] + open_aot_patch_tools compile-patch --gen-snapshot= --kernel= --output= [--snapshot-kind=app-aot-elf|app-aot-macho-dylib|app-aot-assembly] [--macho-object=] [--obfuscate=true] [--load-obfuscation-map=] [--save-obfuscation-map=] + open_aot_patch_tools link --base= --patch= --output= --app-id= --app-build-id= --flavor-id= --license-type= --sdk-hash= --target-os= --target-arch= [--base-flavor-id=] [--base-license-type=] [--obfuscation-map-hash=] [--full-snapshot=true] + open_aot_patch_tools encrypt --input= --output= --key-id= --key-hex=<64 hex chars> --nonce-hex=<24 hex chars> + open_aot_patch_tools verify --input= --key-hex=<64 hex chars> --flavor-id= --license-type= [--base=] [--artifact-sha256=] [--base-flavor-id=] [--base-license-type=] +'''); +} diff --git a/packages/open_aot_patch_tools/doc/open_aot_patch_design.md b/packages/open_aot_patch_tools/doc/open_aot_patch_design.md new file mode 100644 index 00000000..65ef82a6 --- /dev/null +++ b/packages/open_aot_patch_tools/doc/open_aot_patch_design.md @@ -0,0 +1,153 @@ +# Open AOT Patch Design + +This package produces compact, flavor-aware AOT patch artifacts without relying +on `DART_DYNAMIC_MODULES` or the Dart bytecode interpreter. + +## Runtime Model + +Patches are AOT snapshot artifacts. The engine selects patched isolate snapshot +data and instructions before isolate startup, matching Flutter's existing +snapshot mapping flow. The VM data and VM instructions remain from the base +runtime. + +This keeps the design compatible with iOS constraints: + +- no JIT dependency +- no writable executable memory requirement +- no KBC interpreter dependency +- patch metadata must bind the artifact to platform, architecture, SDK hash, + app build id, flavor/license id, base snapshot hash, patch snapshot hash, and + obfuscation map hash when obfuscation is used + +## Delivery Security + +AES-256-GCM protects downloaded patch artifacts in transit and at rest. The +metadata is authenticated as AAD, and the decrypted compact payload is +hash-checked. When a base snapshot is supplied, `verify --base=` also +reconstructs the patch snapshot and checks it against the metadata +`patch_snapshot_hash`. + +AES is not the compatibility boundary. Apps should still pin or sign patch +metadata and enforce app id, build id, flavor id, license type, SDK hash, base +snapshot hash, target OS, and target architecture before accepting a patch. +For license upgrades, artifacts may also include `base_flavor_id` and +`base_license_type`; verifiers can require those fields to prove the patch is a +specific transition such as `free -> pro` rather than a generic pro artifact. +`verify --artifact-sha256=` adds hash pinning for the whole encrypted +delivery artifact, which should be used alongside transport security or a +separate signing layer. + +## Compact Payloads + +By default, `link` chooses the smallest v1 payload representation: + +- `empty` when base and patch snapshots are identical +- `binary-diff-v1` when changed byte ranges are smaller than the full patch +- `full-snapshot` when a diff would be larger + +For runtime handoff tests and simple embedders, pass +`link --full-snapshot=true`. This forces the encrypted payload to decrypt to a +directly loadable `.vmcode` file. That file can be supplied as the first +application library path and mapped by the engine patch cache before isolate +startup. + +The binary diff format is deterministic and reconstructs the full patched +snapshot from the base snapshot before runtime loading. Future SDK-integrated +linkers can replace this with changed isolate code/data sections while keeping +the same metadata and encryption envelope. + +`dump-blobs --key-hex= --base= --output=` is +the open updater handoff: it decrypts the delivery artifact, reconstructs the +loadable VM-code payload, verifies the reconstructed hash against metadata, and +writes the file that the engine patch cache can map before isolate startup. + +The host proof app is `testapps/license_flavor_patch_test`. Run: + +```powershell +dart-sdk-new\tools\sdks\dart-sdk\bin\dart.exe testapps\license_flavor_patch_test\tool\verify_aot_patch.dart +``` + +It compiles a `free` base AOT snapshot and a `pro` patch snapshot, reuses the +base obfuscation map for the patch build, encrypts the patch artifact, verifies +wrong-key and wrong-flavor failures, reconstructs the patched `.vmcode`, and +runs it to confirm `license:pro` and `pro-feature:enabled`. + +## iOS JIT-Disabled Handoff + +iOS release/profile patch artifacts should be generated as Mach-O AOT snapshot +objects, not bytecode and not dynamic modules. Build the patch snapshot on +macOS with the iOS `gen_snapshot` that matches the app's Flutter/Dart SDK: + +```sh +dart run open_aot_patch_tools compile-patch \ + --gen-snapshot=/path/to/ios/gen_snapshot_arm64 \ + --kernel=build/patch_app.dill \ + --output=build/patch.vmcode \ + --snapshot-kind=app-aot-macho-dylib \ + --macho-object=build/patch.o \ + --obfuscate=true \ + --load-obfuscation-map=build/base.obfuscation.json + +dart run open_aot_patch_tools link \ + --base=build/base.vmcode \ + --patch=build/patch.vmcode \ + --output=build/patch.json \ + --app-id=com.example.licenseFlavorPatchTest \ + --app-build-id=ios-build-123 \ + --base-flavor-id=free \ + --base-license-type=free \ + --flavor-id=pro \ + --license-type=pro \ + --sdk-hash= \ + --target-os=ios \ + --target-arch=arm64 \ + --obfuscation-map-hash= \ + --full-snapshot=true +``` + +The encrypted artifact is delivered the same way as the Windows host proof: +`encrypt` wraps it with AES-256-GCM, and the app-owned updater/key callback must +validate metadata before decrypting. After decryption, use +`dump-blobs --key-hex= --base=build/base.vmcode --output=patch.vmcode` to +write the loadable patch file into the updater's next-boot patch location. + +The Flutter engine patch cache is prepared for this handoff: + +- `shorebird_enable_aot_patching` defaults on for iOS, while the legacy + `SHOREBIRD_USE_INTERPRETER` path is opt-in and disabled by default. +- The first `.vmcode` application library path is treated as the patch. For iOS + it may contain a raw Mach-O snapshot or a compact linker header followed by a + Mach-O snapshot. +- VM snapshot symbols continue to resolve from the signed base App.framework; + only isolate data and isolate instructions are replaced by the patch. +- The loader uses Dart's Mach-O snapshot loader on Apple platforms, so the VM + does not need JIT, writable executable memory, KBC, or `DART_DYNAMIC_MODULES`. + +The macOS device/simulator verification pass should build the same +`license_flavor_patch_test` flavor pair with `--target-os=ios` and +`--target-arch=arm64`, install the encrypted `free -> pro` patch, confirm the +status label changes to `license:pro` / `pro-feature:enabled`, and confirm +wrong-key and wrong-flavor artifacts fail before the isolate starts. + +## Obfuscation + +Release and patch builds should both use Dart `--obfuscate`. Patch builds should +pass the release build's obfuscation map through +`gen_snapshot --load-obfuscation-map=` so existing symbols keep their +release names and new patch-only symbols receive fresh names without collisions. + +## Control-Flow Obfuscation Plan + +Control-flow obfuscation should be a separate compiler flag from name +obfuscation and AOT patching. The first safe milestone is an opt-in annotation +or pragma for selected functions, followed by a precompiler pass that: + +- splits basic blocks at safe boundaries after AOT optimizations +- introduces opaque predicates that are stable across deterministic builds +- preserves safepoint, deoptimization, exception, and stack-map correctness +- avoids FFI callbacks, async suspension stubs, recognized intrinsics, and + functions with patch entry points until each category has targeted tests + +The pass should start disabled by default and only graduate after size, +performance, symbolication, and patch compatibility tests exist for host, +Android, and iOS AOT builds. diff --git a/packages/open_aot_patch_tools/lib/open_aot_patch_tools.dart b/packages/open_aot_patch_tools/lib/open_aot_patch_tools.dart new file mode 100644 index 00000000..0e4e0c25 --- /dev/null +++ b/packages/open_aot_patch_tools/lib/open_aot_patch_tools.dart @@ -0,0 +1,4 @@ +/// Open-source helpers for compact AOT patch artifacts. +library; + +export 'src/patch_artifact.dart'; diff --git a/packages/open_aot_patch_tools/lib/src/patch_artifact.dart b/packages/open_aot_patch_tools/lib/src/patch_artifact.dart new file mode 100644 index 00000000..12bc586c --- /dev/null +++ b/packages/open_aot_patch_tools/lib/src/patch_artifact.dart @@ -0,0 +1,688 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:pointycastle/export.dart'; + +/// JSON format marker for an unencrypted compact AOT patch artifact. +const artifactFormat = 'open-aot-vmcode-v1'; + +/// JSON format marker for an AES-GCM encrypted compact AOT patch artifact. +const encryptedArtifactFormat = 'open-aot-vmcode-encrypted-v1'; + +/// Payload kind used when the patch snapshot is identical to the base. +const payloadKindEmpty = 'empty'; + +/// Payload kind used when the artifact stores the full patch snapshot. +const payloadKindFullSnapshot = 'full-snapshot'; + +/// Payload kind used when the artifact stores an open binary diff. +const payloadKindBinaryDiff = 'binary-diff-v1'; + +/// Compatibility metadata bound to a compact AOT patch artifact. +class PatchMetadata { + /// Creates patch metadata for one application build and flavor. + const PatchMetadata({ + required this.appId, + required this.appBuildId, + required this.flavorId, + required this.licenseType, + required this.sdkHash, + required this.baseSnapshotHash, + required this.patchSnapshotHash, + required this.targetOs, + required this.targetArch, + this.baseFlavorId, + this.baseLicenseType, + this.obfuscationMapHash, + }); + + /// Reads metadata from the JSON representation used by patch artifacts. + factory PatchMetadata.fromJson(Map json) => PatchMetadata( + appId: _string(json, 'app_id'), + appBuildId: _string(json, 'app_build_id'), + baseFlavorId: json['base_flavor_id'] as String?, + baseLicenseType: json['base_license_type'] as String?, + flavorId: _string(json, 'flavor_id'), + licenseType: _string(json, 'license_type'), + sdkHash: _string(json, 'sdk_hash'), + baseSnapshotHash: _string(json, 'base_snapshot_hash'), + patchSnapshotHash: _string(json, 'patch_snapshot_hash'), + targetOs: _string(json, 'target_os'), + targetArch: _string(json, 'target_arch'), + obfuscationMapHash: json['obfuscation_map_hash'] as String?, + ); + + /// Stable application identifier. + final String appId; + + /// Application build identifier that produced the base snapshot. + final String appBuildId; + + /// Optional source flavor this patch is allowed to replace. + final String? baseFlavorId; + + /// Optional source license type this patch is allowed to replace. + final String? baseLicenseType; + + /// Target flavor after the patch is applied. + final String flavorId; + + /// Target license type after the patch is applied. + final String licenseType; + + /// Hash identifying the Dart/Flutter SDK build. + final String sdkHash; + + /// SHA-256 hash of the base snapshot bytes. + final String baseSnapshotHash; + + /// SHA-256 hash of the reconstructed patch snapshot bytes. + final String patchSnapshotHash; + + /// Target operating system, for example `windows` or `ios`. + final String targetOs; + + /// Target CPU architecture, for example `x64` or `arm64`. + final String targetArch; + + /// Optional hash of the obfuscation map used by base and patch builds. + final String? obfuscationMapHash; + + /// Converts this metadata to the stable JSON map used as AES-GCM AAD. + Map toJson() => { + 'app_id': appId, + 'app_build_id': appBuildId, + if (baseFlavorId != null) 'base_flavor_id': baseFlavorId, + if (baseLicenseType != null) 'base_license_type': baseLicenseType, + 'flavor_id': flavorId, + 'license_type': licenseType, + 'sdk_hash': sdkHash, + 'base_snapshot_hash': baseSnapshotHash, + 'patch_snapshot_hash': patchSnapshotHash, + 'target_os': targetOs, + 'target_arch': targetArch, + if (obfuscationMapHash != null) 'obfuscation_map_hash': obfuscationMapHash, + }; +} + +/// Unencrypted compact patch artifact. +class PatchArtifact { + /// Creates an unencrypted compact patch artifact. + const PatchArtifact({ + required this.metadata, + required this.payload, + required this.payloadKind, + required this.reconstructedSize, + }); + + /// Reads and validates an unencrypted patch artifact JSON map. + factory PatchArtifact.fromJson(Map json) { + final format = json['format']; + if (format != artifactFormat) { + throw FormatException('Unsupported artifact format: $format'); + } + final payloadKind = + json['payload_kind'] as String? ?? payloadKindFullSnapshot; + final payload = base64Decode(_string(json, 'payload_base64')); + final payloadHash = json['payload_sha256'] as String?; + if (payloadHash != null && payloadHash != sha256Hex(payload)) { + throw const FormatException('Patch payload hash mismatch.'); + } + return PatchArtifact( + metadata: PatchMetadata.fromJson( + (json['metadata']! as Map).cast(), + ), + payload: payload, + payloadKind: payloadKind, + reconstructedSize: (json['reconstructed_size'] as num?)?.toInt(), + ); + } + + /// Metadata used to validate app, SDK, flavor, and snapshot compatibility. + final PatchMetadata metadata; + + /// Compact payload bytes, whose interpretation depends on [payloadKind]. + final List payload; + + /// One of the `payloadKind*` constants. + final String payloadKind; + + /// Expected byte length of the reconstructed patch snapshot. + final int? reconstructedSize; + + /// Converts this artifact to its JSON representation. + Map toJson() => { + 'format': artifactFormat, + 'metadata': metadata.toJson(), + 'payload_kind': payloadKind, + if (reconstructedSize != null) 'reconstructed_size': reconstructedSize, + 'payload_sha256': sha256Hex(payload), + 'payload_base64': base64Encode(payload), + }; + + /// Reconstructs the final patch snapshot from [baseSnapshot]. + List reconstruct(List baseSnapshot) => reconstructPatchSnapshot( + baseSnapshot: baseSnapshot, + payload: payload, + payloadKind: payloadKind, + reconstructedSize: reconstructedSize, + ); +} + +/// AES-256-GCM encrypted compact patch artifact. +class EncryptedPatchArtifact { + /// Creates an encrypted compact patch artifact. + const EncryptedPatchArtifact({ + required this.metadata, + required this.encryptedPayload, + required this.payloadKind, + required this.reconstructedSize, + required this.keyId, + required this.nonce, + required this.tag, + required this.aadSha256, + required this.payloadSha256, + }); + + /// Reads and validates an encrypted patch artifact JSON map. + factory EncryptedPatchArtifact.fromJson(Map json) { + final format = json['format']; + if (format != encryptedArtifactFormat) { + throw FormatException('Unsupported encrypted artifact format: $format'); + } + final encryption = (json['encryption']! as Map).cast(); + if (encryption['algorithm'] != 'AES-256-GCM') { + throw FormatException( + 'Unsupported encryption algorithm: ${encryption['algorithm']}', + ); + } + return EncryptedPatchArtifact( + metadata: PatchMetadata.fromJson( + (json['metadata']! as Map).cast(), + ), + encryptedPayload: base64Decode(_string(json, 'encrypted_payload_base64')), + payloadKind: json['payload_kind'] as String? ?? payloadKindFullSnapshot, + reconstructedSize: (json['reconstructed_size'] as num?)?.toInt(), + keyId: _string(encryption, 'key_id'), + nonce: base64Decode(_string(encryption, 'nonce_base64')), + tag: base64Decode(_string(encryption, 'tag_base64')), + aadSha256: _string(encryption, 'aad_sha256'), + payloadSha256: _string(json, 'payload_sha256'), + ); + } + + /// Metadata used to validate app, SDK, flavor, and snapshot compatibility. + final PatchMetadata metadata; + + /// AES-GCM ciphertext bytes without the authentication tag. + final List encryptedPayload; + + /// One of the `payloadKind*` constants. + final String payloadKind; + + /// Expected byte length of the reconstructed patch snapshot. + final int? reconstructedSize; + + /// Key identifier supplied to the app-owned key callback. + final String keyId; + + /// AES-GCM nonce bytes. + final List nonce; + + /// AES-GCM authentication tag bytes. + final List tag; + + /// SHA-256 hash of the canonical metadata AAD. + final String aadSha256; + + /// SHA-256 hash of the decrypted compact payload bytes. + final String payloadSha256; + + /// Converts this encrypted artifact to its JSON representation. + Map toJson() => { + 'format': encryptedArtifactFormat, + 'metadata': metadata.toJson(), + 'payload_kind': payloadKind, + if (reconstructedSize != null) 'reconstructed_size': reconstructedSize, + 'payload_sha256': payloadSha256, + 'encrypted_payload_base64': base64Encode(encryptedPayload), + 'encryption': { + 'algorithm': 'AES-256-GCM', + 'key_id': keyId, + 'nonce_base64': base64Encode(nonce), + 'tag_base64': base64Encode(tag), + 'aad_sha256': aadSha256, + }, + }; + + /// Decrypts and authenticates the compact payload with [key]. + PatchArtifact decrypt(List key) { + final aad = canonicalJson(metadata.toJson()); + final actualAadSha = sha256Hex(utf8.encode(aad)); + if (actualAadSha != aadSha256) { + throw const FormatException('Artifact metadata AAD hash mismatch.'); + } + final cipherTextAndTag = Uint8List.fromList([...encryptedPayload, ...tag]); + final cipher = GCMBlockCipher(AESEngine()) + ..init( + false, + AEADParameters( + KeyParameter(Uint8List.fromList(key)), + tag.length * 8, + Uint8List.fromList(nonce), + Uint8List.fromList(utf8.encode(aad)), + ), + ); + final Uint8List payload; + try { + payload = cipher.process(cipherTextAndTag); + } on Object catch (error) { + throw FormatException('Patch decryption failed: $error'); + } + if (sha256Hex(payload) != payloadSha256) { + throw const FormatException('Decrypted payload hash mismatch.'); + } + return PatchArtifact( + metadata: metadata, + payload: payload, + payloadKind: payloadKind, + reconstructedSize: reconstructedSize, + ); + } +} + +/// Links base and patch snapshot bytes into a compact patch artifact. +PatchArtifact linkArtifacts({ + required List baseSnapshot, + required List patchSnapshot, + required PatchMetadata metadata, + bool forceFullSnapshot = false, +}) { + final compact = forceFullSnapshot + ? _CompactPayload( + kind: payloadKindFullSnapshot, + payload: List.of(patchSnapshot), + reconstructedSize: patchSnapshot.length, + ) + : _compactPayload(baseSnapshot, patchSnapshot); + return PatchArtifact( + metadata: metadata, + payload: compact.payload, + payloadKind: compact.kind, + reconstructedSize: compact.reconstructedSize, + ); +} + +/// Encrypts a compact patch artifact for secure delivery. +EncryptedPatchArtifact encryptArtifact({ + required PatchArtifact artifact, + required String keyId, + required List key, + required List nonce, +}) { + if (key.length != 32) { + throw ArgumentError.value( + key.length, + 'key.length', + 'AES-256 needs 32 bytes', + ); + } + if (nonce.length != 12) { + throw ArgumentError.value( + nonce.length, + 'nonce.length', + 'AES-GCM nonce must be 12 bytes', + ); + } + final aad = canonicalJson(artifact.metadata.toJson()); + final cipher = GCMBlockCipher(AESEngine()) + ..init( + true, + AEADParameters( + KeyParameter(Uint8List.fromList(key)), + 128, + Uint8List.fromList(nonce), + Uint8List.fromList(utf8.encode(aad)), + ), + ); + final sealed = cipher.process(Uint8List.fromList(artifact.payload)); + return EncryptedPatchArtifact( + metadata: artifact.metadata, + encryptedPayload: sealed.sublist(0, sealed.length - 16), + payloadKind: artifact.payloadKind, + reconstructedSize: artifact.reconstructedSize, + keyId: keyId, + nonce: nonce, + tag: sealed.sublist(sealed.length - 16), + aadSha256: sha256Hex(utf8.encode(aad)), + payloadSha256: sha256Hex(artifact.payload), + ); +} + +/// Verifies that metadata matches the expected target and optional base state. +void verifyMetadata( + PatchMetadata metadata, { + required String flavorId, + required String licenseType, + String? baseFlavorId, + String? baseLicenseType, +}) { + if (baseFlavorId != null && metadata.baseFlavorId != baseFlavorId) { + throw StateError( + 'Patch base flavor "${metadata.baseFlavorId}" does not match ' + '"$baseFlavorId".', + ); + } + if (baseLicenseType != null && metadata.baseLicenseType != baseLicenseType) { + throw StateError( + 'Patch base license "${metadata.baseLicenseType}" does not match ' + '"$baseLicenseType".', + ); + } + if (metadata.flavorId != flavorId) { + throw StateError( + 'Patch flavor "${metadata.flavorId}" does not match "$flavorId".', + ); + } + if (metadata.licenseType != licenseType) { + throw StateError( + 'Patch license "${metadata.licenseType}" does not match "$licenseType".', + ); + } +} + +/// Reconstructs final patch snapshot bytes from a compact payload. +List reconstructPatchSnapshot({ + required List baseSnapshot, + required List payload, + required String payloadKind, + required int? reconstructedSize, +}) { + switch (payloadKind) { + case payloadKindEmpty: + return List.of(baseSnapshot); + case payloadKindFullSnapshot: + return List.of(payload); + case payloadKindBinaryDiff: + if (reconstructedSize == null) { + throw const FormatException( + 'Binary diff payload is missing reconstructed_size.', + ); + } + return _applyBinaryDiff( + baseSnapshot: baseSnapshot, + diff: payload, + reconstructedSize: reconstructedSize, + ); + default: + throw FormatException('Unsupported payload kind: $payloadKind'); + } +} + +/// Encodes [value] as deterministic JSON with sorted map keys. +String canonicalJson(Object? value) { + Object? normalize(Object? input) { + if (input is Map) { + final keys = input.keys.cast().toList()..sort(); + return {for (final key in keys) key: normalize(input[key])}; + } + if (input is Iterable) { + return input.map(normalize).toList(); + } + return input; + } + + return jsonEncode(normalize(value)); +} + +/// Computes a lowercase hexadecimal SHA-256 digest for [bytes]. +String sha256Hex(List bytes) => sha256.convert(bytes).toString(); + +/// Reads a 32-byte AES key from hexadecimal text. +List readKey(String keyHex) { + return readHexBytes(keyHex, expectedBytes: 32, name: 'AES-256 key'); +} + +/// Reads a 12-byte AES-GCM nonce from hexadecimal text. +List readNonce(String nonceHex) { + return readHexBytes(nonceHex, expectedBytes: 12, name: 'AES-GCM nonce'); +} + +/// Reads exactly [expectedBytes] bytes from hexadecimal text. +List readHexBytes( + String hex, { + required int expectedBytes, + required String name, +}) { + final normalized = hex.trim(); + if (normalized.length != expectedBytes * 2) { + throw ArgumentError('$name must be ${expectedBytes * 2} hex characters.'); + } + return [ + for (var i = 0; i < normalized.length; i += 2) + int.parse(normalized.substring(i, i + 2), radix: 16), + ]; +} + +/// Reads a JSON object from [file]. +Map readJsonFile(File file) => + (jsonDecode(file.readAsStringSync()) as Map).cast(); + +String _string(Map json, String key) { + final value = json[key]; + if (value is! String || value.isEmpty) { + throw FormatException('Expected non-empty string field "$key".'); + } + return value; +} + +_CompactPayload _compactPayload( + List baseSnapshot, + List patchSnapshot, +) { + if (_listEquals(baseSnapshot, patchSnapshot)) { + return _CompactPayload( + kind: payloadKindEmpty, + payload: const [], + reconstructedSize: patchSnapshot.length, + ); + } + + final diff = _encodeBinaryDiff( + baseSnapshot: baseSnapshot, + patchSnapshot: patchSnapshot, + ); + if (diff.length < patchSnapshot.length) { + return _CompactPayload( + kind: payloadKindBinaryDiff, + payload: diff, + reconstructedSize: patchSnapshot.length, + ); + } + return _CompactPayload( + kind: payloadKindFullSnapshot, + payload: List.of(patchSnapshot), + reconstructedSize: patchSnapshot.length, + ); +} + +class _CompactPayload { + const _CompactPayload({ + required this.kind, + required this.payload, + required this.reconstructedSize, + }); + + final String kind; + final List payload; + final int reconstructedSize; +} + +List _encodeBinaryDiff({ + required List baseSnapshot, + required List patchSnapshot, +}) { + const magic = [0x4f, 0x41, 0x50, 0x44, 0x31]; // OAPD1. + const equalGapToKeep = 16; + final chunks = <_DiffChunk>[]; + var cursor = 0; + while (cursor < patchSnapshot.length) { + final baseByte = cursor < baseSnapshot.length ? baseSnapshot[cursor] : null; + if (baseByte == patchSnapshot[cursor]) { + cursor++; + continue; + } + + final start = cursor; + var lastDifferent = cursor; + var equalRun = 0; + cursor++; + while (cursor < patchSnapshot.length) { + final baseAtCursor = cursor < baseSnapshot.length + ? baseSnapshot[cursor] + : null; + if (baseAtCursor == patchSnapshot[cursor]) { + equalRun++; + if (equalRun > equalGapToKeep) { + break; + } + } else { + equalRun = 0; + lastDifferent = cursor; + } + cursor++; + } + + final end = lastDifferent + 1; + chunks.add(_DiffChunk(start, patchSnapshot.sublist(start, end))); + cursor = end; + } + + final size = + magic.length + + 8 + + 4 + + chunks.fold( + 0, + (total, chunk) => total + 8 + 4 + chunk.bytes.length, + ); + final output = Uint8List(size); + var offset = 0; + output.setRange(offset, offset + magic.length, magic); + offset += magic.length; + _writeUint64(output, offset, patchSnapshot.length); + offset += 8; + _writeUint32(output, offset, chunks.length); + offset += 4; + for (final chunk in chunks) { + _writeUint64(output, offset, chunk.offset); + offset += 8; + _writeUint32(output, offset, chunk.bytes.length); + offset += 4; + output.setRange(offset, offset + chunk.bytes.length, chunk.bytes); + offset += chunk.bytes.length; + } + return output; +} + +List _applyBinaryDiff({ + required List baseSnapshot, + required List diff, + required int reconstructedSize, +}) { + const magic = [0x4f, 0x41, 0x50, 0x44, 0x31]; // OAPD1. + if (diff.length < magic.length + 8 + 4) { + throw const FormatException('Binary diff payload is too short.'); + } + for (var i = 0; i < magic.length; i++) { + if (diff[i] != magic[i]) { + throw const FormatException('Invalid binary diff magic.'); + } + } + var cursor = magic.length; + final targetSize = _readUint64(diff, cursor); + cursor += 8; + if (targetSize != reconstructedSize) { + throw const FormatException('Binary diff target size mismatch.'); + } + final chunkCount = _readUint32(diff, cursor); + cursor += 4; + + final output = Uint8List(reconstructedSize); + final copied = baseSnapshot.length < reconstructedSize + ? baseSnapshot.length + : reconstructedSize; + output.setRange(0, copied, baseSnapshot); + + for (var i = 0; i < chunkCount; i++) { + if (cursor + 12 > diff.length) { + throw const FormatException('Truncated binary diff chunk header.'); + } + final chunkOffset = _readUint64(diff, cursor); + cursor += 8; + final chunkLength = _readUint32(diff, cursor); + cursor += 4; + if (chunkOffset < 0 || + chunkLength < 0 || + chunkOffset + chunkLength > reconstructedSize || + cursor + chunkLength > diff.length) { + throw const FormatException('Invalid binary diff chunk bounds.'); + } + output.setRange( + chunkOffset, + chunkOffset + chunkLength, + diff, + cursor, + ); + cursor += chunkLength; + } + if (cursor != diff.length) { + throw const FormatException('Unexpected trailing binary diff data.'); + } + return output; +} + +class _DiffChunk { + const _DiffChunk(this.offset, this.bytes); + + final int offset; + final List bytes; +} + +void _writeUint32(Uint8List output, int offset, int value) { + output[offset] = value & 0xff; + output[offset + 1] = (value >> 8) & 0xff; + output[offset + 2] = (value >> 16) & 0xff; + output[offset + 3] = (value >> 24) & 0xff; +} + +void _writeUint64(Uint8List output, int offset, int value) { + for (var i = 0; i < 8; i++) { + output[offset + i] = (value >> (8 * i)) & 0xff; + } +} + +int _readUint32(List input, int offset) { + return input[offset] | + (input[offset + 1] << 8) | + (input[offset + 2] << 16) | + (input[offset + 3] << 24); +} + +int _readUint64(List input, int offset) { + var value = 0; + for (var i = 0; i < 8; i++) { + value |= input[offset + i] << (8 * i); + } + return value; +} + +bool _listEquals(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; +} diff --git a/packages/open_aot_patch_tools/pubspec.yaml b/packages/open_aot_patch_tools/pubspec.yaml new file mode 100644 index 00000000..bf95c02a --- /dev/null +++ b/packages/open_aot_patch_tools/pubspec.yaml @@ -0,0 +1,21 @@ +name: open_aot_patch_tools +description: Open compact AOT patch artifact tools with flavor-aware AES-GCM wrapping. +version: 0.1.0 +publish_to: none + +environment: + sdk: ^3.9.0 +resolution: workspace + +dependencies: + args: ^2.7.0 + crypto: ^3.0.6 + path: ^1.9.1 + pointycastle: ^4.0.0 + +dev_dependencies: + test: ^1.25.15 + very_good_analysis: ^10.2.0 + +executables: + open_aot_patch_tools: diff --git a/packages/open_aot_patch_tools/test/patch_artifact_test.dart b/packages/open_aot_patch_tools/test/patch_artifact_test.dart new file mode 100644 index 00000000..6cd45608 --- /dev/null +++ b/packages/open_aot_patch_tools/test/patch_artifact_test.dart @@ -0,0 +1,397 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:open_aot_patch_tools/open_aot_patch_tools.dart'; +import 'package:test/test.dart'; + +void main() { + const key = [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + ]; + const wrongKey = [ + 31, + 30, + 29, + 28, + 27, + 26, + 25, + 24, + 23, + 22, + 21, + 20, + 19, + 18, + 17, + 16, + 15, + 14, + 13, + 12, + 11, + 10, + 9, + 8, + 7, + 6, + 5, + 4, + 3, + 2, + 1, + 0, + ]; + const nonce = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + + PatchMetadata metadata() => const PatchMetadata( + appId: 'app.test', + appBuildId: '1.0.0+1', + baseFlavorId: 'free', + baseLicenseType: 'free', + flavorId: 'pro', + licenseType: 'pro', + sdkHash: 'sdk', + baseSnapshotHash: 'base', + patchSnapshotHash: 'patch', + targetOs: 'windows', + targetArch: 'x64', + obfuscationMapHash: 'obfuscated', + ); + + test('encrypts and decrypts flavor-aware patch payloads', () { + final base = utf8.encode('free'); + final patch = utf8.encode('pro'); + final artifact = linkArtifacts( + baseSnapshot: base, + patchSnapshot: patch, + metadata: metadata(), + ); + final encrypted = encryptArtifact( + artifact: artifact, + keyId: 'test-key', + key: key, + nonce: nonce, + ); + + verifyMetadata(encrypted.metadata, flavorId: 'pro', licenseType: 'pro'); + verifyMetadata( + encrypted.metadata, + baseFlavorId: 'free', + baseLicenseType: 'free', + flavorId: 'pro', + licenseType: 'pro', + ); + expect(encrypted.decrypt(key).reconstruct(base), patch); + }); + + test('wrong AES key fails before payload is accepted', () { + final artifact = linkArtifacts( + baseSnapshot: utf8.encode('free'), + patchSnapshot: utf8.encode('pro'), + metadata: metadata(), + ); + final encrypted = encryptArtifact( + artifact: artifact, + keyId: 'test-key', + key: key, + nonce: nonce, + ); + + expect(() => encrypted.decrypt(wrongKey), throwsFormatException); + }); + + test('wrong flavor metadata is rejected', () { + final artifact = linkArtifacts( + baseSnapshot: utf8.encode('free'), + patchSnapshot: utf8.encode('pro'), + metadata: metadata(), + ); + final encrypted = encryptArtifact( + artifact: artifact, + keyId: 'test-key', + key: key, + nonce: nonce, + ); + + expect( + () => verifyMetadata( + encrypted.metadata, + flavorId: 'free', + licenseType: 'pro', + ), + throwsStateError, + ); + }); + + test('wrong base flavor metadata is rejected', () { + final artifact = linkArtifacts( + baseSnapshot: utf8.encode('free'), + patchSnapshot: utf8.encode('pro'), + metadata: metadata(), + ); + final encrypted = encryptArtifact( + artifact: artifact, + keyId: 'test-key', + key: key, + nonce: nonce, + ); + + expect( + () => verifyMetadata( + encrypted.metadata, + baseFlavorId: 'enterprise', + baseLicenseType: 'free', + flavorId: 'pro', + licenseType: 'pro', + ), + throwsStateError, + ); + }); + + test('artifact json round trips', () { + final base = utf8.encode('free'); + final patch = utf8.encode('pro'); + final artifact = linkArtifacts( + baseSnapshot: base, + patchSnapshot: patch, + metadata: metadata(), + ); + final decoded = PatchArtifact.fromJson( + (jsonDecode(jsonEncode(artifact.toJson())) as Map).cast(), + ); + + expect(decoded.metadata.flavorId, 'pro'); + expect(decoded.reconstruct(base), patch); + }); + + test('artifact json rejects payload hash mismatch', () { + final artifact = linkArtifacts( + baseSnapshot: utf8.encode('free'), + patchSnapshot: utf8.encode('pro'), + metadata: metadata(), + ).toJson()..['payload_sha256'] = 'not-the-real-hash'; + + expect(() => PatchArtifact.fromJson(artifact), throwsFormatException); + }); + + test('binary diff payload reconstructs patch snapshot', () { + final base = List.filled(1024, 7); + final patch = List.of(base) + ..[512] = 9 + ..[513] = 10; + final artifact = linkArtifacts( + baseSnapshot: base, + patchSnapshot: patch, + metadata: metadata(), + ); + + expect(artifact.payloadKind, payloadKindBinaryDiff); + expect(artifact.payload.length, lessThan(patch.length)); + expect(artifact.reconstruct(base), patch); + }); + + test('full snapshot mode keeps payload directly loadable', () { + final base = List.filled(1024, 7); + final patch = List.of(base)..[512] = 9; + final artifact = linkArtifacts( + baseSnapshot: base, + patchSnapshot: patch, + metadata: metadata(), + forceFullSnapshot: true, + ); + + expect(artifact.payloadKind, payloadKindFullSnapshot); + expect(artifact.payload, patch); + expect(artifact.reconstruct(base), patch); + }); + + test('cli links encrypts verifies and dumps compact patch', () async { + final tempDir = Directory.systemTemp.createTempSync( + 'open_aot_patch_tools.', + ); + try { + final baseFile = File('${tempDir.path}/base.vmcode') + ..writeAsBytesSync(List.filled(1024, 7)); + final patchBytes = List.filled(1024, 7)..[512] = 9; + final patchFile = File('${tempDir.path}/patch.vmcode') + ..writeAsBytesSync(patchBytes); + final artifactFile = File('${tempDir.path}/patch.json'); + final encryptedFile = File('${tempDir.path}/patch.enc.json'); + final reconstructedFile = File('${tempDir.path}/reconstructed.vmcode'); + const keyHex = + '000102030405060708090a0b0c0d0e0f' + '101112131415161718191a1b1c1d1e1f'; + const nonceHex = '000102030405060708090a0b'; + + await _runTool([ + 'link', + '--base=${baseFile.path}', + '--patch=${patchFile.path}', + '--output=${artifactFile.path}', + '--app-id=app.test', + '--app-build-id=1.0.0+1', + '--base-flavor-id=free', + '--base-license-type=free', + '--flavor-id=pro', + '--license-type=pro', + '--sdk-hash=sdk', + '--target-os=windows', + '--target-arch=x64', + ]); + final artifactJson = readJsonFile(artifactFile); + expect(artifactJson['payload_kind'], payloadKindBinaryDiff); + + await _runTool([ + 'link', + '--base=${baseFile.path}', + '--patch=${patchFile.path}', + '--output=${artifactFile.path}', + '--app-id=app.test', + '--app-build-id=1.0.0+1', + '--base-flavor-id=free', + '--base-license-type=free', + '--flavor-id=pro', + '--license-type=pro', + '--sdk-hash=sdk', + '--target-os=windows', + '--target-arch=x64', + '--full-snapshot=true', + ]); + final fullArtifactJson = readJsonFile(artifactFile); + expect(fullArtifactJson['payload_kind'], payloadKindFullSnapshot); + + await _runTool([ + 'encrypt', + '--input=${artifactFile.path}', + '--output=${encryptedFile.path}', + '--key-id=test-key', + '--key-hex=$keyHex', + '--nonce-hex=$nonceHex', + ]); + + await _runTool([ + 'verify', + '--input=${encryptedFile.path}', + '--key-hex=$keyHex', + '--artifact-sha256=${sha256Hex(encryptedFile.readAsBytesSync())}', + '--base-flavor-id=free', + '--base-license-type=free', + '--flavor-id=pro', + '--license-type=pro', + '--base=${baseFile.path}', + ]); + + await _runToolExpectFailure([ + 'verify', + '--input=${encryptedFile.path}', + '--key-hex=$keyHex', + '--artifact-sha256=bad', + '--base-flavor-id=free', + '--base-license-type=free', + '--flavor-id=pro', + '--license-type=pro', + '--base=${baseFile.path}', + ]); + + final dump = await _runTool([ + 'dump-blobs', + '--input=${encryptedFile.path}', + '--key-hex=$keyHex', + '--base=${baseFile.path}', + '--output=${reconstructedFile.path}', + ]); + final dumpJson = (jsonDecode(dump.stdout as String) as Map) + .cast(); + expect( + dumpJson['artifact_sha256'], + sha256Hex(encryptedFile.readAsBytesSync()), + ); + expect(dumpJson['reconstructed_patch_sha256'], sha256Hex(patchBytes)); + expect(reconstructedFile.readAsBytesSync(), patchBytes); + } finally { + tempDir.deleteSync(recursive: true); + } + }); +} + +Future _runTool(List args) async { + final packageDir = _packageDir(); + final result = await Process.run( + Platform.resolvedExecutable, + ['bin/open_aot_patch_tools.dart', ...args], + workingDirectory: packageDir.path, + ); + if (result.exitCode != 0) { + final output = [ + result.exitCode, + 'stdout:', + result.stdout, + 'stderr:', + result.stderr, + ].join('\n'); + fail('open_aot_patch_tools ${args.join(' ')} failed with $output'); + } + return result; +} + +Future _runToolExpectFailure(List args) async { + final packageDir = _packageDir(); + final result = await Process.run( + Platform.resolvedExecutable, + ['bin/open_aot_patch_tools.dart', ...args], + workingDirectory: packageDir.path, + ); + if (result.exitCode == 0) { + fail('open_aot_patch_tools ${args.join(' ')} unexpectedly succeeded'); + } + return result; +} + +Directory _packageDir() { + final direct = Directory.current; + if (File('${direct.path}/bin/open_aot_patch_tools.dart').existsSync()) { + return direct; + } + final workspaceMember = Directory( + '${direct.path}/packages/open_aot_patch_tools', + ); + final workspaceTool = File( + '${workspaceMember.path}/bin/open_aot_patch_tools.dart', + ); + if (workspaceTool.existsSync()) { + return workspaceMember; + } + fail('Unable to locate open_aot_patch_tools package directory.'); +} diff --git a/pubspec.yaml b/pubspec.yaml index ceacb130..82acf171 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,6 +8,7 @@ workspace: - packages/discord_gcp_alerts - packages/flutter_version_resolver - packages/jwt + - packages/open_aot_patch_tools - packages/redis_client - packages/scoped_deps - packages/shorebird_build_trace