#![cfg(all(feature = "encrypt", feature = "ttl"))]
use std::fs;
use std::path::PathBuf;
use emdb::{Cipher, Emdb, EncryptionInput, Error};
const KEY_A: [u8; 32] = *b"alpha-key--32-bytes-exactly-1234";
const KEY_B: [u8; 32] = *b"bravo-key--32-bytes-exactly-5678";
fn tmp_path(label: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0_u128, |d| d.as_nanos());
let tid = std::thread::current().id();
p.push(format!("emdb-enc-admin-{label}-{nanos}-{tid:?}.emdb"));
p
}
fn cleanup(path: &PathBuf) {
let _ = fs::remove_file(path);
let display = path.display();
let _ = fs::remove_file(format!("{display}.lock"));
let _ = fs::remove_file(format!("{display}.enc.tmp"));
let _ = fs::remove_file(format!("{display}.enc.tmp.lock"));
let _ = fs::remove_file(format!("{display}.encbak"));
let _ = fs::remove_file(format!("{display}.encbak.lock"));
let _ = fs::remove_file(format!("{display}.compact.tmp"));
}
#[test]
fn enable_encryption_round_trips_every_record() {
let path = tmp_path("enable-roundtrip");
cleanup(&path);
{
let db = Emdb::open(&path).expect("open plaintext");
for i in 0_u32..50 {
db.insert(format!("k{i:03}"), format!("v{i}"))
.expect("insert");
}
db.flush().expect("flush");
}
Emdb::enable_encryption(&path, EncryptionInput::Key(KEY_A)).expect("enable_encryption");
let db = Emdb::builder()
.path(&path)
.encryption_key(KEY_A)
.build()
.expect("reopen encrypted");
assert_eq!(db.len().expect("len"), 50);
for i in 0_u32..50 {
assert_eq!(
db.get(format!("k{i:03}")).expect("get").as_deref(),
Some(format!("v{i}").as_bytes()),
"record {i} missing after enable_encryption"
);
}
drop(db);
let open_unencrypted = Emdb::open(&path);
assert!(
open_unencrypted.is_err(),
"opening encrypted file without key should fail"
);
cleanup(&path);
}
#[test]
fn disable_encryption_round_trips_every_record() {
let path = tmp_path("disable-roundtrip");
cleanup(&path);
{
let db = Emdb::builder()
.path(&path)
.encryption_key(KEY_A)
.build()
.expect("open encrypted");
for i in 0_u32..30 {
db.insert(format!("k{i:03}"), format!("v{i}"))
.expect("insert");
}
db.flush().expect("flush");
}
Emdb::disable_encryption(&path, EncryptionInput::Key(KEY_A)).expect("disable_encryption");
let db = Emdb::open(&path).expect("reopen plaintext");
assert_eq!(db.len().expect("len"), 30);
for i in 0_u32..30 {
assert_eq!(
db.get(format!("k{i:03}")).expect("get").as_deref(),
Some(format!("v{i}").as_bytes()),
"record {i} missing after disable_encryption"
);
}
cleanup(&path);
}
#[test]
fn rotate_encryption_key_swaps_keys_atomically() {
let path = tmp_path("rotate-roundtrip");
cleanup(&path);
{
let db = Emdb::builder()
.path(&path)
.encryption_key(KEY_A)
.build()
.expect("open with KEY_A");
for i in 0_u32..20 {
db.insert(format!("k{i:03}"), format!("v{i}"))
.expect("insert");
}
db.flush().expect("flush");
}
Emdb::rotate_encryption_key(
&path,
EncryptionInput::Key(KEY_A),
EncryptionInput::Key(KEY_B),
)
.expect("rotate_encryption_key");
{
let db = Emdb::builder()
.path(&path)
.encryption_key(KEY_B)
.build()
.expect("reopen with KEY_B");
assert_eq!(db.len().expect("len"), 20);
for i in 0_u32..20 {
assert_eq!(
db.get(format!("k{i:03}")).expect("get").as_deref(),
Some(format!("v{i}").as_bytes())
);
}
}
let stale_open = Emdb::builder().path(&path).encryption_key(KEY_A).build();
assert!(
matches!(stale_open, Err(Error::EncryptionKeyMismatch)),
"old key should be rejected after rotation, got {:?}",
stale_open.err()
);
cleanup(&path);
}
#[test]
fn wrong_key_on_reopen_surfaces_encryption_key_mismatch() {
let path = tmp_path("wrong-key");
cleanup(&path);
{
let db = Emdb::builder()
.path(&path)
.encryption_key(KEY_A)
.build()
.expect("open with KEY_A");
db.insert("secret", "ciphertext-on-disk").expect("insert");
db.flush().expect("flush");
}
let wrong_open = Emdb::builder().path(&path).encryption_key(KEY_B).build();
assert!(
matches!(wrong_open, Err(Error::EncryptionKeyMismatch)),
"wrong key should surface as EncryptionKeyMismatch, got {:?}",
wrong_open.err()
);
cleanup(&path);
}
#[test]
fn tampered_ciphertext_is_rejected() {
let path = tmp_path("tampered");
cleanup(&path);
{
let db = Emdb::builder()
.path(&path)
.encryption_key(KEY_A)
.build()
.expect("open");
for i in 0_u32..200 {
db.insert(
format!("key-{i:04}"),
format!("value-{i}-padding-padding-padding-padding"),
)
.expect("insert");
}
db.flush().expect("flush");
db.checkpoint().expect("checkpoint");
}
{
let mut bytes = fs::read(&path).expect("read file");
assert!(
bytes.len() > 4096,
"file should be > 4 KiB after 200 records, got {} bytes",
bytes.len()
);
let tamper_at = bytes.len() - 32;
bytes[tamper_at] ^= 0x01;
fs::write(&path, &bytes).expect("write tampered file");
}
let reopen = Emdb::builder().path(&path).encryption_key(KEY_A).build();
match reopen {
Err(_) => {
}
Ok(db) => {
let mut found_corruption = false;
for i in 0_u32..200 {
let key = format!("key-{i:04}");
match db.get(&key) {
Err(_) => {
found_corruption = true;
break;
}
Ok(None) => {
found_corruption = true;
break;
}
Ok(Some(v)) => {
let expected =
format!("value-{i}-padding-padding-padding-padding").into_bytes();
if v != expected {
found_corruption = true;
break;
}
}
}
}
assert!(
found_corruption,
"AEAD should have detected the flipped byte"
);
}
}
cleanup(&path);
}
#[test]
fn encryption_plus_ttl_round_trip() {
use emdb::Ttl;
use std::time::Duration;
let path = tmp_path("encrypt-ttl");
cleanup(&path);
let db = Emdb::builder()
.path(&path)
.encryption_key(KEY_A)
.default_ttl(Duration::from_secs(3600))
.build()
.expect("open");
db.insert("permanent", "value").expect("insert");
db.insert_with_ttl("ephemeral", "ghost", Ttl::After(Duration::from_millis(50)))
.expect("insert_with_ttl");
db.flush().expect("flush");
assert_eq!(
db.get("permanent").expect("get").as_deref(),
Some(b"value".as_slice())
);
assert_eq!(
db.get("ephemeral").expect("get").as_deref(),
Some(b"ghost".as_slice())
);
std::thread::sleep(Duration::from_millis(80));
assert_eq!(
db.get("permanent").expect("get").as_deref(),
Some(b"value".as_slice())
);
assert!(
db.get("ephemeral").expect("get").is_none(),
"ephemeral record should have expired"
);
drop(db);
cleanup(&path);
}
#[test]
fn chacha20_cipher_round_trip() {
let path = tmp_path("chacha20");
cleanup(&path);
{
let db = Emdb::builder()
.path(&path)
.encryption_key(KEY_A)
.cipher(Cipher::ChaCha20Poly1305)
.build()
.expect("open chacha");
for i in 0_u32..10 {
db.insert(format!("k{i}"), format!("v{i}")).expect("insert");
}
db.flush().expect("flush");
}
let db = Emdb::builder()
.path(&path)
.encryption_key(KEY_A)
.cipher(Cipher::ChaCha20Poly1305)
.build()
.expect("reopen chacha");
for i in 0_u32..10 {
assert_eq!(
db.get(format!("k{i}")).expect("get").as_deref(),
Some(format!("v{i}").as_bytes())
);
}
drop(db);
cleanup(&path);
}
#[test]
fn passphrase_round_trip() {
let path = tmp_path("passphrase");
cleanup(&path);
{
let db = Emdb::builder()
.path(&path)
.encryption_passphrase("correct-horse-battery-staple")
.build()
.expect("open with passphrase");
db.insert("k", "v").expect("insert");
db.flush().expect("flush");
}
let db = Emdb::builder()
.path(&path)
.encryption_passphrase("correct-horse-battery-staple")
.build()
.expect("reopen with same passphrase");
assert_eq!(db.get("k").expect("get").as_deref(), Some(b"v".as_slice()));
cleanup(&path);
}
#[test]
fn wrong_passphrase_is_rejected() {
let path = tmp_path("wrong-passphrase");
cleanup(&path);
{
let db = Emdb::builder()
.path(&path)
.encryption_passphrase("correct")
.build()
.expect("open");
db.insert("k", "v").expect("insert");
db.flush().expect("flush");
}
let wrong = Emdb::builder()
.path(&path)
.encryption_passphrase("WRONG")
.build();
assert!(wrong.is_err(), "wrong passphrase should fail; got Ok",);
cleanup(&path);
}