Skip to main content

log_io/sink/
rotating.rs

1//! Size- and time-based rotating file writer.
2
3use std::ffi::OsString;
4use std::fs::{self, File};
5use std::io::{self, BufWriter, Write};
6use std::path::{Path, PathBuf};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9/// Time-based rotation schedule for [`RotatingFile`].
10///
11/// Periods are aligned to UTC: `Daily` rotates at 00:00 UTC, `Hourly`
12/// at the top of each UTC hour.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14#[non_exhaustive]
15pub enum Rotation {
16    /// Never rotate on time; only `max_bytes` (if set) triggers
17    /// rotation.
18    #[default]
19    Never,
20    /// Rotate at the start of every UTC minute. Mostly useful for
21    /// testing a rotation setup.
22    Minutely,
23    /// Rotate at the start of every UTC hour.
24    Hourly,
25    /// Rotate at 00:00 UTC every day.
26    Daily,
27}
28
29impl Rotation {
30    const fn period_secs(self) -> Option<u64> {
31        match self {
32            Self::Never => None,
33            Self::Minutely => Some(60),
34            Self::Hourly => Some(3_600),
35            Self::Daily => Some(86_400),
36        }
37    }
38}
39
40/// A file writer that rotates by size and/or time.
41///
42/// The active file is always at the configured path. On rotation it is
43/// renamed to `<path>.1`, an existing `<path>.1` becomes `<path>.2`,
44/// and so on; the file that would move past `<path>.<max_files>` is
45/// deleted. With `max_files(0)` the active file is deleted at rotation
46/// instead of kept.
47///
48/// Rotation happens between records, never inside one: the size check
49/// runs before each `write` call, and the built-in sinks write a whole
50/// record per call. A record larger than `max_bytes` is written to a
51/// fresh file on its own.
52///
53/// Use it as the writer of a [`super::WriterSink`] (aliased as
54/// [`super::RotatingFileSink`]) or of a [`super::NonBlocking`] sink.
55/// Behind a `NonBlocking` sink, records reach this writer in batches
56/// through the worker's write buffer (64 KiB by default), so a file can
57/// exceed `max_bytes` by up to that buffer size and period boundaries
58/// are honoured per batch; set the worker's `buffer_capacity(0)` for
59/// per-record precision.
60///
61/// # Failed rotation
62///
63/// The current file handle is flushed and closed before any rename, so
64/// rotation works on Windows as well as Unix. A rotation is all or
65/// nothing:
66///
67/// 1. the active file is moved to `<path>.rotating`. If that fails (on
68///    Windows, typically because another process holds the file open
69///    without delete sharing), nothing else has been touched;
70/// 2. the history is shifted up one slot, the oldest file going to
71///    `<path>.pruning` rather than being deleted;
72/// 3. `<path>.rotating` becomes `<path>.1`, and only then is
73///    `<path>.pruning` deleted.
74///
75/// If a rename in steps 2-3 fails, the renames already done are undone
76/// in reverse order. So a failed rotation never deletes or overwrites
77/// rotated history: every rotated file keeps its name and content. The
78/// writer then reopens the active file, keeps appending to it, retries
79/// after another `max_bytes` or at the next period, and reports the
80/// error from the next [`Write::flush`] call (which the logger passes
81/// to its error handler).
82///
83/// If an undo fails as well (a second fault, for example the directory
84/// becoming read-only mid-rotation), the rollback stops and the files
85/// keep whatever names they have at that point; still nothing is
86/// deleted. Records that were in the active file stay in
87/// `<path>.rotating`, the writer starts a new active file, and every
88/// later rotation attempt fails with [`io::ErrorKind::AlreadyExists`]
89/// naming that file (so it is never overwritten) until an operator
90/// moves it aside.
91///
92/// # Examples
93///
94#[cfg_attr(all(feature = "json"), doc = "```no_run")]
95#[cfg_attr(not(all(feature = "json")), doc = "```ignore")]
96/// use log_io::format::JsonFormat;
97/// use log_io::sink::{RotatingFile, Rotation};
98/// use log_io::{Level, Logger};
99///
100/// let file = RotatingFile::builder("logs/app.jsonl")
101///     .max_bytes(50 * 1024 * 1024)
102///     .rotation(Rotation::Daily)
103///     .max_files(14)
104///     .open()?;
105/// let logger = Logger::builder().level(Level::Info).writer(file, JsonFormat::new()).build();
106/// # Ok::<(), std::io::Error>(())
107/// ```
108pub struct RotatingFile {
109    path: PathBuf,
110    file: Option<BufWriter<File>>,
111    size: u64,
112    max_bytes: Option<u64>,
113    rotation: Rotation,
114    max_files: usize,
115    buffer_capacity: usize,
116    /// Unix permission bits for files this writer creates.
117    mode: Option<u32>,
118    /// Allow the path to be a symbolic link.
119    follow_symlinks: bool,
120    /// Index of the UTC period the active file belongs to.
121    period: Option<u64>,
122    /// A rotation failure waiting to be reported by `flush`.
123    deferred_error: Option<io::Error>,
124    now: fn() -> SystemTime,
125}
126
127impl RotatingFile {
128    /// Start configuring a rotating file at `path`. Defaults: no size
129    /// limit, [`Rotation::Never`], keep 5 rotated files, 8 KiB buffer,
130    /// mode `0o600` for new files (Unix), symbolic links refused.
131    pub fn builder(path: impl AsRef<Path>) -> RotatingFileBuilder {
132        RotatingFileBuilder {
133            path: path.as_ref().to_path_buf(),
134            max_bytes: None,
135            rotation: Rotation::Never,
136            max_files: 5,
137            buffer_capacity: 8 * 1024,
138            mode: None,
139            follow_symlinks: false,
140        }
141    }
142
143    /// The path of the active file.
144    #[must_use]
145    pub fn path(&self) -> &Path {
146        &self.path
147    }
148
149    /// Bytes written to the active file so far (including bytes that
150    /// existed when it was opened).
151    #[must_use]
152    pub fn current_size(&self) -> u64 {
153        self.size
154    }
155
156    /// Rotate immediately, regardless of size and time.
157    ///
158    /// # Errors
159    ///
160    /// Returns the I/O error from flushing, renaming, or reopening. On
161    /// error the writer keeps appending to the active file.
162    pub fn rotate_now(&mut self) -> io::Result<()> {
163        self.rotate()
164    }
165
166    fn rotated_path(&self, index: usize) -> PathBuf {
167        let mut name = OsString::from(self.path.as_os_str());
168        name.push(format!(".{index}"));
169        PathBuf::from(name)
170    }
171
172    fn current_period(&self) -> Option<u64> {
173        // Read the clock only when a schedule is configured: `now` costs
174        // tens of nanoseconds per record on some platforms.
175        self.rotation
176            .period_secs()
177            .map(|p| unix_secs((self.now)()) / p)
178    }
179
180    fn should_rotate(&mut self, incoming: usize) -> bool {
181        if self.size == 0 {
182            // Never produce an empty rotated file; just track the period.
183            self.period = self.current_period();
184            return false;
185        }
186        let too_big = self
187            .max_bytes
188            .is_some_and(|max| self.size.saturating_add(incoming as u64) > max);
189        too_big
190            || self
191                .period
192                .is_some_and(|open| self.current_period() != Some(open))
193    }
194
195    fn rotate(&mut self) -> io::Result<()> {
196        if let Some(mut writer) = self.file.take() {
197            if let Err(e) = writer.flush() {
198                self.file = Some(writer);
199                return Err(e);
200            }
201            // Close the handle before renaming: Windows refuses to
202            // rename a file this process still has open.
203            drop(writer);
204        }
205        let rotated = self.move_active_into_history();
206        let reopened = open_append(
207            &self.path,
208            self.buffer_capacity,
209            self.mode,
210            self.follow_symlinks,
211        );
212        match reopened {
213            Ok((writer, size)) => {
214                self.file = Some(writer);
215                // After a failed rotation `size` is the old file's
216                // length; count from zero anyway so the next attempt
217                // happens after another `max_bytes` rather than on every
218                // write.
219                self.size = if rotated.is_ok() { size } else { 0 };
220                self.period = self.current_period();
221                rotated
222            }
223            Err(e) => Err(e),
224        }
225    }
226
227    fn sibling(&self, suffix: &str) -> PathBuf {
228        let mut name = OsString::from(self.path.as_os_str());
229        name.push(suffix);
230        PathBuf::from(name)
231    }
232
233    /// Move the (closed) active file to `<path>.1`, shifting and pruning
234    /// the history, as one all-or-nothing step.
235    ///
236    /// The active file is first moved to a staging name, so the common
237    /// failure (another process holds the active file) is detected
238    /// before any history file is touched. The oldest file is moved to a
239    /// pruning name instead of being deleted, and deleted only after
240    /// every other rename has succeeded. If any rename fails, the ones
241    /// already done are undone in reverse order and the error is
242    /// returned: rotated history is never deleted or overwritten by a
243    /// rotation that did not complete.
244    fn move_active_into_history(&self) -> io::Result<()> {
245        if self.max_files == 0 {
246            // No history is kept: rotation discards the active file.
247            return remove_if_exists(&self.path);
248        }
249        let staging = self.sibling(STAGING_SUFFIX);
250        let pruning = self.sibling(PRUNING_SUFFIX);
251        if fs::symlink_metadata(&staging).is_ok() {
252            // Left behind by a rotation whose rollback also failed. It
253            // holds records; never overwrite it.
254            return Err(io::Error::new(
255                io::ErrorKind::AlreadyExists,
256                format!(
257                    "{} was left by an interrupted rotation and holds log records;                      move it aside to resume rotation",
258                    staging.display()
259                ),
260            ));
261        }
262        // Only a completed rotation leaves this file, and its content
263        // was already pruned by that rotation.
264        remove_if_exists(&pruning)?;
265
266        let mut moves = Vec::with_capacity(self.max_files + 2);
267        moves.push(Move::required(self.path.clone(), staging.clone()));
268        moves.push(Move::optional(
269            self.rotated_path(self.max_files),
270            pruning.clone(),
271        ));
272        for index in (1..self.max_files).rev() {
273            moves.push(Move::optional(
274                self.rotated_path(index),
275                self.rotated_path(index + 1),
276            ));
277        }
278        moves.push(Move::required(staging, self.rotated_path(1)));
279
280        let mut done = Vec::with_capacity(moves.len());
281        for step in &moves {
282            match rename_file(&step.from, &step.to) {
283                Ok(()) => done.push(step),
284                Err(e) if !step.required && e.kind() == io::ErrorKind::NotFound => {}
285                Err(e) => {
286                    roll_back(&done);
287                    return Err(e);
288                }
289            }
290        }
291        // The rotation is complete. If this fails, the pruned file stays
292        // under the pruning name and is removed by the next rotation.
293        remove_if_exists(&pruning)
294    }
295}
296
297/// Name the active file is moved to while the history is shifted.
298const STAGING_SUFFIX: &str = ".rotating";
299/// Name the oldest rotated file is moved to until the rotation is
300/// complete.
301const PRUNING_SUFFIX: &str = ".pruning";
302
303/// One rename of a rotation.
304struct Move {
305    from: PathBuf,
306    to: PathBuf,
307    /// `false` for history slots, which may legitimately be missing.
308    required: bool,
309}
310
311impl Move {
312    const fn required(from: PathBuf, to: PathBuf) -> Self {
313        Self {
314            from,
315            to,
316            required: true,
317        }
318    }
319
320    const fn optional(from: PathBuf, to: PathBuf) -> Self {
321        Self {
322            from,
323            to,
324            required: false,
325        }
326    }
327}
328
329/// Undo completed renames, newest first.
330///
331/// Each undo moves a file back to the name the forward rename vacated,
332/// and every later rename that targeted that name has already been
333/// undone. If an undo fails, stop: undoing an earlier rename could then
334/// replace a file that is still in the way. The files keep their
335/// shifted names (nothing is deleted), and the staging file left behind
336/// makes later rotations refuse to run until an operator steps in.
337fn roll_back(done: &[&Move]) {
338    for step in done.iter().rev() {
339        if rename_file(&step.to, &step.from).is_err() {
340            return;
341        }
342    }
343}
344
345impl Write for RotatingFile {
346    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
347        if self.should_rotate(buf.len()) {
348            if let Err(e) = self.rotate() {
349                // Keep the first failure; report it from `flush`.
350                if self.deferred_error.is_none() {
351                    self.deferred_error = Some(e);
352                }
353            }
354        }
355        let writer = match self.file.as_mut() {
356            Some(w) => w,
357            None => {
358                let (writer, size) = open_append(
359                    &self.path,
360                    self.buffer_capacity,
361                    self.mode,
362                    self.follow_symlinks,
363                )?;
364                self.size = size;
365                self.file.insert(writer)
366            }
367        };
368        writer.write_all(buf)?;
369        self.size = self.size.saturating_add(buf.len() as u64);
370        Ok(buf.len())
371    }
372
373    fn flush(&mut self) -> io::Result<()> {
374        if let Some(writer) = self.file.as_mut() {
375            writer.flush()?;
376        }
377        match self.deferred_error.take() {
378            Some(e) => Err(e),
379            None => Ok(()),
380        }
381    }
382}
383
384impl Drop for RotatingFile {
385    fn drop(&mut self) {
386        if let Some(writer) = self.file.as_mut() {
387            // Nowhere left to report an error from a destructor; the
388            // logger flushes (and reports) before dropping its sinks.
389            let _flush_in_drop = writer.flush();
390        }
391    }
392}
393
394impl core::fmt::Debug for RotatingFile {
395    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
396        f.debug_struct("RotatingFile")
397            .field("path", &self.path)
398            .field("size", &self.size)
399            .field("max_bytes", &self.max_bytes)
400            .field("rotation", &self.rotation)
401            .field("max_files", &self.max_files)
402            .finish_non_exhaustive()
403    }
404}
405
406/// Options for a [`RotatingFile`]. Create with [`RotatingFile::builder`].
407#[derive(Debug, Clone)]
408#[must_use = "call `open()` to create the writer"]
409pub struct RotatingFileBuilder {
410    path: PathBuf,
411    max_bytes: Option<u64>,
412    rotation: Rotation,
413    max_files: usize,
414    buffer_capacity: usize,
415    mode: Option<u32>,
416    follow_symlinks: bool,
417}
418
419impl RotatingFileBuilder {
420    /// Rotate before a write would take the active file past `bytes`.
421    pub const fn max_bytes(mut self, bytes: u64) -> Self {
422        self.max_bytes = Some(bytes);
423        self
424    }
425
426    /// Rotate on a UTC time schedule.
427    pub const fn rotation(mut self, rotation: Rotation) -> Self {
428        self.rotation = rotation;
429        self
430    }
431
432    /// How many rotated files to keep (`<path>.1` ..= `<path>.N`).
433    /// `0` keeps none.
434    pub const fn max_files(mut self, count: usize) -> Self {
435        self.max_files = count;
436        self
437    }
438
439    /// Size of the in-memory write buffer (`0` = unbuffered).
440    pub const fn buffer_capacity(mut self, bytes: usize) -> Self {
441        self.buffer_capacity = bytes;
442        self
443    }
444
445    /// Unix permission bits for every file this writer creates: the active
446    /// file and each new one after a rotation, such as `0o600` or `0o640`.
447    /// The process umask still applies, and an existing file keeps its
448    /// mode. Without this call new files get `0o600` (since 2.2; logs
449    /// often carry tokens and personal data). Use `.mode(0o644)` for the
450    /// earlier behaviour (`0o666` minus the usual umask). On Windows files
451    /// inherit their directory's access control list and the mode has no
452    /// effect.
453    ///
454    /// # Examples
455    ///
456    /// ```no_run
457    /// use log_io::sink::{RotatingFile, Rotation};
458    ///
459    /// let file = RotatingFile::builder("app.log")
460    ///     .rotation(Rotation::Daily)
461    ///     .mode(0o640)
462    ///     .open()?;
463    /// # Ok::<(), std::io::Error>(())
464    /// ```
465    pub const fn mode(mut self, mode: u32) -> Self {
466        self.mode = Some(mode);
467        self
468    }
469
470    /// Allow the path to be a symbolic link (`true`). Default `false`
471    /// (since 2.2): opening, and reopening after every rotation, fails
472    /// when the path is a symbolic link (on Windows any reparse point) or
473    /// not a regular file, so a link planted in a shared log directory
474    /// between rotations cannot redirect log lines into another file. A
475    /// failed reopen is reported like any rotation error.
476    pub const fn follow_symlinks(mut self, follow: bool) -> Self {
477        self.follow_symlinks = follow;
478        self
479    }
480
481    /// Open (or create) the active file in append mode.
482    ///
483    /// If the file already exists and was last modified in an earlier
484    /// period of the time schedule, it is rotated on the first write.
485    ///
486    /// # Errors
487    ///
488    /// Returns the I/O error from opening the file.
489    pub fn open(self) -> io::Result<RotatingFile> {
490        self.open_with_clock(SystemTime::now)
491    }
492
493    fn open_with_clock(self, now: fn() -> SystemTime) -> io::Result<RotatingFile> {
494        let (writer, size) = open_append(
495            &self.path,
496            self.buffer_capacity,
497            self.mode,
498            self.follow_symlinks,
499        )?;
500        let period = self.rotation.period_secs().map(|p| {
501            let started = if size > 0 {
502                writer
503                    .get_ref()
504                    .metadata()
505                    .and_then(|m| m.modified())
506                    .unwrap_or_else(|_| now())
507            } else {
508                now()
509            };
510            unix_secs(started) / p
511        });
512        Ok(RotatingFile {
513            path: self.path,
514            file: Some(writer),
515            size,
516            max_bytes: self.max_bytes,
517            rotation: self.rotation,
518            max_files: self.max_files,
519            buffer_capacity: self.buffer_capacity,
520            mode: self.mode,
521            follow_symlinks: self.follow_symlinks,
522            period,
523            deferred_error: None,
524            now,
525        })
526    }
527}
528
529fn open_append(
530    path: &Path,
531    capacity: usize,
532    mode: Option<u32>,
533    follow_symlinks: bool,
534) -> io::Result<(BufWriter<File>, u64)> {
535    let file = super::open_log_file(path, true, mode, follow_symlinks)?;
536    let size = file.metadata()?.len();
537    Ok((BufWriter::with_capacity(capacity, file), size))
538}
539
540fn unix_secs(t: SystemTime) -> u64 {
541    t.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
542}
543
544fn remove_if_exists(path: &Path) -> io::Result<()> {
545    match fs::remove_file(path) {
546        Err(e) if e.kind() != io::ErrorKind::NotFound => Err(e),
547        _ => Ok(()),
548    }
549}
550
551fn rename_file(from: &Path, to: &Path) -> io::Result<()> {
552    #[cfg(test)]
553    if tests::rename_is_blocked(from, to) {
554        return Err(io::Error::new(
555            io::ErrorKind::PermissionDenied,
556            "rename blocked by the test",
557        ));
558    }
559    fs::rename(from, to)
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565    use std::sync::atomic::{AtomicU64, Ordering};
566
567    struct TempDir(PathBuf);
568
569    impl TempDir {
570        fn new(name: &str) -> Self {
571            let dir =
572                std::env::temp_dir().join(format!("log-io-rotate-{}-{name}", std::process::id()));
573            let _ = fs::remove_dir_all(&dir);
574            fs::create_dir_all(&dir).unwrap();
575            Self(dir)
576        }
577        fn file(&self, name: &str) -> PathBuf {
578            self.0.join(name)
579        }
580    }
581
582    impl Drop for TempDir {
583        fn drop(&mut self) {
584            let _ = fs::remove_dir_all(&self.0);
585        }
586    }
587
588    fn read(path: &Path) -> String {
589        fs::read_to_string(path).unwrap_or_default()
590    }
591
592    #[test]
593    fn test_rotating_file_rotates_by_size_and_keeps_max_files() {
594        let dir = TempDir::new("size");
595        let path = dir.file("app.log");
596        let mut w = RotatingFile::builder(&path)
597            .max_bytes(10)
598            .max_files(2)
599            .open()
600            .unwrap();
601        for record in ["aaaa1\n", "bbbb2\n", "cccc3\n", "dddd4\n"] {
602            w.write_all(record.as_bytes()).unwrap();
603        }
604        w.flush().unwrap();
605        assert_eq!(read(&path), "dddd4\n");
606        assert_eq!(read(&dir.file("app.log.1")), "cccc3\n");
607        assert_eq!(read(&dir.file("app.log.2")), "bbbb2\n");
608        assert!(
609            !dir.file("app.log.3").exists(),
610            "max_files must be enforced"
611        );
612    }
613
614    #[test]
615    fn test_rotating_file_never_splits_a_record() {
616        let dir = TempDir::new("whole");
617        let path = dir.file("a.log");
618        let mut w = RotatingFile::builder(&path).max_bytes(8).open().unwrap();
619        w.write_all(b"0123456789abcdef\n").unwrap();
620        w.write_all(b"xy\n").unwrap();
621        w.flush().unwrap();
622        assert_eq!(read(&dir.file("a.log.1")), "0123456789abcdef\n");
623        assert_eq!(read(&path), "xy\n");
624        assert_eq!(w.current_size(), 3);
625    }
626
627    #[test]
628    fn test_rotating_file_max_files_zero_discards_history() {
629        let dir = TempDir::new("zero");
630        let path = dir.file("z.log");
631        let mut w = RotatingFile::builder(&path)
632            .max_bytes(4)
633            .max_files(0)
634            .open()
635            .unwrap();
636        w.write_all(b"one\n").unwrap();
637        w.write_all(b"two\n").unwrap();
638        w.flush().unwrap();
639        assert_eq!(read(&path), "two\n");
640        assert!(!dir.file("z.log.1").exists());
641    }
642
643    #[test]
644    fn test_rotating_file_appends_to_existing_and_counts_its_size() {
645        let dir = TempDir::new("existing");
646        let path = dir.file("e.log");
647        fs::write(&path, "12345").unwrap();
648        let mut w = RotatingFile::builder(&path).max_bytes(8).open().unwrap();
649        assert_eq!(w.current_size(), 5);
650        w.write_all(b"6789\n").unwrap();
651        w.flush().unwrap();
652        assert_eq!(read(&dir.file("e.log.1")), "12345");
653        assert_eq!(read(&path), "6789\n");
654    }
655
656    static FAKE_NOW: AtomicU64 = AtomicU64::new(0);
657
658    fn fake_clock() -> SystemTime {
659        UNIX_EPOCH + std::time::Duration::from_secs(FAKE_NOW.load(Ordering::SeqCst))
660    }
661
662    #[test]
663    fn test_rotating_file_rotates_on_period_boundary() {
664        let dir = TempDir::new("time");
665        let path = dir.file("t.log");
666        FAKE_NOW.store(86_400 * 10 + 5, Ordering::SeqCst);
667        let mut w = RotatingFile::builder(&path)
668            .rotation(Rotation::Daily)
669            .open_with_clock(fake_clock)
670            .unwrap();
671        w.write_all(b"day10\n").unwrap();
672        FAKE_NOW.store(86_400 * 10 + 80_000, Ordering::SeqCst);
673        w.write_all(b"day10b\n").unwrap();
674        FAKE_NOW.store(86_400 * 11 + 1, Ordering::SeqCst);
675        w.write_all(b"day11\n").unwrap();
676        w.flush().unwrap();
677        assert_eq!(read(&dir.file("t.log.1")), "day10\nday10b\n");
678        assert_eq!(read(&path), "day11\n");
679    }
680
681    #[test]
682    fn test_rotating_file_rotate_now_and_accessors() {
683        let dir = TempDir::new("manual");
684        let path = dir.file("m.log");
685        let mut w = RotatingFile::builder(&path)
686            .rotation(Rotation::Hourly)
687            .buffer_capacity(0)
688            .open()
689            .unwrap();
690        assert_eq!(w.path(), path.as_path());
691        w.write_all(b"before\n").unwrap();
692        w.rotate_now().unwrap();
693        w.write_all(b"after\n").unwrap();
694        w.flush().unwrap();
695        assert_eq!(read(&dir.file("m.log.1")), "before\n");
696        assert_eq!(read(&path), "after\n");
697        assert!(format!("{w:?}").contains("Hourly"));
698        assert_eq!(Rotation::default(), Rotation::Never);
699        assert_eq!(Rotation::Minutely.period_secs(), Some(60));
700    }
701
702    #[test]
703    fn test_rotating_file_prune_failure_is_reported_once_and_keeps_records() {
704        let dir = TempDir::new("fail");
705        let path = dir.file("f.log");
706        let mut w = RotatingFile::builder(&path)
707            .max_bytes(4)
708            .max_files(1)
709            .open()
710            .unwrap();
711        w.write_all(b"one\n").unwrap();
712        // A directory in the oldest slot is moved to the pruning name
713        // but cannot be removed with `remove_file` on any platform.
714        fs::create_dir(dir.file("f.log.1")).unwrap();
715        w.write_all(b"two\n").unwrap();
716        assert!(w.flush().is_err(), "rotation failure must surface on flush");
717        assert!(w.flush().is_ok(), "the error is reported once");
718        assert_eq!(read(&dir.file("f.log.1")), "one\n");
719        assert_eq!(read(&path), "two\n");
720        assert!(dir.file("f.log.pruning").is_dir());
721        // The leftover still cannot be removed, so the next rotation is
722        // refused before anything moves.
723        w.write_all(b"three\n").unwrap();
724        assert!(w.flush().is_err());
725        assert_eq!(read(&dir.file("f.log.1")), "one\n");
726        assert_eq!(read(&path), "two\nthree\n", "no record may be lost");
727    }
728
729    #[test]
730    fn test_rotating_file_rotates_at_exactly_max_bytes_not_before() {
731        let dir = TempDir::new("edge");
732        let path = dir.file("edge.log");
733        let mut w = RotatingFile::builder(&path)
734            .max_bytes(10)
735            .buffer_capacity(0)
736            .open()
737            .unwrap();
738        w.write_all(b"01234").unwrap();
739        w.write_all(b"56789").unwrap();
740        assert!(!dir.file("edge.log.1").exists(), "exactly max_bytes fits");
741        w.write_all(b"a").unwrap();
742        assert_eq!(read(&dir.file("edge.log.1")), "0123456789");
743        assert_eq!(read(&path), "a");
744    }
745
746    #[test]
747    fn test_rotating_file_drop_flushes_buffered_records() {
748        let dir = TempDir::new("drop");
749        let path = dir.file("d.log");
750        let mut w = RotatingFile::builder(&path).open().unwrap();
751        w.write_all(b"buffered\n").unwrap();
752        assert_eq!(read(&path), "", "still in the write buffer");
753        drop(w);
754        assert_eq!(read(&path), "buffered\n");
755    }
756
757    #[test]
758    fn test_rotating_file_never_reads_the_clock_without_a_schedule() {
759        fn exploding_clock() -> SystemTime {
760            panic!("the clock must not be read for Rotation::Never");
761        }
762        let dir = TempDir::new("noclock");
763        let path = dir.file("n.log");
764        let mut w = RotatingFile::builder(&path)
765            .max_bytes(4)
766            .open_with_clock(exploding_clock)
767            .unwrap();
768        w.write_all(b"one\n").unwrap();
769        w.write_all(b"two\n").unwrap();
770        w.flush().unwrap();
771        assert_eq!(read(&dir.file("n.log.1")), "one\n");
772    }
773
774    thread_local! {
775        /// Renames (from, to) that fail with `PermissionDenied`, to
776        /// simulate on every platform what another process holding a
777        /// file does on Windows.
778        static BLOCKED: core::cell::RefCell<Vec<(PathBuf, PathBuf)>> =
779            const { core::cell::RefCell::new(Vec::new()) };
780    }
781
782    pub(super) fn rename_is_blocked(from: &Path, to: &Path) -> bool {
783        BLOCKED.with(|b| b.borrow().iter().any(|(f, t)| f == from && t == to))
784    }
785
786    fn block_rename(from: PathBuf, to: PathBuf) {
787        BLOCKED.with(|b| b.borrow_mut().push((from, to)));
788    }
789
790    fn unblock_renames() {
791        BLOCKED.with(|b| b.borrow_mut().clear());
792    }
793
794    /// Writer with `max_bytes(5)`, `max_files(3)`, the active file
795    /// holding `11111` and history `.1..=.3` seeded.
796    fn seeded_writer(dir: &TempDir, base: &str) -> RotatingFile {
797        let mut w = RotatingFile::builder(dir.file(base))
798            .max_bytes(5)
799            .max_files(3)
800            .buffer_capacity(0)
801            .open()
802            .unwrap();
803        w.write_all(b"11111").unwrap();
804        seed_history(dir, base, 3);
805        w
806    }
807
808    fn rotation_files(dir: &TempDir) -> Vec<String> {
809        let mut names: Vec<String> = fs::read_dir(&dir.0)
810            .unwrap()
811            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
812            .collect();
813        names.sort();
814        names
815    }
816
817    #[test]
818    fn test_rotating_file_blocked_active_rename_keeps_history_on_every_platform() {
819        let dir = TempDir::new("blocked-active");
820        let mut w = seeded_writer(&dir, "a.log");
821        block_rename(dir.file("a.log"), dir.file("a.log.rotating"));
822        for i in 0..6 {
823            w.write_all(format!("r{i}xxx").as_bytes()).unwrap();
824            let _reported = w.flush();
825        }
826        unblock_renames();
827        assert_history_intact(&dir, "a.log", 3);
828        assert_eq!(
829            read(&dir.file("a.log")),
830            "11111r0xxxr1xxxr2xxxr3xxxr4xxxr5xxx"
831        );
832        assert_eq!(
833            rotation_files(&dir),
834            ["a.log", "a.log.1", "a.log.2", "a.log.3"]
835        );
836    }
837
838    #[test]
839    fn test_rotating_file_blocked_history_rename_rolls_back() {
840        let dir = TempDir::new("blocked-history");
841        let mut w = seeded_writer(&dir, "h.log");
842        // The shift moves `.3` to the pruning name and then fails on
843        // `.2 -> .3`; both the prune and the staging move are undone.
844        block_rename(dir.file("h.log.2"), dir.file("h.log.3"));
845        w.write_all(b"r0xxx").unwrap();
846        let err = w.flush().unwrap_err();
847        assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
848        unblock_renames();
849        assert_history_intact(&dir, "h.log", 3);
850        assert_eq!(read(&dir.file("h.log")), "11111r0xxx");
851        assert_eq!(
852            rotation_files(&dir),
853            ["h.log", "h.log.1", "h.log.2", "h.log.3"]
854        );
855    }
856
857    #[test]
858    fn test_rotating_file_blocked_final_rename_rolls_back() {
859        let dir = TempDir::new("blocked-final");
860        let mut w = seeded_writer(&dir, "s.log");
861        block_rename(dir.file("s.log.rotating"), dir.file("s.log.1"));
862        w.write_all(b"r0xxx").unwrap();
863        assert!(w.flush().is_err());
864        unblock_renames();
865        assert_history_intact(&dir, "s.log", 3);
866        assert_eq!(read(&dir.file("s.log")), "11111r0xxx");
867        // Unblocked, the next rotation completes.
868        w.rotate_now().unwrap();
869        w.write_all(b"r1xxx").unwrap();
870        w.flush().unwrap();
871        assert_eq!(read(&dir.file("s.log")), "r1xxx");
872        assert_eq!(read(&dir.file("s.log.1")), "11111r0xxx");
873        assert_eq!(read(&dir.file("s.log.2")), "OLD1\n");
874        assert_eq!(read(&dir.file("s.log.3")), "OLD2\n");
875        assert_eq!(
876            rotation_files(&dir),
877            ["s.log", "s.log.1", "s.log.2", "s.log.3"],
878            "OLD3 is pruned only by the rotation that completed"
879        );
880    }
881
882    #[test]
883    fn test_rotating_file_failed_rollback_deletes_nothing_and_stops_rotating() {
884        let dir = TempDir::new("blocked-rollback");
885        let mut w = seeded_writer(&dir, "r.log");
886        // Forward: the final staging rename fails. Rollback: undoing
887        // `.1 -> .2` fails too, so the rollback stops there.
888        block_rename(dir.file("r.log.rotating"), dir.file("r.log.1"));
889        block_rename(dir.file("r.log.2"), dir.file("r.log.1"));
890        w.write_all(b"r0xxx").unwrap();
891        assert!(w.flush().is_err());
892        unblock_renames();
893        let mut contents: Vec<String> = rotation_files(&dir)
894            .iter()
895            .map(|name| read(&dir.file(name)))
896            .collect();
897        contents.sort();
898        // Every record is still on disk under some name.
899        assert_eq!(contents, ["11111", "OLD1\n", "OLD2\n", "OLD3\n", "r0xxx"]);
900        assert_eq!(read(&dir.file("r.log.rotating")), "11111");
901        // With records stranded in the staging file, rotation refuses to
902        // run rather than overwrite it, and the new file keeps growing.
903        w.write_all(b"r1xxx").unwrap();
904        w.write_all(b"r2xxx").unwrap();
905        let err = w.flush().unwrap_err();
906        assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
907        assert!(err.to_string().contains("r.log.rotating"), "{err}");
908        assert_eq!(read(&dir.file("r.log.rotating")), "11111");
909        assert_eq!(read(&dir.file("r.log")), "r0xxxr1xxxr2xxx");
910    }
911
912    fn seed_history(dir: &TempDir, base: &str, count: usize) {
913        for index in 1..=count {
914            fs::write(
915                dir.file(&format!("{base}.{index}")),
916                format!(
917                    "OLD{index}
918"
919                ),
920            )
921            .unwrap();
922        }
923    }
924
925    fn assert_history_intact(dir: &TempDir, base: &str, count: usize) {
926        for index in 1..=count {
927            assert_eq!(
928                read(&dir.file(&format!("{base}.{index}"))),
929                format!(
930                    "OLD{index}
931"
932                ),
933                "{base}.{index} must survive a failed rotation"
934            );
935        }
936    }
937
938    /// LOG-R01: a rename blocked by another process (Windows opens
939    /// without `FILE_SHARE_DELETE`) must not cost any rotated history,
940    /// no matter how often rotation is retried.
941    #[cfg(windows)]
942    #[test]
943    fn test_rotating_file_held_active_file_keeps_history_on_windows() {
944        use std::os::windows::fs::OpenOptionsExt;
945        const FILE_SHARE_READ: u32 = 0x1;
946        const FILE_SHARE_WRITE: u32 = 0x2;
947        let dir = TempDir::new("held");
948        let path = dir.file("held.log");
949        let mut w = RotatingFile::builder(&path)
950            .max_bytes(5)
951            .max_files(3)
952            .buffer_capacity(0)
953            .open()
954            .unwrap();
955        w.write_all(b"11111").unwrap();
956        seed_history(&dir, "held.log", 3);
957        let holder = fs::OpenOptions::new()
958            .read(true)
959            .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
960            .open(&path)
961            .unwrap();
962        let mut failures = 0;
963        for i in 0..6 {
964            w.write_all(format!("r{i}xxx").as_bytes()).unwrap();
965            if w.flush().is_err() {
966                failures += 1;
967            }
968        }
969        drop(holder);
970        assert!(failures > 0, "the blocked rotation must be reported");
971        assert_history_intact(&dir, "held.log", 3);
972        assert_eq!(read(&path), "11111r0xxxr1xxxr2xxxr3xxxr4xxxr5xxx");
973        assert!(!dir.file("held.log.4").exists());
974        // Once the other handle is gone, rotation resumes normally.
975        w.rotate_now().unwrap();
976        assert_eq!(
977            read(&dir.file("held.log.1")),
978            "11111r0xxxr1xxxr2xxxr3xxxr4xxxr5xxx"
979        );
980        assert_eq!(
981            read(&dir.file("held.log.2")),
982            "OLD1
983"
984        );
985        assert_eq!(
986            read(&dir.file("held.log.3")),
987            "OLD2
988"
989        );
990        assert!(!dir.file("held.log.4").exists(), "max_files still applies");
991    }
992
993    #[cfg(unix)]
994    #[test]
995    fn test_rotating_mode_applies_to_active_and_rotated_files() {
996        use std::os::unix::fs::PermissionsExt;
997        let dir = TempDir::new("mode");
998        let mut w = RotatingFile::builder(dir.file("app.log"))
999            .max_bytes(5)
1000            .max_files(2)
1001            .buffer_capacity(0)
1002            .mode(0o600)
1003            .open()
1004            .unwrap();
1005        w.write_all(b"11111").unwrap();
1006        w.write_all(b"22222").unwrap(); // rotates: a new active file is created
1007        w.flush().unwrap();
1008        for name in ["app.log", "app.log.1"] {
1009            let mode = fs::metadata(dir.file(name)).unwrap().permissions().mode() & 0o777;
1010            assert_eq!(mode, 0o600, "{name}");
1011        }
1012    }
1013
1014    // Review F-2 (2.1.0): the reopen after a rotation followed a symbolic
1015    // link planted at the path in between, and appended log lines to the
1016    // file it pointed to.
1017    #[cfg(unix)]
1018    #[test]
1019    fn test_rotating_reopen_refuses_a_symlink_planted_after_rotation() {
1020        let dir = TempDir::new("symlink");
1021        let victim = dir.file("victim.txt");
1022        fs::write(&victim, "important=1\n").unwrap();
1023        let mut w = RotatingFile::builder(dir.file("app.log"))
1024            .max_bytes(5)
1025            .max_files(2)
1026            .buffer_capacity(0)
1027            .open()
1028            .unwrap();
1029        w.write_all(b"11111").unwrap();
1030        // Close the active file the way a rotation does, then plant a link.
1031        w.rotate_now().unwrap();
1032        fs::remove_file(dir.file("app.log")).unwrap();
1033        std::os::unix::fs::symlink(&victim, dir.file("app.log")).unwrap();
1034        let _ = w.rotate_now();
1035        let _ = w.write_all(b"appended-by-logger");
1036        let _ = w.flush();
1037        assert_eq!(fs::read_to_string(&victim).unwrap(), "important=1\n");
1038    }
1039
1040    // Review F-4 (2.1.0): new log files were world readable by default.
1041    #[cfg(unix)]
1042    #[test]
1043    fn test_rotating_default_mode_is_owner_only() {
1044        use std::os::unix::fs::PermissionsExt;
1045        let dir = TempDir::new("default-mode");
1046        let w = RotatingFile::builder(dir.file("app.log")).open().unwrap();
1047        drop(w);
1048        let mode = fs::metadata(dir.file("app.log"))
1049            .unwrap()
1050            .permissions()
1051            .mode()
1052            & 0o777;
1053        assert_eq!(mode, 0o600);
1054    }
1055}