Skip to main content

bacnet_services/
file.rs

1//! AtomicReadFile / AtomicWriteFile services per ASHRAE 135-2020 Clauses 15.1–15.2.
2
3use bacnet_encoding::{primitives, tags};
4use bacnet_types::error::Error;
5use bacnet_types::primitives::ObjectIdentifier;
6use bytes::BytesMut;
7
8use crate::common::MAX_DECODED_ITEMS;
9
10/// Decode a tag and validate the resulting slice bounds.
11fn checked_slice<'a>(
12    content: &'a [u8],
13    offset: usize,
14    context: &str,
15) -> Result<(&'a [u8], usize), Error> {
16    let (t, p) = tags::decode_tag(content, offset)?;
17    let end = p + t.length as usize;
18    if end > content.len() {
19        return Err(Error::decoding(p, format!("{context} truncated")));
20    }
21    Ok((&content[p..end], end))
22}
23
24// ---------------------------------------------------------------------------
25// AtomicReadFile-Request
26// ---------------------------------------------------------------------------
27
28/// AtomicReadFile-Request — stream or record access.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct AtomicReadFileRequest {
31    pub file_identifier: ObjectIdentifier,
32    pub access: FileAccessMethod,
33}
34
35/// AtomicWriteFile-Request.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct AtomicWriteFileRequest {
38    pub file_identifier: ObjectIdentifier,
39    pub access: FileWriteAccessMethod,
40}
41
42/// File access method for reads.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum FileAccessMethod {
45    /// Stream access: file_start_position, requested_octet_count.
46    Stream {
47        file_start_position: i32,
48        requested_octet_count: u32,
49    },
50    /// Record access: file_start_record, requested_record_count.
51    Record {
52        file_start_record: i32,
53        requested_record_count: u32,
54    },
55}
56
57/// File access method for writes.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum FileWriteAccessMethod {
60    /// Stream access: file_start_position, file_data.
61    Stream {
62        file_start_position: i32,
63        file_data: Vec<u8>,
64    },
65    /// Record access: file_start_record, record_count, file_record_data.
66    Record {
67        file_start_record: i32,
68        record_count: u32,
69        file_record_data: Vec<Vec<u8>>,
70    },
71}
72
73impl AtomicReadFileRequest {
74    pub fn encode(&self, buf: &mut BytesMut) {
75        primitives::encode_app_object_id(buf, &self.file_identifier);
76        match &self.access {
77            FileAccessMethod::Stream {
78                file_start_position,
79                requested_octet_count,
80            } => {
81                tags::encode_opening_tag(buf, 0);
82                primitives::encode_app_signed(buf, *file_start_position);
83                primitives::encode_app_unsigned(buf, *requested_octet_count as u64);
84                tags::encode_closing_tag(buf, 0);
85            }
86            FileAccessMethod::Record {
87                file_start_record,
88                requested_record_count,
89            } => {
90                tags::encode_opening_tag(buf, 1);
91                primitives::encode_app_signed(buf, *file_start_record);
92                primitives::encode_app_unsigned(buf, *requested_record_count as u64);
93                tags::encode_closing_tag(buf, 1);
94            }
95        }
96    }
97
98    pub fn decode(data: &[u8]) -> Result<Self, Error> {
99        let mut offset = 0;
100
101        let (tag, pos) = tags::decode_tag(data, offset)?;
102        let end = pos + tag.length as usize;
103        if end > data.len() {
104            return Err(Error::buffer_too_short(end, data.len()));
105        }
106        let file_identifier = ObjectIdentifier::decode(&data[pos..end])?;
107        offset = end;
108
109        let (tag, tag_end) = tags::decode_tag(data, offset)?;
110        let access = if tag.is_opening_tag(0) {
111            let (content, _) = tags::extract_context_value(data, tag_end, 0)?;
112            let (slice, inner) =
113                checked_slice(content, 0, "AtomicReadFile stream file-start-position")?;
114            let file_start_position = primitives::decode_signed(slice)?;
115            let (slice, _) = checked_slice(
116                content,
117                inner,
118                "AtomicReadFile stream requested-octet-count",
119            )?;
120            let requested_octet_count = primitives::decode_unsigned(slice)? as u32;
121            FileAccessMethod::Stream {
122                file_start_position,
123                requested_octet_count,
124            }
125        } else if tag.is_opening_tag(1) {
126            let (content, _) = tags::extract_context_value(data, tag_end, 1)?;
127            let (slice, inner) =
128                checked_slice(content, 0, "AtomicReadFile record file-start-record")?;
129            let file_start_record = primitives::decode_signed(slice)?;
130            let (slice, _) = checked_slice(
131                content,
132                inner,
133                "AtomicReadFile record requested-record-count",
134            )?;
135            let requested_record_count = primitives::decode_unsigned(slice)? as u32;
136            FileAccessMethod::Record {
137                file_start_record,
138                requested_record_count,
139            }
140        } else {
141            return Err(Error::decoding(offset, "Unknown file access method"));
142        };
143
144        Ok(Self {
145            file_identifier,
146            access,
147        })
148    }
149}
150
151impl AtomicWriteFileRequest {
152    pub fn encode(&self, buf: &mut BytesMut) {
153        primitives::encode_app_object_id(buf, &self.file_identifier);
154        match &self.access {
155            FileWriteAccessMethod::Stream {
156                file_start_position,
157                file_data,
158            } => {
159                tags::encode_opening_tag(buf, 0);
160                primitives::encode_app_signed(buf, *file_start_position);
161                primitives::encode_app_octet_string(buf, file_data);
162                tags::encode_closing_tag(buf, 0);
163            }
164            FileWriteAccessMethod::Record {
165                file_start_record,
166                record_count,
167                file_record_data,
168            } => {
169                tags::encode_opening_tag(buf, 1);
170                primitives::encode_app_signed(buf, *file_start_record);
171                primitives::encode_app_unsigned(buf, *record_count as u64);
172                for record in file_record_data {
173                    primitives::encode_app_octet_string(buf, record);
174                }
175                tags::encode_closing_tag(buf, 1);
176            }
177        }
178    }
179
180    pub fn decode(data: &[u8]) -> Result<Self, Error> {
181        let mut offset = 0;
182
183        let (tag, pos) = tags::decode_tag(data, offset)?;
184        let end = pos + tag.length as usize;
185        if end > data.len() {
186            return Err(Error::buffer_too_short(end, data.len()));
187        }
188        let file_identifier = ObjectIdentifier::decode(&data[pos..end])?;
189        offset = end;
190
191        let (tag, tag_end) = tags::decode_tag(data, offset)?;
192        let access = if tag.is_opening_tag(0) {
193            let (content, _) = tags::extract_context_value(data, tag_end, 0)?;
194            let (slice, inner) =
195                checked_slice(content, 0, "AtomicWriteFile stream file-start-position")?;
196            let file_start_position = primitives::decode_signed(slice)?;
197            let (slice, _) = checked_slice(content, inner, "AtomicWriteFile stream file-data")?;
198            let file_data = slice.to_vec();
199            FileWriteAccessMethod::Stream {
200                file_start_position,
201                file_data,
202            }
203        } else if tag.is_opening_tag(1) {
204            let (content, _) = tags::extract_context_value(data, tag_end, 1)?;
205            let (slice, mut inner) =
206                checked_slice(content, 0, "AtomicWriteFile record file-start-record")?;
207            let file_start_record = primitives::decode_signed(slice)?;
208            let (slice, new_inner) =
209                checked_slice(content, inner, "AtomicWriteFile record record-count")?;
210            let record_count = primitives::decode_unsigned(slice)? as u32;
211            inner = new_inner;
212            if record_count as usize > MAX_DECODED_ITEMS {
213                return Err(Error::decoding(0, "record count exceeds maximum"));
214            }
215            let mut file_record_data = Vec::new();
216            for i in 0..record_count {
217                if inner >= content.len() {
218                    break;
219                }
220                let (slice, new_inner) =
221                    checked_slice(content, inner, &format!("AtomicWriteFile record data[{i}]"))?;
222                file_record_data.push(slice.to_vec());
223                inner = new_inner;
224            }
225            FileWriteAccessMethod::Record {
226                file_start_record,
227                record_count,
228                file_record_data,
229            }
230        } else {
231            return Err(Error::decoding(offset, "Unknown file write access method"));
232        };
233
234        Ok(Self {
235            file_identifier,
236            access,
237        })
238    }
239}
240
241// ---------------------------------------------------------------------------
242// AtomicReadFile-ACK
243// ---------------------------------------------------------------------------
244
245/// AtomicReadFile-ACK — response for stream or record access.
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct AtomicReadFileAck {
248    pub end_of_file: bool,
249    pub access: FileReadAckMethod,
250}
251
252/// Read-ACK access method.
253#[derive(Debug, Clone, PartialEq, Eq)]
254pub enum FileReadAckMethod {
255    /// Stream access: file_start_position + returned data.
256    Stream {
257        file_start_position: i32,
258        file_data: Vec<u8>,
259    },
260    /// Record access: file_start_record + returned records.
261    Record {
262        file_start_record: i32,
263        returned_record_count: u32,
264        file_record_data: Vec<Vec<u8>>,
265    },
266}
267
268impl AtomicReadFileAck {
269    pub fn encode(&self, buf: &mut BytesMut) {
270        primitives::encode_app_boolean(buf, self.end_of_file);
271        match &self.access {
272            FileReadAckMethod::Stream {
273                file_start_position,
274                file_data,
275            } => {
276                tags::encode_opening_tag(buf, 0);
277                primitives::encode_app_signed(buf, *file_start_position);
278                primitives::encode_app_octet_string(buf, file_data);
279                tags::encode_closing_tag(buf, 0);
280            }
281            FileReadAckMethod::Record {
282                file_start_record,
283                returned_record_count,
284                file_record_data,
285            } => {
286                tags::encode_opening_tag(buf, 1);
287                primitives::encode_app_signed(buf, *file_start_record);
288                primitives::encode_app_unsigned(buf, *returned_record_count as u64);
289                for record in file_record_data {
290                    primitives::encode_app_octet_string(buf, record);
291                }
292                tags::encode_closing_tag(buf, 1);
293            }
294        }
295    }
296
297    pub fn decode(data: &[u8]) -> Result<Self, Error> {
298        let mut offset = 0;
299
300        let (tag, pos) = tags::decode_tag(data, offset)?;
301        let end_of_file = tag.length != 0;
302        offset = pos;
303
304        let (tag, tag_end) = tags::decode_tag(data, offset)?;
305        let access = if tag.is_opening_tag(0) {
306            let (content, _) = tags::extract_context_value(data, tag_end, 0)?;
307            let (slice, inner) =
308                checked_slice(content, 0, "AtomicReadFileAck stream file-start-position")?;
309            let file_start_position = primitives::decode_signed(slice)?;
310            let (slice, _) = checked_slice(content, inner, "AtomicReadFileAck stream file-data")?;
311            let file_data = slice.to_vec();
312            FileReadAckMethod::Stream {
313                file_start_position,
314                file_data,
315            }
316        } else if tag.is_opening_tag(1) {
317            let (content, _) = tags::extract_context_value(data, tag_end, 1)?;
318            let (slice, mut inner) =
319                checked_slice(content, 0, "AtomicReadFileAck record file-start-record")?;
320            let file_start_record = primitives::decode_signed(slice)?;
321            let (slice, new_inner) = checked_slice(
322                content,
323                inner,
324                "AtomicReadFileAck record returned-record-count",
325            )?;
326            let returned_record_count = primitives::decode_unsigned(slice)? as u32;
327            inner = new_inner;
328            if returned_record_count as usize > MAX_DECODED_ITEMS {
329                return Err(Error::decoding(0, "record count exceeds maximum"));
330            }
331            let mut file_record_data = Vec::new();
332            for i in 0..returned_record_count {
333                if inner >= content.len() {
334                    break;
335                }
336                let (slice, new_inner) = checked_slice(
337                    content,
338                    inner,
339                    &format!("AtomicReadFileAck record data[{i}]"),
340                )?;
341                file_record_data.push(slice.to_vec());
342                inner = new_inner;
343            }
344            FileReadAckMethod::Record {
345                file_start_record,
346                returned_record_count,
347                file_record_data,
348            }
349        } else {
350            return Err(Error::decoding(
351                offset,
352                "Unknown read file ACK access method",
353            ));
354        };
355
356        Ok(Self {
357            end_of_file,
358            access,
359        })
360    }
361}
362
363// ---------------------------------------------------------------------------
364// AtomicWriteFile-ACK
365// ---------------------------------------------------------------------------
366
367/// AtomicWriteFile-ACK — response for stream or record access.
368#[derive(Debug, Clone, PartialEq, Eq)]
369pub struct AtomicWriteFileAck {
370    pub access: FileWriteAckMethod,
371}
372
373/// Write-ACK access method.
374#[derive(Debug, Clone, PartialEq, Eq)]
375pub enum FileWriteAckMethod {
376    /// Stream: confirmed file_start_position.
377    Stream { file_start_position: i32 },
378    /// Record: confirmed file_start_record.
379    Record { file_start_record: i32 },
380}
381
382impl AtomicWriteFileAck {
383    pub fn encode(&self, buf: &mut BytesMut) {
384        match &self.access {
385            FileWriteAckMethod::Stream {
386                file_start_position,
387            } => {
388                primitives::encode_ctx_signed(buf, 0, *file_start_position);
389            }
390            FileWriteAckMethod::Record { file_start_record } => {
391                primitives::encode_ctx_signed(buf, 1, *file_start_record);
392            }
393        }
394    }
395
396    pub fn decode(data: &[u8]) -> Result<Self, Error> {
397        let (tag, pos) = tags::decode_tag(data, 0)?;
398        let end = pos + tag.length as usize;
399        if end > data.len() {
400            return Err(Error::buffer_too_short(end, data.len()));
401        }
402        let access = if tag.is_context(0) {
403            let file_start_position = primitives::decode_signed(&data[pos..end])?;
404            FileWriteAckMethod::Stream {
405                file_start_position,
406            }
407        } else if tag.is_context(1) {
408            let file_start_record = primitives::decode_signed(&data[pos..end])?;
409            FileWriteAckMethod::Record { file_start_record }
410        } else {
411            return Err(Error::decoding(0, "Unknown write file ACK access method"));
412        };
413
414        Ok(Self { access })
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use bacnet_types::enums::ObjectType;
422
423    fn file_oid() -> ObjectIdentifier {
424        ObjectIdentifier::new(ObjectType::FILE, 1).unwrap()
425    }
426
427    #[test]
428    fn atomic_read_stream_round_trip() {
429        let req = AtomicReadFileRequest {
430            file_identifier: file_oid(),
431            access: FileAccessMethod::Stream {
432                file_start_position: 0,
433                requested_octet_count: 1024,
434            },
435        };
436        let mut buf = BytesMut::new();
437        req.encode(&mut buf);
438        let decoded = AtomicReadFileRequest::decode(&buf).unwrap();
439        assert_eq!(decoded, req);
440    }
441
442    #[test]
443    fn atomic_read_record_round_trip() {
444        let req = AtomicReadFileRequest {
445            file_identifier: file_oid(),
446            access: FileAccessMethod::Record {
447                file_start_record: 5,
448                requested_record_count: 10,
449            },
450        };
451        let mut buf = BytesMut::new();
452        req.encode(&mut buf);
453        let decoded = AtomicReadFileRequest::decode(&buf).unwrap();
454        assert_eq!(decoded, req);
455    }
456
457    #[test]
458    fn atomic_write_stream_round_trip() {
459        let req = AtomicWriteFileRequest {
460            file_identifier: file_oid(),
461            access: FileWriteAccessMethod::Stream {
462                file_start_position: 100,
463                file_data: vec![0x01, 0x02, 0x03, 0x04],
464            },
465        };
466        let mut buf = BytesMut::new();
467        req.encode(&mut buf);
468        let decoded = AtomicWriteFileRequest::decode(&buf).unwrap();
469        assert_eq!(decoded, req);
470    }
471
472    #[test]
473    fn atomic_write_record_round_trip() {
474        let req = AtomicWriteFileRequest {
475            file_identifier: file_oid(),
476            access: FileWriteAccessMethod::Record {
477                file_start_record: 0,
478                record_count: 2,
479                file_record_data: vec![vec![0xAA, 0xBB], vec![0xCC, 0xDD]],
480            },
481        };
482        let mut buf = BytesMut::new();
483        req.encode(&mut buf);
484        let decoded = AtomicWriteFileRequest::decode(&buf).unwrap();
485        assert_eq!(decoded, req);
486    }
487
488    // -----------------------------------------------------------------------
489    // Malformed-input decode error tests
490    // -----------------------------------------------------------------------
491
492    #[test]
493    fn test_decode_atomic_read_file_empty_input() {
494        assert!(AtomicReadFileRequest::decode(&[]).is_err());
495    }
496
497    #[test]
498    fn test_decode_atomic_read_file_truncated_1_byte() {
499        let req = AtomicReadFileRequest {
500            file_identifier: file_oid(),
501            access: FileAccessMethod::Stream {
502                file_start_position: 0,
503                requested_octet_count: 1024,
504            },
505        };
506        let mut buf = BytesMut::new();
507        req.encode(&mut buf);
508        assert!(AtomicReadFileRequest::decode(&buf[..1]).is_err());
509    }
510
511    #[test]
512    fn test_decode_atomic_read_file_truncated_3_bytes() {
513        let req = AtomicReadFileRequest {
514            file_identifier: file_oid(),
515            access: FileAccessMethod::Stream {
516                file_start_position: 0,
517                requested_octet_count: 1024,
518            },
519        };
520        let mut buf = BytesMut::new();
521        req.encode(&mut buf);
522        assert!(AtomicReadFileRequest::decode(&buf[..3]).is_err());
523    }
524
525    #[test]
526    fn test_decode_atomic_read_file_truncated_half() {
527        let req = AtomicReadFileRequest {
528            file_identifier: file_oid(),
529            access: FileAccessMethod::Stream {
530                file_start_position: 0,
531                requested_octet_count: 1024,
532            },
533        };
534        let mut buf = BytesMut::new();
535        req.encode(&mut buf);
536        let half = buf.len() / 2;
537        assert!(AtomicReadFileRequest::decode(&buf[..half]).is_err());
538    }
539
540    #[test]
541    fn test_decode_atomic_read_file_invalid_tag() {
542        assert!(AtomicReadFileRequest::decode(&[0xFF, 0xFF, 0xFF]).is_err());
543    }
544
545    #[test]
546    fn test_decode_atomic_write_file_empty_input() {
547        assert!(AtomicWriteFileRequest::decode(&[]).is_err());
548    }
549
550    #[test]
551    fn test_decode_atomic_write_file_truncated_1_byte() {
552        let req = AtomicWriteFileRequest {
553            file_identifier: file_oid(),
554            access: FileWriteAccessMethod::Stream {
555                file_start_position: 100,
556                file_data: vec![0x01, 0x02, 0x03, 0x04],
557            },
558        };
559        let mut buf = BytesMut::new();
560        req.encode(&mut buf);
561        assert!(AtomicWriteFileRequest::decode(&buf[..1]).is_err());
562    }
563
564    #[test]
565    fn test_decode_atomic_write_file_truncated_3_bytes() {
566        let req = AtomicWriteFileRequest {
567            file_identifier: file_oid(),
568            access: FileWriteAccessMethod::Stream {
569                file_start_position: 100,
570                file_data: vec![0x01, 0x02, 0x03, 0x04],
571            },
572        };
573        let mut buf = BytesMut::new();
574        req.encode(&mut buf);
575        assert!(AtomicWriteFileRequest::decode(&buf[..3]).is_err());
576    }
577
578    #[test]
579    fn test_decode_atomic_write_file_truncated_half() {
580        let req = AtomicWriteFileRequest {
581            file_identifier: file_oid(),
582            access: FileWriteAccessMethod::Stream {
583                file_start_position: 100,
584                file_data: vec![0x01, 0x02, 0x03, 0x04],
585            },
586        };
587        let mut buf = BytesMut::new();
588        req.encode(&mut buf);
589        let half = buf.len() / 2;
590        assert!(AtomicWriteFileRequest::decode(&buf[..half]).is_err());
591    }
592
593    #[test]
594    fn test_decode_atomic_write_file_invalid_tag() {
595        assert!(AtomicWriteFileRequest::decode(&[0xFF, 0xFF, 0xFF]).is_err());
596    }
597
598    #[test]
599    fn atomic_read_file_request_truncated_inner_tag() {
600        // Craft a packet where extract_context_value succeeds but inner tag within
601        // the content claims more bytes than the content slice contains.
602        // Application signed tag (tag 3), lvt=5 (extended length), length=50 → only 2 bytes present.
603        let data = [
604            0xC4, 0x02, 0x80, 0x00, 0x01, // object identifier (FILE:1)
605            // Opening tag [0]
606            0x0E,
607            // App signed tag (tag 3=0x30), extended len (lvt=5 → 0x05): 0x35, len byte: 50
608            0x35, 50, // Only 2 data bytes instead of 50
609            0x01, 0x02, // Closing tag [0]
610            0x0F,
611        ];
612        assert!(AtomicReadFileRequest::decode(&data).is_err());
613    }
614
615    #[test]
616    fn atomic_write_file_request_truncated_inner_tag() {
617        // Same technique for AtomicWriteFile: inner tag claims too many bytes
618        let data = [
619            0xC4, 0x02, 0x80, 0x00, 0x01, // object identifier (FILE:1)
620            // Opening tag [0]
621            0x0E, // App signed tag, extended len, claims 80 bytes
622            0x35, 80,   // Only 1 data byte instead of 80
623            0x01, // Closing tag [0]
624            0x0F,
625        ];
626        assert!(AtomicWriteFileRequest::decode(&data).is_err());
627    }
628
629    // -----------------------------------------------------------------------
630    // AtomicReadFile-ACK round-trip tests
631    // -----------------------------------------------------------------------
632
633    #[test]
634    fn atomic_read_file_ack_stream_round_trip() {
635        let ack = AtomicReadFileAck {
636            end_of_file: false,
637            access: FileReadAckMethod::Stream {
638                file_start_position: 0,
639                file_data: vec![0x48, 0x65, 0x6C, 0x6C, 0x6F],
640            },
641        };
642        let mut buf = BytesMut::new();
643        ack.encode(&mut buf);
644        let decoded = AtomicReadFileAck::decode(&buf).unwrap();
645        assert_eq!(decoded, ack);
646    }
647
648    #[test]
649    fn atomic_read_file_ack_stream_eof_true() {
650        let ack = AtomicReadFileAck {
651            end_of_file: true,
652            access: FileReadAckMethod::Stream {
653                file_start_position: 512,
654                file_data: vec![0xDE, 0xAD],
655            },
656        };
657        let mut buf = BytesMut::new();
658        ack.encode(&mut buf);
659        let decoded = AtomicReadFileAck::decode(&buf).unwrap();
660        assert_eq!(decoded, ack);
661    }
662
663    #[test]
664    fn atomic_read_file_ack_record_round_trip() {
665        let ack = AtomicReadFileAck {
666            end_of_file: true,
667            access: FileReadAckMethod::Record {
668                file_start_record: 5,
669                returned_record_count: 2,
670                file_record_data: vec![vec![0xAA, 0xBB], vec![0xCC, 0xDD, 0xEE]],
671            },
672        };
673        let mut buf = BytesMut::new();
674        ack.encode(&mut buf);
675        let decoded = AtomicReadFileAck::decode(&buf).unwrap();
676        assert_eq!(decoded, ack);
677    }
678
679    #[test]
680    fn atomic_read_file_ack_record_empty() {
681        let ack = AtomicReadFileAck {
682            end_of_file: true,
683            access: FileReadAckMethod::Record {
684                file_start_record: 0,
685                returned_record_count: 0,
686                file_record_data: vec![],
687            },
688        };
689        let mut buf = BytesMut::new();
690        ack.encode(&mut buf);
691        let decoded = AtomicReadFileAck::decode(&buf).unwrap();
692        assert_eq!(decoded, ack);
693    }
694
695    // -----------------------------------------------------------------------
696    // AtomicWriteFile-ACK round-trip tests
697    // -----------------------------------------------------------------------
698
699    #[test]
700    fn atomic_write_file_ack_stream_round_trip() {
701        let ack = AtomicWriteFileAck {
702            access: FileWriteAckMethod::Stream {
703                file_start_position: 100,
704            },
705        };
706        let mut buf = BytesMut::new();
707        ack.encode(&mut buf);
708        let decoded = AtomicWriteFileAck::decode(&buf).unwrap();
709        assert_eq!(decoded, ack);
710    }
711
712    #[test]
713    fn atomic_write_file_ack_stream_negative_position() {
714        let ack = AtomicWriteFileAck {
715            access: FileWriteAckMethod::Stream {
716                file_start_position: -1,
717            },
718        };
719        let mut buf = BytesMut::new();
720        ack.encode(&mut buf);
721        let decoded = AtomicWriteFileAck::decode(&buf).unwrap();
722        assert_eq!(decoded, ack);
723    }
724
725    #[test]
726    fn atomic_write_file_ack_record_round_trip() {
727        let ack = AtomicWriteFileAck {
728            access: FileWriteAckMethod::Record {
729                file_start_record: 42,
730            },
731        };
732        let mut buf = BytesMut::new();
733        ack.encode(&mut buf);
734        let decoded = AtomicWriteFileAck::decode(&buf).unwrap();
735        assert_eq!(decoded, ack);
736    }
737
738    // -----------------------------------------------------------------------
739    // ACK truncated-input error tests
740    // -----------------------------------------------------------------------
741
742    #[test]
743    fn test_decode_read_file_ack_empty_input() {
744        assert!(AtomicReadFileAck::decode(&[]).is_err());
745    }
746
747    #[test]
748    fn test_decode_read_file_ack_truncated() {
749        let ack = AtomicReadFileAck {
750            end_of_file: false,
751            access: FileReadAckMethod::Stream {
752                file_start_position: 0,
753                file_data: vec![0x01, 0x02, 0x03],
754            },
755        };
756        let mut buf = BytesMut::new();
757        ack.encode(&mut buf);
758        // Only boolean tag — missing access method
759        assert!(AtomicReadFileAck::decode(&buf[..1]).is_err());
760        // Half the payload
761        let half = buf.len() / 2;
762        assert!(AtomicReadFileAck::decode(&buf[..half]).is_err());
763    }
764
765    #[test]
766    fn test_decode_write_file_ack_empty_input() {
767        assert!(AtomicWriteFileAck::decode(&[]).is_err());
768    }
769
770    #[test]
771    fn test_decode_write_file_ack_truncated() {
772        let ack = AtomicWriteFileAck {
773            access: FileWriteAckMethod::Stream {
774                file_start_position: 100,
775            },
776        };
777        let mut buf = BytesMut::new();
778        ack.encode(&mut buf);
779        // Just the tag byte, no value
780        if buf.len() > 1 {
781            assert!(AtomicWriteFileAck::decode(&buf[..1]).is_err());
782        }
783    }
784}