Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions open_wearable/lib/models/firmware_image_version.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import 'dart:typed_data';

import 'package:open_earable_flutter/open_earable_flutter.dart';
import 'firmware_version_matcher.dart';

/// Read local image metadata after unpacking; filenames are arbitrary labels.
String? expectedFirmwareVersionForRequest(FirmwareUpdateRequest request) {
final firmware = request.firmware;
if (firmware is RemoteFirmware) {
return normalizeFirmwareVersion(firmware.version);
}
if (firmware is! LocalFirmware) return null;

if (request is MultiImageFirmwareUpdateRequest) {
final applicationImages =
request.firmwareImages?.where((image) => image.image == 0).toList();
if (applicationImages == null || applicationImages.length != 1) return null;
return _mcubootVersion(applicationImages.single.data);
}
if (request is SingleImageFirmwareUpdateRequest) {
return _mcubootVersion(firmware.data);
}
return null;
}

String? _mcubootVersion(Uint8List bytes) {
// MCUboot image_header: little-endian magic, sizes, then image_version at 20.
if (bytes.length < 32) return null;
final header = ByteData.sublistView(bytes);
if (header.getUint32(0, Endian.little) != 0x96f3b83d) return null;
final headerSize = header.getUint16(8, Endian.little);
final imageSize = header.getUint32(12, Endian.little);
if (headerSize < 32 ||
imageSize == 0 ||
headerSize + imageSize > bytes.length) {
return null;
}
final base = '${header.getUint8(20)}.${header.getUint8(21)}.'
'${header.getUint16(22, Endian.little)}';
final tweak = header.getUint32(24, Endian.little);
return tweak == 0 ? base : '$base.$tweak';
}
23 changes: 20 additions & 3 deletions open_wearable/lib/models/firmware_version_matcher.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,29 @@ bool firmwareVersionsMatch(String? expected, String? actual) {
return expectedPrNumber == actualPrNumber;
}

return actualComparison.contains(expectedComparison) ||
expectedComparison.contains(actualComparison);
final corePattern = RegExp(r'^\d+\.\d+\.\d+(?:\.\d+)?');
final expectedCore = corePattern.firstMatch(expectedComparison)?.group(0);
final actualCore = corePattern.firstMatch(actualComparison)?.group(0);
if (expectedCore != null &&
actualCore != null &&
expectedCore != actualCore) {
return false;
}

// A release may match its development/build suffix, never a partial number
// such as 2.2.9 inside 2.2.90 or 12.2.9.
bool containsVersion(String value, String version) => RegExp(
'(^|[^0-9A-Za-z])${RegExp.escape(version)}(?=\$|[^0-9A-Za-z])',
).hasMatch(value);
return containsVersion(actualComparison, expectedComparison) ||
containsVersion(expectedComparison, actualComparison);
}

String _comparisonValue(String value) {
return value.toLowerCase().replaceAll(RegExp(r'\s+'), ' ');
return value
.toLowerCase()
.replaceAll(RegExp(r'\s+'), ' ')
.replaceFirst(RegExp(r'^v(?=\d+\.)'), '');
}

