Skip to content

Commit a140b9c

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

2 files changed

Lines changed: 153 additions & 0 deletions

File tree

cobs/src/main/kotlin/dev/firechip/cobs/Cobsr.kt

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,4 +148,78 @@ public object Cobsr {
148148
}
149149
return decode(src)
150150
}
151+
152+
/**
153+
* Decodes COBS/R data in place, overwriting [buffer] with the decoded output
154+
* and returning its length; the decoded bytes occupy the first `len` elements
155+
* of [buffer].
156+
*
157+
* This needs no output buffer: a COBS/R encoding is never shorter than its
158+
* decoding, so the write position always trails the read position. A length
159+
* code that points past the end of the input is not an error here but the
160+
* reduced final block, whose data byte is the code value itself; appending it
161+
* lands on a byte that has already been read, so decoding never clobbers
162+
* unread input.
163+
*
164+
* @throws CobsDecodeException if [buffer] contains a `0x00` byte.
165+
*/
166+
@JvmStatic
167+
public fun decodeInPlace(buffer: ByteArray): Int = decodeInPlace(buffer, COBS_DELIMITER)
168+
169+
/**
170+
* Decodes COBS/R data that was encoded with an arbitrary [sentinel] byte in
171+
* place, overwriting [buffer] with the decoded output and returning its
172+
* length. A [sentinel] of `0` is identical to [decodeInPlace].
173+
*
174+
* When [sentinel] is non-zero the buffer is first XORed back to its
175+
* `0x00`-based form in place, then decoded in place.
176+
*
177+
* @throws CobsDecodeException if [buffer] is not valid encoded data.
178+
*/
179+
@JvmStatic
180+
public fun decodeInPlace(buffer: ByteArray, sentinel: Byte): Int {
181+
val srcLen = buffer.size
182+
if (srcLen == 0) return 0
183+
184+
val s = sentinel.toInt() and 0xFF
185+
if (s != 0) {
186+
for (i in 0 until srcLen) {
187+
buffer[i] = (buffer[i].toInt() xor s).toByte()
188+
}
189+
}
190+
191+
var writeIndex = 0
192+
var index = 0
193+
194+
while (true) {
195+
val code = buffer[index].toInt() and 0xFF
196+
if (code == 0) {
197+
throw CobsDecodeException("zero byte in COBS/R input", index)
198+
}
199+
index++
200+
val blockEnd = index + code - 1
201+
val copyEnd = if (blockEnd < srcLen) blockEnd else srcLen
202+
while (index < copyEnd) {
203+
val b = buffer[index].toInt() and 0xFF
204+
if (b == 0) {
205+
throw CobsDecodeException("zero byte in COBS/R input", index)
206+
}
207+
// writeIndex < index throughout, so this never clobbers unread input.
208+
buffer[writeIndex++] = b.toByte()
209+
index++
210+
}
211+
if (blockEnd > srcLen) {
212+
// Reduced encoding: the length code was the final data byte. The
213+
// append overwrites a byte already consumed, so it is safe here.
214+
buffer[writeIndex++] = code.toByte()
215+
break
216+
} else if (blockEnd < srcLen) {
217+
if (code < 0xFF) buffer[writeIndex++] = 0
218+
} else {
219+
break
220+
}
221+
}
222+
223+
return writeIndex
224+
}
151225
}

cobs/src/test/kotlin/dev/firechip/cobs/FeaturesTest.kt

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,85 @@ class FeaturesTest {
6969
}
7070
}
7171

72+
// Golden COBS/R encodings ported from the reference suite: valid wire
73+
// sequences, including reduced final blocks (e.g. `bytes(0x02)`), embedded
74+
// zeros, and maximal `0xFF` length codes.
75+
private val cobsrGolden: List<ByteArray> = listOf(
76+
bytes(0x01),
77+
bytes(0x02, 0x01),
78+
bytes(0x02),
79+
bytes(0x03),
80+
bytes(0x7E),
81+
bytes(0x7F),
82+
bytes(0x80),
83+
bytes(0xD5),
84+
bytes(0xFE),
85+
bytes(0xFF),
86+
cat(bytes(0x03), ascii("a"), bytes(0x02)),
87+
cat(bytes(0x03), ascii("a")),
88+
cat(bytes(0xFF), ascii("a")),
89+
bytes(0x06, 0x05, 0x04, 0x03, 0x02, 0x01),
90+
cat(bytes(0x35), ascii("1234")),
91+
cat(bytes(0x06), ascii("12345"), bytes(0x05, 0x04, 0x03, 0x02, 0x01)),
92+
cat(bytes(0x06), ascii("12345"), ascii("9678")),
93+
cat(bytes(0x01, 0x06), ascii("12345"), ascii("9678")),
94+
cat(bytes(0x06), ascii("12345"), bytes(0x05), ascii("6789"), bytes(0x01)),
95+
bytes(0x01, 0x01),
96+
bytes(0x01, 0x01, 0x01),
97+
bytes(0x01, 0x01, 0x01, 0x01),
98+
cat(bytes(0xFE), range(1, 254)),
99+
cat(bytes(0xFF), range(1, 255)),
100+
cat(bytes(0xFF), range(1, 255), bytes(0xFF)),
101+
cat(bytes(0x01, 0xFF), range(1, 255), bytes(0xFF)),
102+
cat(bytes(0xFF), range(2, 255)),
103+
)
104+
105+
/**
106+
* The in-place COBS/R decoder must be byte-identical to the slice decoder on
107+
* every input and agree on accept/reject. Checked over the golden vectors and
108+
* a large seeded-random corpus: half well-formed encodings of random payloads
109+
* (including reduced final blocks) and half arbitrary bytes, which stress the
110+
* reject paths with embedded zeros and length codes running past the end.
111+
*/
112+
@Test
113+
fun cobsrDecodeInPlaceMatchesDecode() {
114+
for (encoded in cobsrGolden) {
115+
for (s in sentinels) assertCobsrInPlaceMatchesSlice(encoded, s)
116+
}
117+
118+
val rng = Random(0x0C0B5A17)
119+
repeat(20000) {
120+
val wire = if (rng.nextBoolean()) {
121+
val len = rng.nextInt(0, 701)
122+
Cobsr.encode(ByteArray(len) { rng.nextInt(0, 256).toByte() })
123+
} else {
124+
val len = rng.nextInt(0, 40)
125+
ByteArray(len) { rng.nextInt(0, 256).toByte() }
126+
}
127+
for (s in sentinels) assertCobsrInPlaceMatchesSlice(wire, s)
128+
}
129+
}
130+
131+
/** Asserts in-place COBS/R decode agrees with the slice decoder: value and error. */
132+
private fun assertCobsrInPlaceMatchesSlice(wire: ByteArray, sentinel: Byte) {
133+
val slice = runCatching { Cobsr.decodeWithSentinel(wire, sentinel) }
134+
val buf = wire.copyOf()
135+
val inPlace = runCatching { Cobsr.decodeInPlace(buf, sentinel) }
136+
137+
if (slice.isSuccess) {
138+
assertTrue(
139+
"in-place must accept what the slice decoder accepts",
140+
inPlace.isSuccess,
141+
)
142+
assertArrayEquals(slice.getOrThrow(), buf.copyOf(inPlace.getOrThrow()))
143+
} else {
144+
assertTrue(
145+
"in-place must reject what the slice decoder rejects",
146+
inPlace.exceptionOrNull() is CobsDecodeException,
147+
)
148+
}
149+
}
150+
72151
@Test
73152
fun framingWithSentinelRoundTrips() {
74153
val rng = Random(0x57EA9000)

0 commit comments

Comments
 (0)