taladb 0.12.0

Embedded, local-first document and vector database: serde documents, JSON filters, vector, full-text and hybrid search, live queries
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
//! Integration tests for AES-GCM-256 encryption at rest.
//!
//! Requires `--features encryption` to compile and run.

#![cfg(feature = "encryption")]

use std::sync::Arc;

use taladb::crypto::{EncryptedBackend, MIN_PBKDF2_ITERATIONS, decrypt, derive_key, encrypt};
use taladb::document::Value;
use taladb::engine::RedbBackend;
use taladb::{Database, Filter};
use zeroize::Zeroizing;

#[test]
fn public_encrypted_open_round_trips_and_rejects_wrong_passphrase() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("secure.redb");
    {
        let db = Database::open_encrypted(&path, "correct horse battery staple").unwrap();
        db.collection("secrets")
            .unwrap()
            .insert(vec![("token".into(), Value::Str("top-secret".into()))])
            .unwrap();
    }
    assert!(path.with_extension("redb.taladb-salt").exists());
    assert!(
        !std::fs::read(&path)
            .unwrap()
            .windows(b"top-secret".len())
            .any(|w| w == b"top-secret")
    );
    assert!(Database::open_encrypted(&path, "wrong passphrase").is_err());
    let db = Database::open_encrypted(&path, "correct horse battery staple").unwrap();
    assert_eq!(
        db.collection("secrets")
            .unwrap()
            .find(Filter::All)
            .unwrap()
            .len(),
        1
    );
}

fn s(v: &str) -> Value {
    Value::Str(v.to_string())
}
fn i(n: i64) -> Value {
    Value::Int(n)
}

// ---------------------------------------------------------------------------
// Low-level encrypt / decrypt
// ---------------------------------------------------------------------------

#[test]
fn encrypt_decrypt_round_trip() {
    let key = Zeroizing::new([0u8; 32]);
    let plaintext = b"Hello, TalaDB encryption!";
    let ciphertext = encrypt(&key, "table", b"key", plaintext).unwrap();
    assert_ne!(
        ciphertext.as_slice(),
        plaintext,
        "ciphertext must differ from plaintext"
    );
    let recovered = decrypt(&key, "table", b"key", &ciphertext).unwrap();
    assert_eq!(recovered.as_slice(), plaintext);
}

#[test]
fn decrypt_with_wrong_key_fails() {
    let key_a = Zeroizing::new([0u8; 32]);
    let key_b = Zeroizing::new([1u8; 32]);
    let ciphertext = encrypt(&key_a, "table", b"key", b"secret data").unwrap();
    let result = decrypt(&key_b, "table", b"key", &ciphertext);
    assert!(result.is_err(), "decryption with wrong key must fail");
}

#[test]
fn decrypt_truncated_ciphertext_fails() {
    let key = Zeroizing::new([0u8; 32]);
    let result = decrypt(&key, "table", b"key", &[0u8; 5]); // shorter than minimum
    assert!(result.is_err(), "truncated ciphertext must return an error");
}

#[test]
fn encrypt_is_nondeterministic() {
    // Each call generates a fresh random nonce — same plaintext produces different ciphertext
    let key = Zeroizing::new([42u8; 32]);
    let plaintext = b"same message";
    let c1 = encrypt(&key, "table", b"key", plaintext).unwrap();
    let c2 = encrypt(&key, "table", b"key", plaintext).unwrap();
    assert_ne!(
        c1, c2,
        "two encryptions of the same plaintext must differ (random nonces)"
    );
}

#[test]
fn aad_table_mismatch_fails() {
    let key = Zeroizing::new([42u8; 32]);
    let ct = encrypt(&key, "table_a", b"key1", b"data").unwrap();
    assert!(
        decrypt(&key, "table_b", b"key1", &ct).is_err(),
        "ciphertext moved to a different table must fail"
    );
}

#[test]
fn aad_key_mismatch_fails() {
    let key = Zeroizing::new([42u8; 32]);
    let ct = encrypt(&key, "table_a", b"key1", b"data").unwrap();
    assert!(
        decrypt(&key, "table_a", b"key2", &ct).is_err(),
        "ciphertext with a different storage key must fail"
    );
}