String? _extractPullRequestNumber(String value) {
Expand Down
17 changes: 2 additions & 15 deletions open_wearable/lib/models/fota_post_update_verification.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:async';
import 'package:open_earable_flutter/open_earable_flutter.dart';
import 'package:open_wearable/models/device_name_formatter.dart';
import 'package:open_wearable/models/firmware_version_matcher.dart';
import 'package:open_wearable/models/firmware_image_version.dart';

/// Metadata returned when a post-update verification check is armed.
class ArmedFotaPostUpdateVerification {
Expand Down Expand Up @@ -99,7 +100,7 @@ class FotaPostUpdateVerificationCoordinator {
_resolveSideLabelFromName(request.peripheral?.name),
);
final expectedFirmwareVersion =
_extractExpectedFirmwareVersion(request.firmware);
expectedFirmwareVersionForRequest(request);

if (expectedName == null && expectedDeviceId == null) {
return null;
Expand Down Expand Up @@ -449,20 +450,6 @@ class FotaPostUpdateVerificationCoordinator {
_pendingIdsController.add(Set<String>.unmodifiable(_pendingById.keys));
}

String? _extractExpectedFirmwareVersion(SelectedFirmware? firmware) {
if (firmware is RemoteFirmware) {
return normalizeFirmwareVersion(firmware.version);
}

if (firmware is LocalFirmware) {
final match =
RegExp(r'(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)').firstMatch(firmware.name);
return normalizeFirmwareVersion(match?.group(1));
}

return null;
}

bool _firmwareVersionsMatch(String expected, String actual) {
return firmwareVersionsMatch(expected, actual);
}
Expand Down
86 changes: 86 additions & 0 deletions open_wearable/test/models/firmware_image_version_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:mcumgr_flutter/mcumgr_flutter.dart' as mcumgr;
import 'package:open_earable_flutter/open_earable_flutter.dart';
import 'package:open_wearable/models/firmware_image_version.dart';
import 'package:open_wearable/models/firmware_version_matcher.dart';

Uint8List image(int major, int minor, int patch, {int tweak = 0}) {
final bytes = Uint8List(64);
final data = ByteData.sublistView(bytes);
data.setUint32(0, 0x96f3b83d, Endian.little);
data.setUint16(8, 32, Endian.little);
data.setUint32(12, 32, Endian.little);
data.setUint8(20, major);
data.setUint8(21, minor);
data.setUint16(22, patch, Endian.little);
data.setUint32(24, tweak, Endian.little);
return bytes;
}

void main() {
test('local ZIP uses application header, not filename or network version',
() {
final request = MultiImageFirmwareUpdateRequest(
firmware: LocalFirmware(
name: 'OpenEarable-2.2.9-validation-e22638ad.zip',
data: Uint8List(0),
type: FirmwareType.multiImage,),
firmwareImages: [
mcumgr.Image(image: 1, data: image(1, 0, 0)),
mcumgr.Image(image: 0, data: image(2, 2, 9)),
],
);
final expected = expectedFirmwareVersionForRequest(request);
expect(expected, '2.2.9');
expect(firmwareVersionsMatch(expected, '2.2.9-dev.104+ge22638ad'), isTrue);
expect(firmwareVersionsMatch(expected, '2.2.8'), isFalse);
expect(firmwareVersionsMatch(expected, '2.2.90'), isFalse);
expect(firmwareVersionsMatch(expected, '12.2.9'), isFalse);
});
test('local signed binary reads little-endian revision and tweak', () {
final request = SingleImageFirmwareUpdateRequest(
firmware: LocalFirmware(
name: 'arbitrary.bin',
data: image(2, 3, 260, tweak: 7),
type: FirmwareType.singleImage,),);
expect(expectedFirmwareVersionForRequest(request), '2.3.260.7');
});
test('malformed images do not fall back to a version-looking filename', () {
final invalid = [
Uint8List(0),
Uint8List(32),
image(2, 2, 9).sublist(0, 40),
];
for (final data in invalid) {
final request = SingleImageFirmwareUpdateRequest(
firmware: LocalFirmware(
name: '2.2.9.bin', data: data, type: FirmwareType.singleImage,),);
expect(expectedFirmwareVersionForRequest(request), isNull);
}
});
test('missing or duplicate application images remain unverified', () {
final request = MultiImageFirmwareUpdateRequest(
firmware: LocalFirmware(
name: '2.2.9.zip',
data: Uint8List(0),
type: FirmwareType.multiImage,),);
expect(expectedFirmwareVersionForRequest(request), isNull);
request.firmwareImages = [mcumgr.Image(image: 1, data: image(1, 0, 0))];
expect(expectedFirmwareVersionForRequest(request), isNull);
request.firmwareImages = [
mcumgr.Image(image: 0, data: image(2, 2, 9)),
mcumgr.Image(image: 0, data: image(2, 2, 8)),
];
expect(expectedFirmwareVersionForRequest(request), isNull);
});
test('remote release labels keep their existing verification behavior', () {
final request = FirmwareUpdateRequest(
firmware: RemoteFirmware(
name: 'release',
version: '2.2.9',
url: 'https://example.test/fw.zip',
type: FirmwareType.multiImage,),);
expect(expectedFirmwareVersionForRequest(request), '2.2.9');
});
}
8 changes: 8 additions & 0 deletions open_wearable/test/models/firmware_version_matcher_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ void main() {
expect(firmwareVersionsMatch('PR #123', '2.2.7'), isFalse);
});

test('release tag prefixes match device labels without partial numbers',
() {
expect(
firmwareVersionsMatch('v2.2.9', '2.2.9-dev.104+ge22638ad'), isTrue,);
expect(firmwareVersionsMatch('v2.2.9', '2.2.90'), isFalse);
expect(firmwareVersionsMatch('2.2.9.1', '2.2.9'), isFalse);
});

test('keeps existing stable version containment behavior', () {
expect(firmwareVersionsMatch('2.2.7', '2.2.7+1'), isTrue);
expect(firmwareVersionsMatch('2.2.7+1', '2.2.7'), isTrue);
Expand Down
Loading