Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 45 additions & 9 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,25 @@ pub struct FileRotate<S: SuffixScheme> {
mode: Option<u32>,
}

/// Error for the function `move_file_with_suffix` - in order to
#[derive(Debug)]
enum MoveFileError {
Io(io::Error),
/// Safeguard for the assumption that the rotated files will not be moved/deleted
/// by an external process / user:
/// If the condition is true, that has likely happened and thus we need to call
/// `scan_suffixes` before trying again.
///
/// If we were to assume said assumption, we would only call `scan_suffixes`
/// in FileRotate::new().
SuffixScanNeeded,
}
impl From<io::Error> for MoveFileError {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}

impl<S: SuffixScheme> FileRotate<S> {
/// Create a new [FileRotate].
///
Expand Down Expand Up @@ -507,17 +526,23 @@ impl<S: SuffixScheme> FileRotate<S> {

/// Recursive function that keeps moving files if there's any file name collision.
/// If `suffix` is `None`, it moves from basepath to next suffix given by the SuffixScheme
/// Assumption: Any collision in file name is due to an old log file.
///
/// Returns the suffix of the new file (the last suffix after possible cascade of renames).
fn move_file_with_suffix(
&mut self,
old_suffix_info: Option<SuffixInfo<S::Repr>>,
) -> io::Result<SuffixInfo<S::Repr>> {
) -> Result<SuffixInfo<S::Repr>, MoveFileError> {
let old_path = match old_suffix_info {
Some(ref suffix) => suffix.to_path(&self.basepath),
None => self.basepath.clone(),
};
if !old_path.exists() {
return Err(MoveFileError::SuffixScanNeeded);
}

// NOTE: this newest_suffix is there only because AppendTimestamp specifically needs
// it. Otherwise it might not be necessary to provide this to `rotate_file`. We could also
// have passed the internal BTreeMap itself, but it would require to make SuffixInfo `pub`.

let newest_suffix = self.suffixes.iter().next().map(|info| &info.suffix);

let new_suffix = self.suffix_scheme.rotate_file(
Expand Down Expand Up @@ -551,10 +576,9 @@ impl<S: SuffixScheme> FileRotate<S> {
new_suffix_info
};

let old_path = match old_suffix_info {
Some(suffix) => suffix.to_path(&self.basepath),
None => self.basepath.clone(),
};
if new_path.exists() {
return Err(MoveFileError::SuffixScanNeeded);
}
Comment on lines +579 to +581
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems very racy to me. If the new_path.exists() == false, a user can still modify the filesystem before we reach fs::rename on line 586. What is the intent of the code here? Won't rescanning suffixes just overwrite said file anyway?

Copy link
Collaborator Author

@Ploppz Ploppz May 21, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right about the race condition.
Rescanning suffixes does not overwrite anything in the fs, it just recreates self.suffixes which is only an internal cache of which log files are on disk. For correct functioning, we rely on the assumption that self.suffixes is correct. If it is not correct (user/system has done file ops), and if not for the asserts which up until now have served as the sole line of defense, we risk overwriting previous log files. Because fn move_file_with_suffix will only move away the destination file if it thinks it exists (look into the recursive nature of the function - it calls itself before fs::rename for this purpose).


// Do the move
assert!(old_path.exists());
Expand All @@ -569,8 +593,20 @@ impl<S: SuffixScheme> FileRotate<S> {

let _ = self.file.take();

// This function will always create a new file. Returns suffix of that file
let new_suffix_info = self.move_file_with_suffix(None)?;
// `move_file_with_suffix` will always (on success) create a new file and return the suffix
// of that file.
// We need a loop here in case it fails for the specific reason that `self.suffixes` is not
// up-to-date, and we need another scan before moving on.
// The only reason for such a discrepancy is external interventions in the filesystem.
let new_suffix_info = loop {
match self.move_file_with_suffix(None) {
Ok(x) => break x,
Err(MoveFileError::SuffixScanNeeded) => {
self.scan_suffixes();
}
Err(MoveFileError::Io(e)) => return Err(e),
}
};
self.suffixes.insert(new_suffix_info);

self.open_file();
Expand Down
79 changes: 79 additions & 0 deletions src/tests.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use super::{suffix::*, *};
use std::iter::FromIterator;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use tempdir::TempDir;
Expand Down Expand Up @@ -371,6 +372,84 @@ fn unix_file_permissions() {
}
}

#[test]
fn user_moves_log_files() {
// FileRotate will be able to recover if its memory of what files exist (`self.suffixes`) turns
// out to not be consistent with the files that are actually on disk... User or external
// process has changed the file listing.

let initial_suffixes = [1, 2, 3];

#[derive(Debug)]
enum Action {
Create(usize),
Remove(usize),
}
use Action::*;

let actions = [Create(4), Remove(1), Remove(2), Remove(3)];

for action in actions {
println!("{:?}", action);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this print

let tmp_dir = TempDir::new("file-rotate-test").unwrap();
let dir = tmp_dir.path();
let log_path = dir.join("log");
for suffix in initial_suffixes {
File::create(dir.join(format!("log.{}", suffix))).unwrap();
}

let mut log = FileRotate::new(
&log_path,
AppendCount::new(5),
ContentLimit::Bytes(1),
Compression::None,
#[cfg(unix)]
None,
);
Comment on lines +401 to +408
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test looks like 4 different tests to me since we recreate a FileRotate. Could we instead actually make 4 tests (and perhaps just put common code in a function)? This test is a bit hard to read right now.


match action {
Create(suffix) => {
let name = format!("log.{}", suffix);
File::create(dir.join(name)).unwrap();
}
Remove(suffix) => {
let name = format!("log.{}", suffix);
std::fs::remove_file(dir.join(name)).unwrap();
}
}

write!(log, "12").unwrap();

let mut expected_set = BTreeSet::from_iter(initial_suffixes.iter().map(|x| SuffixInfo {
suffix: *x,
compressed: false,
}));
match action {
Create(4) => {
// The one that was created
expected_set.insert(SuffixInfo {
suffix: 4,
compressed: false,
});
// An additional one due to rotation
expected_set.insert(SuffixInfo {
suffix: 5,
compressed: false,
});
assert_eq!(log.suffixes, expected_set);
}
// There is only one Create(_) action that is reasonable to test
Create(_) => unreachable!(),
Remove(_) => {
// No matter which file we remove, we would expect rotation to create one
// additional file such that we end up with the same files as we started.
assert_eq!(expected_set, log.suffixes);
}
}
println!("OK");
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this print

}
}

#[quickcheck_macros::quickcheck]
fn arbitrary_lines(count: usize) {
let tmp_dir = TempDir::new("file-rotate-test").unwrap();
Expand Down