1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14#[non_exhaustive]
15pub enum Rotation {
16 #[default]
19 Never,
20 Minutely,
23 Hourly,
25 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#[cfg_attr(all(feature = "json"), doc = "```no_run")]
95#[cfg_attr(not(all(feature = "json")), doc = "```ignore")]
96pub 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 mode: Option<u32>,
118 follow_symlinks: bool,
120 period: Option<u64>,
122 deferred_error: Option<io::Error>,
124 now: fn() -> SystemTime,
125}
126
127impl RotatingFile {
128 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 #[must_use]
145 pub fn path(&self) -> &Path {
146 &self.path
147 }
148
149 #[must_use]
152 pub fn current_size(&self) -> u64 {
153 self.size
154 }
155
156 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 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 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 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 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 fn move_active_into_history(&self) -> io::Result<()> {
245 if self.max_files == 0 {
246 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 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 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 remove_if_exists(&pruning)
294 }
295}
296
297const STAGING_SUFFIX: &str = ".rotating";
299const PRUNING_SUFFIX: &str = ".pruning";
302
303struct Move {
305 from: PathBuf,
306 to: PathBuf,
307 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
329fn 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 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 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#[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 pub const fn max_bytes(mut self, bytes: u64) -> Self {
422 self.max_bytes = Some(bytes);
423 self
424 }
425
426 pub const fn rotation(mut self, rotation: Rotation) -> Self {
428 self.rotation = rotation;
429 self
430 }
431
432 pub const fn max_files(mut self, count: usize) -> Self {
435 self.max_files = count;
436 self
437 }
438
439 pub const fn buffer_capacity(mut self, bytes: usize) -> Self {
441 self.buffer_capacity = bytes;
442 self
443 }
444
445 pub const fn mode(mut self, mode: u32) -> Self {
466 self.mode = Some(mode);
467 self
468 }
469
470 pub const fn follow_symlinks(mut self, follow: bool) -> Self {
477 self.follow_symlinks = follow;
478 self
479 }
480
481 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 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 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 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 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 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 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 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 assert_eq!(contents, ["11111", "OLD1\n", "OLD2\n", "OLD3\n", "r0xxx"]);
900 assert_eq!(read(&dir.file("r.log.rotating")), "11111");
901 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 #[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 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(); 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 #[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 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 #[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}