feat(open_aot_patch_tools): enhance runtime mode handling and iOS App Store safety checks
ci / 📄 License Check (push) Has been cancelled
ci / ✅ Semantic Pull Request (push) Has been cancelled
ci / 🔤 Check Spelling (push) Has been cancelled
ci / 👀 Detect Changes (push) Has been cancelled
ci / 🎯 Build ${{ matrix.package }} (${{ matrix.os }}) (push) Has been cancelled
ci / 🎯 Build ${{ matrix.package }} (push) Has been cancelled
ci / 🔎 Verify ${{ matrix.package }} (push) Has been cancelled
ci / ci (push) Has been cancelled
Shorebird CI / changes (push) Has been cancelled
Shorebird CI / CSpell (push) Has been cancelled
Shorebird CI / artifact_proxy (push) Has been cancelled
Shorebird CI / dex (push) Has been cancelled
Shorebird CI / discord_gcp_alerts (push) Has been cancelled
Shorebird CI / flutter_version_resolver (push) Has been cancelled
Shorebird CI / jwt (push) Has been cancelled
Shorebird CI / scoped_deps (push) Has been cancelled
Shorebird CI / shorebird_build_trace (push) Has been cancelled
Shorebird CI / shorebird_ci (push) Has been cancelled
Shorebird CI / shorebird_cli (push) Has been cancelled
Shorebird CI / shorebird_code_push_client (push) Has been cancelled
Shorebird CI / shorebird_code_push_protocol (push) Has been cancelled
Shorebird CI / shorebird_redis_client (push) Has been cancelled
Shorebird CI / stripe_api (push) Has been cancelled
Shorebird CI / required (push) Has been cancelled

