use super::{
Metadata, Path, PathBuf, Read, RecordIdentifier, Result, open_regular_journal,
parse_record_identifier_text,
};
#[derive(Debug)]
pub(in crate::writer::maintenance) struct RawJournal {
pub(in crate::writer::maintenance) path: PathBuf,
pub(in crate::writer::maintenance) source_bytes: Vec<u8>,
pub(in crate::writer::maintenance) metadata: Metadata,
pub(in crate::writer::maintenance) lines: Vec<RawJournalLine>,
}
impl RawJournal {
pub(in crate::writer::maintenance) fn lines(&self) -> &[RawJournalLine] {
&self.lines
}
pub(in crate::writer::maintenance) fn source_bytes(&self) -> &[u8] {
&self.source_bytes
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub(in crate::writer::maintenance) struct RawJournalLine {
pub(in crate::writer::maintenance) raw_bytes: Vec<u8>,
pub(in crate::writer::maintenance) content_length: usize,
pub(in crate::writer::maintenance) classification: RawJournalLineClassification,
}
impl RawJournalLine {
#[allow(
dead_code,
reason = "the byte-exact line accessor is part of the cleanup scanner's internal contract"
)]
pub(in crate::writer::maintenance) fn raw_bytes(&self) -> &[u8] {
&self.raw_bytes
}
#[allow(
dead_code,
reason = "callers inspecting unusual journal syntax need the un-terminated bytes"
)]
pub(in crate::writer::maintenance) fn content_bytes(&self) -> &[u8] {
&self.raw_bytes[..self.content_length]
}
pub(in crate::writer::maintenance) fn classification(&self) -> &RawJournalLineClassification {
&self.classification
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub(in crate::writer::maintenance) enum RawJournalLineClassification {
ParserSkippedNoSpace,
InvalidRecordIdentifier {
revision_text: Vec<u8>,
},
Record(RawJournalRecord),
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub(in crate::writer::maintenance) struct RawJournalRecord {
pub(in crate::writer::maintenance) record_identifier: RecordIdentifier,
pub(in crate::writer::maintenance) revision_text: Vec<u8>,
pub(in crate::writer::maintenance) tag: Vec<u8>,
pub(in crate::writer::maintenance) timestamp: RawJournalTimestamp,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub(in crate::writer::maintenance) enum RawJournalTimestamp {
Missing,
Malformed {
raw: Vec<u8>,
},
Milliseconds {
raw: Vec<u8>,
value: i64,
},
}
pub(in crate::writer::maintenance) fn scan_raw_journal(directory: &Path) -> Result<RawJournal> {
scan_raw_journal_file(&directory.join("journal.log"))
}
pub(in crate::writer::maintenance) fn scan_raw_journal_file(path: &Path) -> Result<RawJournal> {
let path = path.to_owned();
let (mut file, metadata) = open_regular_journal(&path)?;
let mut source_bytes = Vec::new();
file.read_to_end(&mut source_bytes)?;
let lines = split_and_classify_lines(&source_bytes);
Ok(RawJournal {
path,
source_bytes,
metadata,
lines,
})
}
pub(in crate::writer::maintenance) fn split_and_classify_lines(
source: &[u8],
) -> Vec<RawJournalLine> {
let mut lines = Vec::new();
let mut start = 0;
let mut cursor = 0;
while cursor < source.len() {
let terminator_length = match source[cursor] {
b'\r' if source.get(cursor + 1) == Some(&b'\n') => 2,
b'\n' | b'\r' => 1,
_ => {
cursor += 1;
continue;
}
};
let end = cursor + terminator_length;
lines.push(make_line(&source[start..end], cursor - start));
start = end;
cursor = end;
}
if start < source.len() {
lines.push(make_line(&source[start..], source.len() - start));
}
lines
}
pub(in crate::writer::maintenance) fn make_line(
raw: &[u8],
content_length: usize,
) -> RawJournalLine {
let content = &raw[..content_length];
RawJournalLine {
raw_bytes: raw.to_vec(),
content_length,
classification: classify_line(content),
}
}
pub(in crate::writer::maintenance) fn classify_line(
content: &[u8],
) -> RawJournalLineClassification {
if !content.contains(&b' ') {
return RawJournalLineClassification::ParserSkippedNoSpace;
}
let mut fields = content.split(|byte| *byte == b' ');
let revision_text = fields.next().unwrap_or_default().to_vec();
let tag = fields.next().unwrap_or_default().to_vec();
let record_identifier = std::str::from_utf8(&revision_text)
.ok()
.and_then(parse_record_identifier_text);
let Some(record_identifier) = record_identifier else {
return RawJournalLineClassification::InvalidRecordIdentifier { revision_text };
};
let timestamp = match fields.next() {
None => RawJournalTimestamp::Missing,
Some(raw) => match std::str::from_utf8(raw)
.ok()
.and_then(|text| text.parse::<i64>().ok())
{
Some(value) => RawJournalTimestamp::Milliseconds {
raw: raw.to_vec(),
value,
},
None => RawJournalTimestamp::Malformed { raw: raw.to_vec() },
},
};
RawJournalLineClassification::Record(RawJournalRecord {
record_identifier,
revision_text,
tag,
timestamp,
})
}
#[cfg(test)]
mod tests {
use super::{RawJournalLineClassification, RawJournalTimestamp, scan_raw_journal};
use crate::writer::maintenance::journal::test_support::{FIRST, SECOND, TestDirectory};
#[test]
fn scanner_preserves_every_raw_byte_and_classifies_ignored_lines() {
let directory = TestDirectory::new("raw");
let bytes = format!("{FIRST} root 1\nno-space\rnot-a-record root 2\n{SECOND} custom 3");
directory.write_journal(bytes.as_bytes());
let journal = scan_raw_journal(&directory.path).expect("scan journal");
assert_eq!(journal.source_bytes(), bytes.as_bytes());
assert_eq!(
journal
.lines()
.iter()
.flat_map(crate::writer::maintenance::journal::RawJournalLine::raw_bytes)
.copied()
.collect::<Vec<_>>(),
bytes.as_bytes()
);
assert!(matches!(
journal.lines()[0].classification(),
RawJournalLineClassification::Record(_)
));
assert!(matches!(
journal.lines()[1].classification(),
RawJournalLineClassification::ParserSkippedNoSpace
));
assert!(matches!(
journal.lines()[2].classification(),
RawJournalLineClassification::InvalidRecordIdentifier { .. }
));
assert_eq!(
journal.lines()[3].content_bytes(),
format!("{SECOND} custom 3").as_bytes()
);
}
#[test]
fn malformed_or_missing_timestamp_does_not_invalidate_a_record() {
let directory = TestDirectory::new("timestamp");
let bytes = format!("{FIRST} tag not-a-number\n{SECOND} tag\n");
directory.write_journal(bytes.as_bytes());
let journal = scan_raw_journal(&directory.path).expect("scan journal");
let RawJournalLineClassification::Record(first) = journal.lines()[0].classification()
else {
panic!("malformed timestamp must remain a record");
};
assert_eq!(first.tag, b"tag");
assert_eq!(
first.timestamp,
RawJournalTimestamp::Malformed {
raw: b"not-a-number".to_vec()
}
);
let RawJournalLineClassification::Record(second) = journal.lines()[1].classification()
else {
panic!("missing timestamp must remain a record");
};
assert_eq!(second.timestamp, RawJournalTimestamp::Missing);
}
#[test]
fn invalid_identifiers_are_distinct_from_parser_skipped_lines() {
let directory = TestDirectory::new("invalid-id");
directory.write_journal(b"garbage\ngarbage root 12\n");
let journal = scan_raw_journal(&directory.path).expect("scan journal");
assert!(matches!(
journal.lines()[0].classification(),
RawJournalLineClassification::ParserSkippedNoSpace
));
assert_eq!(
journal.lines()[1].classification(),
&RawJournalLineClassification::InvalidRecordIdentifier {
revision_text: b"garbage".to_vec()
}
);
}
#[cfg(unix)]
#[test]
fn scanner_rejects_a_symlinked_journal() {
use std::os::unix::fs::symlink;
let directory = TestDirectory::new("symlink");
std::fs::write(directory.path.join("target"), b"not the journal").expect("write target");
symlink("target", directory.path.join("journal.log")).expect("create symlink");
assert!(scan_raw_journal(&directory.path).is_err());
}
}