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}