Compare commits
1 Commits
c824005c2d
...
08b8f07bfb
| Author | SHA1 | Date | |
|---|---|---|---|
| 08b8f07bfb |
@@ -32,6 +32,8 @@ void main(List<String> args) {
|
||||
void _link(Map<String, String> 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<String, String> 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<String, String> options) {
|
||||
);
|
||||
}
|
||||
|
||||
String _runtimeModeForLink(Map<String, String> 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<String, String> options) {
|
||||
final artifact = PatchArtifact.fromJson(
|
||||
readJsonFile(File(_required(options, 'input'))),
|
||||
@@ -86,6 +107,7 @@ void _verify(Map<String, String> 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=<file> [--key-hex=<64 hex chars>] [--base=<file>] [--output=<vmcode>] [--now=<iso8601>] [--allow-expired=true]
|
||||
open_aot_patch_tools compile-patch --gen-snapshot=<path> --kernel=<dill> --output=<file> [--snapshot-kind=app-aot-elf|app-aot-macho-dylib|app-aot-assembly] [--macho-object=<file>] [--obfuscate=true] [--load-obfuscation-map=<file>] [--save-obfuscation-map=<file>]
|
||||
open_aot_patch_tools link --base=<file> --patch=<file> --output=<file> --app-id=<id> --app-build-id=<id> --flavor-id=<id> --license-type=<type> --sdk-hash=<hash> --target-os=<os> --target-arch=<arch> [--base-flavor-id=<id>] [--base-license-type=<type>] [--obfuscation-map-hash=<sha256>] [--offline-expires-at=<iso8601>] [--full-snapshot=true]
|
||||
open_aot_patch_tools link --base=<file> --patch=<file> --output=<file> --app-id=<id> --app-build-id=<id> --flavor-id=<id> --license-type=<type> --sdk-hash=<hash> --target-os=<os> --target-arch=<arch> [--runtime-mode=native-aot|dart-bytecode-interpreter] [--base-flavor-id=<id>] [--base-license-type=<type>] [--obfuscation-map-hash=<sha256>] [--offline-expires-at=<iso8601>] [--full-snapshot=true]
|
||||
open_aot_patch_tools encrypt --input=<file> --output=<file> --key-id=<id> --key-hex=<64 hex chars> --nonce-hex=<24 hex chars>
|
||||
open_aot_patch_tools verify --input=<file> --key-hex=<64 hex chars> --flavor-id=<id> --license-type=<type> [--base=<file>] [--artifact-sha256=<sha256>] [--base-flavor-id=<id>] [--base-license-type=<type>] [--now=<iso8601>] [--allow-expired=true]
|
||||
open_aot_patch_tools verify --input=<file> --key-hex=<64 hex chars> --flavor-id=<id> --license-type=<type> [--base=<file>] [--artifact-sha256=<sha256>] [--base-flavor-id=<id>] [--base-license-type=<type>] [--require-ios-app-store-safe=true] [--now=<iso8601>] [--allow-expired=true]
|
||||
''');
|
||||
}
|
||||
|
||||
@@ -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=<key> --base=<base.vmcode> --output=<patch.vmcode>` 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=<flutter-engine-or-dart-sdk-hash> \
|
||||
--target-os=ios \
|
||||
--target-arch=arm64 \
|
||||
--runtime-mode=native-aot \
|
||||
--obfuscation-map-hash=<sha256-of-base-obfuscation-map> \
|
||||
--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=<key> --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=<flutter-engine-or-dart-sdk-hash> \
|
||||
--target-os=ios \
|
||||
--target-arch=arm64 \
|
||||
--runtime-mode=dart-bytecode-interpreter \
|
||||
--obfuscation-map-hash=<sha256-of-base-obfuscation-map> \
|
||||
--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: <flutter-engine-or-dart-sdk-hash>
|
||||
aot_patch_base_snapshot_hash: <sha256-of-base-bytecode-snapshot>
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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<String, Object?> 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<int>.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
|
||||
|
||||
@@ -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<int>.filled(1024, 7);
|
||||
final patch = List<int>.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}',
|
||||
|
||||
Reference in New Issue
Block a user