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