// ---------------------------------------------------------------------------
// derive_key
// ---------------------------------------------------------------------------

#[test]
fn derive_key_is_deterministic() {
    let k1 = derive_key("passphrase", b"salt1234", MIN_PBKDF2_ITERATIONS).unwrap();
    let k2 = derive_key("passphrase", b"salt1234", MIN_PBKDF2_ITERATIONS).unwrap();
    assert_eq!(*k1, *k2, "same passphrase + salt must derive the same key");
}

#[test]
fn derive_key_differs_with_different_passphrase() {
    let k1 = derive_key("passphrase_a", b"salt", MIN_PBKDF2_ITERATIONS).unwrap();
    let k2 = derive_key("passphrase_b", b"salt", MIN_PBKDF2_ITERATIONS).unwrap();
    assert_ne!(*k1, *k2);
}

#[test]
fn derive_key_differs_with_different_salt() {
    let k1 = derive_key("passphrase", b"salt_a", MIN_PBKDF2_ITERATIONS).unwrap();
    let k2 = derive_key("passphrase", b"salt_b", MIN_PBKDF2_ITERATIONS).unwrap();
    assert_ne!(*k1, *k2);
}

#[test]
fn derive_key_rejects_low_iterations() {
    let err = derive_key("pass", b"salt1234567890ab", 999).unwrap_err();
    assert!(err.to_string().contains("iterations too low"));
}

// ---------------------------------------------------------------------------
// EncryptedBackend — full CRUD through Database
// ---------------------------------------------------------------------------

fn encrypted_db(key: [u8; 32]) -> Database {
    let inner = Arc::new(RedbBackend::open_in_memory().unwrap());
    let enc = Box::new(EncryptedBackend::new(inner, Zeroizing::new(key)));
    Database::open_with_backend(enc).unwrap()
}

#[test]
fn encrypted_backend_insert_and_find() {
    let key = [7u8; 32];
    let db = encrypted_db(key);
    let col = db.collection("secrets").unwrap();

    col.insert(vec![("secret".into(), s("my password"))])
        .unwrap();

    let results = col
        .find(Filter::Eq("secret".into(), s("my password")))
        .unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].get("secret"), Some(&s("my password")));
}

#[test]
fn encrypted_backend_update_and_delete() {
    let key = [8u8; 32];
    let db = encrypted_db(key);
    let col = db.collection("data").unwrap();

    col.insert(vec![("value".into(), i(1))]).unwrap();

    let updated = col
        .update_one(
            Filter::Eq("value".into(), i(1)),
            taladb::Update::Set(vec![("value".into(), i(2))]),
        )
        .unwrap();
    assert!(updated);

    let results = col.find(Filter::Eq("value".into(), i(2))).unwrap();
    assert_eq!(results.len(), 1);

    let deleted = col.delete_one(Filter::Eq("value".into(), i(2))).unwrap();
    assert!(deleted);
    assert_eq!(col.find(Filter::All).unwrap().len(), 0);
}

#[test]
fn encrypted_backend_snapshot_round_trip() {
    let key = [9u8; 32];
    let db = encrypted_db(key);
    let col = db.collection("notes").unwrap();

    col.insert(vec![("body".into(), s("encrypted note"))])
        .unwrap();

    let snapshot = db.export_snapshot().unwrap();

    // Restore from snapshot into a fresh encrypted backend
    let inner2 = Arc::new(RedbBackend::open_in_memory().unwrap());
    let _enc2 = Box::new(EncryptedBackend::new(inner2, Zeroizing::new(key)));

    // Restore directly into in-memory (snapshot stores plaintext-after-decrypt)
    let db3 = Database::restore_from_snapshot(&snapshot).unwrap();
    let col3 = db3.collection("notes").unwrap();
    let results = col3.find(Filter::All).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].get("body"), Some(&s("encrypted note")));
}

#[test]
fn encrypted_backend_index_works() {
    let key = [10u8; 32];
    let db = encrypted_db(key);
    let col = db.collection("users").unwrap();

    col.create_index("email").unwrap();
    col.insert(vec![
        ("email".into(), s("alice@example.com")),
        ("age".into(), i(30)),
    ])
    .unwrap();
    col.insert(vec![
        ("email".into(), s("bob@example.com")),
        ("age".into(), i(25)),
    ])
    .unwrap();

    let results = col
        .find(Filter::Eq("email".into(), s("alice@example.com")))
        .unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].get("age"), Some(&i(30)));
}

