fix(core): reassembled observations must decode canonically

The reassembler decoded with postcard::from_bytes, which accepts
trailing bytes. It now uses take_from_bytes and rejects a non-empty
remainder, as Frame::decode does (spec §10 F13).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 08:44:09 -07:00
co-authored by Claude Opus 5.5
parent eda9746d8e
commit d983fea024
+37 -2
View File
@@ -178,8 +178,10 @@ impl Reassembler {
total += l; total += l;
} }
match postcard::from_bytes::<Observation>(&recon[..total]) { // Require a canonical encoding (F13): the reassembled bytes must be
Ok(obs) if obs.id() == id => { // exactly one observation, with no trailing bytes.
match postcard::take_from_bytes::<Observation>(&recon[..total]) {
Ok((obs, rest)) if rest.is_empty() && obs.id() == id => {
self.reset_slot(); self.reset_slot();
Some(obs) Some(obs)
} }
@@ -578,6 +580,39 @@ mod tests {
assert!(last.is_none(), "corrupted reassembly must not yield an obs"); assert!(last.is_none(), "corrupted reassembly must not yield an obs");
} }
/// F13: a reassembled observation must decode canonically. The
/// observation's encoding followed by one trailing byte is rejected even
/// though its prefix decodes to the right id.
#[test]
fn reassembled_trailing_byte_is_rejected() {
let obs = obs_with_payload([8; 16], 1024);
let mut buf = [0u8; consts::MAX_FRAME * 2];
let mut bytes = postcard::to_slice(&obs, &mut buf).unwrap().to_vec();
bytes.push(0);
let id = obs.id();
let chunk = 600;
let count = bytes.len().div_ceil(chunk) as u8;
let mut r = Reassembler::new();
let mut last = None;
for (i, data) in bytes.chunks(chunk).enumerate() {
last = r.feed(id, i as u8, count, data, 0);
}
assert!(
last.is_none(),
"trailing bytes after the observation are malformed"
);
// Without the trailing byte the same stream reassembles.
bytes.pop();
let count = bytes.len().div_ceil(chunk) as u8;
let mut r = Reassembler::new();
let mut last = None;
for (i, data) in bytes.chunks(chunk).enumerate() {
last = r.feed(id, i as u8, count, data, 0);
}
assert_eq!(last, Some(obs));
}
#[test] #[test]
fn fragment_too_large_returns_error() { fn fragment_too_large_returns_error() {
let obs = obs_with_payload([9; 16], consts::MAX_PAYLOAD); let obs = obs_with_payload([9; 16], consts::MAX_PAYLOAD);