1use crate::{
26 consts::{
27 BTRFS_SEND_A_ATIME, BTRFS_SEND_A_CLONE_CTRANSID,
28 BTRFS_SEND_A_CLONE_LEN, BTRFS_SEND_A_CLONE_OFFSET,
29 BTRFS_SEND_A_CLONE_PATH, BTRFS_SEND_A_CLONE_UUID,
30 BTRFS_SEND_A_COMPRESSION, BTRFS_SEND_A_CTIME, BTRFS_SEND_A_CTRANSID,
31 BTRFS_SEND_A_DATA, BTRFS_SEND_A_ENCRYPTION,
32 BTRFS_SEND_A_FALLOCATE_MODE, BTRFS_SEND_A_FILE_OFFSET,
33 BTRFS_SEND_A_FILEATTR, BTRFS_SEND_A_GID, BTRFS_SEND_A_MODE,
34 BTRFS_SEND_A_MTIME, BTRFS_SEND_A_PATH, BTRFS_SEND_A_PATH_LINK,
35 BTRFS_SEND_A_PATH_TO, BTRFS_SEND_A_RDEV, BTRFS_SEND_A_SIZE,
36 BTRFS_SEND_A_UID, BTRFS_SEND_A_UNENCODED_FILE_LEN,
37 BTRFS_SEND_A_UNENCODED_LEN, BTRFS_SEND_A_UNENCODED_OFFSET,
38 BTRFS_SEND_A_UUID, BTRFS_SEND_A_VERITY_ALGORITHM,
39 BTRFS_SEND_A_VERITY_BLOCK_SIZE, BTRFS_SEND_A_VERITY_SALT_DATA,
40 BTRFS_SEND_A_VERITY_SIG_DATA, BTRFS_SEND_A_XATTR_DATA,
41 BTRFS_SEND_A_XATTR_NAME, BTRFS_SEND_C_CHMOD, BTRFS_SEND_C_CHOWN,
42 BTRFS_SEND_C_CLONE, BTRFS_SEND_C_ENABLE_VERITY,
43 BTRFS_SEND_C_ENCODED_WRITE, BTRFS_SEND_C_END, BTRFS_SEND_C_FALLOCATE,
44 BTRFS_SEND_C_FILEATTR, BTRFS_SEND_C_LINK, BTRFS_SEND_C_MKDIR,
45 BTRFS_SEND_C_MKFIFO, BTRFS_SEND_C_MKFILE, BTRFS_SEND_C_MKNOD,
46 BTRFS_SEND_C_MKSOCK, BTRFS_SEND_C_REMOVE_XATTR, BTRFS_SEND_C_RENAME,
47 BTRFS_SEND_C_RMDIR, BTRFS_SEND_C_SET_XATTR, BTRFS_SEND_C_SNAPSHOT,
48 BTRFS_SEND_C_SUBVOL, BTRFS_SEND_C_SYMLINK, BTRFS_SEND_C_TRUNCATE,
49 BTRFS_SEND_C_UNLINK, BTRFS_SEND_C_UPDATE_EXTENT, BTRFS_SEND_C_UTIMES,
50 BTRFS_SEND_C_WRITE, CMD_HEADER_LEN, SEND_STREAM_MAGIC,
51 SEND_STREAM_MAGIC_LEN, STREAM_HEADER_LEN,
52 },
53 stream::{StreamCommand, Timespec},
54};
55use std::io::{self, Write};
56use uuid::Uuid;
57
58#[derive(Debug)]
66pub struct StreamWriter<W: Write> {
67 inner: W,
68 version: u32,
69}
70
71impl<W: Write> StreamWriter<W> {
72 pub fn new(mut inner: W, version: u32) -> io::Result<Self> {
80 if version == 0 || version > 3 {
81 return Err(io::Error::new(
82 io::ErrorKind::InvalidInput,
83 format!(
84 "unsupported send stream version {version} (supported: 1-3)"
85 ),
86 ));
87 }
88 let mut header = [0u8; STREAM_HEADER_LEN];
89 header[..SEND_STREAM_MAGIC_LEN].copy_from_slice(SEND_STREAM_MAGIC);
90 header[SEND_STREAM_MAGIC_LEN..STREAM_HEADER_LEN]
91 .copy_from_slice(&version.to_le_bytes());
92 inner.write_all(&header)?;
93 Ok(Self { inner, version })
94 }
95
96 #[must_use]
98 pub fn version(&self) -> u32 {
99 self.version
100 }
101
102 pub fn finish(mut self) -> io::Result<W> {
111 self.inner.flush()?;
112 Ok(self.inner)
113 }
114
115 pub fn write_command(&mut self, cmd: &StreamCommand) -> io::Result<()> {
126 let mut payload = Vec::new();
127 let cmd_id = encode_command(cmd, &mut payload, self.version)?;
128 self.write_framed(cmd_id, &payload)
129 }
130
131 fn write_framed(&mut self, cmd_id: u16, payload: &[u8]) -> io::Result<()> {
132 let payload_len = u32::try_from(payload.len()).map_err(|_| {
133 io::Error::new(
134 io::ErrorKind::InvalidInput,
135 format!(
136 "command {cmd_id} payload exceeds u32 length field: {} bytes",
137 payload.len(),
138 ),
139 )
140 })?;
141
142 let mut header = [0u8; CMD_HEADER_LEN];
143 header[0..4].copy_from_slice(&payload_len.to_le_bytes());
144 header[4..6].copy_from_slice(&cmd_id.to_le_bytes());
145 let crc = crc32c::crc32c_append(!0, &header[0..6]);
151 let crc = crc32c::crc32c_append(crc, &[0u8; 4]);
152 let crc = !crc32c::crc32c_append(crc, payload);
153 header[6..10].copy_from_slice(&crc.to_le_bytes());
154
155 self.inner.write_all(&header)?;
156 self.inner.write_all(payload)?;
157 Ok(())
158 }
159}
160
161#[allow(clippy::too_many_lines)]
173fn encode_command(
174 cmd: &StreamCommand,
175 out: &mut Vec<u8>,
176 version: u32,
177) -> io::Result<u16> {
178 match cmd {
179 StreamCommand::Subvol {
180 path,
181 uuid,
182 ctransid,
183 } => {
184 put_attr_str(out, BTRFS_SEND_A_PATH, path)?;
185 put_attr_uuid(out, BTRFS_SEND_A_UUID, uuid);
186 put_attr_u64(out, BTRFS_SEND_A_CTRANSID, *ctransid);
187 Ok(BTRFS_SEND_C_SUBVOL)
188 }
189 StreamCommand::Snapshot {
190 path,
191 uuid,
192 ctransid,
193 clone_uuid,
194 clone_ctransid,
195 } => {
196 put_attr_str(out, BTRFS_SEND_A_PATH, path)?;
197 put_attr_uuid(out, BTRFS_SEND_A_UUID, uuid);
198 put_attr_u64(out, BTRFS_SEND_A_CTRANSID, *ctransid);
199 put_attr_uuid(out, BTRFS_SEND_A_CLONE_UUID, clone_uuid);
200 put_attr_u64(out, BTRFS_SEND_A_CLONE_CTRANSID, *clone_ctransid);
201 Ok(BTRFS_SEND_C_SNAPSHOT)
202 }
203 StreamCommand::Mkfile { path } => {
204 path_only(out, path, BTRFS_SEND_C_MKFILE)
205 }
206 StreamCommand::Mkdir { path } => {
207 path_only(out, path, BTRFS_SEND_C_MKDIR)
208 }
209 StreamCommand::Mknod { path, mode, rdev } => {
210 put_attr_str(out, BTRFS_SEND_A_PATH, path)?;
211 put_attr_u64(out, BTRFS_SEND_A_MODE, *mode);
212 put_attr_u64(out, BTRFS_SEND_A_RDEV, *rdev);
213 Ok(BTRFS_SEND_C_MKNOD)
214 }
215 StreamCommand::Mkfifo { path } => {
216 path_only(out, path, BTRFS_SEND_C_MKFIFO)
217 }
218 StreamCommand::Mksock { path } => {
219 path_only(out, path, BTRFS_SEND_C_MKSOCK)
220 }
221 StreamCommand::Symlink { path, target } => {
222 put_attr_str(out, BTRFS_SEND_A_PATH, path)?;
223 put_attr_str(out, BTRFS_SEND_A_PATH_LINK, target)?;
224 Ok(BTRFS_SEND_C_SYMLINK)
225 }
226 StreamCommand::Rename { from, to } => {
227 put_attr_str(out, BTRFS_SEND_A_PATH, from)?;
228 put_attr_str(out, BTRFS_SEND_A_PATH_TO, to)?;
229 Ok(BTRFS_SEND_C_RENAME)
230 }
231 StreamCommand::Link { path, target } => {
232 put_attr_str(out, BTRFS_SEND_A_PATH, path)?;
233 put_attr_str(out, BTRFS_SEND_A_PATH_LINK, target)?;
234 Ok(BTRFS_SEND_C_LINK)
235 }
236 StreamCommand::Unlink { path } => {
237 path_only(out, path, BTRFS_SEND_C_UNLINK)
238 }
239 StreamCommand::Rmdir { path } => {
240 path_only(out, path, BTRFS_SEND_C_RMDIR)
241 }
242 StreamCommand::Write { path, offset, data } => {
243 put_attr_str(out, BTRFS_SEND_A_PATH, path)?;
244 put_attr_u64(out, BTRFS_SEND_A_FILE_OFFSET, *offset);
245 put_attr_data(out, data, version)?;
249 Ok(BTRFS_SEND_C_WRITE)
250 }
251 StreamCommand::Clone {
252 path,
253 offset,
254 len,
255 clone_uuid,
256 clone_ctransid,
257 clone_path,
258 clone_offset,
259 } => {
260 put_attr_str(out, BTRFS_SEND_A_PATH, path)?;
261 put_attr_u64(out, BTRFS_SEND_A_FILE_OFFSET, *offset);
262 put_attr_u64(out, BTRFS_SEND_A_CLONE_LEN, *len);
263 put_attr_uuid(out, BTRFS_SEND_A_CLONE_UUID, clone_uuid);
264 put_attr_u64(out, BTRFS_SEND_A_CLONE_CTRANSID, *clone_ctransid);
265 put_attr_str(out, BTRFS_SEND_A_CLONE_PATH, clone_path)?;
266 put_attr_u64(out, BTRFS_SEND_A_CLONE_OFFSET, *clone_offset);
267 Ok(BTRFS_SEND_C_CLONE)
268 }
269 StreamCommand::SetXattr { path, name, data } => {
270 put_attr_str(out, BTRFS_SEND_A_PATH, path)?;
271 put_attr_str(out, BTRFS_SEND_A_XATTR_NAME, name)?;
272 put_attr_bytes(out, BTRFS_SEND_A_XATTR_DATA, data)?;
273 Ok(BTRFS_SEND_C_SET_XATTR)
274 }
275 StreamCommand::RemoveXattr { path, name } => {
276 put_attr_str(out, BTRFS_SEND_A_PATH, path)?;
277 put_attr_str(out, BTRFS_SEND_A_XATTR_NAME, name)?;
278 Ok(BTRFS_SEND_C_REMOVE_XATTR)
279 }
280 StreamCommand::Truncate { path, size } => {
281 put_attr_str(out, BTRFS_SEND_A_PATH, path)?;
282 put_attr_u64(out, BTRFS_SEND_A_SIZE, *size);
283 Ok(BTRFS_SEND_C_TRUNCATE)
284 }
285 StreamCommand::Chmod { path, mode } => {
286 put_attr_str(out, BTRFS_SEND_A_PATH, path)?;
287 put_attr_u64(out, BTRFS_SEND_A_MODE, *mode);
288 Ok(BTRFS_SEND_C_CHMOD)
289 }
290 StreamCommand::Chown { path, uid, gid } => {
291 put_attr_str(out, BTRFS_SEND_A_PATH, path)?;
292 put_attr_u64(out, BTRFS_SEND_A_UID, *uid);
293 put_attr_u64(out, BTRFS_SEND_A_GID, *gid);
294 Ok(BTRFS_SEND_C_CHOWN)
295 }
296 StreamCommand::Utimes {
297 path,
298 atime,
299 mtime,
300 ctime,
301 } => {
302 put_attr_str(out, BTRFS_SEND_A_PATH, path)?;
303 put_attr_timespec(out, BTRFS_SEND_A_ATIME, atime);
304 put_attr_timespec(out, BTRFS_SEND_A_MTIME, mtime);
305 put_attr_timespec(out, BTRFS_SEND_A_CTIME, ctime);
306 Ok(BTRFS_SEND_C_UTIMES)
307 }
308 StreamCommand::UpdateExtent { path, offset, len } => {
309 put_attr_str(out, BTRFS_SEND_A_PATH, path)?;
310 put_attr_u64(out, BTRFS_SEND_A_FILE_OFFSET, *offset);
311 put_attr_u64(out, BTRFS_SEND_A_SIZE, *len);
312 Ok(BTRFS_SEND_C_UPDATE_EXTENT)
313 }
314 StreamCommand::EncodedWrite {
315 path,
316 offset,
317 unencoded_file_len,
318 unencoded_len,
319 unencoded_offset,
320 compression,
321 encryption,
322 data,
323 } => {
324 put_attr_str(out, BTRFS_SEND_A_PATH, path)?;
325 put_attr_u64(out, BTRFS_SEND_A_FILE_OFFSET, *offset);
326 put_attr_u64(
327 out,
328 BTRFS_SEND_A_UNENCODED_FILE_LEN,
329 *unencoded_file_len,
330 );
331 put_attr_u64(out, BTRFS_SEND_A_UNENCODED_LEN, *unencoded_len);
332 put_attr_u64(out, BTRFS_SEND_A_UNENCODED_OFFSET, *unencoded_offset);
333 put_attr_u32(out, BTRFS_SEND_A_COMPRESSION, *compression);
334 put_attr_u32(out, BTRFS_SEND_A_ENCRYPTION, *encryption);
335 put_attr_data(out, data, version)?;
336 Ok(BTRFS_SEND_C_ENCODED_WRITE)
337 }
338 StreamCommand::Fallocate {
339 path,
340 mode,
341 offset,
342 len,
343 } => {
344 put_attr_str(out, BTRFS_SEND_A_PATH, path)?;
345 put_attr_u32(out, BTRFS_SEND_A_FALLOCATE_MODE, *mode);
346 put_attr_u64(out, BTRFS_SEND_A_FILE_OFFSET, *offset);
347 put_attr_u64(out, BTRFS_SEND_A_SIZE, *len);
348 Ok(BTRFS_SEND_C_FALLOCATE)
349 }
350 StreamCommand::Fileattr { path, attr } => {
351 put_attr_str(out, BTRFS_SEND_A_PATH, path)?;
352 put_attr_u64(out, BTRFS_SEND_A_FILEATTR, *attr);
353 Ok(BTRFS_SEND_C_FILEATTR)
354 }
355 StreamCommand::EnableVerity {
356 path,
357 algorithm,
358 block_size,
359 salt,
360 sig,
361 } => {
362 put_attr_str(out, BTRFS_SEND_A_PATH, path)?;
363 put_attr_u8(out, BTRFS_SEND_A_VERITY_ALGORITHM, *algorithm);
364 put_attr_u32(out, BTRFS_SEND_A_VERITY_BLOCK_SIZE, *block_size);
365 put_attr_bytes(out, BTRFS_SEND_A_VERITY_SALT_DATA, salt)?;
366 put_attr_bytes(out, BTRFS_SEND_A_VERITY_SIG_DATA, sig)?;
367 Ok(BTRFS_SEND_C_ENABLE_VERITY)
368 }
369 StreamCommand::End => Ok(BTRFS_SEND_C_END),
370 }
371}
372
373fn path_only(out: &mut Vec<u8>, path: &str, cmd_id: u16) -> io::Result<u16> {
374 put_attr_str(out, BTRFS_SEND_A_PATH, path)?;
375 Ok(cmd_id)
376}
377
378fn put_attr_bytes(out: &mut Vec<u8>, attr: u16, data: &[u8]) -> io::Result<()> {
384 let len = u16::try_from(data.len()).map_err(|_| {
385 io::Error::new(
386 io::ErrorKind::InvalidInput,
387 format!(
388 "attribute {attr} payload exceeds u16 length field: {} bytes",
389 data.len(),
390 ),
391 )
392 })?;
393 out.extend_from_slice(&attr.to_le_bytes());
394 out.extend_from_slice(&len.to_le_bytes());
395 out.extend_from_slice(data);
396 Ok(())
397}
398
399fn put_attr_str(out: &mut Vec<u8>, attr: u16, s: &str) -> io::Result<()> {
400 put_attr_bytes(out, attr, s.as_bytes())
401}
402
403fn put_attr_data(
410 out: &mut Vec<u8>,
411 data: &[u8],
412 version: u32,
413) -> io::Result<()> {
414 if version >= 2 {
415 out.extend_from_slice(&BTRFS_SEND_A_DATA.to_le_bytes());
416 out.extend_from_slice(data);
417 Ok(())
418 } else {
419 put_attr_bytes(out, BTRFS_SEND_A_DATA, data)
420 }
421}
422
423fn put_attr_u64(out: &mut Vec<u8>, attr: u16, v: u64) {
424 put_attr_bytes(out, attr, &v.to_le_bytes())
425 .expect("8-byte payload always fits");
426}
427
428fn put_attr_u32(out: &mut Vec<u8>, attr: u16, v: u32) {
429 put_attr_bytes(out, attr, &v.to_le_bytes())
430 .expect("4-byte payload always fits");
431}
432
433fn put_attr_u8(out: &mut Vec<u8>, attr: u16, v: u8) {
434 put_attr_bytes(out, attr, &[v]).expect("1-byte payload always fits");
435}
436
437fn put_attr_uuid(out: &mut Vec<u8>, attr: u16, uuid: &Uuid) {
438 put_attr_bytes(out, attr, uuid.as_bytes())
439 .expect("16-byte UUID always fits");
440}
441
442fn put_attr_timespec(out: &mut Vec<u8>, attr: u16, t: &Timespec) {
443 let mut buf = [0u8; 12];
444 buf[0..8].copy_from_slice(&t.sec.to_le_bytes());
445 buf[8..12].copy_from_slice(&t.nsec.to_le_bytes());
446 put_attr_bytes(out, attr, &buf).expect("12-byte timespec always fits");
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452 use crate::StreamReader;
453
454 fn roundtrip(version: u32, cmds: &[StreamCommand]) {
460 let mut buf: Vec<u8> = Vec::new();
461 let mut writer = StreamWriter::new(&mut buf, version).unwrap();
462 for cmd in cmds {
463 writer.write_command(cmd).unwrap();
464 }
465 writer.finish().unwrap();
466
467 let mut reader = StreamReader::new(buf.as_slice()).unwrap();
468 assert_eq!(reader.version(), version);
469 for expected in cmds {
470 let got = reader.next_command().expect("parse").expect("not eof");
471 assert_eq!(format!("{got:?}"), format!("{expected:?}"));
472 }
473 assert!(reader.next_command().expect("eof check").is_none());
474 }
475
476 #[test]
477 fn header_only_roundtrips_each_version() {
478 for v in [1, 2, 3] {
479 let mut buf = Vec::new();
480 let writer = StreamWriter::new(&mut buf, v).unwrap();
481 writer.finish().unwrap();
482 let reader = StreamReader::new(buf.as_slice()).unwrap();
483 assert_eq!(reader.version(), v);
484 }
485 }
486
487 #[test]
488 fn invalid_versions_rejected() {
489 let mut buf = Vec::new();
490 assert!(StreamWriter::new(&mut buf, 0).is_err());
491 let mut buf = Vec::new();
492 assert!(StreamWriter::new(&mut buf, 4).is_err());
493 }
494
495 #[test]
496 fn subvol_and_snapshot_roundtrip() {
497 let uuid = Uuid::from_u128(0x0011_2233_4455_6677_8899_aabb_ccdd_eeff);
498 let clone_uuid =
499 Uuid::from_u128(0xdead_beef_dead_beef_dead_beef_dead_beef);
500 roundtrip(
501 1,
502 &[
503 StreamCommand::Subvol {
504 path: "snap1".into(),
505 uuid,
506 ctransid: 42,
507 },
508 StreamCommand::Snapshot {
509 path: "snap2".into(),
510 uuid,
511 ctransid: 100,
512 clone_uuid,
513 clone_ctransid: 50,
514 },
515 StreamCommand::End,
516 ],
517 );
518 }
519
520 #[test]
521 fn filesystem_object_creation_roundtrips() {
522 roundtrip(
523 1,
524 &[
525 StreamCommand::Mkfile {
526 path: "f.txt".into(),
527 },
528 StreamCommand::Mkdir { path: "d".into() },
529 StreamCommand::Mknod {
530 path: "n".into(),
531 mode: 0o600,
532 rdev: 0x102,
533 },
534 StreamCommand::Mkfifo { path: "p".into() },
535 StreamCommand::Mksock { path: "s".into() },
536 StreamCommand::Symlink {
537 path: "l".into(),
538 target: "f.txt".into(),
539 },
540 ],
541 );
542 }
543
544 #[test]
545 fn rename_link_unlink_rmdir_roundtrip() {
546 roundtrip(
547 1,
548 &[
549 StreamCommand::Rename {
550 from: "a".into(),
551 to: "b".into(),
552 },
553 StreamCommand::Link {
554 path: "alias".into(),
555 target: "real".into(),
556 },
557 StreamCommand::Unlink { path: "old".into() },
558 StreamCommand::Rmdir {
559 path: "empty".into(),
560 },
561 ],
562 );
563 }
564
565 #[test]
566 fn write_clone_truncate_roundtrip() {
567 let clone_uuid =
568 Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef);
569 roundtrip(
570 1,
571 &[
572 StreamCommand::Write {
573 path: "f".into(),
574 offset: 4096,
575 data: vec![0x42; 1024],
576 },
577 StreamCommand::Clone {
578 path: "dst".into(),
579 offset: 8192,
580 len: 4096,
581 clone_uuid,
582 clone_ctransid: 7,
583 clone_path: "src".into(),
584 clone_offset: 0,
585 },
586 StreamCommand::Truncate {
587 path: "f".into(),
588 size: 65536,
589 },
590 ],
591 );
592 }
593
594 #[test]
595 fn xattr_perms_times_roundtrip() {
596 roundtrip(
597 1,
598 &[
599 StreamCommand::SetXattr {
600 path: "f".into(),
601 name: "user.greeting".into(),
602 data: b"hello".to_vec(),
603 },
604 StreamCommand::RemoveXattr {
605 path: "f".into(),
606 name: "user.gone".into(),
607 },
608 StreamCommand::Chmod {
609 path: "f".into(),
610 mode: 0o644,
611 },
612 StreamCommand::Chown {
613 path: "f".into(),
614 uid: 1000,
615 gid: 1001,
616 },
617 StreamCommand::Utimes {
618 path: "f".into(),
619 atime: Timespec { sec: 100, nsec: 1 },
620 mtime: Timespec { sec: 200, nsec: 2 },
621 ctime: Timespec { sec: 300, nsec: 3 },
622 },
623 StreamCommand::UpdateExtent {
624 path: "f".into(),
625 offset: 0,
626 len: 4096,
627 },
628 ],
629 );
630 }
631
632 #[test]
633 fn v2_commands_roundtrip() {
634 roundtrip(
635 2,
636 &[
637 StreamCommand::EncodedWrite {
638 path: "f".into(),
639 offset: 0,
640 unencoded_file_len: 4096,
641 unencoded_len: 4096,
642 unencoded_offset: 0,
643 compression: 2, encryption: 0,
645 data: vec![0xab; 256],
646 },
647 StreamCommand::Fallocate {
648 path: "f".into(),
649 mode: 3, offset: 4096,
651 len: 4096,
652 },
653 StreamCommand::Fileattr {
654 path: "f".into(),
655 attr: 0x40, },
657 ],
658 );
659 }
660
661 #[test]
662 fn v3_enable_verity_roundtrips() {
663 roundtrip(
664 3,
665 &[StreamCommand::EnableVerity {
666 path: "f".into(),
667 algorithm: 1,
668 block_size: 4096,
669 salt: vec![1, 2, 3, 4],
670 sig: vec![],
671 }],
672 );
673 }
674
675 #[test]
676 fn end_command_roundtrips() {
677 roundtrip(1, &[StreamCommand::End]);
678 }
679
680 #[test]
681 fn corrupted_payload_fails_crc_check() {
682 let mut buf = Vec::new();
683 let mut writer = StreamWriter::new(&mut buf, 1).unwrap();
684 writer
685 .write_command(&StreamCommand::Mkfile { path: "f".into() })
686 .unwrap();
687 writer.finish().unwrap();
688
689 let payload_start = STREAM_HEADER_LEN + CMD_HEADER_LEN;
692 buf[payload_start + 4] ^= 0x01;
693
694 let mut reader = StreamReader::new(buf.as_slice()).unwrap();
695 let err = reader.next_command().unwrap_err();
696 assert!(
697 matches!(err, crate::StreamError::CrcMismatch { .. }),
698 "expected CrcMismatch, got {err:?}",
699 );
700 }
701}