#[test]
fn encrypted_backend_multiple_docs() {
    let key = [11u8; 32];
    let db = encrypted_db(key);
    let col = db.collection("items").unwrap();

    for n in 0..20i64 {
        col.insert(vec![("n".into(), i(n))]).unwrap();
    }

    let all = col.find(Filter::All).unwrap();
    assert_eq!(all.len(), 20);

    let filtered = col.find(Filter::Gte("n".into(), i(10))).unwrap();
    assert_eq!(filtered.len(), 10);
}

// ---------------------------------------------------------------------------
// rekey — key rotation
// ---------------------------------------------------------------------------

#[test]
fn rekey_returns_positive_count_on_non_empty_db() {
    use taladb::engine::RedbBackend;
    use taladb::rekey;
    use zeroize::Zeroizing;

    let old_key = Zeroizing::new([1u8; 32]);
    let new_key = Zeroizing::new([2u8; 32]);

    let inner = Arc::new(RedbBackend::open_in_memory().unwrap());
    let inner_dyn: Arc<dyn taladb::StorageBackend> = inner.clone();
    let enc = Box::new(taladb::crypto::EncryptedBackend::new(
        inner_dyn,
        Zeroizing::new([1u8; 32]),
    ));
    let db = taladb::Database::open_with_backend(enc).unwrap();
    let col = db.collection("secrets").unwrap();
    col.insert(vec![("v".into(), s("hello"))]).unwrap();
    col.insert(vec![("v".into(), s("world"))]).unwrap();

    // Rekey the raw (non-encrypted) backend that the encrypted backend wraps.
    // We need access to the inner backend — use the inner RedbBackend directly.
    let count = rekey(inner.as_ref(), &old_key, &new_key).unwrap();
    assert!(
        count > 0,
        "rekey must report at least one re-encrypted value"
    );
}

#[test]
fn rekey_wrong_old_key_fails() {
    use taladb::engine::RedbBackend;
    use taladb::rekey;
    use zeroize::Zeroizing;

    let correct_key = Zeroizing::new([1u8; 32]);
    let wrong_key = Zeroizing::new([99u8; 32]);
    let new_key = Zeroizing::new([2u8; 32]);

    let inner = Arc::new(RedbBackend::open_in_memory().unwrap());
    let inner_dyn: Arc<dyn taladb::StorageBackend> = inner.clone();
    let enc = Box::new(taladb::crypto::EncryptedBackend::new(
        inner_dyn,
        Zeroizing::new([1u8; 32]),
    ));
    let db = taladb::Database::open_with_backend(enc).unwrap();
    db.collection("secrets")
        .unwrap()
        .insert(vec![("v".into(), s("hello"))])
        .unwrap();

    let result = rekey(inner.as_ref(), &wrong_key, &new_key);
    assert!(result.is_err(), "rekey with wrong old_key must fail");
    // Correct key should still work (data untouched after failed rekey)
    let ok = rekey(inner.as_ref(), &correct_key, &new_key);
    assert!(ok.is_ok());
}

#[test]
fn rekey_empty_db_returns_zero() {
    use taladb::engine::RedbBackend;
    use taladb::rekey;
    use zeroize::Zeroizing;

    let backend = RedbBackend::open_in_memory().unwrap();
    let old_key = Zeroizing::new([1u8; 32]);
    let new_key = Zeroizing::new([2u8; 32]);
    let count = rekey(&backend, &old_key, &new_key).unwrap();
    assert_eq!(count, 0);
}

// ---------------------------------------------------------------------------
// Field-level encryption on Collection
// ---------------------------------------------------------------------------

fn field_enc_db() -> (taladb::Database, zeroize::Zeroizing<[u8; 32]>) {
    use zeroize::Zeroizing;
    let key = Zeroizing::new([42u8; 32]);
    let db = taladb::Database::open_in_memory().unwrap();
    (db, key)
}

