Skip to content

Commit a61a989

Browse files
committed
feat: add COBS/R in-place decode
Mirrors basic-COBS in-place; reduced final block appends the code byte.
1 parent a58cfe5 commit a61a989

3 files changed

Lines changed: 171 additions & 0 deletions

File tree

lib/cobs_codec.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ export 'src/cobsr.dart'
5858
CobsrEncoder,
5959
cobsr,
6060
cobsrDecode,
61+
cobsrDecodeInPlace,
62+
cobsrDecodeInPlaceWithSentinel,
6163
cobsrDecodeWithSentinel,
6264
cobsrEncode,
6365
cobsrEncodeWithSentinel;

lib/src/cobsr.dart

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,81 @@ Uint8List cobsrDecodeWithSentinel(List<int> input, int sentinel) {
166166
return cobsrDecode(copy);
167167
}
168168

169+
/// Decodes COBS/R data in place, overwriting [buffer] with the decoded bytes and
170+
/// returning their length; the decoded output occupies `buffer[0..n]`.
171+
///
172+
/// This needs no separate output buffer: the COBS/R decoded length never exceeds
173+
/// the encoded length, so the write position always trails the read position.
174+
/// The reduced final block appends its length-code byte onto a position that has
175+
/// already been read, so unread input is never clobbered. The bytes of [buffer]
176+
/// beyond the returned length are left in an unspecified (partially overwritten)
177+
/// state.
178+
///
179+
/// As with [cobsrDecode], a length code that points past the end of [buffer] is
180+
/// not an error: it signals the reduced final block, and the length code itself
181+
/// is appended as the final data byte.
182+
///
183+
/// Throws a [CobsDecodeException] if [buffer] contains a `0x00` byte.
184+
int cobsrDecodeInPlace(Uint8List buffer) {
185+
final srcLen = buffer.length;
186+
if (srcLen == 0) return 0;
187+
188+
var writeIndex = 0;
189+
var index = 0;
190+
191+
while (true) {
192+
final code = buffer[index];
193+
if (code == 0) {
194+
throw CobsDecodeException('zero byte in COBS/R input', buffer, index);
195+
}
196+
index++;
197+
final blockEnd = index + code - 1;
198+
final copyEnd = blockEnd < srcLen ? blockEnd : srcLen;
199+
for (; index < copyEnd; index++) {
200+
final byte = buffer[index];
201+
if (byte == 0) {
202+
throw CobsDecodeException('zero byte in COBS/R input', buffer, index);
203+
}
204+
// `writeIndex` trails `index` throughout, so this never clobbers a byte
205+
// that has not yet been read.
206+
buffer[writeIndex++] = byte;
207+
}
208+
if (blockEnd > srcLen) {
209+
// Reduced encoding: the length code was really the final data byte. The
210+
// append lands on an already-read byte (`writeIndex < index == srcLen`),
211+
// so it is safe to perform in place.
212+
buffer[writeIndex++] = code;
213+
break;
214+
} else if (blockEnd < srcLen) {
215+
if (code < 0xFF) buffer[writeIndex++] = 0;
216+
} else {
217+
break;
218+
}
219+
}
220+
221+
return writeIndex;
222+
}
223+
224+
/// Decodes COBS/R data that was encoded with an arbitrary [sentinel] byte in
225+
/// place, overwriting [buffer] with the decoded bytes and returning their
226+
/// length; the decoded output occupies `buffer[0..n]`.
227+
///
228+
/// When [sentinel] is non-zero, [buffer] is first XORed with it (masked to the
229+
/// low 8 bits) and then decoded in place by [cobsrDecodeInPlace].
230+
/// `sentinel == 0` is identical to [cobsrDecodeInPlace]. As an in-place
231+
/// operation this necessarily consumes (overwrites) [buffer].
232+
///
233+
/// Throws a [CobsDecodeException] if [buffer] is not valid.
234+
int cobsrDecodeInPlaceWithSentinel(Uint8List buffer, int sentinel) {
235+
final s = sentinel & 0xFF;
236+
if (s != 0) {
237+
for (var i = 0; i < buffer.length; i++) {
238+
buffer[i] ^= s;
239+
}
240+
}
241+
return cobsrDecodeInPlace(buffer);
242+
}
243+
169244
/// A [Codec] that encodes and decodes bytes with Consistent Overhead Byte
170245
/// Stuffing — Reduced (COBS/R).
171246
///

