armdb 0.11.0

sharded bitcask key-value storage optimized for NVMe
Documentation
//! An encrypted collection in a transaction: the log must be encrypted too, and
//! the whole path — commit, crash, replay — must work under the envelope.
#![cfg(all(feature = "encryption", feature = "test-faults"))]

use std::sync::Arc;

use armdb::armour::Db;
use armdb::{CollectionMeta, Config, NoHook, RapiraCodec, TtlHook, TxConfig, TypedMap};
use armour_core::GetType;
use rapira::Rapira;

type Id = [u8; 8];

fn id(n: u64) -> Id {
    n.to_be_bytes()
}

#[derive(Clone, Debug, PartialEq, Eq, GetType, Rapira)]
struct Secret {
    text: String,
}
impl CollectionMeta for Secret {
    type SelfId = Id;
    const NAME: &'static str = "secrets";
}

const DATA_KEY: [u8; 32] = [0x11; 32];
const LOG_KEY: [u8; 32] = [0x22; 32];

fn open_db(dir: &std::path::Path, log_key: Option<[u8; 32]>) -> Arc<Db> {
    Arc::new(
        Db::open_with_tx_config(
            dir,
            TxConfig {
                log_encryption_key: log_key,
                ..TxConfig::default()
            },
        )
        .unwrap(),
    )
}

fn open_secrets(db: &Arc<Db>) -> Arc<TypedMap<Id, Secret, RapiraCodec, TtlHook<Secret, NoHook>>> {
    // `Config::test()` returns a `Config`, not a builder (`config.rs`), so the
    // key is set on the value — the shape `config.rs`'s own encryption tests use.
    let mut cfg = Config::test();
    cfg.encryption_key = Some(DATA_KEY);
    db.open_typed_map::<Secret, RapiraCodec, _>(cfg, NoHook, &[])
        .unwrap()
}

#[test]
fn tx_encrypted_commit_replays_after_a_crash_and_keeps_the_log_sealed() {
    let dir = tempfile::tempdir().unwrap();
    {
        let db = open_db(dir.path(), Some(LOG_KEY));
        let secrets = open_secrets(&db);
        Db::tx_debug_skip_apply(true);
        db.tx()
            .put(
                &secrets,
                &id(2),
                &Secret {
                    text: "top secret".into(),
                },
            )
            .unwrap()
            .durable()
            .commit()
            .unwrap();
        Db::tx_debug_skip_apply(false);
        assert!(secrets.get(&id(2)).is_none());
        db.close().unwrap();
    }

    // The value must not be readable in the log's bytes.
    let seg = std::fs::read(dir.path().join("txlog").join("000001.log")).unwrap();
    assert!(
        seg.windows(10).all(|w| w != b"top secret"),
        "the transaction log stored an encrypted collection's value in the clear"
    );

    let db = open_db(dir.path(), Some(LOG_KEY));
    let secrets = open_secrets(&db);
    assert_eq!(
        secrets.get(&id(2)).map(|s| s.text.clone()),
        Some("top secret".to_string()),
        "the pending record replays through the envelope"
    );
}

#[test]
fn tx_encrypted_log_fails_closed_without_the_key() {
    let dir = tempfile::tempdir().unwrap();
    {
        let db = open_db(dir.path(), Some(LOG_KEY));
        let secrets = open_secrets(&db);
        Db::tx_debug_skip_apply(true);
        db.tx()
            .put(&secrets, &id(3), &Secret { text: "x".into() })
            .unwrap()
            .durable()
            .commit()
            .unwrap();
        Db::tx_debug_skip_apply(false);
        db.close().unwrap();
    }

    // A pending encrypted record with no key configured: the open must fail
    // rather than silently drop a committed transaction.
    let err = Db::open_with_tx_config(dir.path(), TxConfig::default())
        .err()
        .expect("open without log key must fail");
    assert!(
        matches!(
            err,
            armdb::DbError::EncryptionError(_) | armdb::DbError::FormatMismatch(_)
        ),
        "{err}"
    );

    // The wrong key is equally fatal.
    let err = Db::open_with_tx_config(
        dir.path(),
        TxConfig {
            log_encryption_key: Some([0x33; 32]),
            ..TxConfig::default()
        },
    )
    .err()
    .expect("open with wrong log key must fail");
    assert!(matches!(err, armdb::DbError::EncryptionError(_)), "{err}");
}