Skip to main content

log_io/sink/
file.rs

1//! Buffered file sink.
2
3use std::fs::File;
4use std::io::{BufWriter, Write};
5use std::path::Path;
6use std::sync::Mutex;
7
8use super::{CachePadded, Sink, lock_recover, open_log_file, write_locked};
9use crate::error::{Error, Result};
10use crate::format::Format;
11use crate::record::Record;
12
13/// Default size of the write buffer in front of the file.
14const DEFAULT_BUFFER: usize = 8 * 1024;
15
16/// Writes records to a file through a write buffer.
17///
18/// # Durability
19///
20/// Records are buffered in memory (8 KiB by default) and reach the
21/// operating system when the buffer fills or the sink is flushed. A
22/// flush happens:
23///
24/// * after every record at or above the logger's flush level (by
25///   default `Level::Error`, see `LoggerBuilder::flush_on`);
26/// * on [`crate::Logger::flush`] and [`crate::Logger::shutdown`];
27/// * when a [`crate::FlushGuard`] is dropped;
28/// * when the last clone of the [`crate::Logger`] is dropped.
29///
30/// Anything still buffered is **lost** if the process ends without
31/// one of those happening: `std::process::abort`, `panic = "abort"`
32/// without [`crate::global::install_panic_hook`], a fatal signal,
33/// `std::process::exit` (which skips destructors), or simply returning
34/// from `main` while the logger lives in a `static` (statics are never
35/// dropped). Keep the [`crate::FlushGuard`] returned by
36/// [`crate::init`] alive in `main`, call [`crate::Logger::shutdown`]
37/// before `process::exit`, or build the sink with
38/// [`FileSinkBuilder::buffer_capacity`]`(0)` to write every record
39/// through immediately.
40///
41/// Flushing hands data to the OS, which survives a crash of this
42/// process but not a power loss or kernel crash. Enable
43/// [`FileSinkBuilder::sync_on_flush`] to also `fsync` (`sync_data`)
44/// on every flush.
45///
46/// # Examples
47///
48#[cfg_attr(all(feature = "json"), doc = "```no_run")]
49#[cfg_attr(not(all(feature = "json")), doc = "```ignore")]
50/// use log_io::format::JsonFormat;
51/// use log_io::sink::FileSink;
52/// use log_io::{Level, Logger};
53///
54/// let sink = FileSink::builder("app.jsonl", JsonFormat::new())
55///     .append(true)
56///     .sync_on_flush(true)
57///     .open()?;
58/// let logger = Logger::builder().level(Level::Info).with_sink(sink).build();
59/// log_io::error!(logger, "disk almost full"); // flushed and synced before returning
60/// # Ok::<(), log_io::Error>(())
61/// ```
62pub struct FileSink<F: Format> {
63    format: F,
64    target: CachePadded<Mutex<BufWriter<File>>>,
65    sync_on_flush: bool,
66}
67
68impl<F: Format> FileSink<F> {
69    /// Open `path` for appending (creating it if needed) with default
70    /// options.
71    ///
72    /// # Errors
73    ///
74    /// Returns [`Error::Io`] if the file cannot be opened or created.
75    pub fn append<P: AsRef<Path>>(path: P, format: F) -> Result<Self> {
76        Self::builder(path, format).append(true).open()
77    }
78
79    /// Open `path` for writing, truncating any existing content.
80    ///
81    /// The file is truncated only after the checks described under
82    /// [`FileSinkBuilder::follow_symlinks`] pass, so a symbolic link at
83    /// `path` never truncates the file it points to.
84    ///
85    /// # Errors
86    ///
87    /// Returns [`Error::Io`] if the file cannot be opened or created, or
88    /// if `path` is a symbolic link or not a regular file.
89    pub fn create<P: AsRef<Path>>(path: P, format: F) -> Result<Self> {
90        Self::builder(path, format).append(false).open()
91    }
92
93    /// Start configuring a file sink. Defaults: append, 8 KiB buffer,
94    /// no `fsync`, mode `0o600` for a new file (Unix), symbolic links
95    /// refused.
96    pub fn builder<P: AsRef<Path>>(path: P, format: F) -> FileSinkBuilder<F> {
97        FileSinkBuilder {
98            path: path.as_ref().to_path_buf(),
99            format,
100            append: true,
101            buffer_capacity: DEFAULT_BUFFER,
102            sync_on_flush: false,
103            mode: None,
104            follow_symlinks: false,
105        }
106    }
107}
108
109impl<F: Format> Sink for FileSink<F> {
110    fn write_record(&self, record: &Record<'_>) -> Result<()> {
111        write_locked(&self.target, &self.format, record)
112    }
113
114    fn flush(&self) -> Result<()> {
115        let mut file = lock_recover(&self.target);
116        file.flush()?;
117        if self.sync_on_flush {
118            file.get_ref().sync_data().map_err(Error::from)?;
119        }
120        Ok(())
121    }
122}
123
124impl<F: Format + core::fmt::Debug> core::fmt::Debug for FileSink<F> {
125    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
126        f.debug_struct("FileSink")
127            .field("format", &self.format)
128            .field("sync_on_flush", &self.sync_on_flush)
129            .finish_non_exhaustive()
130    }
131}
132
133/// Options for a [`FileSink`]. Create with [`FileSink::builder`].
134#[derive(Debug)]
135#[must_use = "call `open()` to create the sink"]
136pub struct FileSinkBuilder<F> {
137    path: std::path::PathBuf,
138    format: F,
139    append: bool,
140    buffer_capacity: usize,
141    sync_on_flush: bool,
142    mode: Option<u32>,
143    follow_symlinks: bool,
144}
145
146impl<F: Format> FileSinkBuilder<F> {
147    /// Append to an existing file (`true`, the default) or truncate it
148    /// (`false`). The file is created if it does not exist.
149    pub const fn append(mut self, append: bool) -> Self {
150        self.append = append;
151        self
152    }
153
154    /// Size of the in-memory write buffer in bytes. `0` disables
155    /// buffering: every record is written to the OS before
156    /// `write_record` returns, at the cost of one system call per
157    /// record.
158    pub const fn buffer_capacity(mut self, bytes: usize) -> Self {
159        self.buffer_capacity = bytes;
160        self
161    }
162
163    /// Also call `File::sync_data` on every flush, so flushed records
164    /// survive an OS crash or power loss. This makes each flush much
165    /// slower (one disk sync).
166    pub const fn sync_on_flush(mut self, enabled: bool) -> Self {
167        self.sync_on_flush = enabled;
168        self
169    }
170
171    /// Unix permission bits for the file when this call creates it, such
172    /// as `0o600` (owner only) or `0o640`. The process umask still applies,
173    /// and an existing file keeps its mode. Without this call a new file
174    /// gets `0o600` (since 2.2; logs often carry tokens and personal
175    /// data). Use `.mode(0o644)` for the earlier behaviour (`0o666` minus
176    /// the usual umask). On Windows the file inherits its directory's
177    /// access control list and the mode has no effect.
178    ///
179    /// # Examples
180    ///
181    #[cfg_attr(all(feature = "json"), doc = "```no_run")]
182    #[cfg_attr(not(all(feature = "json")), doc = "```ignore")]
183    /// use log_io::format::JsonFormat;
184    /// use log_io::sink::FileSink;
185    ///
186    /// let sink = FileSink::builder("app.jsonl", JsonFormat::new())
187    ///     .mode(0o600)
188    ///     .open()?;
189    /// # Ok::<(), log_io::Error>(())
190    /// ```
191    pub const fn mode(mut self, mode: u32) -> Self {
192        self.mode = Some(mode);
193        self
194    }
195
196    /// Allow the path to be a symbolic link (`true`). Default `false`
197    /// (since 2.2): opening fails when the path is a symbolic link (on
198    /// Windows any reparse point) or not a regular file, so a link planted
199    /// in a shared log directory cannot redirect log lines into, or
200    /// truncate, another file. On Unix the open uses `O_NOFOLLOW`, and the
201    /// opened file is checked to be the one at the path.
202    pub const fn follow_symlinks(mut self, follow: bool) -> Self {
203        self.follow_symlinks = follow;
204        self
205    }
206
207    /// Open the file and build the sink.
208    ///
209    /// # Errors
210    ///
211    /// Returns [`Error::Io`] if the file cannot be opened or created, or
212    /// if the path is a symbolic link or not a regular file (see
213    /// [`Self::follow_symlinks`]).
214    pub fn open(self) -> Result<FileSink<F>> {
215        let file = open_log_file(&self.path, self.append, self.mode, self.follow_symlinks)?;
216        Ok(FileSink {
217            format: self.format,
218            target: CachePadded(Mutex::new(BufWriter::with_capacity(
219                self.buffer_capacity,
220                file,
221            ))),
222            sync_on_flush: self.sync_on_flush,
223        })
224    }
225}
226
227#[cfg(all(test, feature = "json"))]
228mod tests {
229    use super::*;
230    use crate::format::JsonFormat;
231    use crate::{Level, Metadata};
232
233    fn temp_path(name: &str) -> std::path::PathBuf {
234        let dir = std::env::temp_dir().join(format!("log-io-file-{}-{name}", std::process::id()));
235        let _ = std::fs::remove_file(&dir);
236        dir
237    }
238
239    fn record(msg: &str) -> Record<'_> {
240        Record::new(Metadata::new(Level::Info, "t"), msg, &[])
241    }
242
243    #[test]
244    fn test_file_sink_append_preserves_existing_content() {
245        let path = temp_path("append");
246        std::fs::write(&path, "existing\n").unwrap();
247        let sink = FileSink::append(&path, JsonFormat::new()).unwrap();
248        sink.write_record(&record("one")).unwrap();
249        sink.flush().unwrap();
250        let text = std::fs::read_to_string(&path).unwrap();
251        assert!(text.starts_with("existing\n"), "{text}");
252        assert!(text.contains("\"message\":\"one\""), "{text}");
253        let _ = std::fs::remove_file(&path);
254    }
255
256    #[test]
257    fn test_file_sink_create_truncates() {
258        let path = temp_path("create");
259        std::fs::write(&path, "old content\n").unwrap();
260        let sink = FileSink::create(&path, JsonFormat::new()).unwrap();
261        sink.write_record(&record("fresh")).unwrap();
262        sink.shutdown().unwrap();
263        let text = std::fs::read_to_string(&path).unwrap();
264        assert!(!text.contains("old content"), "{text}");
265        assert!(text.contains("fresh"), "{text}");
266        let _ = std::fs::remove_file(&path);
267    }
268
269    #[test]
270    fn test_file_sink_buffers_until_flush() {
271        let path = temp_path("buffered");
272        let sink = FileSink::create(&path, JsonFormat::new()).unwrap();
273        sink.write_record(&record("pending")).unwrap();
274        assert_eq!(std::fs::read(&path).unwrap().len(), 0, "still buffered");
275        sink.flush().unwrap();
276        assert!(!std::fs::read(&path).unwrap().is_empty());
277        let _ = std::fs::remove_file(&path);
278    }
279
280    #[test]
281    fn test_file_sink_unbuffered_writes_through() {
282        let path = temp_path("unbuffered");
283        let sink = FileSink::builder(&path, JsonFormat::new())
284            .append(false)
285            .buffer_capacity(0)
286            .sync_on_flush(true)
287            .open()
288            .unwrap();
289        sink.write_record(&record("now")).unwrap();
290        assert!(
291            std::fs::read_to_string(&path).unwrap().contains("now"),
292            "unbuffered sink must write before returning"
293        );
294        sink.flush().unwrap();
295        assert!(format!("{sink:?}").contains("sync_on_flush: true"));
296        let _ = std::fs::remove_file(&path);
297    }
298
299    #[test]
300    fn test_file_sink_open_error_is_reported() {
301        let dir = std::env::temp_dir().join(format!("log-io-missing-{}", std::process::id()));
302        let path = dir.join("nested").join("file.log");
303        let err = FileSink::append(&path, JsonFormat::new()).unwrap_err();
304        assert!(matches!(err, Error::Io(_)));
305    }
306
307    #[cfg(unix)]
308    #[test]
309    fn test_file_sink_mode_applies_to_a_new_file() {
310        use std::os::unix::fs::PermissionsExt;
311        let path = temp_path("mode.jsonl");
312        let sink = FileSink::builder(&path, JsonFormat::new())
313            .mode(0o600)
314            .open()
315            .unwrap();
316        drop(sink);
317        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
318        assert_eq!(mode, 0o600);
319        let _ = std::fs::remove_file(&path);
320    }
321
322    // Review F-4 (2.1.0): new log files were world readable by default.
323    #[cfg(unix)]
324    #[test]
325    fn test_file_sink_default_mode_is_owner_only() {
326        use std::os::unix::fs::PermissionsExt;
327        let path = temp_path("default-mode.jsonl");
328        let sink = FileSink::append(&path, JsonFormat::new()).unwrap();
329        drop(sink);
330        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
331        assert_eq!(mode, 0o600);
332        let _ = std::fs::remove_file(&path);
333    }
334
335    // Review F-2 (2.1.0): `FileSink::create` followed a symbolic link and
336    // truncated the file it pointed to; `append` appended to it.
337    #[test]
338    fn test_file_sink_refuses_a_symlink_and_leaves_its_target_alone() {
339        let victim = temp_path("victim.txt");
340        let link = temp_path("link.jsonl");
341        std::fs::write(&victim, "important=1\n").unwrap();
342        let _ = std::fs::remove_file(&link);
343        #[cfg(unix)]
344        let made = std::os::unix::fs::symlink(&victim, &link);
345        #[cfg(windows)]
346        let made = std::os::windows::fs::symlink_file(&victim, &link);
347        if made.is_err() {
348            // Creating symbolic links needs a privilege on Windows.
349            let _ = std::fs::remove_file(&victim);
350            return;
351        }
352        assert!(FileSink::create(&link, JsonFormat::new()).is_err());
353        assert!(FileSink::append(&link, JsonFormat::new()).is_err());
354        assert_eq!(std::fs::read_to_string(&victim).unwrap(), "important=1\n");
355        let followed = FileSink::builder(&link, JsonFormat::new())
356            .follow_symlinks(true)
357            .open();
358        assert!(
359            followed.is_ok(),
360            "follow_symlinks(true) must allow the link"
361        );
362        drop(followed);
363        let _ = std::fs::remove_file(&link);
364        let _ = std::fs::remove_file(&victim);
365    }
366
367    #[test]
368    fn test_file_sink_refuses_a_directory() {
369        let dir = temp_path("a-directory");
370        let _ = std::fs::remove_dir(&dir);
371        std::fs::create_dir(&dir).unwrap();
372        assert!(FileSink::append(&dir, JsonFormat::new()).is_err());
373        let _ = std::fs::remove_dir(&dir);
374    }
375
376    #[test]
377    fn test_file_sink_create_truncates_a_regular_file() {
378        let path = temp_path("truncate.jsonl");
379        std::fs::write(&path, "old content that is long\n").unwrap();
380        let sink = FileSink::create(&path, JsonFormat::new()).unwrap();
381        sink.write_record(&record("new")).unwrap();
382        sink.flush().unwrap();
383        let text = std::fs::read_to_string(&path).unwrap();
384        assert!(!text.contains("old content"), "{text}");
385        assert!(text.contains("\"message\":\"new\""), "{text}");
386        let _ = std::fs::remove_file(&path);
387    }
388
389    #[test]
390    fn test_file_sink_without_mode_still_opens() {
391        let path = temp_path("no-mode.jsonl");
392        assert!(FileSink::builder(&path, JsonFormat::new()).open().is_ok());
393        let _ = std::fs::remove_file(&path);
394    }
395}