fix: detect native changes in Windows patches (#2758)

This commit is contained in:
Bryan Oltman
2025-01-14 12:06:51 -05:00
committed by GitHub
parent 267c0d63d2
commit a334703d72
10 changed files with 195 additions and 47 deletions
+1
View File
@@ -80,6 +80,7 @@ words:
- propertylistserialization
- pubspec
- pwsh
- rdata
- reactivecircus # From .github dir, doesn't show up in "**" check?
- readlink
- reinit
@@ -0,0 +1,14 @@
import 'dart:typed_data';
/// Reads a 16-bit integer as a little-endian value from the provided bytes.
int readInt16(Uint8List bytes, int offset) {
return bytes[offset + 1] << 8 | bytes[offset];
}
/// Reads a 32-bit integer as a little-endian value from the provided bytes.
int readInt32(Uint8List bytes, int offset) {
return bytes[offset + 3] << 24 |
bytes[offset + 2] << 16 |
bytes[offset + 1] << 8 |
bytes[offset];
}
@@ -1,24 +1,18 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:shorebird_cli/src/archive_analysis/byte_utils.dart';
const _machOHeaderSize = 32;
const _uuidLoadCommandType = 0x1b;
/// Utilities for interacting with Mach-O files.
/// See https://en.wikipedia.org/wiki/Mach-O.
class MachO {
/// Reads a 32-bit integer as a little-endian value from the provided bytes.
static int _readInt32(Uint8List bytes, int offset) {
return bytes[offset + 3] << 24 |
bytes[offset + 2] << 16 |
bytes[offset + 1] << 8 |
bytes[offset];
}
/// Returns `true` if the provided file is a Mach-O file.
static bool isMachOFile(File file) {
final bytes = file.readAsBytesSync();
final magic = _readInt32(bytes, 0);
final magic = readInt32(bytes, 0);
// These are the magic numbers for Mach-O files.
// See https://en.wikipedia.org/wiki/Mach-O#Mach-O_header
@@ -35,13 +29,13 @@ class MachO {
// The number of load commands is a 32-bit int at offset 16. We could
// probably write a more robust MachO header parser, but this is all we need
// for now.
final numberOfLoadCommands = _readInt32(bytes, 16);
final numberOfLoadCommands = readInt32(bytes, 16);
// The load commands are immediately after the header.
var offset = _machOHeaderSize;
for (var i = 0; i < numberOfLoadCommands; i++) {
final commandType = _readInt32(bytes, offset);
final commandLength = _readInt32(bytes, offset + 4);
final commandType = readInt32(bytes, offset);
final commandLength = readInt32(bytes, offset + 4);
if (commandType == _uuidLoadCommandType) {
// Zero out the UUID bytes.
@@ -1,22 +1,93 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:shorebird_cli/src/archive_analysis/byte_utils.dart';
/// Utilities for interacting with Windows Portable Executable files.
class PortableExecutable {
/// Zeroes out the timestamps in the provided PE file to enable comparison of
/// binaries with different build times.
///
/// Flutter app EXEs include the build time as a timestamp twice in the file.
/// We need to zero these out so we can check for actual binary differences.
///
/// Timestamps are DWORD (4-byte) values at:
/// 1. offset 0x110 in the PE header.
/// 2. offset 0x6e14 (seems to be in section 1, need to figure out a robust
/// way to find this).
/// 1. In the PE header. The offset of this header is always at 0x3c in the
/// file, and the timestamp is at offset 0x8 from the start of the PE.
/// 2. In the .rdata section. I have not yet found a precise way to
/// determine the timestamp's offset in this section, so we read through
/// the section in 4-byte increments and zero out any DWORDs that match
/// the timestamp from the PE header.
///
/// https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#coff-file-header-object-and-image
static Uint8List bytesWithZeroedTimestamps(File file) {
final bytes = file.readAsBytesSync();
final timestampLocations = [0x110, 0x6e14];
for (final location in timestampLocations) {
bytes.setRange(location, location + 4, List.filled(4, 0));
// https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#signature-image-only
//
// PE files have a 4-byte "signature" that identifies them as PE files. This
// signature is at the offset specified at 0x3c, and the PE header starts
// after this.
//
// The PE header is 20 bytes long and contains the following fields:
// 0x0: 2 bytes for machine
// 0x2: 2 bytes for number of sections
// 0x4: 4 bytes for time date stamp
// 0x8: 4 bytes for pointer to symbol table
// 0xc: 4 bytes for number of symbols
// 0x10: 2 bytes for size of optional header
const peHeaderSize = 0x14;
final signatureOffset = readInt32(bytes, 0x3c);
final peHeaderOffset = signatureOffset + 0x4;
final numSections = readInt16(bytes, peHeaderOffset + 0x2);
final peHeaderTimestampOffset = peHeaderOffset + 0x4;
final optionalHeaderSize = readInt16(bytes, peHeaderOffset + 0x10);
final peHeaderTimestamp = readInt32(bytes, peHeaderTimestampOffset);
// Zero out the first timestamp
bytes.setRange(
peHeaderTimestampOffset,
peHeaderTimestampOffset + 4,
List.filled(4, 0),
);
// After the PE header is the optional header, which is of variable size.
// It does not contain any information we currently care about, so we skip
// that and proceed to the section table, which follows the optional header.
//
// Each section header is 40 (0x28) bytes long.
//
// https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#section-table-section-headers
const sectionHeaderSize = 0x28;
final sectionHeadersOffset =
peHeaderOffset + peHeaderSize + optionalHeaderSize;
int? rawDataOffset;
int? rawDataSize;
for (var i = 0; i < numSections; i++) {
final sectionOffset = sectionHeadersOffset + i * sectionHeaderSize;
final sectionName = String.fromCharCodes(
bytes.sublist(sectionOffset, sectionOffset + 8),
);
if (sectionName.startsWith('.rdata')) {
rawDataSize = readInt32(bytes, sectionOffset + 0x10);
rawDataOffset = readInt32(bytes, sectionOffset + 0x14);
break;
}
}
// If we could not find an .rdata section, that means that this PE file is
// likely malformed.
if (rawDataOffset == null || rawDataSize == null) {
throw Exception('Could not find .rdata section');
}
// Iterate through the .rdata section and zero out any instances of the
// timestamp from the PE header that we find.
for (var i = 0; i < rawDataSize; i += 4) {
final currentOffset = rawDataOffset + i;
if (readInt32(bytes, currentOffset) == peHeaderTimestamp) {
bytes.setRange(currentOffset, currentOffset + 4, List.filled(4, 0));
}
}
return bytes;
@@ -36,11 +36,8 @@ class WindowsArchiveDiffer extends ArchiveDiffer {
@override
bool isNativeFilePath(String filePath) {
// TODO(bryanoltman): reenable this check once we can reliably report
// native diffs
// const nativeFileExtensions = ['.dll', '.exe'];
// return nativeFileExtensions.contains(p.extension(filePath));
return false;
const nativeFileExtensions = ['.dll', '.exe'];
return nativeFileExtensions.contains(p.extension(filePath));
}
@override
@@ -14,7 +14,7 @@ final minimumSupportedWindowsFlutterVersion = Version(3, 27, 2);
/// Revisions of Flutter 3.27.1 that support windows.
const windowsFlutterGitHashesBelowMinVersion = {
'56228c343d6c7fd3e1e548dbb290f9713bb22aa9'
'56228c343d6c7fd3e1e548dbb290f9713bb22aa9',
};
/// A warning message printed at the start of `shorebird release windows` and
@@ -0,0 +1,75 @@
import 'dart:io';
import 'package:archive/archive_io.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/archive_analysis/portable_executable.dart';
import 'package:test/test.dart';
void main() {
group(PortableExecutable, () {
group('when no .rdata section exists', () {
late File file;
setUp(() {
final tempDir = Directory.systemTemp.createTempSync();
file = File(p.join(tempDir.path, 'my.exe'))
..writeAsBytesSync(List.filled(1000, 0));
});
test('throws exception', () {
expect(
() => PortableExecutable.bytesWithZeroedTimestamps(file),
throwsA(
isA<Exception>().having(
(e) => e.toString(),
'string value',
'Exception: Could not find .rdata section',
),
),
);
});
});
group('when given a valid exe', () {
late File file;
final winArchivesFixturesBasePath =
p.join('test', 'fixtures', 'win_archives');
final releasePath = p.join(
winArchivesFixturesBasePath,
'release.zip',
);
setUp(() async {
final tempDir = Directory.systemTemp.createTempSync();
final inputStream = InputFileStream(releasePath);
final archive = ZipDecoder().decodeStream(inputStream);
await extractArchiveToDisk(archive, tempDir.path);
file = File(p.join(tempDir.path, 'hello_windows.exe'));
});
test('zeroes out timestamps', () {
// Known locations of timestamps in this executable
const timestampLocations = [0x110, 0x6e14];
final beforeBytes = file.readAsBytesSync();
final afterBytes = PortableExecutable.bytesWithZeroedTimestamps(file);
expect(
beforeBytes.sublist(timestampLocations[0], timestampLocations[0] + 4),
isNot(equals([0, 0, 0, 0])),
);
expect(
beforeBytes.sublist(timestampLocations[1], timestampLocations[1] + 4),
isNot(equals([0, 0, 0, 0])),
);
expect(
afterBytes.sublist(timestampLocations[0], timestampLocations[0] + 4),
equals([0, 0, 0, 0]),
);
expect(
afterBytes.sublist(timestampLocations[1], timestampLocations[1] + 4),
equals([0, 0, 0, 0]),
);
});
});
});
}
@@ -42,32 +42,28 @@ void main() {
});
});
group(
'isNativeFilePath',
() {
group('when file extension is .dll', () {
test('returns true', () {
final result = differ.isNativeFilePath('foo.dll');
expect(result, isTrue);
});
group('isNativeFilePath', () {
group('when file extension is .dll', () {
test('returns true', () {
final result = differ.isNativeFilePath('foo.dll');
expect(result, isTrue);
});
});
group('when file extension is .exe', () {
test('returns true', () {
final result = differ.isNativeFilePath('foo.exe');
expect(result, isTrue);
});
group('when file extension is .exe', () {
test('returns true', () {
final result = differ.isNativeFilePath('foo.exe');
expect(result, isTrue);
});
});
group('when file extension is not .dll or .exe', () {
test('returns false', () {
final result = differ.isNativeFilePath('foo.so');
expect(result, isFalse);
});
group('when file extension is not .dll or .exe', () {
test('returns false', () {
final result = differ.isNativeFilePath('foo.so');
expect(result, isFalse);
});
},
skip: 'Disabled until we can reliably report native diffs',
);
});
});
group('changedFiles', () {
final winArchivesFixturesBasePath =
@@ -12,7 +12,6 @@ import 'package:shorebird_cli/src/common_arguments.dart';
import 'package:shorebird_cli/src/config/config.dart';
import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_cli/src/metadata/metadata.dart';
import 'package:shorebird_cli/src/platform/macos.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/release_type.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
@@ -250,7 +250,8 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''',
when(
() => shorebirdFlutter.getRevisionForVersion(any()),
).thenAnswer(
(_) async => windowsFlutterGitHashesBelowMinVersion.first);
(_) async => windowsFlutterGitHashesBelowMinVersion.first,
);
});
test('completes normally', () async {