feat: add bootloader DFU protocol validation

This commit is contained in:
2026-04-29 17:56:32 +02:00
parent eb26c759e8
commit b673c9100d
7 changed files with 534 additions and 86 deletions

View File

@ -3,97 +3,158 @@ import 'package:abawo_bt_app/service/dfu_protocol.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('DfuProtocol CRC32', () {
group('BootloaderDfuProtocol CRC32', () {
test('matches known vector', () {
final crc = DfuProtocol.crc32('123456789'.codeUnits);
final crc = BootloaderDfuProtocol.crc32('123456789'.codeUnits);
expect(crc, 0xCBF43926);
});
});
group('DfuProtocol control payload encoding', () {
test('encodes START payload with exact 11-byte LE layout', () {
final payload = DfuProtocol.encodeStartPayload(
const DfuStartPayload(
group('BootloaderDfuProtocol control payload encoding', () {
test('encodes START payload with exact 19-byte LE layout', () {
final payload = BootloaderDfuProtocol.encodeStartPayload(
const BootloaderDfuStartPayload(
totalLength: 0x1234,
imageCrc32: 0x89ABCDEF,
appStart: universalShifterDfuAppStart,
imageVersion: 0x10203040,
sessionId: 0x22,
flags: universalShifterDfuFlagEncrypted,
flags: universalShifterDfuFlagNone,
),
);
expect(payload.length, 11);
expect(
payload,
[
universalShifterDfuOpcodeStart,
0x34,
0x12,
0x00,
0x00,
0xEF,
0xCD,
0xAB,
0x89,
0x22,
universalShifterDfuFlagEncrypted,
],
);
expect(payload.length, 19);
expect(payload, [
universalShifterDfuOpcodeStart,
0x34,
0x12,
0x00,
0x00,
0xEF,
0xCD,
0xAB,
0x89,
0x00,
0x00,
0x03,
0x00,
0x40,
0x30,
0x20,
0x10,
0x22,
universalShifterDfuFlagNone,
]);
});
test('encodes FINISH and ABORT payloads as one byte', () {
test('encodes FINISH, ABORT, and GET_STATUS payloads', () {
expect(
DfuProtocol.encodeFinishPayload(), [universalShifterDfuOpcodeFinish]);
BootloaderDfuProtocol.encodeFinishPayload(0x12),
[universalShifterDfuOpcodeFinish, 0x12],
);
expect(
DfuProtocol.encodeAbortPayload(), [universalShifterDfuOpcodeAbort]);
BootloaderDfuProtocol.encodeAbortPayload(0x34),
[universalShifterDfuOpcodeAbort, 0x34],
);
expect(
BootloaderDfuProtocol.encodeGetStatusPayload(),
[universalShifterDfuOpcodeGetStatus],
);
});
});
group('DfuProtocol data frame building', () {
test('builds 64-byte frames and handles final partial payload', () {
final image = List<int>.generate(80, (index) => index);
final frames = DfuProtocol.buildDataFrames(image);
group('BootloaderDfuProtocol data frame building', () {
test('builds offset frames with payload CRC and variable final length', () {
final image = List<int>.generate(60, (index) => index);
final frames = BootloaderDfuProtocol.buildDataFrames(
imageBytes: image,
sessionId: 0x7A,
);
expect(frames.length, 2);
expect(frames[0].sequence, 0);
expect(frames[0].sessionId, 0x7A);
expect(frames[0].offset, 0);
expect(frames[0].payloadLength, universalShifterDfuFramePayloadSizeBytes);
expect(frames[0].payloadLength,
universalShifterBootloaderDfuMaxPayloadSizeBytes);
expect(frames[0].bytes.length, universalShifterDfuFrameSizeBytes);
expect(frames[0].bytes.sublist(1, 64), image.sublist(0, 63));
expect(frames[0].bytes[0], 0x7A);
expect(frames[0].bytes.sublist(1, 5), [0, 0, 0, 0]);
expect(
frames[0].bytes.sublist(5, 9),
_leU32Bytes(BootloaderDfuProtocol.crc32(image.sublist(0, 55))),
);
expect(frames[0].bytes.sublist(9), image.sublist(0, 55));
expect(frames[1].sequence, 1);
expect(frames[1].offset, 63);
expect(frames[1].payloadLength, 17);
expect(frames[1].bytes.length, universalShifterDfuFrameSizeBytes);
expect(frames[1].bytes.sublist(1, 18), image.sublist(63, 80));
expect(frames[1].offset, 55);
expect(frames[1].payloadLength, 5);
expect(frames[1].bytes.length, 14);
expect(frames[1].bytes.sublist(1, 5), [55, 0, 0, 0]);
expect(frames[1].bytes.sublist(9), image.sublist(55));
});
test('uses deterministic wrapping sequence numbers from custom start', () {
final image = List<int>.generate(
3 * universalShifterDfuFramePayloadSizeBytes,
(index) => index & 0xFF);
test('uses caller supplied payload size for low-MTU links', () {
final image = List<int>.generate(15, (index) => index);
final frames = BootloaderDfuProtocol.buildDataFrames(
imageBytes: image,
sessionId: 0x01,
payloadSize: 4,
);
final frames = DfuProtocol.buildDataFrames(image, startSequence: 0xFE);
expect(frames.map((frame) => frame.payloadLength), [4, 4, 4, 3]);
expect(frames.map((frame) => frame.offset), [0, 4, 8, 12]);
});
expect(frames.length, 3);
expect(frames[0].sequence, 0xFE);
expect(frames[1].sequence, 0xFF);
expect(frames[2].sequence, 0x00);
test('calculates safe payload size from negotiated MTU', () {
expect(
BootloaderDfuProtocol.maxPayloadSizeForMtu(64),
universalShifterBootloaderDfuMaxPayloadSizeBytes - 3,
);
expect(BootloaderDfuProtocol.maxPayloadSizeForMtu(23), 11);
expect(BootloaderDfuProtocol.maxPayloadSizeForMtu(12), 0);
expect(
BootloaderDfuProtocol.maxPayloadSizeForMtu(128),
universalShifterBootloaderDfuMaxPayloadSizeBytes,
);
});
});
group('DfuProtocol sequence and ACK helpers', () {
test('wraps sequence values and computes ack+1 rewind', () {
expect(DfuProtocol.nextSequence(0x00), 0x01);
expect(DfuProtocol.nextSequence(0xFF), 0x00);
group('BootloaderDfuProtocol status parsing', () {
test('parses bootloader status payload', () {
final status = BootloaderDfuProtocol.parseStatusPayload(
[0x00, 0x22, 0x78, 0x56, 0x34, 0x12],
);
expect(DfuProtocol.rewindSequenceFromAck(0x05), 0x06);
expect(DfuProtocol.rewindSequenceFromAck(0xFF), 0x00);
expect(status.code, DfuBootloaderStatusCode.ok);
expect(status.rawCode, 0x00);
expect(status.sessionId, 0x22);
expect(status.expectedOffset, 0x12345678);
expect(status.isOk, isTrue);
});
test('computes wrapping sequence distance', () {
expect(DfuProtocol.sequenceDistance(250, 2), 8);
expect(DfuProtocol.sequenceDistance(1, 1), 0);
test('preserves unknown status codes', () {
final status = BootloaderDfuProtocol.parseStatusPayload(
[0xFE, 0x00, 0x00, 0x00, 0x00, 0x00],
);
expect(status.code, DfuBootloaderStatusCode.unknown);
expect(status.rawCode, 0xFE);
});
test('rejects malformed status payloads', () {
expect(
() => BootloaderDfuProtocol.parseStatusPayload(const [0, 1]),
throwsFormatException,
);
});
});
}
List<int> _leU32Bytes(int value) {
return [
value & 0xFF,
(value >> 8) & 0xFF,
(value >> 16) & 0xFF,
(value >> 24) & 0xFF,
];
}

View File

@ -2,34 +2,40 @@ import 'dart:typed_data';
import 'package:abawo_bt_app/model/firmware_file_selection.dart';
import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:abawo_bt_app/service/dfu_protocol.dart';
import 'package:abawo_bt_app/service/firmware_file_selection_service.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('FirmwareFileSelectionService', () {
test('prepares v1 metadata for selected .bin firmware', () async {
test('prepares bootloader metadata for selected .bin firmware', () async {
final image = _validBootloaderImage();
final service = FirmwareFileSelectionService(
filePicker: _FakeFirmwareFilePicker(
selection: FirmwarePickerSelection(
fileName: 'firmware.BIN',
filePath: '/tmp/firmware.BIN',
fileBytes: Uint8List.fromList(<int>[1, 2, 3, 4]),
fileBytes: image,
),
),
sessionIdGenerator: () => 0x1AB,
);
final result = await service.selectAndPrepareDfuV1();
final result = await service.selectAndPrepareBootloaderDfu();
expect(result.isSuccess, isTrue);
final firmware = result.firmware!;
expect(firmware.fileName, 'firmware.BIN');
expect(firmware.filePath, '/tmp/firmware.BIN');
expect(firmware.fileBytes, <int>[1, 2, 3, 4]);
expect(firmware.metadata.totalLength, 4);
expect(firmware.metadata.crc32, 0xB63CFBCD);
expect(firmware.fileBytes, image);
expect(firmware.metadata.totalLength, image.length);
expect(firmware.metadata.crc32, BootloaderDfuProtocol.crc32(image));
expect(firmware.metadata.appStart, universalShifterDfuAppStart);
expect(firmware.metadata.imageVersion, 0);
expect(firmware.metadata.sessionId, 0xAB);
expect(firmware.metadata.flags, universalShifterDfuFlagNone);
expect(firmware.metadata.vectorStackPointer, 0x20001000);
expect(firmware.metadata.vectorReset, 0x00030009);
});
test('returns canceled result when user dismisses picker', () async {
@ -37,7 +43,7 @@ void main() {
filePicker: _FakeFirmwareFilePicker(selection: null),
);
final result = await service.selectAndPrepareDfuV1();
final result = await service.selectAndPrepareBootloaderDfu();
expect(result.isSuccess, isFalse);
expect(result.isCanceled, isTrue);
@ -49,12 +55,12 @@ void main() {
filePicker: _FakeFirmwareFilePicker(
selection: FirmwarePickerSelection(
fileName: 'firmware.hex',
fileBytes: Uint8List.fromList(<int>[1]),
fileBytes: _validBootloaderImage(),
),
),
);
final result = await service.selectAndPrepareDfuV1();
final result = await service.selectAndPrepareBootloaderDfu();
expect(result.isSuccess, isFalse);
expect(result.failure?.reason,
@ -71,26 +77,82 @@ void main() {
),
);
final result = await service.selectAndPrepareDfuV1();
final result = await service.selectAndPrepareBootloaderDfu();
expect(result.isSuccess, isFalse);
expect(result.failure?.reason, FirmwareSelectionFailureReason.emptyFile);
});
test('rejects images that are too small for a vector table', () async {
final service = FirmwareFileSelectionService(
filePicker: _FakeFirmwareFilePicker(
selection: FirmwarePickerSelection(
fileName: 'firmware.bin',
fileBytes: Uint8List.fromList(<int>[1, 2, 3, 4]),
),
),
);
final result = await service.selectAndPrepareBootloaderDfu();
expect(result.isSuccess, isFalse);
expect(
result.failure?.reason, FirmwareSelectionFailureReason.imageTooSmall);
});
test('rejects images larger than the application slot', () async {
final image = Uint8List(universalShifterDfuAppSlotSizeBytes + 1);
_writeLeU32(image, 0, 0x20001000);
_writeLeU32(image, 4, 0x00030009);
final service = FirmwareFileSelectionService(
filePicker: _FakeFirmwareFilePicker(
selection: FirmwarePickerSelection(
fileName: 'firmware.bin',
fileBytes: image,
),
),
);
final result = await service.selectAndPrepareBootloaderDfu();
expect(result.isSuccess, isFalse);
expect(
result.failure?.reason, FirmwareSelectionFailureReason.imageTooLarge);
});
test('rejects images with invalid vector table', () async {
final image = _validBootloaderImage();
_writeLeU32(image, 0, 0x10001000);
final service = FirmwareFileSelectionService(
filePicker: _FakeFirmwareFilePicker(
selection: FirmwarePickerSelection(
fileName: 'firmware.bin',
fileBytes: image,
),
),
);
final result = await service.selectAndPrepareBootloaderDfu();
expect(result.isSuccess, isFalse);
expect(result.failure?.reason,
FirmwareSelectionFailureReason.invalidVectorTable);
});
test('generates session id per run', () async {
var nextSession = 9;
final service = FirmwareFileSelectionService(
filePicker: _FakeFirmwareFilePicker(
selection: FirmwarePickerSelection(
fileName: 'firmware.bin',
fileBytes: Uint8List.fromList(<int>[10]),
fileBytes: _validBootloaderImage(),
),
),
sessionIdGenerator: () => nextSession++,
);
final first = await service.selectAndPrepareDfuV1();
final second = await service.selectAndPrepareDfuV1();
final first = await service.selectAndPrepareBootloaderDfu();
final second = await service.selectAndPrepareBootloaderDfu();
expect(first.firmware?.metadata.sessionId, 9);
expect(second.firmware?.metadata.sessionId, 10);
@ -104,7 +166,7 @@ void main() {
),
);
final result = await service.selectAndPrepareDfuV1();
final result = await service.selectAndPrepareBootloaderDfu();
expect(result.isSuccess, isFalse);
expect(result.failure?.reason, FirmwareSelectionFailureReason.readFailed);
@ -113,6 +175,18 @@ void main() {
});
}
Uint8List _validBootloaderImage() {
final image = Uint8List(16);
_writeLeU32(image, 0, 0x20001000);
_writeLeU32(image, 4, 0x00030009);
return image;
}
void _writeLeU32(Uint8List bytes, int offset, int value) {
final data = ByteData.sublistView(bytes);
data.setUint32(offset, value, Endian.little);
}
class _FakeFirmwareFilePicker implements FirmwareFilePicker {
_FakeFirmwareFilePicker({
required this.selection,