lkv 0.1.0

A lightweight and fast embedded key-value store for Rust.
Documentation
use super::*;
use crate::VerificationMode;

#[test]
fn rejects_corrupt_base_lengths() -> Result<()> {
    let dir = temp_dir();
    let mut db = Database::open(&dir)?;
    db.put(b"key", b"value")?;
    db.compact()?;
    let record_offset = db.base.offset + BASE_HEADER as u64 + db.base.slots * SLOT_SIZE as u64;
    drop(db);
    let mut file = OpenOptions::new().write(true).open(&dir)?;
    file.seek(SeekFrom::Start(record_offset + 4))?;
    file.write_all(&u32::MAX.to_le_bytes())?;
    file.sync_all()?;
    let error = Database::open_with_options(
        &dir,
        DatabaseOptions {
            verification: VerificationMode::Full,
            ..DatabaseOptions::default()
        },
    )
    .err()
    .expect("corrupt base must fail open");
    assert_eq!(error.kind(), ErrorKind::InvalidData);
    remove_test_database(&dir)?;
    Ok(())
}

#[test]
fn on_read_defers_bad_record_bounds_and_iteration_returns_error() -> Result<()> {
    let dir = temp_dir();
    let mut db = Database::open(&dir)?;
    db.put(b"key", b"value")?;
    db.compact()?;
    let record_offset = db.base.offset + BASE_HEADER as u64 + db.base.slots * SLOT_SIZE as u64;
    drop(db);

    let mut file = OpenOptions::new().write(true).open(&dir)?;
    file.seek(SeekFrom::Start(record_offset + 4))?;
    file.write_all(&u32::MAX.to_le_bytes())?;
    file.sync_all()?;
    drop(file);

    let db = Database::open(&dir)?;
    let mut entries = db.iter()?;
    let error = entries
        .next()
        .expect("corrupt record must produce one iterator error")
        .expect_err("corrupt record must not be returned");
    assert_eq!(error.kind(), ErrorKind::InvalidData);
    assert!(entries.next().is_none());
    drop(entries);
    drop(db);
    remove_test_database(&dir)?;
    Ok(())
}

#[test]
fn semantic_verification_enables_only_the_trusted_iterator_path() -> Result<()> {
    let dir = temp_dir();
    let mut db = Database::open(&dir)?;
    db.put(b"key", b"value")?;
    db.compact()?;
    assert!(db.iter_raw().base_trusted);
    drop(db);

    let db = Database::open(&dir)?;
    assert!(!db.iter_raw().base_trusted);
    db.verify()?;
    assert!(db.iter_raw().base_trusted);
    drop(db);
    remove_test_database(&dir)?;
    Ok(())
}

#[test]
fn rejects_base_value_corruption() -> Result<()> {
    let dir = temp_dir();
    let mut db = Database::open(&dir)?;
    db.put(b"key", b"value")?;
    db.compact()?;
    let value_offset = db.base.offset
        + BASE_HEADER as u64
        + db.base.slots * SLOT_SIZE as u64
        + 8
        + b"key".len() as u64;
    drop(db);

    let path = dir.clone();
    let mut file = OpenOptions::new().read(true).write(true).open(&path)?;
    file.seek(SeekFrom::Start(value_offset))?;
    let mut byte = [0];
    file.read_exact(&mut byte)?;
    byte[0] ^= 0xff;
    file.seek(SeekFrom::Start(value_offset))?;
    file.write_all(&byte)?;
    file.sync_all()?;
    let db = Database::open(&dir)?;
    let error = db
        .get(b"key")
        .expect_err("corrupt base block must fail on first read");
    assert_eq!(error.kind(), ErrorKind::InvalidData);
    drop(db);
    remove_test_database(&dir)?;
    Ok(())
}

