use super::*;
use crate::file::MmapMut;
use crate::file::writer::JournalWriter;
use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;
#[derive(Clone, Copy, Debug)]
struct ExpectedSanitizedHeader {
header_size: u64,
n_data: u64,
n_fields: u64,
n_tags: u64,
n_entry_arrays: u64,
data_hash_chain_depth: u64,
field_hash_chain_depth: u64,
tail_entry_array_offset: u32,
tail_entry_array_n_entries: u32,
tail_entry_offset: u64,
}
const HEADER_SANITIZE_CASES: &[ExpectedSanitizedHeader] = &[
ExpectedSanitizedHeader {
header_size: 208,
n_data: 0,
n_fields: 0,
n_tags: 0,
n_entry_arrays: 0,
data_hash_chain_depth: 0,
field_hash_chain_depth: 0,
tail_entry_array_offset: 0,
tail_entry_array_n_entries: 0,
tail_entry_offset: 0,
},
ExpectedSanitizedHeader {
header_size: 216,
n_data: 11,
n_fields: 0,
n_tags: 0,
n_entry_arrays: 0,
data_hash_chain_depth: 0,
field_hash_chain_depth: 0,
tail_entry_array_offset: 0,
tail_entry_array_n_entries: 0,
tail_entry_offset: 0,
},
ExpectedSanitizedHeader {
header_size: 220,
n_data: 11,
n_fields: 0,
n_tags: 0,
n_entry_arrays: 0,
data_hash_chain_depth: 0,
field_hash_chain_depth: 0,
tail_entry_array_offset: 0,
tail_entry_array_n_entries: 0,
tail_entry_offset: 0,
},
ExpectedSanitizedHeader {
header_size: 224,
n_data: 11,
n_fields: 22,
n_tags: 0,
n_entry_arrays: 0,
data_hash_chain_depth: 0,
field_hash_chain_depth: 0,
tail_entry_array_offset: 0,
tail_entry_array_n_entries: 0,
tail_entry_offset: 0,
},
ExpectedSanitizedHeader {
header_size: 232,
n_data: 11,
n_fields: 22,
n_tags: 33,
n_entry_arrays: 0,
data_hash_chain_depth: 0,
field_hash_chain_depth: 0,
tail_entry_array_offset: 0,
tail_entry_array_n_entries: 0,
tail_entry_offset: 0,
},
ExpectedSanitizedHeader {
header_size: 240,
n_data: 11,
n_fields: 22,
n_tags: 33,
n_entry_arrays: 44,
data_hash_chain_depth: 0,
field_hash_chain_depth: 0,
tail_entry_array_offset: 0,
tail_entry_array_n_entries: 0,
tail_entry_offset: 0,
},
ExpectedSanitizedHeader {
header_size: 248,
n_data: 11,
n_fields: 22,
n_tags: 33,
n_entry_arrays: 44,
data_hash_chain_depth: 55,
field_hash_chain_depth: 0,
tail_entry_array_offset: 0,
tail_entry_array_n_entries: 0,
tail_entry_offset: 0,
},
ExpectedSanitizedHeader {
header_size: 250,
n_data: 11,
n_fields: 22,
n_tags: 33,
n_entry_arrays: 44,
data_hash_chain_depth: 55,
field_hash_chain_depth: 0,
tail_entry_array_offset: 0,
tail_entry_array_n_entries: 0,
tail_entry_offset: 0,
},
ExpectedSanitizedHeader {
header_size: 256,
n_data: 11,
n_fields: 22,
n_tags: 33,
n_entry_arrays: 44,
data_hash_chain_depth: 55,
field_hash_chain_depth: 66,
tail_entry_array_offset: 0,
tail_entry_array_n_entries: 0,
tail_entry_offset: 0,
},
ExpectedSanitizedHeader {
header_size: 260,
n_data: 11,
n_fields: 22,
n_tags: 33,
n_entry_arrays: 44,
data_hash_chain_depth: 55,
field_hash_chain_depth: 66,
tail_entry_array_offset: 77,
tail_entry_array_n_entries: 0,
tail_entry_offset: 0,
},
ExpectedSanitizedHeader {
header_size: 264,
n_data: 11,
n_fields: 22,
n_tags: 33,
n_entry_arrays: 44,
data_hash_chain_depth: 55,
field_hash_chain_depth: 66,
tail_entry_array_offset: 77,
tail_entry_array_n_entries: 88,
tail_entry_offset: 0,
},
ExpectedSanitizedHeader {
header_size: 268,
n_data: 11,
n_fields: 22,
n_tags: 33,
n_entry_arrays: 44,
data_hash_chain_depth: 55,
field_hash_chain_depth: 66,
tail_entry_array_offset: 77,
tail_entry_array_n_entries: 88,
tail_entry_offset: 0,
},
ExpectedSanitizedHeader {
header_size: 272,
n_data: 11,
n_fields: 22,
n_tags: 33,
n_entry_arrays: 44,
data_hash_chain_depth: 55,
field_hash_chain_depth: 66,
tail_entry_array_offset: 77,
tail_entry_array_n_entries: 88,
tail_entry_offset: 99,
},
ExpectedSanitizedHeader {
header_size: 300,
n_data: 11,
n_fields: 22,
n_tags: 33,
n_entry_arrays: 44,
data_hash_chain_depth: 55,
field_hash_chain_depth: 66,
tail_entry_array_offset: 77,
tail_entry_array_n_entries: 88,
tail_entry_offset: 99,
},
];
fn test_uuid(seed: u8) -> uuid::Uuid {
uuid::Uuid::from_bytes([seed; 16])
}
#[test]
fn payload_parts_structured_equals_contiguous_payload() {
let parts = PayloadParts::structured(b"NAME", b"\x00=VALUE");
assert!(parts.equals_slice(b"NAME=\x00=VALUE"));
assert!(!parts.equals_slice(b"NAME=VALUE"));
assert!(!parts.equals_slice(b"OTHER=\x00=VALUE"));
}
#[test]
fn sanitize_header_for_historical_size_matches_per_field_boundaries() {
for expected in HEADER_SANITIZE_CASES {
assert_sanitized_header(*expected);
}
}
fn assert_sanitized_header(expected: ExpectedSanitizedHeader) {
let sanitized = sanitize_header_for_size(JournalHeader {
header_size: expected.header_size,
n_data: 11,
n_fields: 22,
n_tags: 33,
n_entry_arrays: 44,
data_hash_chain_depth: 55,
field_hash_chain_depth: 66,
tail_entry_array_offset: 77,
tail_entry_array_n_entries: 88,
tail_entry_offset: 99,
..JournalHeader::default()
});
assert_eq!(sanitized.n_data, expected.n_data, "{expected:?}");
assert_eq!(sanitized.n_fields, expected.n_fields, "{expected:?}");
assert_eq!(sanitized.n_tags, expected.n_tags, "{expected:?}");
assert_eq!(
sanitized.n_entry_arrays, expected.n_entry_arrays,
"{expected:?}"
);
assert_eq!(
sanitized.data_hash_chain_depth, expected.data_hash_chain_depth,
"{expected:?}"
);
assert_eq!(
sanitized.field_hash_chain_depth, expected.field_hash_chain_depth,
"{expected:?}"
);
assert_eq!(
sanitized.tail_entry_array_offset, expected.tail_entry_array_offset,
"{expected:?}"
);
assert_eq!(
sanitized.tail_entry_array_n_entries, expected.tail_entry_array_n_entries,
"{expected:?}"
);
assert_eq!(
sanitized.tail_entry_offset, expected.tail_entry_offset,
"{expected:?}"
);
}
#[test]
fn writer_lock_helper_rejects_second_acquire() {
use crate::file::lock::WriterLock;
let dir = TempDir::new().expect("create temp dir");
let journal_dir = dir.path().join("journals");
std::fs::create_dir_all(&journal_dir).expect("create journal dir");
let path = journal_dir.join("system.journal");
let repo_file =
crate::repository::File::from_path(&path).expect("test journal path should parse");
let options = JournalFileOptions::new(test_uuid(1), test_uuid(2), test_uuid(3));
let mut writer_lock =
WriterLock::acquire(path.to_string_lossy().as_ref()).expect("acquire writer lock");
let _journal_file: JournalFile<crate::file::MmapMut> =
JournalFile::create(&repo_file, options).expect("create journal");
match WriterLock::acquire(path.to_string_lossy().as_ref()) {
Ok(mut lock) => {
let _ = lock.release();
panic!("second WriterLock::acquire succeeded while writer lock is held")
}
Err(err) => {
assert_eq!(err.kind(), std::io::ErrorKind::WouldBlock);
}
}
writer_lock.release().expect("release writer lock");
}
#[test]
fn writer_lock_is_disabled_by_default() {
let dir = TempDir::new().expect("create temp dir");
let journal_dir = dir.path().join("journals");
std::fs::create_dir_all(&journal_dir).expect("create journal dir");
let path = journal_dir.join("system.journal");
let repo_file =
crate::repository::File::from_path(&path).expect("test journal path should parse");
let options = JournalFileOptions::new(test_uuid(1), test_uuid(2), test_uuid(3));
let journal_file: JournalFile<crate::file::MmapMut> =
JournalFile::create(&repo_file, options).expect("create journal");
drop(journal_file);
assert!(!PathBuf::from(format!("{}.lock", path.display())).exists());
}
#[cfg(unix)]
#[test]
fn create_uses_journal_file_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().expect("create temp dir");
let journal_dir = dir.path().join("journals");
std::fs::create_dir_all(&journal_dir).expect("create journal dir");
let path = journal_dir.join("system.journal");
let repo_file =
crate::repository::File::from_path(&path).expect("test journal path should parse");
let _journal_file: JournalFile<crate::file::MmapMut> = JournalFile::create(
&repo_file,
JournalFileOptions::new(test_uuid(1), test_uuid(2), test_uuid(3)),
)
.expect("create journal");
let mode = std::fs::metadata(&path)
.expect("stat journal")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o640);
let override_path = journal_dir.join("override.journal");
let override_file =
crate::repository::File::from_path(&override_path).expect("test journal path should parse");
let _journal_file: JournalFile<crate::file::MmapMut> = JournalFile::create(
&override_file,
JournalFileOptions::new(test_uuid(1), test_uuid(2), test_uuid(3)).with_file_mode(0o600),
)
.expect("create journal with override mode");
let override_mode = std::fs::metadata(&override_path)
.expect("stat override journal")
.permissions()
.mode()
& 0o777;
assert_eq!(override_mode, 0o600);
}
#[test]
fn open_for_append_rejects_unkeyed_journal_without_mutation() {
let dir = TempDir::new().expect("create temp dir");
let journal_dir = dir.path().join("journals");
std::fs::create_dir_all(&journal_dir).expect("create journal dir");
let path = journal_dir.join("system.journal");
let repo_file =
crate::repository::File::from_path(&path).expect("test journal path should parse");
let mut journal_file: JournalFile<crate::file::MmapMut> = JournalFile::create(
&repo_file,
JournalFileOptions::new(test_uuid(1), test_uuid(2), test_uuid(3)).with_keyed_hash(false),
)
.expect("create unkeyed journal");
journal_file.sync().expect("sync unkeyed journal");
drop(journal_file);
let before = std::fs::read(&path).expect("read journal before append-open");
let err =
match JournalFile::<crate::file::MmapMut>::open_for_append(&repo_file, 8 * 1024 * 1024) {
Ok(_) => panic!("unkeyed journal append-open should be rejected"),
Err(err) => err,
};
assert!(matches!(err, JournalError::UnsupportedJournalFile));
let after = std::fs::read(&path).expect("read journal after append-open");
assert_eq!(after, before);
}
#[test]
fn entry_data_iterator_reports_zero_offsets_as_invalid() {
let dir = TempDir::new().expect("create temp dir");
let journal_dir = dir.path().join("journals");
std::fs::create_dir_all(&journal_dir).expect("create journal dir");
let path = journal_dir.join("system.journal");
let repo_file =
crate::repository::File::from_path(&path).expect("test journal path should parse");
let mut journal_file = JournalFile::create(
&repo_file,
JournalFileOptions::new(test_uuid(1), test_uuid(2), test_uuid(3)),
)
.expect("create journal");
let mut writer = JournalWriter::new(&mut journal_file, 1, test_uuid(4)).expect("create writer");
let payloads = [b"MESSAGE=test".as_slice(), b"PRIORITY=6".as_slice()];
writer
.add_entry(&mut journal_file, &payloads, 1_000_000, 100)
.expect("write entry");
let mut entry_offsets = Vec::new();
journal_file
.entry_offsets(&mut entry_offsets)
.expect("collect entry offsets");
let entry_offset = *entry_offsets.first().expect("journal entry");
{
let mut entry_guard = journal_file
.entry_mut(entry_offset, None)
.expect("entry guard");
match &mut entry_guard.items {
EntryItemsType::Regular(items) => items[0].object_offset = 0,
EntryItemsType::Compact(items) => items[0].object_offset = 0,
}
}
let mut iter = journal_file
.entry_data_objects(entry_offset)
.expect("entry iterator");
assert!(matches!(
iter.next(),
Some(Err(JournalError::InvalidOffset))
));
assert!(
iter.next().is_none(),
"iterator should stop after the error"
);
}
fn first_data_offset(journal_file: &JournalFile<crate::file::MmapMut>) -> NonZeroU64 {
let mut entry_offsets = Vec::new();
journal_file
.entry_offsets(&mut entry_offsets)
.expect("collect entry offsets");
let entry_offset = *entry_offsets.first().expect("journal entry");
let entry = journal_file.entry_ref(entry_offset).expect("entry object");
match &entry.items {
EntryItemsType::Regular(items) => {
NonZeroU64::new(items[0].object_offset).expect("data offset")
}
EntryItemsType::Compact(items) => {
NonZeroU64::new(items[0].object_offset as u64).expect("data offset")
}
}
}
#[test]
fn visit_data_payload_at_returns_compact_uncompressed_payload() {
let dir = TempDir::new().expect("create temp dir");
let journal_dir = dir.path().join("journals");
std::fs::create_dir_all(&journal_dir).expect("create journal dir");
let path = journal_dir.join("system.journal");
let repo_file =
crate::repository::File::from_path(&path).expect("test journal path should parse");
let mut journal_file = JournalFile::create(
&repo_file,
JournalFileOptions::new(test_uuid(1), test_uuid(2), test_uuid(3)).with_compact(true),
)
.expect("create compact journal");
let mut writer = JournalWriter::new(&mut journal_file, 1, test_uuid(4)).expect("create writer");
writer
.add_entry(
&mut journal_file,
&[b"MESSAGE=compact payload".as_slice()],
1_000_000,
100,
)
.expect("write compact entry");
let offset = first_data_offset(&journal_file);
let mut decompressed = Vec::new();
let mut observed = Vec::new();
journal_file
.visit_data_payload_at(offset, &mut decompressed, |payload| {
observed.extend_from_slice(payload);
Ok(())
})
.expect("visit payload");
assert_eq!(observed, b"MESSAGE=compact payload");
}
#[test]
fn visit_data_payload_at_decompresses_payload() {
let dir = TempDir::new().expect("create temp dir");
let journal_dir = dir.path().join("journals");
std::fs::create_dir_all(&journal_dir).expect("create journal dir");
let path = journal_dir.join("system.journal");
let repo_file =
crate::repository::File::from_path(&path).expect("test journal path should parse");
let mut journal_file = JournalFile::create(
&repo_file,
JournalFileOptions::new(test_uuid(1), test_uuid(2), test_uuid(3))
.with_compression(Compression::Zstd)
.with_compress_threshold(8),
)
.expect("create compressed journal");
let mut writer = JournalWriter::new(&mut journal_file, 1, test_uuid(4)).expect("create writer");
let payload = format!("MESSAGE={}", "x".repeat(1024));
writer
.add_entry(&mut journal_file, &[payload.as_bytes()], 1_000_000, 100)
.expect("write compressed entry");
let offset = first_data_offset(&journal_file);
let mut decompressed = Vec::new();
let mut observed = Vec::new();
journal_file
.visit_data_payload_at(offset, &mut decompressed, |payload| {
observed.extend_from_slice(payload);
Ok(())
})
.expect("visit payload");
assert_eq!(observed, payload.as_bytes());
}
#[test]
fn compact_writer_reader_and_stock_journalctl() {
let dir = TempDir::new().expect("create temp dir");
let journal_dir = dir.path().join("journals");
std::fs::create_dir_all(&journal_dir).expect("create journal dir");
let path = journal_dir.join("system.journal");
let journal_file = create_compact_test_journal(&path);
assert_compact_header_and_payloads(&journal_file);
assert_stock_journalctl_compact_read_and_verify(&path);
}
fn create_compact_test_journal(path: &PathBuf) -> JournalFile<MmapMut> {
let repo_file =
crate::repository::File::from_path(&path).expect("test journal path should parse");
let mut journal_file = JournalFile::create(
&repo_file,
JournalFileOptions::new(test_uuid(1), test_uuid(2), test_uuid(3)).with_compact(true),
)
.expect("create compact journal");
let mut writer = JournalWriter::new(&mut journal_file, 1, test_uuid(4)).expect("create writer");
writer
.add_entry(
&mut journal_file,
&[
b"MESSAGE=compact entry".as_slice(),
b"BINARY=\x00\x01\xfe\xff".as_slice(),
],
1_000_000,
100,
)
.expect("write first compact entry");
writer
.add_entry(
&mut journal_file,
&[
b"MESSAGE=second compact entry".as_slice(),
b"PRIORITY=6".as_slice(),
],
1_000_001,
101,
)
.expect("write second compact entry");
journal_file.sync().expect("sync compact journal");
journal_file
}
fn assert_compact_header_and_payloads(journal_file: &JournalFile<MmapMut>) {
assert!(
journal_file
.journal_header_ref()
.has_incompatible_flag(HeaderIncompatibleFlags::Compact)
);
let mut entry_offsets = Vec::new();
journal_file
.entry_offsets(&mut entry_offsets)
.expect("collect compact entry offsets");
assert_eq!(entry_offsets.len(), 2);
let payloads = journal_file
.entry_data_objects(entry_offsets[0])
.expect("compact entry data iterator")
.map(|item| item.map(|object| object.raw_payload().to_vec()))
.collect::<Result<Vec<_>>>()
.expect("read compact data objects");
assert!(payloads.iter().any(|p| p == b"MESSAGE=compact entry"));
assert!(payloads.iter().any(|p| p == b"BINARY=\x00\x01\xfe\xff"));
}
fn assert_stock_journalctl_compact_read_and_verify(path: &PathBuf) {
if !journalctl_available() {
eprintln!("journalctl not available; skipping stock compact assertions");
return;
}
assert_stock_journalctl_compact_read(path);
assert_stock_journalctl_compact_verify(path);
}
fn assert_stock_journalctl_compact_read(path: &PathBuf) {
let output = Command::new("journalctl")
.arg("--file")
.arg(&path)
.arg("--output=json")
.arg("--no-pager")
.output()
.expect("run journalctl compact read");
assert!(
output.status.success(),
"journalctl compact read failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
output
.stdout
.split(|b| *b == b'\n')
.filter(|line| !line.is_empty())
.count(),
2
);
}
fn assert_stock_journalctl_compact_verify(path: &PathBuf) {
let output = Command::new("journalctl")
.arg("--verify")
.arg("--file")
.arg(&path)
.arg("--no-pager")
.output()
.expect("run journalctl compact verify");
assert!(
output.status.success(),
"journalctl compact verify failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn journalctl_available() -> bool {
Command::new("journalctl")
.arg("--version")
.output()
.is_ok_and(|output| output.status.success())
}