feat(open_aot_patch_tools): add compact AOT patch artifact tools with AES-GCM encryption
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

- 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.
This commit is contained in:
Tony
2026-06-23 13:01:48 +08:00
parent 6fc2d8dfa3
commit b1d17f0339
7 changed files with 1588 additions and 0 deletions
@@ -0,0 +1,324 @@
import 'dart:convert';
import 'dart:io';
import 'package:open_aot_patch_tools/open_aot_patch_tools.dart';
void main(List<String> 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<String, String> 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<String, String> 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<String, String> 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<String, Object?>(),
);
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<int> 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<String, String> options) {
final input = File(_required(options, 'input'));
final bytes = input.readAsBytesSync();
Map<String, Object?> report;
try {
final json = (jsonDecode(utf8.decode(bytes)) as Map)
.cast<String, Object?>();
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<int>? _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 <int>[]
: 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<String, String> 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 = <String>['--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<String, String> _parse(List<String> args) {
final parsed = <String, String>{};
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<String, String> options, String name) {
final value = options[name];
if (value == null || value.isEmpty) {
throw ArgumentError('Missing --$name');
}
return value;
}
bool _boolOption(
Map<String, String> 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=<file> [--key-hex=<64 hex chars>] [--base=<file>] [--output=<vmcode>]
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>] [--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>]
''');
}
@@ -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=<file>` 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=<hash>` 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=<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 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=<flutter-engine-or-dart-sdk-hash> \
--target-os=ios \
--target-arch=arm64 \
--obfuscation-map-hash=<sha256-of-base-obfuscation-map> \
--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.
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=<file>` 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.
@@ -0,0 +1,4 @@
/// Open-source helpers for compact AOT patch artifacts.
library;
export '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<String, Object?> 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<String, Object?> 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<String, Object?> 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<String, Object?>(),
),
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<int> 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<String, Object?> 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<int> reconstruct(List<int> 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<String, Object?> json) {
final format = json['format'];
if (format != encryptedArtifactFormat) {
throw FormatException('Unsupported encrypted artifact format: $format');
}
final encryption = (json['encryption']! as Map).cast<String, Object?>();
if (encryption['algorithm'] != 'AES-256-GCM') {
throw FormatException(
'Unsupported encryption algorithm: ${encryption['algorithm']}',
);
}
return EncryptedPatchArtifact(
metadata: PatchMetadata.fromJson(
(json['metadata']! as Map).cast<String, Object?>(),
),
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<int> 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<int> nonce;
/// AES-GCM authentication tag bytes.
final List<int> 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<String, Object?> 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<int> 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<int> baseSnapshot,
required List<int> patchSnapshot,
required PatchMetadata metadata,
bool forceFullSnapshot = false,
}) {
final compact = forceFullSnapshot
? _CompactPayload(
kind: payloadKindFullSnapshot,
payload: List<int>.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<int> key,
required List<int> 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<int> reconstructPatchSnapshot({
required List<int> baseSnapshot,
required List<int> payload,
required String payloadKind,
required int? reconstructedSize,
}) {
switch (payloadKind) {
case payloadKindEmpty:
return List<int>.of(baseSnapshot);
case payloadKindFullSnapshot:
return List<int>.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<String>().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<int> bytes) => sha256.convert(bytes).toString();
/// Reads a 32-byte AES key from hexadecimal text.
List<int> readKey(String keyHex) {
return readHexBytes(keyHex, expectedBytes: 32, name: 'AES-256 key');
}
/// Reads a 12-byte AES-GCM nonce from hexadecimal text.
List<int> readNonce(String nonceHex) {
return readHexBytes(nonceHex, expectedBytes: 12, name: 'AES-GCM nonce');
}
/// Reads exactly [expectedBytes] bytes from hexadecimal text.
List<int> 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<String, Object?> readJsonFile(File file) =>
(jsonDecode(file.readAsStringSync()) as Map).cast<String, Object?>();
String _string(Map<String, Object?> 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<int> baseSnapshot,
List<int> 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<int>.of(patchSnapshot),
reconstructedSize: patchSnapshot.length,
);
}
class _CompactPayload {
const _CompactPayload({
required this.kind,
required this.payload,
required this.reconstructedSize,
});
final String kind;
final List<int> payload;
final int reconstructedSize;
}
List<int> _encodeBinaryDiff({
required List<int> baseSnapshot,
required List<int> 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<int>(
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<int> _applyBinaryDiff({
required List<int> baseSnapshot,
required List<int> 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<int> 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<int> input, int offset) {
return input[offset] |
(input[offset + 1] << 8) |
(input[offset + 2] << 16) |
(input[offset + 3] << 24);
}
int _readUint64(List<int> input, int offset) {
var value = 0;
for (var i = 0; i < 8; i++) {
value |= input[offset + i] << (8 * i);
}
return value;
}
bool _listEquals(List<int> a, List<int> 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;
}
@@ -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:
@@ -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<int>.filled(1024, 7);
final patch = List<int>.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<int>.filled(1024, 7);
final patch = List<int>.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<int>.filled(1024, 7));
final patchBytes = List<int>.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<String, Object?>();
expect(
dumpJson['artifact_sha256'],
sha256Hex(encryptedFile.readAsBytesSync()),
);
expect(dumpJson['reconstructed_patch_sha256'], sha256Hex(patchBytes));
expect(reconstructedFile.readAsBytesSync(), patchBytes);
} finally {
tempDir.deleteSync(recursive: true);
}
});
}
Future<ProcessResult> _runTool(List<String> 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<ProcessResult> _runToolExpectFailure(List<String> 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.');
}
+1
View File
@@ -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