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 index e2321aa8..105b9c76 100644 --- a/packages/open_aot_patch_tools/bin/open_aot_patch_tools.dart +++ b/packages/open_aot_patch_tools/bin/open_aot_patch_tools.dart @@ -32,6 +32,8 @@ void main(List args) { void _link(Map options) { final base = File(_required(options, 'base')).readAsBytesSync(); final patch = File(_required(options, 'patch')).readAsBytesSync(); + final targetOs = _required(options, 'target-os'); + final runtimeMode = _runtimeModeForLink(options, targetOs); final metadata = PatchMetadata( appId: _required(options, 'app-id'), appBuildId: _required(options, 'app-build-id'), @@ -42,8 +44,9 @@ void _link(Map options) { sdkHash: _required(options, 'sdk-hash'), baseSnapshotHash: sha256Hex(base), patchSnapshotHash: sha256Hex(patch), - targetOs: _required(options, 'target-os'), + targetOs: targetOs, targetArch: _required(options, 'target-arch'), + runtimeMode: runtimeMode, obfuscationMapHash: options['obfuscation-map-hash'], offlineExpiresAt: _optionalIso8601Utc(options['offline-expires-at']), ); @@ -58,6 +61,24 @@ void _link(Map options) { ); } +String _runtimeModeForLink(Map options, String targetOs) { + final runtimeMode = options['runtime-mode']; + if (targetOs == 'ios' && (runtimeMode == null || runtimeMode.isEmpty)) { + throw ArgumentError( + 'Missing --runtime-mode for iOS. Use ' + '--runtime-mode=$runtimeModeDartBytecodeInterpreter for App Store-safe ' + 'interpreter patches, or explicitly pass ' + '--runtime-mode=$runtimeModeNativeAot for development-only native AOT ' + 'artifacts.', + ); + } + final resolved = runtimeMode ?? runtimeModeNativeAot; + if (!knownRuntimeModes.contains(resolved)) { + throw ArgumentError('Unsupported --runtime-mode=$resolved'); + } + return resolved; +} + void _encrypt(Map options) { final artifact = PatchArtifact.fromJson( readJsonFile(File(_required(options, 'input'))), @@ -86,6 +107,7 @@ void _verify(Map options) { baseLicenseType: options['base-license-type'], flavorId: _required(options, 'flavor-id'), licenseType: _required(options, 'license-type'), + requireIosAppStoreSafe: _boolOption(options, 'require-ios-app-store-safe'), now: _optionalDateTime(options['now']), allowExpired: _boolOption(options, 'allow-expired'), ); @@ -353,8 +375,8 @@ void _usage() { Usage: open_aot_patch_tools dump-blobs --input= [--key-hex=<64 hex chars>] [--base=] [--output=] [--now=] [--allow-expired=true] 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=] [--offline-expires-at=] [--full-snapshot=true] + open_aot_patch_tools link --base= --patch= --output= --app-id= --app-build-id= --flavor-id= --license-type= --sdk-hash= --target-os= --target-arch= [--runtime-mode=native-aot|dart-bytecode-interpreter] [--base-flavor-id=] [--base-license-type=] [--obfuscation-map-hash=] [--offline-expires-at=] [--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=] [--now=] [--allow-expired=true] + open_aot_patch_tools verify --input= --key-hex=<64 hex chars> --flavor-id= --license-type= [--base=] [--artifact-sha256=] [--base-flavor-id=] [--base-license-type=] [--require-ios-app-store-safe=true] [--now=] [--allow-expired=true] '''); } 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 index f9702a85..a864f847 100644 --- a/packages/open_aot_patch_tools/doc/open_aot_patch_design.md +++ b/packages/open_aot_patch_tools/doc/open_aot_patch_design.md @@ -1,7 +1,10 @@ # Open AOT Patch Design -This package produces compact, flavor-aware AOT patch artifacts without relying -on `DART_DYNAMIC_MODULES` or the Dart bytecode interpreter. +This package produces compact, flavor-aware patch artifacts without relying on +`DART_DYNAMIC_MODULES`. Native AOT artifacts are still useful for host, +Android, macOS, and development-device iOS validation, but iOS App Store patch +delivery must use a no-DDM interpreter runtime mode instead of downloaded native +snapshot text. The public-source boundary is documented in `../../../OPEN_SOURCE_REPLACEMENTS.md`. The Shorebird runtime updater itself is @@ -11,16 +14,21 @@ AOT patch generation flow. ## 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. +The artifact metadata names the runtime mode required by the patch payload: + +- `native-aot`: patched isolate snapshot data/instructions are selected before + isolate startup, matching Flutter's existing snapshot mapping flow. VM data + and VM instructions remain from the base runtime. +- `dart-bytecode-interpreter`: iOS App Store-safe payloads are data consumed by + already-reviewed VM interpreter code. This is not `DART_DYNAMIC_MODULES`, and + the VM must reject dynamic-module payloads for this project. This keeps the design compatible with iOS constraints: - no JIT dependency -- no writable executable memory requirement -- no KBC interpreter dependency +- no writable executable memory requirement for App Store payloads +- no downloaded native snapshot text in App Store payloads +- no `DART_DYNAMIC_MODULES` - 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 @@ -59,17 +67,26 @@ optional `remove_patch` object, which contains the same patch number, ## Compact Payloads -By default, `link` chooses the smallest v1 payload representation: +For native-AOT artifacts, `link` chooses the smallest v1 payload +representation by default: - `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 +For native-AOT 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. +startup on platforms where native patch execution is allowed. Do not use this +native-AOT handoff for iOS App Store delivery. + +For `dart-bytecode-interpreter` artifacts, the linker always emits +`full-snapshot` payloads. The current open runtime has no compact-diff +reconstruction bridge inside the VM, so interpreter patches must decrypt to +bytecode data. Product iOS now has an initial AOT-safe replacement mapper for +bytecode that describes already-loaded libraries/classes/functions; broader +payload shapes still need compiler and runtime work. The binary diff format is deterministic and reconstructs the full patched snapshot from the base snapshot before runtime loading. Future SDK-integrated @@ -77,9 +94,9 @@ 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 native-AOT 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: @@ -92,11 +109,26 @@ 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 App Store 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: +`DART_DYNAMIC_MODULES` must not be enabled for this project. It adds the dynamic +module interpreter surface and can package code that should not ship in the +target app. + +The native Mach-O `.vmcode` path is development-only on iOS. It proves the base +snapshot metadata, updater state machine, and flavor/license transition, but it +still maps downloaded native snapshot text as executable memory. A physical +device accepts that only when the payload is signed appropriately, and that is +not the App Store patch route. + +The App Store route follows Shorebird's public architecture shape: the updater +selects a patch before Dart starts, then the Dart SDK installs a payload that is +interpreted by VM code already present in the app. The payload runtime mode is +`dart-bytecode-interpreter`; it is explicitly separate from +`DART_DYNAMIC_MODULES`. + +For native development-device checks, 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 \ @@ -121,34 +153,97 @@ dart run open_aot_patch_tools link \ --sdk-hash= \ --target-os=ios \ --target-arch=arm64 \ + --runtime-mode=native-aot \ --obfuscation-map-hash= \ --offline-expires-at=2030-01-01T00:00:00Z \ --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. +For App Store-safe iOS artifacts, the linker must use: + +```sh +dart run open_aot_patch_tools link \ + --base=build/base.interp \ + --patch=build/patch.interp \ + --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 \ + --runtime-mode=dart-bytecode-interpreter \ + --obfuscation-map-hash= \ + --offline-expires-at=2030-01-01T00:00:00Z +``` + +`--full-snapshot=true` is not needed for `dart-bytecode-interpreter`; it is +forced by the linker and enforced by `Dart_InstallAotPatch`. + +The encrypted artifact is delivered the same way as the host proof: `encrypt` +wraps it with AES-256-GCM, and the app-owned updater/key callback must validate +metadata before decrypting. `Dart_InstallAotPatch` rejects iOS native-AOT +artifacts, rejects any dynamic-modules runtime mode, and rejects compact +interpreter payloads before payload installation. + +The open Flutter engine bridge can now pass the encrypted artifact selected by +the updater into `Dart_InstallAotPatch`, then hand the decrypted bytecode to +`Dart_ReloadBytecodePatch`. This bridge is intentionally no-DDM and never adds +interpreter patches to native `application_library_paths`. In product iOS AOT, +`Dart_ReloadBytecodePatch` uses the no-DDM bytecode replacement mapper instead +of stock VM reload: it reads the bytecode component as data, resolves existing +libraries/classes/functions from the signed base snapshot, and switches matched +functions to the VM's signed `InterpretCall` stub with attached bytecode. This +does not enable `DART_DYNAMIC_MODULES` and does not map downloaded executable +memory. + +For local encrypted interpreter tests, `shorebird.yaml` may provide a +development AES key and expected metadata: + +```yaml +aot_patch_key_id: test-key +aot_patch_key_hex: 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f +aot_patch_base_flavor_id: free +aot_patch_base_license_type: free +aot_patch_flavor_id: pro +aot_patch_license_type: pro +aot_patch_sdk_hash: +aot_patch_base_snapshot_hash: +``` + +Embedding a symmetric key in `shorebird.yaml` is a development bridge, not the +final production key strategy. The production replacement should supply the AES +key through an app-owned callback or platform key source while keeping the same +VM validation and bytecode reload path. 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`. +- `dart_enable_aot_patching` is enabled for iOS without enabling + `dart_dynamic_modules`. +- `shorebird_use_interpreter` is enabled for iOS and the native + `shorebird_enable_aot_patching` snapshot loader is disabled. +- Interpreter patch paths are not inserted into `application_library_paths`; + that list is reserved for native snapshot symbol lookup. +- Encrypted interpreter artifacts are decrypted to data and handed to the VM + patch API; no downloaded native text is mapped executable. +- Product iOS includes the first no-DDM function replacement mapper for changed + existing functions. New libraries/classes/top-level helpers and compact + interpreter reconstruction remain future work. +- VM snapshot symbols continue to resolve from the signed base App.framework. 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. +`license_flavor_patch_test` flavor pair with `--target-os=ios`, +`--target-arch=arm64`, and `--runtime-mode=dart-bytecode-interpreter`, 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. The current open runtime has the +metadata, SDK safety gate, encrypted artifact-to-bytecode handoff, no-DDM VM API +guard, and initial product-AOT replacement mapper; the compatible interpreter +payload compiler, production key callback, compact interpreter reconstruction, +and real-device behavior verification remain the next implementation steps. ## Obfuscation diff --git a/packages/open_aot_patch_tools/lib/src/patch_artifact.dart b/packages/open_aot_patch_tools/lib/src/patch_artifact.dart index c71a90d3..591b39c1 100644 --- a/packages/open_aot_patch_tools/lib/src/patch_artifact.dart +++ b/packages/open_aot_patch_tools/lib/src/patch_artifact.dart @@ -20,6 +20,21 @@ const payloadKindFullSnapshot = 'full-snapshot'; /// Payload kind used when the artifact stores an open binary diff. const payloadKindBinaryDiff = 'binary-diff-v1'; +/// Runtime mode used by today's open patch loader. The payload contains native +/// AOT snapshot instructions and therefore needs executable mapping at runtime. +const runtimeModeNativeAot = 'native-aot'; + +/// Runtime mode for App Store-safe iOS payloads interpreted by Dart VM code +/// already present in the app. The current open runtime requires these +/// artifacts to carry a directly loadable full bytecode snapshot. +const runtimeModeDartBytecodeInterpreter = 'dart-bytecode-interpreter'; + +/// Runtime modes understood by this artifact format. +const knownRuntimeModes = { + runtimeModeNativeAot, + runtimeModeDartBytecodeInterpreter, +}; + /// Compatibility metadata bound to a compact AOT patch artifact. class PatchMetadata { /// Creates patch metadata for one application build and flavor. @@ -33,6 +48,7 @@ class PatchMetadata { required this.patchSnapshotHash, required this.targetOs, required this.targetArch, + this.runtimeMode = runtimeModeNativeAot, this.baseFlavorId, this.baseLicenseType, this.obfuscationMapHash, @@ -52,6 +68,7 @@ class PatchMetadata { patchSnapshotHash: _string(json, 'patch_snapshot_hash'), targetOs: _string(json, 'target_os'), targetArch: _string(json, 'target_arch'), + runtimeMode: json['runtime_mode'] as String? ?? runtimeModeNativeAot, obfuscationMapHash: json['obfuscation_map_hash'] as String?, offlineExpiresAt: _optionalIso8601UtcString(json, 'offline_expires_at'), ); @@ -89,6 +106,12 @@ class PatchMetadata { /// Target CPU architecture, for example `x64` or `arm64`. final String targetArch; + /// Runtime execution mode required by this patch payload. + /// + /// Existing artifacts without this field are treated as + /// [runtimeModeNativeAot] for backward compatibility. + final String runtimeMode; + /// Optional hash of the obfuscation map used by base and patch builds. final String? obfuscationMapHash; @@ -109,6 +132,24 @@ class PatchMetadata { return !checkedAt.isBefore(expiresAt); } + /// Whether this payload requires mapping newly supplied native instructions + /// as executable memory at runtime. + bool get requiresRuntimeExecutableMapping { + _checkKnownRuntimeMode(runtimeMode); + return runtimeMode == runtimeModeNativeAot; + } + + /// Whether this artifact is safe to install in an iOS App Store build under + /// the current open runtime model. + /// + /// The current native-AOT iOS loader is useful for development and local + /// device testing, but it is not App Store-safe because it executes newly + /// supplied native snapshot text from the app data container. + bool get isIosAppStoreSafe { + _checkKnownRuntimeMode(runtimeMode); + return targetOs != 'ios' || !requiresRuntimeExecutableMapping; + } + /// Converts this metadata to the stable JSON map used as AES-GCM AAD. Map toJson() => { 'app_id': appId, @@ -122,6 +163,7 @@ class PatchMetadata { 'patch_snapshot_hash': patchSnapshotHash, 'target_os': targetOs, 'target_arch': targetArch, + 'runtime_mode': runtimeMode, if (obfuscationMapHash != null) 'obfuscation_map_hash': obfuscationMapHash, if (offlineExpiresAt != null) 'offline_expires_at': offlineExpiresAt, }; @@ -320,7 +362,11 @@ PatchArtifact linkArtifacts({ required PatchMetadata metadata, bool forceFullSnapshot = false, }) { - final compact = forceFullSnapshot + _checkKnownRuntimeMode(metadata.runtimeMode); + final mustUseFullSnapshot = + forceFullSnapshot || + metadata.runtimeMode == runtimeModeDartBytecodeInterpreter; + final compact = mustUseFullSnapshot ? _CompactPayload( kind: payloadKindFullSnapshot, payload: List.of(patchSnapshot), @@ -388,9 +434,11 @@ void verifyMetadata( required String licenseType, String? baseFlavorId, String? baseLicenseType, + bool requireIosAppStoreSafe = false, DateTime? now, bool allowExpired = false, }) { + _checkKnownRuntimeMode(metadata.runtimeMode); if (baseFlavorId != null && metadata.baseFlavorId != baseFlavorId) { throw StateError( 'Patch base flavor "${metadata.baseFlavorId}" does not match ' @@ -418,6 +466,19 @@ void verifyMetadata( 'Patch offline license expired at ${metadata.offlineExpiresAt}.', ); } + if (requireIosAppStoreSafe && !metadata.isIosAppStoreSafe) { + throw StateError( + 'Patch runtime mode "${metadata.runtimeMode}" for target_os ' + '"${metadata.targetOs}" requires runtime executable mapping and is not ' + 'safe for iOS App Store delivery.', + ); + } +} + +void _checkKnownRuntimeMode(String runtimeMode) { + if (!knownRuntimeModes.contains(runtimeMode)) { + throw StateError('Unsupported patch runtime mode "$runtimeMode".'); + } } /// Returns true when a locally installed patch must be removed before startup diff --git a/packages/open_aot_patch_tools/test/patch_artifact_test.dart b/packages/open_aot_patch_tools/test/patch_artifact_test.dart index 1ea137bc..e6d25cf9 100644 --- a/packages/open_aot_patch_tools/test/patch_artifact_test.dart +++ b/packages/open_aot_patch_tools/test/patch_artifact_test.dart @@ -75,7 +75,11 @@ void main() { ]; const nonce = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; - PatchMetadata metadata({String? offlineExpiresAt}) => PatchMetadata( + PatchMetadata metadata({ + String? offlineExpiresAt, + String targetOs = 'windows', + String runtimeMode = runtimeModeNativeAot, + }) => PatchMetadata( appId: 'app.test', appBuildId: '1.0.0+1', baseFlavorId: 'free', @@ -85,8 +89,9 @@ void main() { sdkHash: 'sdk', baseSnapshotHash: 'base', patchSnapshotHash: 'patch', - targetOs: 'windows', + targetOs: targetOs, targetArch: 'x64', + runtimeMode: runtimeMode, obfuscationMapHash: 'obfuscated', offlineExpiresAt: offlineExpiresAt, ); @@ -283,6 +288,73 @@ void main() { expect(artifact.reconstruct(base), patch); }); + test('interpreter runtime 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( + targetOs: 'ios', + runtimeMode: runtimeModeDartBytecodeInterpreter, + ), + ); + + expect(artifact.payloadKind, payloadKindFullSnapshot); + expect(artifact.payload, patch); + expect(artifact.reconstruct(base), patch); + }); + + test('iOS native AOT metadata is not App Store-safe', () { + final iosNative = metadata(targetOs: 'ios'); + + expect(iosNative.runtimeMode, runtimeModeNativeAot); + expect(iosNative.requiresRuntimeExecutableMapping, isTrue); + expect(iosNative.isIosAppStoreSafe, isFalse); + expect( + () => verifyMetadata( + iosNative, + flavorId: 'pro', + licenseType: 'pro', + requireIosAppStoreSafe: true, + ), + throwsStateError, + ); + }); + + test('iOS interpreter metadata satisfies the App Store safety gate', () { + final iosInterpreter = metadata( + targetOs: 'ios', + runtimeMode: runtimeModeDartBytecodeInterpreter, + ); + + expect(iosInterpreter.requiresRuntimeExecutableMapping, isFalse); + expect(iosInterpreter.isIosAppStoreSafe, isTrue); + verifyMetadata( + iosInterpreter, + flavorId: 'pro', + licenseType: 'pro', + requireIosAppStoreSafe: true, + ); + }); + + test('dynamic modules runtime metadata is rejected', () { + final dynamicModules = metadata(runtimeMode: 'dart-dynamic-modules'); + + expect( + () => verifyMetadata( + dynamicModules, + flavorId: 'pro', + licenseType: 'pro', + ), + throwsStateError, + ); + expect( + () => dynamicModules.requiresRuntimeExecutableMapping, + throwsStateError, + ); + }); + test('cli links encrypts verifies and dumps compact patch', () async { final tempDir = Directory.systemTemp.createTempSync( 'open_aot_patch_tools.', @@ -339,7 +411,7 @@ void main() { final fullArtifactJson = readJsonFile(artifactFile); expect(fullArtifactJson['payload_kind'], payloadKindFullSnapshot); expect( - (fullArtifactJson['metadata'] as Map)['offline_expires_at'], + (fullArtifactJson['metadata']! as Map)['offline_expires_at'], '2030-01-01T00:00:00.000Z', ); @@ -389,6 +461,125 @@ void main() { '--base=${baseFile.path}', ]); + 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=ios', + '--target-arch=arm64', + '--runtime-mode=$runtimeModeNativeAot', + '--full-snapshot=true', + ]); + await _runTool([ + 'encrypt', + '--input=${artifactFile.path}', + '--output=${encryptedFile.path}', + '--key-id=test-key', + '--key-hex=$keyHex', + '--nonce-hex=$nonceHex', + ]); + await _runToolExpectFailure([ + 'verify', + '--input=${encryptedFile.path}', + '--key-hex=$keyHex', + '--base-flavor-id=free', + '--base-license-type=free', + '--flavor-id=pro', + '--license-type=pro', + '--base=${baseFile.path}', + '--require-ios-app-store-safe=true', + ]); + + await _runToolExpectFailure([ + '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=ios', + '--target-arch=arm64', + '--runtime-mode=dart-dynamic-modules', + '--full-snapshot=true', + ]); + + 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=ios', + '--target-arch=arm64', + '--runtime-mode=$runtimeModeDartBytecodeInterpreter', + ]); + final interpreterArtifactJson = readJsonFile(artifactFile); + expect(interpreterArtifactJson['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', + '--base-flavor-id=free', + '--base-license-type=free', + '--flavor-id=pro', + '--license-type=pro', + '--base=${baseFile.path}', + '--require-ios-app-store-safe=true', + ]); + + 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', + '--offline-expires-at=2030-01-01T00:00:00Z', + ]); + await _runTool([ + 'encrypt', + '--input=${artifactFile.path}', + '--output=${encryptedFile.path}', + '--key-id=test-key', + '--key-hex=$keyHex', + '--nonce-hex=$nonceHex', + ]); + final dump = await _runTool([ 'dump-blobs', '--input=${encryptedFile.path}',