systemd-journal-sdk 0.6.2

Pure-Rust systemd journal reader and writer SDK
Documentation
use super::*;

fn verification_key(opts: &SealOptions) -> String {
    let seed_hex = opts
        .seed
        .iter()
        .map(|b| format!("{:02x}", b))
        .collect::<String>();
    let start = opts.start_usec / opts.interval_usec;
    format!(
        "{seed_hex}/{start:x}-{interval:x}",
        interval = opts.interval_usec
    )
}

fn write_sealed_verify_file(path: &Path) -> SealOptions {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).expect("create journal parent");
    }
    let repo_file = RepoFile::from_path(path)
        .unwrap_or_else(|| panic!("test journal path should parse: {}", path.display()));
    let seal = test_seal_opts();
    let mut journal_file = JournalFile::<MmapMut>::create(
        &repo_file,
        JournalFileOptions::new(test_uuid(1), test_uuid(2), test_uuid(3)).with_seal(seal.clone()),
    )
    .expect("create sealed journal");
    let mut writer = JournalWriter::new(&mut journal_file, 1, test_uuid(4)).expect("create writer");
    writer
        .add_entry(
            &mut journal_file,
            &[b"MESSAGE=sealed-covered".as_slice()],
            1_500_000,
            100,
        )
        .expect("write covered entry");
    writer
        .add_entry(
            &mut journal_file,
            &[b"MESSAGE=later-entry".as_slice()],
            2_500_000,
            200,
        )
        .expect("write later entry");
    journal_file.sync().expect("sync journal");
    seal
}

fn tamper_data_payload(path: &Path, payload: &[u8]) {
    let mut data = std::fs::read(path).expect("read journal bytes");
    let header_size = u64::from_le_bytes(data[88..96].try_into().unwrap());
    let tail_object_offset = u64::from_le_bytes(data[136..144].try_into().unwrap());
    let incompatible_flags = u32::from_le_bytes(data[12..16].try_into().unwrap());
    let payload_offset = if incompatible_flags & INCOMPATIBLE_COMPACT != 0 {
        COMPACT_DATA_OBJECT_HEADER_SIZE
    } else {
        DATA_OBJECT_HEADER_SIZE
    };

    let mut offset = header_size;
    let mut tag_count = 0usize;
    let mut second_tag_offset = 0u64;
    let mut target_payload_offset = 0u64;
    let mut target_object_offset = 0u64;

    loop {
        assert!(
            offset + OBJECT_HEADER_SIZE <= data.len() as u64,
            "object header at {offset} exceeds file"
        );
        let typ = data[offset as usize];
        let size = u64::from_le_bytes(
            data[(offset as usize + 8)..(offset as usize + 16)]
                .try_into()
                .unwrap(),
        );
        assert!(
            size >= OBJECT_HEADER_SIZE,
            "invalid object size {size} at {offset}"
        );
        let aligned_size = align8(size);
        assert!(
            offset + aligned_size <= data.len() as u64,
            "object at {offset} exceeds file"
        );

        if typ == OBJECT_TYPE_TAG {
            tag_count += 1;
            if tag_count == 2 {
                second_tag_offset = offset;
            }
        } else if typ == OBJECT_TYPE_DATA && size > payload_offset {
            let start = (offset + payload_offset) as usize;
            let end = (offset + size) as usize;
            if &data[start..end] == payload {
                target_payload_offset = start as u64;
                target_object_offset = offset;
            }
        }

        if offset == tail_object_offset {
            break;
        }
        offset += aligned_size;
    }

    assert_ne!(target_payload_offset, 0, "payload not found");
    assert_ne!(second_tag_offset, 0, "second TAG not found");
    assert!(
        target_object_offset < second_tag_offset,
        "DATA object {target_object_offset} is not covered by second TAG {second_tag_offset}"
    );
    data[target_payload_offset as usize] ^= 0x01;
    std::fs::write(path, data).expect("write tampered journal bytes");
}

#[test]
fn verify_file_detects_corruption() {
    let path = repo_root().join("fixtures/systemd/test-data/corrupted/zstd-truncated-frame.zst");
    let err = verify_file(&path).expect_err("expected verification error for truncated zstd frame");
    let msg = err.to_string();
    assert!(
        msg.to_lowercase().contains("corrupt"),
        "expected error to contain 'corrupt', got: {msg}"
    );
}

#[test]
fn verify_file_passes_on_valid_fixture() {
    let path = repo_root().join("fixtures/systemd/test-data/no-rtc/system.journal.zst");
    verify_file(&path).expect("expected verification to pass for valid fixture");
}