Signed-off-by: Tony <tonylu@tony-cloud.com>
This commit is contained in:
Tony
2026-06-25 01:57:17 +08:00
parent c824005c2d
commit 08b8f07bfb
4 changed files with 412 additions and 43 deletions
@@ -32,6 +32,8 @@ void main(List<String> args) {
void _link(Map<String, String> options) { void _link(Map<String, String> options) {
final base = File(_required(options, 'base')).readAsBytesSync(); final base = File(_required(options, 'base')).readAsBytesSync();
final patch = File(_required(options, 'patch')).readAsBytesSync(); final patch = File(_required(options, 'patch')).readAsBytesSync();
final targetOs = _required(options, 'target-os');
final runtimeMode = _runtimeModeForLink(options, targetOs);
final metadata = PatchMetadata( final metadata = PatchMetadata(
appId: _required(options, 'app-id'), appId: _required(options, 'app-id'),
appBuildId: _required(options, 'app-build-id'), appBuildId: _required(options, 'app-build-id'),
@@ -42,8 +44,9 @@ void _link(Map<String, String> options) {
sdkHash: _required(options, 'sdk-hash'), sdkHash: _required(options, 'sdk-hash'),
baseSnapshotHash: sha256Hex(base), baseSnapshotHash: sha256Hex(base),
patchSnapshotHash: sha256Hex(patch), patchSnapshotHash: sha256Hex(patch),
targetOs: _required(options, 'target-os'), targetOs: targetOs,
targetArch: _required(options, 'target-arch'), targetArch: _required(options, 'target-arch'),
runtimeMode: runtimeMode,
obfuscationMapHash: options['obfuscation-map-hash'], obfuscationMapHash: options['obfuscation-map-hash'],
offlineExpiresAt: _optionalIso8601Utc(options['offline-expires-at']), 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) { void _encrypt(Map<String, String> options) {
final artifact = PatchArtifact.fromJson( final artifact = PatchArtifact.fromJson(
readJsonFile(File(_required(options, 'input'))), readJsonFile(File(_required(options, 'input'))),
@@ -86,6 +107,7 @@ void _verify(Map<String, String> options) {
baseLicenseType: options['base-license-type'], baseLicenseType: options['base-license-type'],
flavorId: _required(options, 'flavor-id'), flavorId: _required(options, 'flavor-id'),
licenseType: _required(options, 'license-type'), licenseType: _required(options, 'license-type'),
requireIosAppStoreSafe: _boolOption(options, 'require-ios-app-store-safe'),
now: _optionalDateTime(options['now']), now: _optionalDateTime(options['now']),
allowExpired: _boolOption(options, 'allow-expired'), allowExpired: _boolOption(options, 'allow-expired'),
); );
@@ -353,8 +375,8 @@ void _usage() {
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 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 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 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 # Open AOT Patch Design
This package produces compact, flavor-aware AOT patch artifacts without relying This package produces compact, flavor-aware patch artifacts without relying on
on `DART_DYNAMIC_MODULES` or the Dart bytecode interpreter. `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 The public-source boundary is documented in
`../../../OPEN_SOURCE_REPLACEMENTS.md`. The Shorebird runtime updater itself is `../../../OPEN_SOURCE_REPLACEMENTS.md`. The Shorebird runtime updater itself is
@@ -11,16 +14,21 @@ AOT patch generation flow.
## Runtime Model ## Runtime Model
Patches are AOT snapshot artifacts. The engine selects patched isolate snapshot The artifact metadata names the runtime mode required by the patch payload:
data and instructions before isolate startup, matching Flutter's existing
snapshot mapping flow. The VM data and VM instructions remain from the base - `native-aot`: patched isolate snapshot data/instructions are selected before
runtime. 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: This keeps the design compatible with iOS constraints:
- no JIT dependency - no JIT dependency
- no writable executable memory requirement - no writable executable memory requirement for App Store payloads
- no KBC interpreter dependency - no downloaded native snapshot text in App Store payloads
- no `DART_DYNAMIC_MODULES`
- patch metadata must bind the artifact to platform, architecture, SDK hash, - patch metadata must bind the artifact to platform, architecture, SDK hash,
app build id, flavor/license id, base snapshot hash, patch snapshot hash, and app build id, flavor/license id, base snapshot hash, patch snapshot hash, and
obfuscation map hash when obfuscation is used obfuscation map hash when obfuscation is used
@@ -59,17 +67,26 @@ optional `remove_patch` object, which contains the same patch number,
## Compact Payloads ## 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 - `empty` when base and patch snapshots are identical
- `binary-diff-v1` when changed byte ranges are smaller than the full patch - `binary-diff-v1` when changed byte ranges are smaller than the full patch
- `full-snapshot` when a diff would be larger - `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 `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 directly loadable `.vmcode` file. That file can be supplied as the first
application library path and mapped by the engine patch cache before isolate 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 The binary diff format is deterministic and reconstructs the full patched
snapshot from the base snapshot before runtime loading. Future SDK-integrated 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. the same metadata and encryption envelope.
`dump-blobs --key-hex=<key> --base=<base.vmcode> --output=<patch.vmcode>` is `dump-blobs --key-hex=<key> --base=<base.vmcode> --output=<patch.vmcode>` is
the open updater handoff: it decrypts the delivery artifact, reconstructs the the native-AOT updater handoff: it decrypts the delivery artifact, reconstructs
loadable VM-code payload, verifies the reconstructed hash against metadata, and the loadable VM-code payload, verifies the reconstructed hash against metadata,
writes the file that the engine patch cache can map before isolate startup. 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: 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 wrong-key and wrong-flavor failures, reconstructs the patched `.vmcode`, and
runs it to confirm `license:pro` and `pro-feature:enabled`. 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 `DART_DYNAMIC_MODULES` must not be enabled for this project. It adds the dynamic
objects, not bytecode and not dynamic modules. Build the patch snapshot on module interpreter surface and can package code that should not ship in the
macOS with the iOS `gen_snapshot` that matches the app's Flutter/Dart SDK: 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 ```sh
dart run open_aot_patch_tools compile-patch \ 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> \ --sdk-hash=<flutter-engine-or-dart-sdk-hash> \
--target-os=ios \ --target-os=ios \
--target-arch=arm64 \ --target-arch=arm64 \
--runtime-mode=native-aot \
--obfuscation-map-hash=<sha256-of-base-obfuscation-map> \ --obfuscation-map-hash=<sha256-of-base-obfuscation-map> \
--offline-expires-at=2030-01-01T00:00:00Z \ --offline-expires-at=2030-01-01T00:00:00Z \
--full-snapshot=true --full-snapshot=true
``` ```
The encrypted artifact is delivered the same way as the Windows host proof: For App Store-safe iOS artifacts, the linker must use:
`encrypt` wraps it with AES-256-GCM, and the app-owned updater/key callback must
validate metadata before decrypting. After decryption, use ```sh
`dump-blobs --key-hex=<key> --base=build/base.vmcode --output=patch.vmcode` to dart run open_aot_patch_tools link \
write the loadable patch file into the updater's next-boot patch location. --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: The Flutter engine patch cache is prepared for this handoff:
- `shorebird_enable_aot_patching` defaults on for iOS, while the legacy - `dart_enable_aot_patching` is enabled for iOS without enabling
`SHOREBIRD_USE_INTERPRETER` path is opt-in and disabled by default. `dart_dynamic_modules`.
- The first `.vmcode` application library path is treated as the patch. For iOS - `shorebird_use_interpreter` is enabled for iOS and the native
it may contain a raw Mach-O snapshot or a compact linker header followed by a `shorebird_enable_aot_patching` snapshot loader is disabled.
Mach-O snapshot. - Interpreter patch paths are not inserted into `application_library_paths`;
- VM snapshot symbols continue to resolve from the signed base App.framework; that list is reserved for native snapshot symbol lookup.
only isolate data and isolate instructions are replaced by the patch. - Encrypted interpreter artifacts are decrypted to data and handed to the VM
- The loader uses Dart's Mach-O snapshot loader on Apple platforms, so the VM patch API; no downloaded native text is mapped executable.
does not need JIT, writable executable memory, KBC, or `DART_DYNAMIC_MODULES`. - 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 The macOS device/simulator verification pass should build the same
`license_flavor_patch_test` flavor pair with `--target-os=ios` and `license_flavor_patch_test` flavor pair with `--target-os=ios`,
`--target-arch=arm64`, install the encrypted `free -> pro` patch, confirm the `--target-arch=arm64`, and `--runtime-mode=dart-bytecode-interpreter`, install
status label changes to `license:pro` / `pro-feature:enabled`, and confirm the encrypted `free -> pro` patch, confirm the status label changes to
wrong-key and wrong-flavor artifacts fail before the isolate starts. `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 ## Obfuscation
@@ -20,6 +20,21 @@ const payloadKindFullSnapshot = 'full-snapshot';
/// Payload kind used when the artifact stores an open binary diff. /// Payload kind used when the artifact stores an open binary diff.
const payloadKindBinaryDiff = 'binary-diff-v1'; 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. /// Compatibility metadata bound to a compact AOT patch artifact.
class PatchMetadata { class PatchMetadata {
/// Creates patch metadata for one application build and flavor. /// Creates patch metadata for one application build and flavor.
@@ -33,6 +48,7 @@ class PatchMetadata {
required this.patchSnapshotHash, required this.patchSnapshotHash,
required this.targetOs, required this.targetOs,
required this.targetArch, required this.targetArch,
this.runtimeMode = runtimeModeNativeAot,
this.baseFlavorId, this.baseFlavorId,
this.baseLicenseType, this.baseLicenseType,
this.obfuscationMapHash, this.obfuscationMapHash,
@@ -52,6 +68,7 @@ class PatchMetadata {
patchSnapshotHash: _string(json, 'patch_snapshot_hash'), patchSnapshotHash: _string(json, 'patch_snapshot_hash'),
targetOs: _string(json, 'target_os'), targetOs: _string(json, 'target_os'),
targetArch: _string(json, 'target_arch'), targetArch: _string(json, 'target_arch'),
runtimeMode: json['runtime_mode'] as String? ?? runtimeModeNativeAot,
obfuscationMapHash: json['obfuscation_map_hash'] as String?, obfuscationMapHash: json['obfuscation_map_hash'] as String?,
offlineExpiresAt: _optionalIso8601UtcString(json, 'offline_expires_at'), offlineExpiresAt: _optionalIso8601UtcString(json, 'offline_expires_at'),
); );
@@ -89,6 +106,12 @@ class PatchMetadata {
/// Target CPU architecture, for example `x64` or `arm64`. /// Target CPU architecture, for example `x64` or `arm64`.
final String targetArch; 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. /// Optional hash of the obfuscation map used by base and patch builds.
final String? obfuscationMapHash; final String? obfuscationMapHash;
@@ -109,6 +132,24 @@ class PatchMetadata {
return !checkedAt.isBefore(expiresAt); 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. /// Converts this metadata to the stable JSON map used as AES-GCM AAD.
Map<String, Object?> toJson() => { Map<String, Object?> toJson() => {
'app_id': appId, 'app_id': appId,
@@ -122,6 +163,7 @@ class PatchMetadata {
'patch_snapshot_hash': patchSnapshotHash, 'patch_snapshot_hash': patchSnapshotHash,
'target_os': targetOs, 'target_os': targetOs,
'target_arch': targetArch, 'target_arch': targetArch,
'runtime_mode': runtimeMode,
if (obfuscationMapHash != null) 'obfuscation_map_hash': obfuscationMapHash, if (obfuscationMapHash != null) 'obfuscation_map_hash': obfuscationMapHash,
if (offlineExpiresAt != null) 'offline_expires_at': offlineExpiresAt, if (offlineExpiresAt != null) 'offline_expires_at': offlineExpiresAt,
}; };
@@ -320,7 +362,11 @@ PatchArtifact linkArtifacts({
required PatchMetadata metadata, required PatchMetadata metadata,
bool forceFullSnapshot = false, bool forceFullSnapshot = false,
}) { }) {
final compact = forceFullSnapshot _checkKnownRuntimeMode(metadata.runtimeMode);
final mustUseFullSnapshot =
forceFullSnapshot ||
metadata.runtimeMode == runtimeModeDartBytecodeInterpreter;
final compact = mustUseFullSnapshot
? _CompactPayload( ? _CompactPayload(
kind: payloadKindFullSnapshot, kind: payloadKindFullSnapshot,
payload: List<int>.of(patchSnapshot), payload: List<int>.of(patchSnapshot),
@@ -388,9 +434,11 @@ void verifyMetadata(
required String licenseType, required String licenseType,
String? baseFlavorId, String? baseFlavorId,
String? baseLicenseType, String? baseLicenseType,
bool requireIosAppStoreSafe = false,
DateTime? now, DateTime? now,
bool allowExpired = false, bool allowExpired = false,
}) { }) {
_checkKnownRuntimeMode(metadata.runtimeMode);
if (baseFlavorId != null && metadata.baseFlavorId != baseFlavorId) { if (baseFlavorId != null && metadata.baseFlavorId != baseFlavorId) {
throw StateError( throw StateError(
'Patch base flavor "${metadata.baseFlavorId}" does not match ' 'Patch base flavor "${metadata.baseFlavorId}" does not match '
@@ -418,6 +466,19 @@ void verifyMetadata(
'Patch offline license expired at ${metadata.offlineExpiresAt}.', '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 /// 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]; 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', appId: 'app.test',
appBuildId: '1.0.0+1', appBuildId: '1.0.0+1',
baseFlavorId: 'free', baseFlavorId: 'free',
@@ -85,8 +89,9 @@ void main() {
sdkHash: 'sdk', sdkHash: 'sdk',
baseSnapshotHash: 'base', baseSnapshotHash: 'base',
patchSnapshotHash: 'patch', patchSnapshotHash: 'patch',
targetOs: 'windows', targetOs: targetOs,
targetArch: 'x64', targetArch: 'x64',
runtimeMode: runtimeMode,
obfuscationMapHash: 'obfuscated', obfuscationMapHash: 'obfuscated',
offlineExpiresAt: offlineExpiresAt, offlineExpiresAt: offlineExpiresAt,
); );
@@ -283,6 +288,73 @@ void main() {
expect(artifact.reconstruct(base), patch); 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 { test('cli links encrypts verifies and dumps compact patch', () async {
final tempDir = Directory.systemTemp.createTempSync( final tempDir = Directory.systemTemp.createTempSync(
'open_aot_patch_tools.', 'open_aot_patch_tools.',
@@ -339,7 +411,7 @@ void main() {
final fullArtifactJson = readJsonFile(artifactFile); final fullArtifactJson = readJsonFile(artifactFile);
expect(fullArtifactJson['payload_kind'], payloadKindFullSnapshot); expect(fullArtifactJson['payload_kind'], payloadKindFullSnapshot);
expect( expect(
(fullArtifactJson['metadata'] as Map)['offline_expires_at'], (fullArtifactJson['metadata']! as Map)['offline_expires_at'],
'2030-01-01T00:00:00.000Z', '2030-01-01T00:00:00.000Z',
); );
@@ -389,6 +461,125 @@ void main() {
'--base=${baseFile.path}', '--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([ final dump = await _runTool([
'dump-blobs', 'dump-blobs',
'--input=${encryptedFile.path}', '--input=${encryptedFile.path}',