feat: add semantic DEX diffing to suppress false native change warnings (#3644)
This commit is contained in:
@@ -22,6 +22,7 @@ words:
|
||||
- bitcode
|
||||
- bryanoltman
|
||||
- bundletool
|
||||
- Dalvik # Android Dalvik VM / DEX file format
|
||||
- canvaskit
|
||||
- carryforward
|
||||
- cipd
|
||||
@@ -90,6 +91,7 @@ words:
|
||||
- PRNG
|
||||
- propertylistserialization
|
||||
- propertylistserialization
|
||||
- protos # Protocol buffer generated files
|
||||
- pubspec
|
||||
- pwsh
|
||||
- quantiles
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# dex
|
||||
|
||||
DEX binary parser and semantic differ for Android Dalvik Executable files.
|
||||
|
||||
## Usage
|
||||
|
||||
### Parsing
|
||||
|
||||
```dart
|
||||
import 'package:dex/dex.dart';
|
||||
|
||||
final bytes = File('classes.dex').readAsBytesSync();
|
||||
final dex = const DexParser().parse(bytes);
|
||||
|
||||
print('${dex.strings.length} strings');
|
||||
print('${dex.classDefs.length} classes');
|
||||
|
||||
for (final classDef in dex.classDefs) {
|
||||
print('${classDef.className} extends ${classDef.superclass}');
|
||||
}
|
||||
```
|
||||
|
||||
### Diffing
|
||||
|
||||
```dart
|
||||
import 'package:dex/dex.dart';
|
||||
|
||||
const parser = DexParser();
|
||||
final oldDex = parser.parse(oldBytes);
|
||||
final newDex = parser.parse(newBytes);
|
||||
|
||||
final result = const DexDiffer().diff(oldDex, newDex);
|
||||
|
||||
if (result.isSafe) {
|
||||
print('Only safe differences (e.g. source file paths)');
|
||||
} else {
|
||||
print(result.describe());
|
||||
}
|
||||
```
|
||||
|
||||
## What gets compared
|
||||
|
||||
The differ compares DEX files structurally with full index remapping to
|
||||
handle string table reordering caused by build path differences.
|
||||
|
||||
| Layer | What's checked |
|
||||
|-------|---------------|
|
||||
| Classes | Added/removed classes, access flags, superclass, interfaces |
|
||||
| Methods | Added/removed methods, access flags |
|
||||
| Fields | Added/removed fields, access flags |
|
||||
| Bytecode | Instruction-by-instruction comparison with pool-index remapping |
|
||||
| Try/catch | Handler types and addresses |
|
||||
| Annotations | Class, field, method, and parameter annotations |
|
||||
| Static values | Static field initial values |
|
||||
|
||||
Only `source_file` attributes and `debug_info` offsets are classified as
|
||||
safe to differ. Everything else is treated as breaking.
|
||||
@@ -0,0 +1 @@
|
||||
include: ../../analysis_options.yaml
|
||||
@@ -0,0 +1,30 @@
|
||||
export 'src/dex_differ.dart'
|
||||
show DexDiffResult, DexDiffer, DexDifference, DexDifferenceKind;
|
||||
export 'src/dex_parser.dart'
|
||||
show
|
||||
DexAnnotation,
|
||||
DexAnnotationDirectory,
|
||||
DexClassData,
|
||||
DexClassDef,
|
||||
DexCodeItem,
|
||||
DexEncodedAnnotation,
|
||||
DexEncodedArray,
|
||||
DexEncodedBoolean,
|
||||
DexEncodedEnum,
|
||||
DexEncodedField,
|
||||
DexEncodedFieldRef,
|
||||
DexEncodedMethod,
|
||||
DexEncodedMethodHandle,
|
||||
DexEncodedMethodRef,
|
||||
DexEncodedMethodType,
|
||||
DexEncodedNull,
|
||||
DexEncodedPrimitive,
|
||||
DexEncodedString,
|
||||
DexEncodedType,
|
||||
DexEncodedValue,
|
||||
DexFieldId,
|
||||
DexFile,
|
||||
DexHeader,
|
||||
DexMethodId,
|
||||
DexParser,
|
||||
DexProtoId;
|
||||
@@ -0,0 +1,459 @@
|
||||
import 'package:dex/src/dex_parser.dart';
|
||||
|
||||
/// The kind of difference found between two DEX files.
|
||||
enum DexDifferenceKind {
|
||||
/// Source file attribute changed (safe — build path difference).
|
||||
sourceFileChanged,
|
||||
|
||||
/// A class was added.
|
||||
classAdded,
|
||||
|
||||
/// A class was removed.
|
||||
classRemoved,
|
||||
|
||||
/// A method was added to a class.
|
||||
methodAdded,
|
||||
|
||||
/// A method was removed from a class.
|
||||
methodRemoved,
|
||||
|
||||
/// A field was added to a class.
|
||||
fieldAdded,
|
||||
|
||||
/// A field was removed from a class.
|
||||
fieldRemoved,
|
||||
|
||||
/// Access flags changed on a class, method, or field.
|
||||
accessFlagsChanged,
|
||||
|
||||
/// The superclass of a class changed.
|
||||
superclassChanged,
|
||||
|
||||
/// The interface list of a class changed.
|
||||
interfacesChanged,
|
||||
|
||||
/// Method bytecode changed.
|
||||
bytecodeChanged,
|
||||
|
||||
/// Annotations changed.
|
||||
annotationsChanged,
|
||||
|
||||
/// Static field initial values changed.
|
||||
staticValuesChanged;
|
||||
|
||||
/// Whether this kind of difference is safe (does not affect runtime
|
||||
/// behavior).
|
||||
bool get isSafe => this == sourceFileChanged;
|
||||
}
|
||||
|
||||
/// {@template dex_difference}
|
||||
/// A single difference found between two DEX files.
|
||||
/// {@endtemplate}
|
||||
class DexDifference {
|
||||
/// {@macro dex_difference}
|
||||
const DexDifference({required this.kind, required this.description});
|
||||
|
||||
/// The classification of this difference.
|
||||
final DexDifferenceKind kind;
|
||||
|
||||
/// A human-readable description of the difference.
|
||||
final String description;
|
||||
}
|
||||
|
||||
/// {@template dex_diff_result}
|
||||
/// The result of comparing two DEX files.
|
||||
/// {@endtemplate}
|
||||
class DexDiffResult {
|
||||
/// {@macro dex_diff_result}
|
||||
const DexDiffResult({required this.differences});
|
||||
|
||||
/// Creates an empty (identical) diff result.
|
||||
const DexDiffResult.identical() : differences = const [];
|
||||
|
||||
/// All differences found between the two DEX files.
|
||||
final List<DexDifference> differences;
|
||||
|
||||
/// Differences that are safe to ignore (e.g. source file paths).
|
||||
Iterable<DexDifference> get safeDifferences =>
|
||||
differences.where((d) => d.kind.isSafe);
|
||||
|
||||
/// Differences that indicate real code changes.
|
||||
Iterable<DexDifference> get breakingDifferences =>
|
||||
differences.where((d) => !d.kind.isSafe);
|
||||
|
||||
/// Whether all differences are safe to ignore.
|
||||
bool get isSafe => breakingDifferences.isEmpty;
|
||||
|
||||
/// A human-readable summary of the differences.
|
||||
String describe() {
|
||||
final buffer = StringBuffer();
|
||||
final safe = safeDifferences.toList();
|
||||
final breaking = breakingDifferences.toList();
|
||||
|
||||
if (safe.isNotEmpty) {
|
||||
buffer.writeln(
|
||||
'Safe differences (${safe.length}):',
|
||||
);
|
||||
for (final diff in safe) {
|
||||
buffer.writeln(' - ${diff.description}');
|
||||
}
|
||||
}
|
||||
|
||||
if (breaking.isNotEmpty) {
|
||||
buffer.writeln(
|
||||
'Breaking differences (${breaking.length}):',
|
||||
);
|
||||
for (final diff in breaking) {
|
||||
buffer.writeln(' - ${diff.description}');
|
||||
}
|
||||
}
|
||||
|
||||
return buffer.toString().trimRight();
|
||||
}
|
||||
}
|
||||
|
||||
/// {@template dex_differ}
|
||||
/// Compares two parsed [DexFile]s and produces a [DexDiffResult]
|
||||
/// describing the semantic differences between them.
|
||||
/// {@endtemplate}
|
||||
class DexDiffer {
|
||||
/// {@macro dex_differ}
|
||||
const DexDiffer();
|
||||
|
||||
/// Compares two DEX files and returns the differences.
|
||||
DexDiffResult diff(DexFile oldFile, DexFile newFile) {
|
||||
final differences = <DexDifference>[];
|
||||
|
||||
final oldClasses = {
|
||||
for (final c in oldFile.classDefs) c.className: c,
|
||||
};
|
||||
final newClasses = {
|
||||
for (final c in newFile.classDefs) c.className: c,
|
||||
};
|
||||
|
||||
final oldClassNames = oldClasses.keys.toSet();
|
||||
final newClassNames = newClasses.keys.toSet();
|
||||
|
||||
for (final added in newClassNames.difference(oldClassNames)) {
|
||||
differences.add(
|
||||
DexDifference(
|
||||
kind: DexDifferenceKind.classAdded,
|
||||
description: 'Class added: $added',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (final removed in oldClassNames.difference(newClassNames)) {
|
||||
differences.add(
|
||||
DexDifference(
|
||||
kind: DexDifferenceKind.classRemoved,
|
||||
description: 'Class removed: $removed',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final matched = oldClassNames.intersection(newClassNames);
|
||||
|
||||
for (final name in matched) {
|
||||
_compareClassStructure(
|
||||
oldClasses[name]!,
|
||||
newClasses[name]!,
|
||||
differences,
|
||||
);
|
||||
}
|
||||
|
||||
// If there are already breaking structural differences, no
|
||||
// need to do deeper comparison — we'll report breaking
|
||||
// regardless.
|
||||
if (differences.any((d) => !d.kind.isSafe)) {
|
||||
return DexDiffResult(differences: differences);
|
||||
}
|
||||
|
||||
// Compare bytecode, annotations, and static values using
|
||||
// the pre-resolved canonical representations.
|
||||
for (final name in matched) {
|
||||
_compareClassData(
|
||||
name,
|
||||
oldClasses[name]!,
|
||||
newClasses[name]!,
|
||||
differences,
|
||||
);
|
||||
}
|
||||
|
||||
return DexDiffResult(differences: differences);
|
||||
}
|
||||
|
||||
void _compareClassStructure(
|
||||
DexClassDef oldClass,
|
||||
DexClassDef newClass,
|
||||
List<DexDifference> differences,
|
||||
) {
|
||||
final name = oldClass.className;
|
||||
|
||||
if (oldClass.sourceFile != newClass.sourceFile) {
|
||||
differences.add(
|
||||
DexDifference(
|
||||
kind: DexDifferenceKind.sourceFileChanged,
|
||||
description:
|
||||
'$name: source file changed from '
|
||||
'"${oldClass.sourceFile}" to '
|
||||
'"${newClass.sourceFile}"',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (oldClass.accessFlags != newClass.accessFlags) {
|
||||
differences.add(
|
||||
DexDifference(
|
||||
kind: DexDifferenceKind.accessFlagsChanged,
|
||||
description:
|
||||
'$name: class access flags changed from '
|
||||
'0x${oldClass.accessFlags.toRadixString(16)} to '
|
||||
'0x${newClass.accessFlags.toRadixString(16)}',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (oldClass.superclass != newClass.superclass) {
|
||||
differences.add(
|
||||
DexDifference(
|
||||
kind: DexDifferenceKind.superclassChanged,
|
||||
description:
|
||||
'$name: superclass changed from '
|
||||
'${oldClass.superclass} to '
|
||||
'${newClass.superclass}',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!_listEquals(oldClass.interfaces, newClass.interfaces)) {
|
||||
differences.add(
|
||||
DexDifference(
|
||||
kind: DexDifferenceKind.interfacesChanged,
|
||||
description: '$name: interfaces changed',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_compareMembers(
|
||||
className: name,
|
||||
oldData: oldClass.classData,
|
||||
newData: newClass.classData,
|
||||
extract: (data) => {
|
||||
for (final f in [
|
||||
...data.staticFields,
|
||||
...data.instanceFields,
|
||||
])
|
||||
'${f.field.className}.${f.field.fieldName}'
|
||||
':${f.field.typeName}':
|
||||
f.accessFlags,
|
||||
},
|
||||
memberLabel: 'field',
|
||||
addedKind: DexDifferenceKind.fieldAdded,
|
||||
removedKind: DexDifferenceKind.fieldRemoved,
|
||||
differences: differences,
|
||||
);
|
||||
|
||||
_compareMembers(
|
||||
className: name,
|
||||
oldData: oldClass.classData,
|
||||
newData: newClass.classData,
|
||||
extract: (data) => {
|
||||
for (final m in [
|
||||
...data.directMethods,
|
||||
...data.virtualMethods,
|
||||
])
|
||||
_methodKey(m): m.accessFlags,
|
||||
},
|
||||
memberLabel: 'method',
|
||||
addedKind: DexDifferenceKind.methodAdded,
|
||||
removedKind: DexDifferenceKind.methodRemoved,
|
||||
differences: differences,
|
||||
);
|
||||
}
|
||||
|
||||
// -- Per-class data comparison ------------------------------------------
|
||||
|
||||
void _compareClassData(
|
||||
String className,
|
||||
DexClassDef oldClass,
|
||||
DexClassDef newClass,
|
||||
List<DexDifference> differences,
|
||||
) {
|
||||
_compareCodeItems(
|
||||
className,
|
||||
oldClass,
|
||||
newClass,
|
||||
differences,
|
||||
);
|
||||
|
||||
if (oldClass.annotations != newClass.annotations) {
|
||||
// Both null means equal — this only triggers when they
|
||||
// actually differ (including one being null and other not).
|
||||
if (oldClass.annotations != null || newClass.annotations != null) {
|
||||
differences.add(
|
||||
DexDifference(
|
||||
kind: DexDifferenceKind.annotationsChanged,
|
||||
description:
|
||||
oldClass.annotations == null || newClass.annotations == null
|
||||
? '$className: annotations added or removed'
|
||||
: '$className: annotations changed',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!_nullableListEquals(
|
||||
oldClass.staticValues,
|
||||
newClass.staticValues,
|
||||
)) {
|
||||
if (oldClass.staticValues != null || newClass.staticValues != null) {
|
||||
differences.add(
|
||||
DexDifference(
|
||||
kind: DexDifferenceKind.staticValuesChanged,
|
||||
description:
|
||||
oldClass.staticValues == null || newClass.staticValues == null
|
||||
? '$className: static field initial values '
|
||||
'added or removed'
|
||||
: '$className: static field initial values '
|
||||
'changed',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _compareCodeItems(
|
||||
String className,
|
||||
DexClassDef oldClass,
|
||||
DexClassDef newClass,
|
||||
List<DexDifference> differences,
|
||||
) {
|
||||
final oldMethods = _methodMap(oldClass.classData);
|
||||
final newMethods = _methodMap(newClass.classData);
|
||||
|
||||
for (final entry in oldMethods.entries) {
|
||||
final newMethod = newMethods[entry.key];
|
||||
if (newMethod == null) continue; // caught structurally
|
||||
|
||||
final oldCode = entry.value.code;
|
||||
final newCode = newMethod.code;
|
||||
|
||||
if (!_codeItemsEqual(oldCode, newCode)) {
|
||||
differences.add(
|
||||
DexDifference(
|
||||
kind: DexDifferenceKind.bytecodeChanged,
|
||||
description:
|
||||
'$className: bytecode changed in '
|
||||
'${entry.key}',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Map<String, DexEncodedMethod> _methodMap(
|
||||
DexClassData? data,
|
||||
) {
|
||||
if (data == null) return const {};
|
||||
return {
|
||||
for (final m in [
|
||||
...data.directMethods,
|
||||
...data.virtualMethods,
|
||||
])
|
||||
_methodKey(m): m,
|
||||
};
|
||||
}
|
||||
|
||||
static bool _codeItemsEqual(
|
||||
DexCodeItem? oldCode,
|
||||
DexCodeItem? newCode,
|
||||
) {
|
||||
if (oldCode == null && newCode == null) return true;
|
||||
if (oldCode == null || newCode == null) return false;
|
||||
|
||||
return oldCode.registersSize == newCode.registersSize &&
|
||||
oldCode.insSize == newCode.insSize &&
|
||||
oldCode.outsSize == newCode.outsSize &&
|
||||
oldCode.canonicalBytecode == newCode.canonicalBytecode;
|
||||
}
|
||||
|
||||
// -- Member comparison (shared helper) ----------------------------------
|
||||
|
||||
void _compareMembers({
|
||||
required String className,
|
||||
required DexClassData? oldData,
|
||||
required DexClassData? newData,
|
||||
required Map<String, int> Function(DexClassData) extract,
|
||||
required String memberLabel,
|
||||
required DexDifferenceKind addedKind,
|
||||
required DexDifferenceKind removedKind,
|
||||
required List<DexDifference> differences,
|
||||
}) {
|
||||
final oldMembers = oldData != null ? extract(oldData) : <String, int>{};
|
||||
final newMembers = newData != null ? extract(newData) : <String, int>{};
|
||||
|
||||
final oldKeys = oldMembers.keys.toSet();
|
||||
final newKeys = newMembers.keys.toSet();
|
||||
|
||||
for (final added in newKeys.difference(oldKeys)) {
|
||||
differences.add(
|
||||
DexDifference(
|
||||
kind: addedKind,
|
||||
description: '$className: $memberLabel added: $added',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (final removed in oldKeys.difference(newKeys)) {
|
||||
differences.add(
|
||||
DexDifference(
|
||||
kind: removedKind,
|
||||
description: '$className: $memberLabel removed: $removed',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (final common in oldKeys.intersection(newKeys)) {
|
||||
if (oldMembers[common] != newMembers[common]) {
|
||||
differences.add(
|
||||
DexDifference(
|
||||
kind: DexDifferenceKind.accessFlagsChanged,
|
||||
description:
|
||||
'$className: $memberLabel $common access '
|
||||
'flags changed from '
|
||||
'0x${oldMembers[common]!.toRadixString(16)} '
|
||||
'to '
|
||||
'0x${newMembers[common]!.toRadixString(16)}',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Utility methods ----------------------------------------------------
|
||||
|
||||
static String _methodKey(DexEncodedMethod m) {
|
||||
final params = m.method.proto.parameterTypes.join(', ');
|
||||
return '${m.method.className}.${m.method.methodName}'
|
||||
'($params)${m.method.proto.returnType}';
|
||||
}
|
||||
|
||||
bool _listEquals<T>(List<T> a, List<T> 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;
|
||||
}
|
||||
|
||||
static bool _nullableListEquals<T>(List<T>? a, List<T>? b) {
|
||||
if (a == null && b == null) return true;
|
||||
if (a == null || b == null) return false;
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
name: dex
|
||||
description: DEX binary parser and semantic differ for Android Dalvik Executable files.
|
||||
version: 0.1.0
|
||||
publish_to: none
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: ">=3.9.0 <4.0.0"
|
||||
|
||||
dependencies:
|
||||
meta: ^1.16.0
|
||||
|
||||
dev_dependencies:
|
||||
path: ^1.9.1
|
||||
test: ^1.26.3
|
||||
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,255 @@
|
||||
// cspell:words Lcom Ljava
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dex/src/dex_differ.dart';
|
||||
import 'package:dex/src/dex_parser.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group(DexDiffer, () {
|
||||
const parser = DexParser();
|
||||
const differ = DexDiffer();
|
||||
|
||||
final dexFixturesPath = p.join('test', 'fixtures', 'dex');
|
||||
|
||||
late DexFile baseDex;
|
||||
|
||||
setUp(() {
|
||||
baseDex = parser.parse(
|
||||
File(p.join(dexFixturesPath, 'base.dex')).readAsBytesSync(),
|
||||
);
|
||||
});
|
||||
|
||||
DexFile parseDexFixture(String name) {
|
||||
return parser.parse(
|
||||
File(p.join(dexFixturesPath, name)).readAsBytesSync(),
|
||||
);
|
||||
}
|
||||
|
||||
test('identical files produce empty diff', () {
|
||||
final result = differ.diff(baseDex, baseDex);
|
||||
expect(result.safeDifferences, isEmpty);
|
||||
expect(result.breakingDifferences, isEmpty);
|
||||
expect(result.isSafe, isTrue);
|
||||
});
|
||||
|
||||
test('path-only difference is classified as safe', () {
|
||||
final pathDex = parseDexFixture('path_only_diff.dex');
|
||||
final result = differ.diff(baseDex, pathDex);
|
||||
expect(result.safeDifferences, isNotEmpty);
|
||||
expect(result.breakingDifferences, isEmpty);
|
||||
expect(result.isSafe, isTrue);
|
||||
expect(
|
||||
result.safeDifferences.every(
|
||||
(d) => d.kind == DexDifferenceKind.sourceFileChanged,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('added method is classified as breaking', () {
|
||||
final methodAddedDex = parseDexFixture('method_added.dex');
|
||||
final result = differ.diff(baseDex, methodAddedDex);
|
||||
expect(result.isSafe, isFalse);
|
||||
expect(
|
||||
result.breakingDifferences.any(
|
||||
(d) => d.kind == DexDifferenceKind.methodAdded,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('removed field is classified as breaking', () {
|
||||
final fieldRemovedDex = parseDexFixture('field_removed.dex');
|
||||
final result = differ.diff(baseDex, fieldRemovedDex);
|
||||
expect(result.isSafe, isFalse);
|
||||
expect(
|
||||
result.breakingDifferences.any(
|
||||
(d) => d.kind == DexDifferenceKind.fieldRemoved,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('changed superclass is classified as breaking', () {
|
||||
final superclassChangedDex = parseDexFixture('superclass_changed.dex');
|
||||
final result = differ.diff(baseDex, superclassChangedDex);
|
||||
expect(result.isSafe, isFalse);
|
||||
expect(
|
||||
result.breakingDifferences.any(
|
||||
(d) => d.kind == DexDifferenceKind.superclassChanged,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('mixed safe and breaking differences are not safe', () {
|
||||
final superclassChangedDex = parseDexFixture('superclass_changed.dex');
|
||||
final result = differ.diff(baseDex, superclassChangedDex);
|
||||
expect(result.isSafe, isFalse);
|
||||
});
|
||||
|
||||
group('bytecode comparison', () {
|
||||
late DexFile baseWithCode;
|
||||
|
||||
setUp(() {
|
||||
baseWithCode = parseDexFixture('base_with_code.dex');
|
||||
});
|
||||
|
||||
test('identical bytecode is safe', () {
|
||||
final result = differ.diff(baseWithCode, baseWithCode);
|
||||
expect(result.isSafe, isTrue);
|
||||
});
|
||||
|
||||
test('changed bytecode is breaking', () {
|
||||
final codeChanged = parseDexFixture('code_changed.dex');
|
||||
final result = differ.diff(baseWithCode, codeChanged);
|
||||
expect(result.isSafe, isFalse);
|
||||
expect(
|
||||
result.breakingDifferences.any(
|
||||
(d) => d.kind == DexDifferenceKind.bytecodeChanged,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('path-only diff with identical bytecode is safe', () {
|
||||
final pathWithCode = parseDexFixture('path_only_with_code.dex');
|
||||
final result = differ.diff(baseWithCode, pathWithCode);
|
||||
expect(result.isSafe, isTrue);
|
||||
expect(result.safeDifferences, isNotEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('describe', () {
|
||||
test('formats safe-only differences', () {
|
||||
final pathDex = parseDexFixture('path_only_diff.dex');
|
||||
final result = differ.diff(baseDex, pathDex);
|
||||
final description = result.describe();
|
||||
expect(description, contains('Safe differences'));
|
||||
expect(description, contains('source file changed'));
|
||||
expect(description, isNot(contains('Breaking differences')));
|
||||
});
|
||||
|
||||
test('formats breaking differences', () {
|
||||
final methodAddedDex = parseDexFixture('method_added.dex');
|
||||
final result = differ.diff(baseDex, methodAddedDex);
|
||||
final description = result.describe();
|
||||
expect(description, contains('Breaking differences'));
|
||||
expect(description, contains('method added'));
|
||||
});
|
||||
|
||||
test('empty diff produces empty string', () {
|
||||
final result = differ.diff(baseDex, baseDex);
|
||||
expect(result.describe(), isEmpty);
|
||||
});
|
||||
|
||||
test('path-only diff produces exact output', () {
|
||||
final pathDex = parseDexFixture('path_only_diff.dex');
|
||||
final result = differ.diff(baseDex, pathDex);
|
||||
expect(
|
||||
result.describe(),
|
||||
equals(
|
||||
'''
|
||||
Safe differences (2):
|
||||
- Lcom/example/Helper;: source file changed from "Helper.java" to "/different/path/Helper.java"
|
||||
- Lcom/example/MyClass;: source file changed from "MyClass.java" to "/different/path/MyClass.java"''',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('method-added diff produces exact output', () {
|
||||
final methodAddedDex = parseDexFixture('method_added.dex');
|
||||
final result = differ.diff(baseDex, methodAddedDex);
|
||||
expect(
|
||||
result.describe(),
|
||||
equals(
|
||||
'''
|
||||
Breaking differences (1):
|
||||
- Lcom/example/MyClass;: method added: '''
|
||||
'Lcom/example/MyClass;.newMethod()V',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('bytecode-changed diff produces exact output', () {
|
||||
final baseWithCode = parseDexFixture('base_with_code.dex');
|
||||
final codeChanged = parseDexFixture('code_changed.dex');
|
||||
final result = differ.diff(baseWithCode, codeChanged);
|
||||
expect(
|
||||
result.describe(),
|
||||
equals(
|
||||
'''
|
||||
Breaking differences (1):
|
||||
- Lcom/example/Foo;: bytecode changed in '''
|
||||
'Lcom/example/Foo;.<init>()V',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('DexDiffResult.identical', () {
|
||||
test('creates an empty result', () {
|
||||
const result = DexDiffResult.identical();
|
||||
expect(result.safeDifferences, isEmpty);
|
||||
expect(result.breakingDifferences, isEmpty);
|
||||
expect(result.isSafe, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('DexDifferenceKind.isSafe', () {
|
||||
test('sourceFileChanged is safe', () {
|
||||
expect(DexDifferenceKind.sourceFileChanged.isSafe, isTrue);
|
||||
});
|
||||
|
||||
test('classAdded is not safe', () {
|
||||
expect(DexDifferenceKind.classAdded.isSafe, isFalse);
|
||||
});
|
||||
|
||||
test('classRemoved is not safe', () {
|
||||
expect(DexDifferenceKind.classRemoved.isSafe, isFalse);
|
||||
});
|
||||
|
||||
test('methodAdded is not safe', () {
|
||||
expect(DexDifferenceKind.methodAdded.isSafe, isFalse);
|
||||
});
|
||||
|
||||
test('methodRemoved is not safe', () {
|
||||
expect(DexDifferenceKind.methodRemoved.isSafe, isFalse);
|
||||
});
|
||||
|
||||
test('fieldAdded is not safe', () {
|
||||
expect(DexDifferenceKind.fieldAdded.isSafe, isFalse);
|
||||
});
|
||||
|
||||
test('fieldRemoved is not safe', () {
|
||||
expect(DexDifferenceKind.fieldRemoved.isSafe, isFalse);
|
||||
});
|
||||
|
||||
test('accessFlagsChanged is not safe', () {
|
||||
expect(DexDifferenceKind.accessFlagsChanged.isSafe, isFalse);
|
||||
});
|
||||
|
||||
test('superclassChanged is not safe', () {
|
||||
expect(DexDifferenceKind.superclassChanged.isSafe, isFalse);
|
||||
});
|
||||
|
||||
test('interfacesChanged is not safe', () {
|
||||
expect(DexDifferenceKind.interfacesChanged.isSafe, isFalse);
|
||||
});
|
||||
|
||||
test('bytecodeChanged is not safe', () {
|
||||
expect(DexDifferenceKind.bytecodeChanged.isSafe, isFalse);
|
||||
});
|
||||
|
||||
test('annotationsChanged is not safe', () {
|
||||
expect(DexDifferenceKind.annotationsChanged.isSafe, isFalse);
|
||||
});
|
||||
|
||||
test('staticValuesChanged is not safe', () {
|
||||
expect(DexDifferenceKind.staticValuesChanged.isSafe, isFalse);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// cspell:words Lcom Ljava Uleb
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dex/src/dex_parser.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group(DexParser, () {
|
||||
const parser = DexParser();
|
||||
|
||||
late Uint8List baseDexBytes;
|
||||
|
||||
setUp(() {
|
||||
baseDexBytes = File(
|
||||
p.join('test', 'fixtures', 'dex', 'base.dex'),
|
||||
).readAsBytesSync();
|
||||
});
|
||||
|
||||
group('parse', () {
|
||||
test('parses a valid DEX file', () {
|
||||
final dex = parser.parse(baseDexBytes);
|
||||
expect(dex.strings, hasLength(13));
|
||||
expect(dex.typeDescriptors, hasLength(5));
|
||||
expect(dex.protoIds, hasLength(2));
|
||||
expect(dex.fieldIds, hasLength(2));
|
||||
expect(dex.methodIds, hasLength(3));
|
||||
expect(dex.classDefs, hasLength(2));
|
||||
});
|
||||
|
||||
test('resolves string table values', () {
|
||||
final dex = parser.parse(baseDexBytes);
|
||||
expect(dex.strings, contains('<init>'));
|
||||
expect(dex.strings, contains('Lcom/example/MyClass;'));
|
||||
expect(dex.strings, contains('Ljava/lang/Object;'));
|
||||
expect(dex.strings, contains('myField'));
|
||||
expect(dex.strings, contains('getValue'));
|
||||
});
|
||||
|
||||
test('resolves type descriptors', () {
|
||||
final dex = parser.parse(baseDexBytes);
|
||||
expect(dex.typeDescriptors, contains('I'));
|
||||
expect(dex.typeDescriptors, contains('V'));
|
||||
expect(dex.typeDescriptors, contains('Lcom/example/MyClass;'));
|
||||
expect(dex.typeDescriptors, contains('Lcom/example/Helper;'));
|
||||
expect(dex.typeDescriptors, contains('Ljava/lang/Object;'));
|
||||
});
|
||||
|
||||
test('resolves field descriptors', () {
|
||||
final dex = parser.parse(baseDexBytes);
|
||||
final valueField = dex.fieldIds.firstWhere(
|
||||
(f) => f.fieldName == 'value',
|
||||
);
|
||||
expect(valueField.className, equals('Lcom/example/Helper;'));
|
||||
expect(valueField.typeName, equals('I'));
|
||||
|
||||
final myField = dex.fieldIds.firstWhere(
|
||||
(f) => f.fieldName == 'myField',
|
||||
);
|
||||
expect(myField.className, equals('Lcom/example/MyClass;'));
|
||||
expect(myField.typeName, equals('I'));
|
||||
});
|
||||
|
||||
test('resolves method descriptors', () {
|
||||
final dex = parser.parse(baseDexBytes);
|
||||
final getValue = dex.methodIds.firstWhere(
|
||||
(m) => m.methodName == 'getValue',
|
||||
);
|
||||
expect(getValue.className, equals('Lcom/example/Helper;'));
|
||||
expect(getValue.proto.returnType, equals('I'));
|
||||
expect(getValue.proto.parameterTypes, isEmpty);
|
||||
});
|
||||
|
||||
test('resolves class definitions', () {
|
||||
final dex = parser.parse(baseDexBytes);
|
||||
final myClass = dex.classDefs.firstWhere(
|
||||
(c) => c.className == 'Lcom/example/MyClass;',
|
||||
);
|
||||
expect(myClass.accessFlags, equals(1)); // public
|
||||
expect(myClass.superclass, equals('Ljava/lang/Object;'));
|
||||
expect(myClass.interfaces, isEmpty);
|
||||
expect(myClass.sourceFile, equals('MyClass.java'));
|
||||
expect(myClass.classData, isNotNull);
|
||||
expect(myClass.classData!.instanceFields, hasLength(1));
|
||||
expect(myClass.classData!.directMethods, hasLength(1));
|
||||
});
|
||||
|
||||
test('resolves class data fields and methods', () {
|
||||
final dex = parser.parse(baseDexBytes);
|
||||
final helper = dex.classDefs.firstWhere(
|
||||
(c) => c.className == 'Lcom/example/Helper;',
|
||||
);
|
||||
expect(helper.classData, isNotNull);
|
||||
expect(helper.classData!.instanceFields, hasLength(1));
|
||||
expect(
|
||||
helper.classData!.instanceFields[0].field.fieldName,
|
||||
equals('value'),
|
||||
);
|
||||
expect(helper.classData!.directMethods, hasLength(1));
|
||||
expect(
|
||||
helper.classData!.directMethods[0].method.methodName,
|
||||
equals('<init>'),
|
||||
);
|
||||
expect(helper.classData!.virtualMethods, hasLength(1));
|
||||
expect(
|
||||
helper.classData!.virtualMethods[0].method.methodName,
|
||||
equals('getValue'),
|
||||
);
|
||||
});
|
||||
|
||||
test('parses code items', () {
|
||||
final dex = parser.parse(
|
||||
File(
|
||||
p.join('test', 'fixtures', 'dex', 'base_with_code.dex'),
|
||||
).readAsBytesSync(),
|
||||
);
|
||||
final method = dex.classDefs[0].classData!.directMethods[0];
|
||||
expect(method.code, isA<DexCodeItem>());
|
||||
expect(method.code!.registersSize, isNonZero);
|
||||
});
|
||||
|
||||
test('DexCodeItem has expected field values', () {
|
||||
final dex = parser.parse(
|
||||
File(
|
||||
p.join('test', 'fixtures', 'dex', 'base_with_code.dex'),
|
||||
).readAsBytesSync(),
|
||||
);
|
||||
final method = dex.classDefs[0].classData!.directMethods[0];
|
||||
final code = method.code!;
|
||||
expect(code.registersSize, equals(1));
|
||||
expect(code.insSize, equals(1));
|
||||
expect(code.outsSize, equals(0));
|
||||
expect(code.canonicalBytecode, isNotEmpty);
|
||||
});
|
||||
|
||||
test('parses annotations and staticValues', () {
|
||||
final dex = parser.parse(baseDexBytes);
|
||||
// Our test fixtures don't have annotations or static values.
|
||||
for (final classDef in dex.classDefs) {
|
||||
expect(classDef.annotations, isNull);
|
||||
expect(classDef.staticValues, isNull);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('error handling', () {
|
||||
test('throws FormatException for truncated file', () {
|
||||
expect(
|
||||
() => parser.parse(Uint8List.fromList([0x64, 0x65, 0x78])),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('throws FormatException for invalid magic bytes', () {
|
||||
final bad = Uint8List(112)..fillRange(0, 112, 0);
|
||||
expect(() => parser.parse(bad), throwsFormatException);
|
||||
});
|
||||
});
|
||||
|
||||
// readUleb128, readUint16, and readUint32 are now internal to
|
||||
// _BinaryReader and exercised transitively through parse().
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,25 @@
|
||||
// cspell:words unparseable
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:dex/dex.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart';
|
||||
import 'package:shorebird_cli/src/archive_analysis/file_set_diff.dart';
|
||||
|
||||
/// A [FileSetDiff] that also carries semantic DEX diff results.
|
||||
class AndroidFileSetDiff extends FileSetDiff {
|
||||
/// Creates an [AndroidFileSetDiff] with DEX diff results.
|
||||
const AndroidFileSetDiff({
|
||||
required super.addedPaths,
|
||||
required super.removedPaths,
|
||||
required super.changedPaths,
|
||||
this.dexDiffResults = const {},
|
||||
});
|
||||
|
||||
/// DEX diff results for breaking changes, keyed by file path.
|
||||
final Map<String, DexDiffResult> dexDiffResults;
|
||||
}
|
||||
|
||||
/// {@template android_archive_differ}
|
||||
/// Finds differences between two Android archives (either AABs or AARs).
|
||||
@@ -29,6 +49,85 @@ class AndroidArchiveDiffer extends ArchiveDiffer {
|
||||
/// {@macro android_archive_differ}
|
||||
const AndroidArchiveDiffer();
|
||||
|
||||
@override
|
||||
Future<AndroidFileSetDiff> changedFiles(
|
||||
String oldArchivePath,
|
||||
String newArchivePath,
|
||||
) async {
|
||||
final fileSetDiff = await super.changedFiles(
|
||||
oldArchivePath,
|
||||
newArchivePath,
|
||||
);
|
||||
|
||||
final dexPaths = fileSetDiff.changedPaths
|
||||
.where((p) => p.endsWith('.dex'))
|
||||
.toList();
|
||||
|
||||
if (dexPaths.isEmpty) {
|
||||
return AndroidFileSetDiff(
|
||||
addedPaths: fileSetDiff.addedPaths,
|
||||
removedPaths: fileSetDiff.removedPaths,
|
||||
changedPaths: fileSetDiff.changedPaths,
|
||||
);
|
||||
}
|
||||
|
||||
// Extract DEX file bytes from both archives.
|
||||
final oldDexBytes = _extractDexFiles(oldArchivePath, dexPaths);
|
||||
final newDexBytes = _extractDexFiles(newArchivePath, dexPaths);
|
||||
|
||||
const parser = DexParser();
|
||||
const differ = DexDiffer();
|
||||
final safePaths = <String>{};
|
||||
final dexDiffResults = <String, DexDiffResult>{};
|
||||
|
||||
for (final path in dexPaths) {
|
||||
final oldBytes = oldDexBytes[path];
|
||||
final newBytes = newDexBytes[path];
|
||||
if (oldBytes == null || newBytes == null) continue;
|
||||
|
||||
try {
|
||||
final oldDex = parser.parse(oldBytes);
|
||||
final newDex = parser.parse(newBytes);
|
||||
final result = differ.diff(oldDex, newDex);
|
||||
|
||||
if (result.isSafe) {
|
||||
safePaths.add(path);
|
||||
} else {
|
||||
dexDiffResults[path] = result;
|
||||
}
|
||||
// Catch all exceptions so unparseable DEX files are conservatively
|
||||
// treated as changed rather than crashing the diff.
|
||||
// ignore: avoid_catches_without_on_clauses
|
||||
} catch (_) {
|
||||
// If parsing fails, conservatively keep the path as changed.
|
||||
}
|
||||
}
|
||||
|
||||
return AndroidFileSetDiff(
|
||||
addedPaths: fileSetDiff.addedPaths,
|
||||
removedPaths: fileSetDiff.removedPaths,
|
||||
changedPaths: fileSetDiff.changedPaths.difference(safePaths),
|
||||
dexDiffResults: dexDiffResults,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Uint8List> _extractDexFiles(
|
||||
String archivePath,
|
||||
List<String> paths,
|
||||
) {
|
||||
final pathSet = paths.toSet();
|
||||
final result = <String, Uint8List>{};
|
||||
final archive = ZipDecoder().decodeStream(
|
||||
InputFileStream(archivePath),
|
||||
);
|
||||
for (final file in archive.files) {
|
||||
if (file.isFile && pathSet.contains(file.name)) {
|
||||
result[file.name] = Uint8List.fromList(file.content);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
bool isAssetFilePath(String filePath) {
|
||||
const assetDirNames = ['assets', 'res'];
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// cspell:words dexdump
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:mason_logger/mason_logger.dart';
|
||||
import 'package:scoped_deps/scoped_deps.dart';
|
||||
import 'package:shorebird_cli/src/archive_analysis/android_archive_differ.dart';
|
||||
import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart';
|
||||
import 'package:shorebird_cli/src/logging/logging.dart';
|
||||
import 'package:shorebird_cli/src/shorebird_documentation.dart';
|
||||
@@ -80,14 +82,35 @@ class PatchDiffChecker {
|
||||
yellow.wrap(
|
||||
archiveDiffer.nativeFileSetDiff(contentDiffs).prettyString,
|
||||
),
|
||||
)
|
||||
..info(
|
||||
yellow.wrap(
|
||||
'''
|
||||
);
|
||||
|
||||
If you don't know why you're seeing this error, visit our troubleshooting page at ${nativeChangesTroubleshootingUrl.toLink()}''',
|
||||
// Show detailed DEX diff information if available.
|
||||
if (contentDiffs is AndroidFileSetDiff &&
|
||||
contentDiffs.dexDiffResults.isNotEmpty) {
|
||||
for (final dexPath
|
||||
in archiveDiffer
|
||||
.nativeFileSetDiff(contentDiffs)
|
||||
.changedPaths
|
||||
.where((p) => p.endsWith('.dex'))) {
|
||||
final dexResult = contentDiffs.dexDiffResults[dexPath];
|
||||
if (dexResult != null) {
|
||||
logger.info(yellow.wrap(dexResult.describe()));
|
||||
}
|
||||
}
|
||||
logger.info(
|
||||
yellow.wrap(
|
||||
'\nFor detailed DEX disassembly, run: dexdump -d <file>',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
yellow.wrap(
|
||||
'''
|
||||
|
||||
If you don't know why you're seeing this error, visit our troubleshooting page at ${nativeChangesTroubleshootingUrl.toLink()}''',
|
||||
),
|
||||
);
|
||||
|
||||
if (!allowNativeChanges) {
|
||||
if (!shorebirdEnv.canAcceptUserInput) {
|
||||
|
||||
@@ -18,6 +18,8 @@ dependencies:
|
||||
clock: ^1.1.2
|
||||
collection: ^1.19.1
|
||||
crypto: ^3.0.6
|
||||
dex:
|
||||
path: ../dex
|
||||
equatable: ^2.0.7
|
||||
googleapis_auth: ^2.0.0
|
||||
http: ^1.5.0
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -60,6 +60,58 @@ void main() {
|
||||
'META-INF/MANIFEST.MF',
|
||||
});
|
||||
});
|
||||
|
||||
test('filters out DEX files with only path differences', () async {
|
||||
final baseDexAabPath = p.join(
|
||||
aabFixturesBasePath,
|
||||
'base_dex_test.aab',
|
||||
);
|
||||
final pathOnlyAabPath = p.join(
|
||||
aabFixturesBasePath,
|
||||
'changed_dex_path_only.aab',
|
||||
);
|
||||
|
||||
final fileSetDiff = await differ.changedFiles(
|
||||
baseDexAabPath,
|
||||
pathOnlyAabPath,
|
||||
);
|
||||
// DEX file should be filtered out since only source paths differ.
|
||||
expect(
|
||||
fileSetDiff.changedPaths.where((p) => p.endsWith('.dex')).isEmpty,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
differ.containsPotentiallyBreakingNativeDiffs(fileSetDiff),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps DEX files with structural changes', () async {
|
||||
final baseDexAabPath = p.join(
|
||||
aabFixturesBasePath,
|
||||
'base_dex_test.aab',
|
||||
);
|
||||
final methodAddedAabPath = p.join(
|
||||
aabFixturesBasePath,
|
||||
'changed_dex_method_added.aab',
|
||||
);
|
||||
|
||||
final fileSetDiff = await differ.changedFiles(
|
||||
baseDexAabPath,
|
||||
methodAddedAabPath,
|
||||
);
|
||||
// DEX file should remain since there are structural changes.
|
||||
expect(
|
||||
fileSetDiff.changedPaths
|
||||
.where((p) => p.endsWith('.dex'))
|
||||
.isNotEmpty,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
differ.containsPotentiallyBreakingNativeDiffs(fileSetDiff),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('contentDifferences', () {
|
||||
|
||||
@@ -4,6 +4,7 @@ environment:
|
||||
sdk: ^3.9.0
|
||||
workspace:
|
||||
- packages/artifact_proxy
|
||||
- packages/dex
|
||||
- packages/discord_gcp_alerts
|
||||
- packages/flutter_version_resolver
|
||||
- packages/jwt
|
||||
|
||||
Reference in New Issue
Block a user