#[test]
fn field_encryption_insert_find_round_trip() {
    use zeroize::Zeroizing;
    let (db, key) = field_enc_db();

    let col = db
        .collection("users")
        .unwrap()
        .with_field_encryption(vec!["ssn".into()], Zeroizing::new([42u8; 32]));

    col.insert(vec![
        ("name".into(), s("Alice")),
        ("ssn".into(), s("123-45-6789")),
    ])
    .unwrap();

    // Reading through same handle decrypts transparently
    let docs = col.find(Filter::All).unwrap();
    assert_eq!(docs.len(), 1);
    assert_eq!(docs[0].get("name"), Some(&s("Alice")));
    assert_eq!(docs[0].get("ssn"), Some(&s("123-45-6789")));

    drop(key);
}

#[test]
fn encrypted_field_not_queryable_by_plaintext_value() {
    use zeroize::Zeroizing;
    let (db, _key) = field_enc_db();

    let col = db
        .collection("records")
        .unwrap()
        .with_field_encryption(vec!["secret".into()], Zeroizing::new([42u8; 32]));

    col.insert(vec![("secret".into(), s("topsecret"))]).unwrap();

    // Querying by plaintext value on an encrypted field should return nothing
    // (the stored value is ciphertext bytes, not a string)
    let found = col
        .find(Filter::Eq("secret".into(), s("topsecret")))
        .unwrap();
    assert!(
        found.is_empty(),
        "encrypted field must not be queryable by plaintext value"
    );
}

#[test]
fn non_encrypted_field_remains_indexable() {
    use zeroize::Zeroizing;
    let (db, _key) = field_enc_db();

    let col = db
        .collection("users")
        .unwrap()
        .with_field_encryption(vec!["ssn".into()], Zeroizing::new([42u8; 32]));
    col.create_index("email").unwrap();

    col.insert(vec![
        ("email".into(), s("alice@example.com")),
        ("ssn".into(), s("111-22-3333")),
    ])
    .unwrap();
    col.insert(vec![
        ("email".into(), s("bob@example.com")),
        ("ssn".into(), s("444-55-6666")),
    ])
    .unwrap();

    let found = col
        .find(Filter::Eq("email".into(), s("alice@example.com")))
        .unwrap();
    assert_eq!(found.len(), 1);
    // Encrypted field still decrypts on this handle
    assert_eq!(found[0].get("ssn"), Some(&s("111-22-3333")));
}

#[test]
fn field_encryption_multiple_docs() {
    use zeroize::Zeroizing;
    let (db, _key) = field_enc_db();

    let col = db
        .collection("items")
        .unwrap()
        .with_field_encryption(vec!["pin".into()], Zeroizing::new([42u8; 32]));

    for n in 0..10i64 {
        col.insert(vec![
            ("n".into(), i(n)),
            ("pin".into(), s(&format!("pin-{n}"))),
        ])
        .unwrap();
    }

    let docs = col.find(Filter::All).unwrap();
    assert_eq!(docs.len(), 10);
    for doc in &docs {
        let pin = doc.get("pin").unwrap();
        if let Value::Str(p) = pin {
            assert!(p.starts_with("pin-"), "pin must decrypt correctly: {p}");
        } else {
            panic!("pin field must decrypt to a string, got {pin:?}");
        }
    }
}

#[test]
fn field_encryption_survives_snapshot_round_trip() {
    use zeroize::Zeroizing;
    let (db, _key) = field_enc_db();

    let col = db
        .collection("secrets")
        .unwrap()
        .with_field_encryption(vec!["token".into()], Zeroizing::new([42u8; 32]));
    col.insert(vec![
        ("name".into(), s("Alice")),
        ("token".into(), s("abc123")),
    ])
    .unwrap();

    let bytes = db.export_snapshot().unwrap();
    let db2 = taladb::Database::restore_from_snapshot(&bytes).unwrap();

    // Open with same field encryption config on the restored db
    let col2 = db2
        .collection("secrets")
        .unwrap()
        .with_field_encryption(vec!["token".into()], Zeroizing::new([42u8; 32]));

    let docs = col2.find(Filter::All).unwrap();
    assert_eq!(docs.len(), 1);
    assert_eq!(docs[0].get("name"), Some(&s("Alice")));
    assert_eq!(docs[0].get("token"), Some(&s("abc123")));
}