Skip to content
Merged
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
21 changes: 20 additions & 1 deletion lib/src/framing.dart
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,20 @@ Frame? parseFrame(Uint8List raw) {
/// out of the running buffer. length-based reassembler.
class FrameReassembler {
final List<int> _buf = [];
int _resyncs = 0;

/// Number of times the reassembler skipped a byte because the envelope did
/// not hold up (bad SOF, implausible length, or a length field whose crc8
/// did not match). Callers use this to detect a degraded link — a bad
/// length is discarded here, so it never reaches [Frame.valid].
int get resyncs => _resyncs;

List<Frame> feed(List<int> chunk) {
final out = <Frame>[];
_buf.addAll(chunk);

bool resync() {
_resyncs++;
// Find next SOF after index 0.
int nxt = -1;
for (int i = 1; i < _buf.length; i++) {
Expand Down Expand Up @@ -112,6 +120,14 @@ class FrameReassembler {
if (!resync()) break;
continue;
}
// The crc8 protects the length field and nothing else, so check it
// before acting on `declared`. Skipping this consumes up to 4092 bytes
// of good stream on a single corrupted length byte — records the band
// is about to trim from flash and will not send again.
if (_buf[3] != crc8(Uint8List.fromList([_buf[1], _buf[2]]))) {
if (!resync()) break;
continue;
}
if (_buf.length < total) break; // wait for the rest of this frame
final frame = parseFrame(Uint8List.fromList(_buf.sublist(0, total)));
if (frame != null) out.add(frame);
Expand All @@ -127,5 +143,8 @@ class FrameReassembler {
return out;
}

void reset() => _buf.clear();
void reset() {
_buf.clear();
_resyncs = 0;
}
}
25 changes: 24 additions & 1 deletion lib/src/live.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,34 @@ class ImuFrame {
Map<String, dynamic> toMap() => {'ts': ts, 'idx': idx, 'mags': mags, 'xs': xs, 'ys': ys, 'zs': zs};
}

/// Nibble value for an ASCII hex code unit, or -1 if it is not a hex digit.
int _nibble(int c) {
if (c >= 0x30 && c <= 0x39) return c - 0x30; // 0-9
if (c >= 0x61 && c <= 0x66) return c - 0x57; // a-f
if (c >= 0x41 && c <= 0x46) return c - 0x37; // A-F
return -1;
}

Uint8List hexToBytes(String hex) {
final trimmed = hex.trim();
// An odd-length string is a truncated record, not a shorter one. Flooring to
// whole bytes would drop the trailing nibble and hand the caller a payload
// that decodes cleanly at the wrong length.
if (trimmed.length.isOdd) {
throw FormatException('odd-length hex', trimmed, trimmed.length);
}
final out = Uint8List(trimmed.length ~/ 2);
for (int i = 0; i < out.length; i++) {
out[i] = int.parse(trimmed.substring(i * 2, i * 2 + 2), radix: 16);
final hi = _nibble(trimmed.codeUnitAt(i * 2));
final lo = _nibble(trimmed.codeUnitAt(i * 2 + 1));
// Keep throwing on non-hex input. Callers rely on it: live.dart,
// substrate.dart, db.dart and ble_engine.dart all treat a FormatException
// as "this string is not a record", and a lookup that returned 0 instead
// would hand them fabricated bytes.
if (hi < 0 || lo < 0) {
throw FormatException('not a hex byte', trimmed, i * 2);
}
out[i] = (hi << 4) | lo;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return out;
}
Expand Down
24 changes: 19 additions & 5 deletions lib/src/records.dart
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,13 @@ class R24 {
final int ambientRaw;

/// Untouched payload [13:] as hex — kept for re-decode as the map improves.
final String rawTail;
///
/// Encoded on first read, not at parse time. Nothing in the stack reads it
/// today, and eagerly hex-encoding 83 bytes per record dominated parseR24's
/// cost on the offload path.
String get rawTail => _rawTailHex ??= _hexFrom(_rawTailBytes, 0);
final Uint8List _rawTailBytes;
String? _rawTailHex;

R24({
required this.histVersion,
Expand All @@ -71,8 +77,8 @@ class R24 {
required this.spo2IrRaw,
required this.skinTempRaw,
required this.ambientRaw,
required this.rawTail,
});
required Uint8List rawTailBytes,
}) : _rawTailBytes = rawTailBytes;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Map matching the TS `out` shape (snake_case keys) for parity comparison.
Map<String, dynamic> toMap() => {
Expand Down Expand Up @@ -204,7 +210,11 @@ R24? _parseV25(Uint8List inner) {
spo2IrRaw: 0,
skinTempRaw: 0,
ambientRaw: 0,
rawTail: _hexFrom(inner, 13),
// COPY, not sublistView: a view aliases the caller's buffer, and rawTail
// is now encoded lazily, so a later mutation of `inner` would change bytes
// that were supposed to be a snapshot of parse time.
rawTailBytes:
inner.length > 13 ? Uint8List.fromList(inner.sublist(13)) : Uint8List(0),
);
}

Expand Down Expand Up @@ -368,7 +378,11 @@ R24? _parseV24Layout(
spo2IrRaw: view.getUint16(66, Endian.little),
skinTempRaw: view.getUint16(68, Endian.little),
ambientRaw: view.getUint16(70, Endian.little),
rawTail: _hexFrom(inner, 13),
// COPY, not sublistView: a view aliases the caller's buffer, and rawTail
// is now encoded lazily, so a later mutation of `inner` would change bytes
// that were supposed to be a snapshot of parse time.
rawTailBytes:
inner.length > 13 ? Uint8List.fromList(inner.sublist(13)) : Uint8List(0),
);
}

Expand Down
25 changes: 25 additions & 0 deletions test/decode_guards_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -388,4 +388,29 @@ void main() {
expect(() => cmdBuzz(0, 0), returnsNormally);
});
});

group('hexToBytes', () {
test('decodes both cases and tolerates surrounding whitespace', () {
expect(hexToBytes(' 0aFF10 '), equals([0x0a, 0xff, 0x10]));
expect(hexToBytes(''), isEmpty);
});

test('rawTail snapshots the record, even if the buffer is reused', () {
final inner = hexToBytes(_goodV24);
final before = inner[13];
final rec = parseR24(inner)!;
// rawTail is encoded lazily, so the record must own its bytes: mutating
// the caller's buffer afterwards must not change what it reports.
inner[13] = before ^ 0xFF;
expect(rec.rawTail.substring(0, 2),
before.toRadixString(16).padLeft(2, '0'),
reason: 'rawTail must reflect parse time, not later mutation');
});

test('throws on non-hex input instead of fabricating bytes', () {
for (final bad in ['0g', 'zz', '0x', '00 11', 'a', 'abc']) {
expect(() => hexToBytes(bad), throwsFormatException, reason: bad);
}
});
});
}
29 changes: 29 additions & 0 deletions test/framing_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -148,5 +148,34 @@ void main() {
expect(rest.length, 1);
expect(rest.first.valid, isTrue);
});

test('a corrupted length byte does not swallow the frame behind it', () {
final a = buildCommand(3, Cmd.getDataRange, List<int>.filled(64, 0x11));
final b = buildCommand(4, Cmd.getHelloHarvard, const [0x00]);
final stream = <int>[...a, ...b];
// Flip one bit in the low length byte. crc8 no longer matches, so the
// declared length must not be trusted.
stream[1] ^= 0x01;

final re = FrameReassembler();
final frames = re.feed(stream);

expect(re.resyncs, greaterThan(0), reason: 'bad length must force a resync');
expect(frames.length, 1, reason: 'the intact second frame must survive');
expect(frames.single.valid, isTrue);
expect(frames.single.seq, 4);
});

test('a dropped byte costs one frame, not the rest of the stream', () {
final a = buildCommand(5, Cmd.getDataRange, List<int>.filled(32, 0x22));
final b = buildCommand(6, Cmd.getHelloHarvard, const [0x00]);
final stream = <int>[...a, ...b]..removeAt(2); // drop a length byte

final re = FrameReassembler();
final frames = re.feed(stream);

expect(frames.map((f) => f.seq), contains(6));
expect(frames.every((f) => f.valid), isTrue);
});
});
}
Loading