#[test]
fn on_read_verifies_every_block_spanned_by_a_value() -> Result<()> {
    let dir = temp_dir();
    let mut db = Database::open(&dir)?;
    let value = vec![b'v'; CHECKSUM_BLOCK_SIZE * 2];
    db.put(b"large", &value)?;
    db.compact()?;
    let corrupt_offset = db.base.offset + CHECKSUM_BLOCK_SIZE as u64 + 123;
    drop(db);

    let path = dir.clone();
    let mut file = OpenOptions::new().read(true).write(true).open(&path)?;
    file.seek(SeekFrom::Start(corrupt_offset))?;
    let mut byte = [0];
    file.read_exact(&mut byte)?;
    byte[0] ^= 0xff;
    file.seek(SeekFrom::Start(corrupt_offset))?;
    file.write_all(&byte)?;
    file.sync_all()?;
    drop(file);

    let db = Database::open(&dir)?;
    let error = db.get(b"large").expect_err("second block is corrupt");
    match error {
        Error::Corrupted(corruption) => {
            assert_eq!(corruption.kind(), CorruptionKind::BlockChecksum);
            assert_eq!(corruption.block_index(), Some(1));
        }
        other => panic!("unexpected error: {other}"),
    }
    drop(db);
    remove_test_database(&dir)?;
    Ok(())
}

#[test]
fn on_read_open_still_rejects_checksum_metadata_corruption() -> Result<()> {
    let dir = temp_dir();
    let mut db = Database::open(&dir)?;
    db.put(b"key", b"value")?;
    db.compact()?;
    let checksum_table_offset = db.base.offset + db.base.verifier.data_size() as u64;
    drop(db);

    let path = dir.clone();
    let mut file = OpenOptions::new().write(true).open(&path)?;
    file.seek(SeekFrom::Start(checksum_table_offset))?;
    file.write_all(&0u32.to_le_bytes())?;
    file.sync_all()?;
    let error = Database::open(&dir)
        .err()
        .expect("checksum table metadata must be protected");
    assert!(matches!(error, Error::Corrupted(_)));
    remove_test_database(&dir)?;
    Ok(())
}

#[test]
fn map_segment_rejects_ranges_beyond_the_file() -> Result<()> {
    let dir = temp_dir();
    let db = Database::open(&dir)?;
    let file = db.storage.file().expect("persistent test database");
    let file_len = file.metadata()?.len();
    let error = segment::map(file, file_len, 1).unwrap_err();
    assert_eq!(error.kind(), ErrorKind::InvalidData);
    drop(db);
    remove_test_database(&dir)?;
    Ok(())
}

#[test]
fn unknown_format_version_is_reported_as_unsupported() -> Result<()> {
    let dir = temp_dir();
    let db = Database::open(&dir)?;
    drop(db);
    let mut file = OpenOptions::new().read(true).write(true).open(&dir)?;
    let mut newer = [0; SUPERBLOCK_SIZE as usize];
    file.seek(SeekFrom::Start(SUPERBLOCK_SIZE))?;
    file.read_exact(&mut newer)?;
    newer[8..12].copy_from_slice(&(FORMAT_VERSION + 1).to_le_bytes());
    newer[16..24].copy_from_slice(&2u64.to_le_bytes());
    let checksum_offset = HEADER_SIZE - size_of::<u32>();
    let checksum = crc32c::crc32c(&newer[..checksum_offset]);
    newer[checksum_offset..HEADER_SIZE].copy_from_slice(&checksum.to_le_bytes());
    file.seek(SeekFrom::Start(0))?;
    file.write_all(&newer)?;
    file.seek(SeekFrom::End(0))?;
    file.write_all(&[1, 2, 3])?;
    file.sync_all()?;
    let original_len = file.metadata()?.len();
    drop(file);

    let error = Database::open(&dir)
        .err()
        .expect("unknown version must be rejected");
    assert!(matches!(error, Error::Unsupported(_)));
    assert_eq!(fs::metadata(&dir)?.len(), original_len);
    remove_test_database(&dir)?;
    Ok(())
}