Skip to main content

audit_trail/sinks/
file.rs

1//! Append-only file-backed [`Sink`]. Requires the `std` feature.
2
3use alloc::vec::Vec;
4use std::fs::{File, OpenOptions};
5use std::io::{self, BufWriter, Read, Seek, SeekFrom, Write};
6use std::path::Path;
7
8use crate::codec;
9use crate::error::SinkError;
10use crate::record::Record;
11use crate::sink::Sink;
12
13/// Append-only file-backed [`Sink`].
14///
15/// Wraps any `W: io::Write` and serialises records through the stable
16/// [`crate::codec`]. The format header is written exactly once, by
17/// [`FileSink::open_or_create`] when the target file is empty.
18///
19/// Writes are not auto-flushed. Call [`FileSink::flush`] (or wrap the
20/// inner writer in a `BufWriter` and rely on `Drop`) at appropriate
21/// checkpoints — a typical pattern is flushing after every append or
22/// after every batch.
23pub struct FileSink<W: Write> {
24    writer: W,
25    scratch: Vec<u8>,
26}
27
28impl FileSink<BufWriter<File>> {
29    /// Open `path` for append-only audit writes, creating it if absent.
30    ///
31    /// On a freshly-created file this writes the format header. On an
32    /// existing file this validates the header and positions the writer
33    /// at end-of-file. The underlying [`File`] is wrapped in a
34    /// [`BufWriter`].
35    ///
36    /// # Errors
37    ///
38    /// Surface I/O errors from opening, validating, or seeking the file.
39    /// A bad existing header is reported as
40    /// [`io::ErrorKind::InvalidData`].
41    pub fn open_or_create(path: impl AsRef<Path>) -> io::Result<Self> {
42        let mut file = OpenOptions::new()
43            .read(true)
44            .write(true)
45            .create(true)
46            .truncate(false)
47            .open(path)?;
48
49        let len = file.seek(SeekFrom::End(0))?;
50        if len == 0 {
51            // Brand-new file — write the format header.
52            let _ = file.seek(SeekFrom::Start(0))?;
53            let mut header = Vec::with_capacity(codec::FILE_HEADER_LEN);
54            codec::write_file_header(&mut header);
55            file.write_all(&header)?;
56            let _ = file.seek(SeekFrom::End(0))?;
57        } else {
58            // Existing file — validate the header.
59            let _ = file.seek(SeekFrom::Start(0))?;
60            let mut header = [0u8; codec::FILE_HEADER_LEN];
61            file.read_exact(&mut header)
62                .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "missing audit header"))?;
63            codec::verify_file_header(&header)
64                .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid audit header"))?;
65            let _ = file.seek(SeekFrom::End(0))?;
66        }
67
68        Ok(Self::new(BufWriter::new(file)))
69    }
70}
71
72impl<W: Write> FileSink<W> {
73    /// Wrap an existing writer that is already positioned correctly
74    /// (header already written, ready to append records).
75    ///
76    /// Most callers want [`FileSink::open_or_create`] instead.
77    #[inline]
78    pub fn new(writer: W) -> Self {
79        Self {
80            writer,
81            scratch: Vec::with_capacity(256),
82        }
83    }
84
85    /// Flush the underlying writer.
86    ///
87    /// # Errors
88    ///
89    /// Surfaces the writer's I/O error verbatim.
90    #[inline]
91    pub fn flush(&mut self) -> io::Result<()> {
92        self.writer.flush()
93    }
94
95    /// Consume the sink and return the underlying writer.
96    #[inline]
97    pub fn into_writer(self) -> W {
98        self.writer
99    }
100}
101
102impl<W: Write> Sink for FileSink<W> {
103    fn write(&mut self, record: &Record<'_>) -> core::result::Result<(), SinkError> {
104        self.scratch.clear();
105        codec::encode_record(record, &mut self.scratch).map_err(|_| SinkError::Other)?;
106        self.writer
107            .write_all(&self.scratch)
108            .map_err(|_| SinkError::Io)?;
109        Ok(())
110    }
111}