use super::fmt;
pub const DEFAULT_MAXIMUM_GC_JOURNAL_FILE_BYTES: u64 = 64 * 1024 * 1024;
pub const DEFAULT_MAXIMUM_GC_JOURNAL_LINE_BYTES: usize = 1024 * 1024;
pub const DEFAULT_MAXIMUM_GC_JOURNAL_ENTRIES: usize = 250_000;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[non_exhaustive]
pub struct GarbageCollectionJournalReadOptions {
pub maximum_file_bytes: u64,
pub maximum_line_bytes: usize,
pub maximum_entries: usize,
}
impl Default for GarbageCollectionJournalReadOptions {
fn default() -> Self {
Self {
maximum_file_bytes: DEFAULT_MAXIMUM_GC_JOURNAL_FILE_BYTES,
maximum_line_bytes: DEFAULT_MAXIMUM_GC_JOURNAL_LINE_BYTES,
maximum_entries: DEFAULT_MAXIMUM_GC_JOURNAL_ENTRIES,
}
}
}
impl GarbageCollectionJournalReadOptions {
#[must_use]
pub const fn new(
maximum_file_bytes: u64,
maximum_line_bytes: usize,
maximum_entries: usize,
) -> Self {
Self {
maximum_file_bytes,
maximum_line_bytes,
maximum_entries,
}
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum GarbageCollectionJournalReadError {
InputOutput(std::io::Error),
FileByteLimitExceeded {
maximum_file_bytes: u64,
observed_file_bytes: u64,
},
LineByteLimitExceeded {
line_number: usize,
maximum_line_bytes: usize,
attempted_line_bytes: usize,
},
EntryLimitExceeded {
maximum_entries: usize,
attempted_entries: usize,
},
InvalidUtf8 {
line_number: usize,
invalid_byte_offset: u64,
error_length: Option<usize>,
},
}
impl fmt::Display for GarbageCollectionJournalReadError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InputOutput(source) => write!(formatter, "input/output error: {source}"),
Self::FileByteLimitExceeded {
maximum_file_bytes,
observed_file_bytes,
} => write!(
formatter,
"garbage-collection journal contains at least {observed_file_bytes} bytes, \
exceeding the {maximum_file_bytes}-byte limit"
),
Self::LineByteLimitExceeded {
line_number,
maximum_line_bytes,
attempted_line_bytes,
} => write!(
formatter,
"garbage-collection journal line {line_number} would contain \
{attempted_line_bytes} bytes, exceeding the {maximum_line_bytes}-byte limit"
),
Self::EntryLimitExceeded {
maximum_entries,
attempted_entries,
} => write!(
formatter,
"garbage-collection journal would contain {attempted_entries} entries, \
exceeding the {maximum_entries}-entry limit"
),
Self::InvalidUtf8 {
line_number,
invalid_byte_offset,
..
} => write!(
formatter,
"garbage-collection journal line {line_number} contains invalid UTF-8 at byte \
offset {invalid_byte_offset}"
),
}
}
}
impl std::error::Error for GarbageCollectionJournalReadError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::InputOutput(source) => Some(source),
Self::FileByteLimitExceeded { .. }
| Self::LineByteLimitExceeded { .. }
| Self::EntryLimitExceeded { .. }
| Self::InvalidUtf8 { .. } => None,
}
}
}
impl From<std::io::Error> for GarbageCollectionJournalReadError {
fn from(source: std::io::Error) -> Self {
Self::InputOutput(source)
}
}
pub type GarbageCollectionJournalReadResult<Value> =
std::result::Result<Value, GarbageCollectionJournalReadError>;
#[cfg(test)]
mod tests {
use super::{
DEFAULT_MAXIMUM_GC_JOURNAL_FILE_BYTES, GarbageCollectionJournalReadError,
GarbageCollectionJournalReadOptions,
};
use crate::gc_journal::test_support::TestDirectory;
use crate::gc_journal::{
read_all_gc_journal, read_all_gc_journal_with_options, read_gc_journal,
};
#[test]
fn sparse_oversized_file_fails_from_the_default_metadata_limit() {
let directory = TestDirectory::new("sparse-file-limit");
let path = directory.path.join("gc.log");
let file = std::fs::File::create(&path).expect("create sparse fixture");
file.set_len(DEFAULT_MAXIMUM_GC_JOURNAL_FILE_BYTES + 1)
.expect("set sparse fixture length");
drop(file);
let error =
read_all_gc_journal_with_options(&path, GarbageCollectionJournalReadOptions::default())
.expect_err("sparse oversized file must fail before reading");
assert!(matches!(
error,
GarbageCollectionJournalReadError::FileByteLimitExceeded {
maximum_file_bytes: DEFAULT_MAXIMUM_GC_JOURNAL_FILE_BYTES,
observed_file_bytes,
} if observed_file_bytes == DEFAULT_MAXIMUM_GC_JOURNAL_FILE_BYTES + 1
));
assert!(matches!(
read_gc_journal(&path),
Err(GarbageCollectionJournalReadError::FileByteLimitExceeded {
maximum_file_bytes: DEFAULT_MAXIMUM_GC_JOURNAL_FILE_BYTES,
observed_file_bytes,
}) if observed_file_bytes == DEFAULT_MAXIMUM_GC_JOURNAL_FILE_BYTES + 1
));
assert!(matches!(
read_all_gc_journal(&path),
Err(GarbageCollectionJournalReadError::FileByteLimitExceeded {
maximum_file_bytes: DEFAULT_MAXIMUM_GC_JOURNAL_FILE_BYTES,
observed_file_bytes,
}) if observed_file_bytes == DEFAULT_MAXIMUM_GC_JOURNAL_FILE_BYTES + 1
));
assert_eq!(
std::fs::metadata(&path)
.expect("metadata after bounded reads")
.len(),
DEFAULT_MAXIMUM_GC_JOURNAL_FILE_BYTES + 1
);
}
#[test]
fn line_limit_rejects_the_next_byte_before_parsing() {
let directory = TestDirectory::new("line-limit");
let path = directory.path.join("gc.log");
std::fs::write(&path, b"1234\n").expect("write overlong line fixture");
let options = GarbageCollectionJournalReadOptions {
maximum_file_bytes: 5,
maximum_line_bytes: 3,
maximum_entries: 1,
};
let error = read_all_gc_journal_with_options(&path, options)
.expect_err("fourth line byte must exceed configured limit");
assert!(matches!(
error,
GarbageCollectionJournalReadError::LineByteLimitExceeded {
line_number: 1,
maximum_line_bytes: 3,
attempted_line_bytes: 4,
}
));
}
#[test]
fn custom_limits_accept_exact_boundaries_and_reject_the_next_unit() {
let directory = TestDirectory::new("exact-resource-limits");
let path = directory.path.join("gc.log");
let fixture = "x\r\né".as_bytes();
std::fs::write(&path, fixture).expect("write exact-limit fixture");
let exact = GarbageCollectionJournalReadOptions {
maximum_file_bytes: u64::try_from(fixture.len()).expect("fixture length fits u64"),
maximum_line_bytes: 2,
maximum_entries: 2,
};
let entries = read_all_gc_journal_with_options(&path, exact)
.expect("all three resource dimensions accept equality");
assert_eq!(entries.len(), 2);
let file_error = read_all_gc_journal_with_options(
&path,
GarbageCollectionJournalReadOptions {
maximum_file_bytes: u64::try_from(fixture.len() - 1)
.expect("fixture length fits u64"),
..exact
},
)
.expect_err("one byte below the file size must fail");
assert!(matches!(
file_error,
GarbageCollectionJournalReadError::FileByteLimitExceeded {
maximum_file_bytes: 4,
observed_file_bytes: 5,
}
));
let line_error = read_all_gc_journal_with_options(
&path,
GarbageCollectionJournalReadOptions {
maximum_line_bytes: 1,
..exact
},
)
.expect_err("a two-byte UTF-8 value must not fit a one-byte line limit");
assert!(matches!(
line_error,
GarbageCollectionJournalReadError::LineByteLimitExceeded {
line_number: 2,
maximum_line_bytes: 1,
attempted_line_bytes: 2,
}
));
let entry_error = read_all_gc_journal_with_options(
&path,
GarbageCollectionJournalReadOptions {
maximum_entries: 1,
..exact
},
)
.expect_err("the second physical line must exceed a one-entry limit");
assert!(matches!(
entry_error,
GarbageCollectionJournalReadError::EntryLimitExceeded {
maximum_entries: 1,
attempted_entries: 2,
}
));
}
#[test]
fn entry_limit_bounds_newline_heavy_input_work() {
let directory = TestDirectory::new("entry-limit");
let path = directory.path.join("gc.log");
let fixture = vec![b'\n'; 10_000];
std::fs::write(&path, fixture).expect("write newline-heavy fixture");
let options = GarbageCollectionJournalReadOptions {
maximum_file_bytes: 10_000,
maximum_line_bytes: 0,
maximum_entries: 32,
};
let error = read_all_gc_journal_with_options(&path, options)
.expect_err("thirty-third line must exceed configured limit");
assert!(matches!(
error,
GarbageCollectionJournalReadError::EntryLimitExceeded {
maximum_entries: 32,
attempted_entries: 33,
}
));
}
}