#[test]
fn verify_file_with_key_validates_sealed_hmacs() {
    let dir = tempfile::tempdir().expect("create temp dir");
    let path = dir
        .path()
        .join("00000000-0000-0000-0000-000000000001/system.journal");
    let seal = write_sealed_verify_file(&path);
    let key = verification_key(&seal);

    verify_file_with_key(&path, &key).expect("sealed verification should pass");
    let zst_path = dir.path().join("sealed.journal.zst");
    let source = std::fs::read(&path).expect("read sealed journal");
    let compressed = ruzstd::encoding::compress_to_vec(
        source.as_slice(),
        ruzstd::encoding::CompressionLevel::Fastest,
    );
    std::fs::write(&zst_path, compressed).expect("write compressed sealed journal");
    verify_file_with_key(&zst_path, &key).expect("compressed sealed verification should pass");

    verify_file_with_key(&path, "000000000000000000000001/1-f4240")
        .expect_err("wrong key should fail");

    tamper_data_payload(&path, b"MESSAGE=sealed-covered");
    verify_file_with_key(&path, &key).expect_err("authenticated DATA tamper should fail");
}

#[test]
fn verify_file_with_key_rejects_aligned_size_overflow() {
    let dir = tempfile::tempdir().expect("create temp dir");
    let path = dir
        .path()
        .join("00000000-0000-0000-0000-000000000001/system.journal");
    let seal = write_sealed_verify_file(&path);
    let key = verification_key(&seal);

    let mut data = std::fs::read(&path).expect("read sealed journal");
    let header_size = u64::from_le_bytes(data[88..96].try_into().unwrap()) as usize;
    data[header_size + 8..header_size + 16].copy_from_slice(&u64::MAX.to_le_bytes());
    std::fs::write(&path, data).expect("write malformed journal");

    let err = verify_file_with_key(&path, &key)
        .expect_err("aligned-size overflow should fail verification");
    let msg = err.to_string();
    assert!(
        msg.contains("overflows alignment"),
        "expected alignment overflow error, got: {msg}"
    );
}

#[test]
fn verify_file_with_key_rejects_short_sealed_header_without_panic() {
    let dir = tempfile::tempdir().expect("create temp dir");
    let path = dir
        .path()
        .join("00000000-0000-0000-0000-000000000001/system.journal");
    std::fs::create_dir_all(path.parent().expect("journal parent")).expect("create parent");

    let mut data = vec![0u8; HEADER_MIN_SIZE as usize];
    data[0..8].copy_from_slice(b"LPKSHHRH");
    data[8..12].copy_from_slice(&1u32.to_le_bytes());
    data[16] = 1;
    data[88..96].copy_from_slice(&HEADER_MIN_SIZE.to_le_bytes());
    std::fs::write(&path, data).expect("write short sealed header");

    let err = verify_file_with_key(&path, "000000000000000000000000/1-f4240")
        .expect_err("short sealed header should not verify");
    let msg = err.to_string();
    assert!(
        msg.contains("open/decompression failed")
            || msg.contains("corrupt")
            || msg.contains("verification"),
        "expected controlled verification error, got: {msg}"
    );
}

#[test]
fn verify_file_rejects_referenced_zero_sized_data_object() {
    let fixture = repo_root().join("fixtures/systemd/test-data/no-rtc/system.journal.zst");
    let path = decompress_zst_to_temp(&fixture, "rust-sdk-verify-corrupt")
        .expect("decompress fixture to temporary journal");
    let _cleanup = TempPath(path.clone());

    let data_offset = {
        let reader = FileReader::open(&path).expect("open decompressed journal");
        reader.inner.with_file(|file| {
            let mut entry_offsets = Vec::new();
            file.entry_offsets(&mut entry_offsets)
                .expect("collect entry offsets");
            let mut data_offsets = Vec::new();
            file.entry_data_object_offsets(entry_offsets[0], &mut data_offsets)
                .expect("collect data offsets");
            data_offsets[0]
        })
    };

    let mut bytes = std::fs::read(&path).expect("read journal bytes");
    let size_start = data_offset.get() as usize + 8;
    bytes[size_start..size_start + 8].copy_from_slice(&0u64.to_le_bytes());
    std::fs::write(&path, bytes).expect("write corrupted journal bytes");

    let err = verify_file(&path)
        .expect_err("strict verification should reject referenced zero-sized data object");
    let msg = err.to_string();
    assert!(
        msg.to_lowercase().contains("corrupt"),
        "expected error to contain 'corrupt', got: {msg}"
    );
    assert!(
        msg.contains("data object") || msg.contains("object size"),
        "expected strict data-object or object-size error, got: {msg}"
    );
}