test/features_test.dart

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,100 @@ void main() {
9292
expect(Uint8List.sublistView(buf, 0, n), cobsDecode(encoded));
9393
}
9494
});
95+
96+
test('cobsrDecodeInPlace matches cobsrDecode (differential)', () {
97+
// Assert that the in-place COBS/R decoder agrees byte-for-byte with the
98+
// slice decoder on valid input, and agrees on acceptance vs rejection for
99+
// arbitrary (possibly invalid) input.
100+
void checkAgrees(List<int> encoded) {
101+
Uint8List? sliceResult;
102+
var sliceThrew = false;
103+
try {
104+
sliceResult = cobsrDecode(encoded);
105+
} on CobsDecodeException {
106+
sliceThrew = true;
107+
}
108+
109+
final buf = Uint8List.fromList(encoded);
110+
Uint8List? inPlaceResult;
111+
var inPlaceThrew = false;
112+
try {
113+
final n = cobsrDecodeInPlace(buf);
114+
inPlaceResult = Uint8List.sublistView(buf, 0, n);
115+
} on CobsDecodeException {
116+
inPlaceThrew = true;
117+
}
118+
119+
expect(inPlaceThrew, sliceThrew,
120+
reason: 'accept/reject must match for $encoded');
121+
if (!sliceThrew) {
122+
expect(inPlaceResult, sliceResult, reason: 'decoding $encoded');
123+
}
124+
}
125+
126+
// Golden COBS/R encoded vectors (ported from cobsr_test.dart), chosen to
127+
// exercise reduced final blocks, embedded zeros, and maximal (0xFF) codes.
128+
final golden = <List<int>>[
129+
[0x01],
130+
[0x02],
131+
[0x7F],
132+
[0xFF],
133+
[0x02, 0x01],
134+
[0x03, ...ascii('a'), 0x02],
135+
[0x03, ...ascii('a')],
136+
[0xFF, ...ascii('a')],
137+
[0x06, 0x05, 0x04, 0x03, 0x02, 0x01],
138+
[0x35, ...ascii('1234')],
139+
[0x06, ...ascii('12345'), 0x05, 0x04, 0x03, 0x02, 0x01],
140+
[0x06, ...ascii('12345'), ...ascii('9678')],
141+
[0x01, 0x06, ...ascii('12345'), ...ascii('9678')],
142+
[0x06, ...ascii('12345'), 0x05, ...ascii('6789'), 0x01],
143+
[0x01, 0x01],
144+
[0x01, 0x01, 0x01],
145+
[0xFE, ...range(1, 254)],
146+
[0xFF, ...range(1, 255)],
147+
[0xFF, ...range(1, 255), 0xFF],
148+
[0x01, 0xFF, ...range(1, 255), 0xFF],
149+
];
150+
for (final encoded in golden) {
151+
checkAgrees(encoded);
152+
}
153+
154+
// Large seeded-random corpus.
155+
final rng = Random(0xC0B5B12A);
156+
for (var t = 0; t < 6000; t++) {
157+
// Valid encodings from the reference encoder exercise the reduced-block
158+
// path and embedded-zero blocks.
159+
checkAgrees(cobsrEncode(randomPacket(rng, 700)));
160+
161+
// Raw random bytes are mostly invalid (embedded zeros / bad codes) and
162+
// pin down accept/reject agreement between the two decoders.
163+
final rawLen = rng.nextInt(48);
164+
checkAgrees(List<int>.generate(rawLen, (_) => rng.nextInt(256)));
165+
166+
// Non-zero random bytes are always valid COBS/R and heavily exercise
167+
// high length codes and reduced final blocks.
168+
final nzLen = 1 + rng.nextInt(48);
169+
checkAgrees(List<int>.generate(nzLen, (_) => 1 + rng.nextInt(255)));
170+
}
171+
});
172+
173+
test('cobsrDecodeInPlaceWithSentinel matches slice decode (differential)',
174+
() {
175+
final rng = Random(0x1234C0B5);
176+
for (final s in sentinels) {
177+
for (var t = 0; t < 4000; t++) {
178+
final packet = randomPacket(rng, 700);
179+
final encoded = cobsrEncodeWithSentinel(packet, s);
180+
final expected = cobsrDecodeWithSentinel(encoded, s);
181+
182+
final buf = Uint8List.fromList(encoded);
183+
final n = cobsrDecodeInPlaceWithSentinel(buf, s);
184+
expect(Uint8List.sublistView(buf, 0, n), expected);
185+
expect(Uint8List.sublistView(buf, 0, n), packet);
186+
}
187+
}
188+
});
95189
});
96190

97191
group('framing with a sentinel', () {

0 commit comments

Comments
 (0)