1/*
  2 * SPDX-FileCopyrightText: 2026 Copyright (c) Contributors to the Eclipse Foundation
  3 *
  4 * See the NOTICE file(s) distributed with this work for additional
  5 * information regarding copyright ownership.
  6 *
  7 * This program and the accompanying materials are made available under the
  8 * terms of the Apache License Version 2.0 which is available at
  9 * https://www.apache.org/licenses/LICENSE-2.0
 10 *
 11 * SPDX-License-Identifier: Apache-2.0
 12 */
 13
 14//! Integration tests for the local filesystem storage backend.
 15
 16use std::io::Write as _;
 17
 18use cda_interfaces::storage_api::{
 19    Collection as _, CollectionName, RandomAccessData as _, Storage, StorageError,
 20};
 21use cda_storage::LocalStorage;
 22
 23/// Create a fresh `LocalStorage` in a unique temp directory.
 24fn create_test_storage() -> (LocalStorage, tempfile::TempDir) {
 25    let dir = tempfile::tempdir().expect("Failed to create temp dir");
 26    let storage = LocalStorage::new(dir.path()).expect("Failed to create LocalStorage");
 27    (storage, dir)
 28}
 29
 30#[tokio::test]
 31async fn write_read_roundtrip() {
 32    let (storage, _dir) = create_test_storage();
 33    let name = CollectionName::DiagnosticDatabase;
 34    let collection = storage.get_or_create_collection(&name).await.unwrap();
 35
 36    let mut tx = storage.begin_transaction().unwrap();
 37    let mut data: &[u8] = b"hello world";
 38    collection
 39        .write(&mut tx, "greeting", &mut data)
 40        .await
 41        .unwrap();
 42    tx.commit().await.unwrap();
 43
 44    let handle = collection.read("greeting").await.unwrap();
 45    let mut buf = vec![0u8; 11];
 46    let n = handle.read_at(0, &mut buf).unwrap();
 47    assert_eq!(n, 11);
 48    assert_eq!(&buf, b"hello world");
 49}
 50
 51#[tokio::test]
 52async fn read_nonexistent_key_returns_not_found() {
 53    let (storage, _dir) = create_test_storage();
 54    let name = CollectionName::DiagnosticDatabase;
 55    let collection = storage.get_or_create_collection(&name).await.unwrap();
 56
 57    let result = collection.read("nonexistent").await;
 58    assert!(matches!(result, Err(StorageError::KeyNotFound(_))));
 59}
 60
 61#[tokio::test]
 62async fn delete_removes_key_after_commit() {
 63    let (storage, _dir) = create_test_storage();
 64    let name = CollectionName::DiagnosticDatabase;
 65    let collection = storage.get_or_create_collection(&name).await.unwrap();
 66
 67    // Write a key.
 68    let mut tx = storage.begin_transaction().unwrap();
 69    let mut data: &[u8] = b"to be deleted";
 70    collection
 71        .write(&mut tx, "doomed", &mut data)
 72        .await
 73        .unwrap();
 74    tx.commit().await.unwrap();
 75
 76    // Delete the key.
 77    let mut tx = storage.begin_transaction().unwrap();
 78    collection.delete(&mut tx, "doomed").await.unwrap();
 79    tx.commit().await.unwrap();
 80
 81    let result = collection.read("doomed").await;
 82    assert!(matches!(result, Err(StorageError::KeyNotFound(_))));
 83}
 84
 85#[tokio::test]
 86async fn delete_all_removes_all_keys() {
 87    let (storage, _dir) = create_test_storage();
 88    let name = CollectionName::DiagnosticDatabase;
 89    let collection = storage.get_or_create_collection(&name).await.unwrap();
 90
 91    // Write multiple keys.
 92    let mut tx = storage.begin_transaction().unwrap();
 93    let mut d1: &[u8] = b"one";
 94    let mut d2: &[u8] = b"two";
 95    collection.write(&mut tx, "key1", &mut d1).await.unwrap();
 96    collection.write(&mut tx, "key2", &mut d2).await.unwrap();
 97    tx.commit().await.unwrap();
 98
 99    // Delete all.
100    let mut tx = storage.begin_transaction().unwrap();
101    collection.delete_all(&mut tx).await.unwrap();
102    tx.commit().await.unwrap();
103
104    let is_empty = collection.is_empty().await.unwrap();
105    assert!(is_empty);
106}
107
108// Transaction semantics
109#[tokio::test]
110async fn rollback_discards_writes() {
111    let (storage, _dir) = create_test_storage();
112    let name = CollectionName::DiagnosticDatabase;
113    let collection = storage.get_or_create_collection(&name).await.unwrap();
114
115    let mut tx = storage.begin_transaction().unwrap();
116    let mut data: &[u8] = b"should not persist";
117    collection
118        .write(&mut tx, "ephemeral", &mut data)
119        .await
120        .unwrap();
121    tx.rollback();
122
123    let result = collection.read("ephemeral").await;
124    assert!(matches!(result, Err(StorageError::KeyNotFound(_))));
125}
126
127#[tokio::test]
128async fn drop_without_commit_is_implicit_rollback() {
129    let (storage, _dir) = create_test_storage();
130    let name = CollectionName::DiagnosticDatabase;
131    let collection = storage.get_or_create_collection(&name).await.unwrap();
132
133    {
134        let mut tx = storage.begin_transaction().unwrap();
135        let mut data: &[u8] = b"dropped";
136        collection.write(&mut tx, "ghost", &mut data).await.unwrap();
137        // tx is dropped here without commit.
138    }
139
140    let result = collection.read("ghost").await;
141    assert!(matches!(result, Err(StorageError::KeyNotFound(_))));
142}
143
144#[tokio::test]
145async fn no_read_your_writes() {
146    let (storage, _dir) = create_test_storage();
147    let name = CollectionName::DiagnosticDatabase;
148    let collection = storage.get_or_create_collection(&name).await.unwrap();
149
150    let mut tx = storage.begin_transaction().unwrap();
151    let mut data: &[u8] = b"uncommitted";
152    collection
153        .write(&mut tx, "pending", &mut data)
154        .await
155        .unwrap();
156
157    // Read should not see the uncommitted write.
158    let result = collection.read("pending").await;
159    assert!(matches!(result, Err(StorageError::KeyNotFound(_))));
160
161    tx.commit().await.unwrap();
162
163    // Now it should be visible.
164    let handle = collection.read("pending").await.unwrap();
165    assert_eq!(handle.data_size().unwrap(), 11);
166}
167
168#[tokio::test]
169async fn single_transaction_enforcement() {
170    let (storage, _dir) = create_test_storage();
171
172    let _tx = storage.begin_transaction().unwrap();
173    let result = storage.begin_transaction();
174    assert!(matches!(result, Err(StorageError::TransactionBusy)));
175}
176
177#[tokio::test]
178async fn can_begin_transaction_after_previous_commits() {
179    let (storage, _dir) = create_test_storage();
180
181    let tx = storage.begin_transaction().unwrap();
182    tx.commit().await.unwrap();
183
184    // Should be able to begin a new transaction now.
185    let tx2 = storage.begin_transaction().unwrap();
186    tx2.commit().await.unwrap();
187}
188
189#[tokio::test]
190async fn can_begin_transaction_after_previous_rollback() {
191    let (storage, _dir) = create_test_storage();
192
193    let tx = storage.begin_transaction().unwrap();
194    tx.rollback();
195
196    let tx2 = storage.begin_transaction().unwrap();
197    tx2.commit().await.unwrap();
198}
199
200#[tokio::test]
201async fn case_insensitive_keys() {
202    let (storage, _dir) = create_test_storage();
203    let name = CollectionName::DiagnosticDatabase;
204    let collection = storage.get_or_create_collection(&name).await.unwrap();
205
206    let mut tx = storage.begin_transaction().unwrap();
207    let mut data: &[u8] = b"case test";
208    collection
209        .write(&mut tx, "FooBar", &mut data)
210        .await
211        .unwrap();
212    tx.commit().await.unwrap();
213
214    // Reading with different cases should find the same entry.
215    let handle = collection.read("foobar").await.unwrap();
216    assert_eq!(handle.data_size().unwrap(), 9);
217
218    let handle = collection.read("FOOBAR").await.unwrap();
219    assert_eq!(handle.data_size().unwrap(), 9);
220}
221
222// Collection management
223#[tokio::test]
224async fn get_nonexistent_collection_returns_not_found() {
225    let (storage, _dir) = create_test_storage();
226    let name = CollectionName::Custom("nonexistent".to_string());
227    let result = storage.get_collection(&name).await;
228    assert!(matches!(result, Err(StorageError::CollectionNotFound(_))));
229}
230
231#[tokio::test]
232async fn create_and_delete_collection() {
233    let (storage, _dir) = create_test_storage();
234    let name = CollectionName::Custom("temp_collection".to_string());
235
236    let mut tx = storage.begin_transaction().unwrap();
237    let collection = storage.create_collection(&mut tx, &name).await.unwrap();
238
239    // Write data into the new collection (before commit, the dir doesn't exist yet for reads
240    // but the write goes to staging).
241    let mut data: &[u8] = b"in new collection";
242    collection.write(&mut tx, "item", &mut data).await.unwrap();
243    tx.commit().await.unwrap();
244
245    // Should be readable now. Drop the handle before the next transaction to avoid deadlock.
246    let collection = storage.get_collection(&name).await.unwrap();
247    {
248        let handle = collection.read("item").await.unwrap();
249        assert_eq!(handle.data_size().unwrap(), 17);
250    }
251
252    // Delete the collection.
253    let mut tx = storage.begin_transaction().unwrap();
254    storage.delete_collection(&mut tx, &name).await.unwrap();
255    tx.commit().await.unwrap();
256
257    let result = storage.get_collection(&name).await;
258    assert!(matches!(result, Err(StorageError::CollectionNotFound(_))));
259}
260
261#[tokio::test]
262async fn copy_collection() {
263    let (storage, _dir) = create_test_storage();
264    let source = CollectionName::DiagnosticDatabase;
265    let dest = CollectionName::DiagnosticDatabaseBackup;
266
267    // Write some data to source.
268    let source_col = storage.get_or_create_collection(&source).await.unwrap();
269    let mut tx = storage.begin_transaction().unwrap();
270    let mut d1: &[u8] = b"alpha";
271    let mut d2: &[u8] = b"beta";
272    source_col.write(&mut tx, "a", &mut d1).await.unwrap();
273    source_col.write(&mut tx, "b", &mut d2).await.unwrap();
274    tx.commit().await.unwrap();
275
276    // Copy source to dest.
277    let mut tx = storage.begin_transaction().unwrap();
278    storage
279        .copy_collection(&mut tx, &source, &dest)
280        .await
281        .unwrap();
282    tx.commit().await.unwrap();
283
284    // Verify dest has the same data.
285    let dest_col = storage.get_collection(&dest).await.unwrap();
286    let handle_a = dest_col.read("a").await.unwrap();
287    let mut buf_a = vec![0u8; 5];
288    handle_a.read_at(0, &mut buf_a).unwrap();
289    assert_eq!(&buf_a, b"alpha");
290
291    let handle_b = dest_col.read("b").await.unwrap();
292    let mut buf_b = vec![0u8; 4];
293    handle_b.read_at(0, &mut buf_b).unwrap();
294    assert_eq!(&buf_b, b"beta");
295}
296
297#[tokio::test]
298async fn copy_collection_replaces_existing_dest() {
299    let (storage, _dir) = create_test_storage();
300    let source = CollectionName::DiagnosticDatabase;
301    let dest = CollectionName::DiagnosticDatabaseBackup;
302
303    // Write data to source.
304    let source_col = storage.get_or_create_collection(&source).await.unwrap();
305    let mut tx = storage.begin_transaction().unwrap();
306    let mut d1: &[u8] = b"alpha";
307    source_col.write(&mut tx, "a", &mut d1).await.unwrap();
308    tx.commit().await.unwrap();
309
310    // Write different data to dest (pre-existing content that should be replaced).
311    let dest_col = storage.get_or_create_collection(&dest).await.unwrap();
312    let mut tx = storage.begin_transaction().unwrap();
313    let mut d_old: &[u8] = b"old_stuff";
314    let mut d_extra: &[u8] = b"extra_file";
315    dest_col.write(&mut tx, "a", &mut d_old).await.unwrap();
316    dest_col
317        .write(&mut tx, "extra", &mut d_extra)
318        .await
319        .unwrap();
320    tx.commit().await.unwrap();
321
322    // Copy source -> dest. Dest should be fully replaced.
323    let mut tx = storage.begin_transaction().unwrap();
324    storage
325        .copy_collection(&mut tx, &source, &dest)
326        .await
327        .unwrap();
328    tx.commit().await.unwrap();
329
330    // Verify dest has exactly source's contents (key "a" with "alpha"), not the old data.
331    let dest_col = storage.get_collection(&dest).await.unwrap();
332    let handle_a = dest_col.read("a").await.unwrap();
333    let mut buf = vec![0u8; 5];
334    handle_a.read_at(0, &mut buf).unwrap();
335    assert_eq!(&buf, b"alpha");
336
337    // The "extra" key that was only in dest should be gone (replaced, not merged).
338    let result = dest_col.read("extra").await;
339    assert!(matches!(result, Err(StorageError::KeyNotFound(_))));
340}
341
342// Metadata, list, len
343#[tokio::test]
344async fn metadata_returns_correct_size() {
345    let (storage, _dir) = create_test_storage();
346    let name = CollectionName::DiagnosticDatabase;
347    let collection = storage.get_or_create_collection(&name).await.unwrap();
348
349    let mut tx = storage.begin_transaction().unwrap();
350    let mut data: &[u8] = b"twelve chars";
351    collection.write(&mut tx, "sized", &mut data).await.unwrap();
352    tx.commit().await.unwrap();
353
354    let meta = collection.metadata("sized").await.unwrap();
355    assert_eq!(meta.name, "sized");
356    assert_eq!(meta.data_size, 12);
357}
358
359#[tokio::test]
360async fn list_and_len_reflect_committed_state() {
361    let (storage, _dir) = create_test_storage();
362    let name = CollectionName::DiagnosticDatabase;
363    let collection = storage.get_or_create_collection(&name).await.unwrap();
364
365    assert!(collection.is_empty().await.unwrap());
366    assert_eq!(collection.len().await.unwrap(), 0);
367
368    let mut tx = storage.begin_transaction().unwrap();
369    let mut d1: &[u8] = b"x";
370    let mut d2: &[u8] = b"y";
371    let mut d3: &[u8] = b"z";
372    collection.write(&mut tx, "one", &mut d1).await.unwrap();
373    collection.write(&mut tx, "two", &mut d2).await.unwrap();
374    collection.write(&mut tx, "three", &mut d3).await.unwrap();
375    tx.commit().await.unwrap();
376
377    assert_eq!(collection.len().await.unwrap(), 3);
378    assert!(!collection.is_empty().await.unwrap());
379
380    let mut keys = collection.list().await.unwrap();
381    keys.sort();
382    assert_eq!(keys, vec!["one", "three", "two"]);
383}
384
385// Random access data
386#[tokio::test]
387async fn random_access_read_at_offset() {
388    let (storage, _dir) = create_test_storage();
389    let name = CollectionName::DiagnosticDatabase;
390    let collection = storage.get_or_create_collection(&name).await.unwrap();
391
392    let mut tx = storage.begin_transaction().unwrap();
393    let mut data: &[u8] = b"0123456789ABCDEF";
394    collection.write(&mut tx, "hex", &mut data).await.unwrap();
395    tx.commit().await.unwrap();
396
397    let handle = collection.read("hex").await.unwrap();
398    assert_eq!(handle.data_size().unwrap(), 16);
399
400    // Read from offset 10.
401    let mut buf = vec![0u8; 6];
402    let n = handle.read_at(10, &mut buf).unwrap();
403    assert_eq!(n, 6);
404    assert_eq!(&buf, b"ABCDEF");
405
406    // Read from offset 14..only 2 bytes remaining.
407    let mut buf = vec![0u8; 6];
408    let n = handle.read_at(14, &mut buf).unwrap();
409    assert_eq!(n, 2);
410    assert_eq!(buf.get(..2).unwrap(), b"EF");
411}
412
413// Recovery tests
414
415/// Simulates a crash during the recording phase: a WAL file exists with `RECORDING` status and
416/// entries, and orphaned staging files are present, but nothing was ever applied to the
417/// collections directory. Recovery must discard the WAL and staging files and leave the
418/// collection untouched, since the transaction never reached the commit phase.
[docs]419/// [[ test~storage-atomicity-recovery-discards-recording-phase-crash, Recovery discards an incomplete transaction that crashed while still recording, test ]]
420#[tokio::test]
421async fn recovery_cleans_up_incomplete_transaction() {
422    let dir = tempfile::tempdir().unwrap();
423    let root = dir.path();
424    let collections_dir = root.join("collections");
425    let journal_dir = root.join("journal");
426    let staging_dir = journal_dir.join("staging");
427    std::fs::create_dir_all(&collections_dir).unwrap();
428    std::fs::create_dir_all(&staging_dir).unwrap();
429
430    // Create a WAL with RECORDING header and a single write operation.
431    let wal_path = journal_dir.join("transaction.wal");
432    cda_storage::wal::create_wal(&wal_path).unwrap();
433
434    let staged = staging_dir.join("some_file.tmp");
435    std::fs::write(&staged, b"orphaned data").unwrap();
436    let staged_str = staged
437        .to_str()
438        .expect("Failed to convert staging path to string")
439        .to_string();
440
441    cda_storage::wal::append_operation(
442        &wal_path,
443        &cda_interfaces::storage_api::Operation::Write {
444            collection: CollectionName::DiagnosticDatabase,
445            key: "test".to_string(),
446            staged_path: staged_str,
447        },
448    )
449    .unwrap();
450
451    // Creating a new LocalStorage should trigger recovery.
452    let storage = LocalStorage::new(root).unwrap();
453
454    // The WAL and staging file should be cleaned up.
455    assert!(!wal_path.exists());
456    assert!(!staged.exists());
457
458    // And the collection should not have the key.
459    let collection = storage
460        .get_or_create_collection(&CollectionName::DiagnosticDatabase)
461        .await
462        .unwrap();
463    let result = collection.read("test").await;
464    assert!(matches!(result, Err(StorageError::KeyNotFound(_))));
465}
466
467/// Simulates a crash during the commit phase where an existing key was being overwritten: the
468/// WAL has `COMMITTING` status and a `.bak` file exists alongside the partially-written new
469/// file. Recovery must restore the original data from the `.bak` file, upholding the
470/// all-or-nothing guarantee for `Write` operations that overwrite existing keys.
[docs]471/// [[ test~storage-atomicity-recovery-restores-overwritten-file, Recovery restores the original file from its backup after an interrupted overwrite, test ]]
472#[tokio::test]
473async fn recovery_rolls_back_partial_commit_with_bak_files() {
474    let dir = tempfile::tempdir().unwrap();
475    let root = dir.path();
476    let collections_dir = root.join("collections");
477    let journal_dir = root.join("journal");
478    let staging_dir = journal_dir.join("staging");
479    let db_dir = collections_dir.join("diagnostic_database");
480    std::fs::create_dir_all(&db_dir).unwrap();
481    std::fs::create_dir_all(&staging_dir).unwrap();
482
483    // Simulate: original file was backed up, new file was partially written.
484    std::fs::write(db_dir.join("mykey.bak"), b"original data").unwrap();
485    std::fs::write(db_dir.join("mykey"), b"new data").unwrap();
486
487    // Create a WAL with COMMITTING status.
488    let wal_path = journal_dir.join("transaction.wal");
489    cda_storage::wal::create_wal(&wal_path).unwrap();
490    cda_storage::wal::append_operation(
491        &wal_path,
492        &cda_interfaces::storage_api::Operation::Write {
493            collection: CollectionName::DiagnosticDatabase,
494            key: "mykey".to_string(),
495            staged_path: "/tmp/does_not_matter.tmp".to_string(),
496        },
497    )
498    .unwrap();
499    cda_storage::wal::mark_committing(&wal_path).unwrap();
500
501    // Recovery should detect COMMITTING + .bak files and restore them.
502    let storage = LocalStorage::new(root).unwrap();
503
504    let collection = storage
505        .get_collection(&CollectionName::DiagnosticDatabase)
506        .await
507        .unwrap();
508    let handle = collection.read("mykey").await.unwrap();
509    let mut buf = vec![0u8; 13];
510    let n = handle.read_at(0, &mut buf).unwrap();
511    assert_eq!(n, 13);
512    assert_eq!(&buf, b"original data");
513}
514
515/// Whgen a crash happens during commit where only NEW files were  created (no overwrites, so no `.bak` files exist).
516/// Recovery must still detect the partial commit via the `COMMITTING` WAL status and remove the newly created files,
517/// since a `Write` operation that introduces a brand-new key must be all-or-nothing just like an overwrite.
[docs]518/// [[ test~storage-atomicity-recovery-removes-new-file, Recovery removes a newly-written file left by an interrupted commit with no backup to restore, test ]]
519#[tokio::test]
520async fn recovery_rolls_back_new_file_writes_without_bak() {
521    let dir = tempfile::tempdir().unwrap();
522    let root = dir.path();
523    let collections_dir = root.join("collections");
524    let journal_dir = root.join("journal");
525    let staging_dir = journal_dir.join("staging");
526    let db_dir = collections_dir.join("diagnostic_database");
527    std::fs::create_dir_all(&db_dir).unwrap();
528    std::fs::create_dir_all(&staging_dir).unwrap();
529
530    // Simulate: a new file was written into the collection during a partially applied commit.
531    // No .bak file exists because there was nothing to overwrite.
532    std::fs::write(db_dir.join("new_key"), b"partially committed data").unwrap();
533
534    // Create a WAL with COMMITTING status containing the write operation.
535    let wal_path = journal_dir.join("transaction.wal");
536    cda_storage::wal::create_wal(&wal_path).unwrap();
537    cda_storage::wal::append_operation(
538        &wal_path,
539        &cda_interfaces::storage_api::Operation::Write {
540            collection: CollectionName::DiagnosticDatabase,
541            key: "new_key".to_string(),
542            staged_path: "/tmp/does_not_matter.tmp".to_string(),
543        },
544    )
545    .unwrap();
546    cda_storage::wal::mark_committing(&wal_path).unwrap();
547
548    // Recovery should detect COMMITTING, read the WAL, and remove the new file.
549    let storage = LocalStorage::new(root).unwrap();
550
551    let collection = storage
552        .get_collection(&CollectionName::DiagnosticDatabase)
553        .await
554        .unwrap();
555    let result = collection.read("new_key").await;
556    assert!(matches!(result, Err(StorageError::KeyNotFound(_))));
557}
558
559/// Simulates a crash during commit of a `CreateCollection` operation, where no backup can exist
560/// because the collection is entirely new. Recovery must remove the empty collection directory
561/// so that a partially-applied `CreateCollection` never leaves a visible trace behind.
[docs]562/// [[ test~storage-atomicity-recovery-removes-new-collection, Recovery removes an empty collection directory left by an interrupted `CreateCollection` commit, test ]]
563#[tokio::test]
564async fn recovery_rolls_back_new_collection_without_bak() {
565    let dir = tempfile::tempdir().unwrap();
566    let root = dir.path();
567    let collections_dir = root.join("collections");
568    let journal_dir = root.join("journal");
569    let staging_dir = journal_dir.join("staging");
570    std::fs::create_dir_all(&collections_dir).unwrap();
571    std::fs::create_dir_all(&staging_dir).unwrap();
572
573    // Simulate: a new empty collection directory was created during partial commit.
574    let new_col_dir = collections_dir.join("brand_new");
575    std::fs::create_dir_all(&new_col_dir).unwrap();
576
577    // Create a WAL with COMMITTING status.
578    let wal_path = journal_dir.join("transaction.wal");
579    cda_storage::wal::create_wal(&wal_path).unwrap();
580    cda_storage::wal::append_operation(
581        &wal_path,
582        &cda_interfaces::storage_api::Operation::CreateCollection {
583            name: CollectionName::Custom("brand_new".to_string()),
584        },
585    )
586    .unwrap();
587    cda_storage::wal::mark_committing(&wal_path).unwrap();
588
589    // Recovery should remove the newly created collection directory.
590    let _storage = LocalStorage::new(root).unwrap();
591
592    assert!(!new_col_dir.exists());
593}
594
595/// Simulates a case where a commit succeeded (the WAL was deleted, marking the point of no
596/// return) but a subsequent crash interrupted the best-effort `.bak` cleanup step. Recovery must
597/// treat the absence of a WAL as "already committed" and simply delete the orphaned backups,
598/// keeping the already-committed data intact.
[docs]599/// [[ test~storage-atomicity-recovery-cleans-orphaned-backups, Recovery cleans up orphaned backup files left after a successful commit, test ]]
600#[tokio::test]
601async fn recovery_handles_no_wal_with_orphaned_bak_files() {
602    let dir = tempfile::tempdir().unwrap();
603    let root = dir.path();
604    let collections_dir = root.join("collections");
605    let journal_dir = root.join("journal");
606    let staging_dir = journal_dir.join("staging");
607    let db_dir = collections_dir.join("diagnostic_database");
608    std::fs::create_dir_all(&db_dir).unwrap();
609    std::fs::create_dir_all(&staging_dir).unwrap();
610
611    // The new committed data is in place, but old .bak files remain.
612    std::fs::write(db_dir.join("mykey"), b"new committed data").unwrap();
613    std::fs::write(db_dir.join("mykey.bak"), b"old data").unwrap();
614
615    // No WAL file exists (it was successfully deleted as point of no return).
616    let wal_path = journal_dir.join("transaction.wal");
617    assert!(!wal_path.exists());
618
619    // Recovery should delete the orphaned .bak and keep the committed data.
620    let storage = LocalStorage::new(root).unwrap();
621
622    assert!(!db_dir.join("mykey.bak").exists());
623    let collection = storage
624        .get_collection(&CollectionName::DiagnosticDatabase)
625        .await
626        .unwrap();
627    let handle = collection.read("mykey").await.unwrap();
628    let mut buf = vec![0u8; 18];
629    let n = handle.read_at(0, &mut buf).unwrap();
630    assert_eq!(n, 18);
631    assert_eq!(&buf, b"new committed data");
632}
633
634/// Creates a WAL with a valid entry followed by corrupted (torn-write) bytes. Recovery must
635/// stop reading at the first invalid entry and discard the whole transaction, since a
636/// `RECORDING`-status WAL means nothing was ever applied to collections - so a corrupt tail is
637/// safe to ignore rather than treated as unrecoverable filesystem corruption.
[docs]638/// [[ test~storage-atomicity-recovery-discards-corrupt-wal, Recovery discards a WAL with a corrupt checksum during the recording phase, test ]]
639#[tokio::test]
640async fn recovery_discards_wal_with_corrupt_checksum() {
641    let dir = tempfile::tempdir().unwrap();
642    let root = dir.path();
643    let collections_dir = root.join("collections");
644    let journal_dir = root.join("journal");
645    let staging_dir = journal_dir.join("staging");
646    std::fs::create_dir_all(&collections_dir).unwrap();
647    std::fs::create_dir_all(&staging_dir).unwrap();
648
649    let wal_path = journal_dir.join("transaction.wal");
650    cda_storage::wal::create_wal(&wal_path).unwrap();
651
652    // Append a valid entry.
653    cda_storage::wal::append_operation(
654        &wal_path,
655        &cda_interfaces::storage_api::Operation::CreateCollection {
656            name: CollectionName::Custom("valid_collection".to_string()),
657        },
658    )
659    .unwrap();
660
661    // Append garbage bytes to simulate a torn write (corrupt checksum).
662    let mut file = std::fs::OpenOptions::new()
663        .append(true)
664        .open(&wal_path)
665        .unwrap();
666    file.write_all(&[0xFF; 20]).unwrap();
667
668    // Recovery should succeed, the corrupt entry is simply ignored.
669    let _storage = LocalStorage::new(root).unwrap();
670
671    // The WAL should be cleaned up.
672    assert!(!wal_path.exists());
673}
674
675#[tokio::test]
676async fn overwrite_existing_key() {
677    let (storage, _dir) = create_test_storage();
678    let name = CollectionName::DiagnosticDatabase;
679    let collection = storage.get_or_create_collection(&name).await.unwrap();
680
681    // Write initial value.
682    let mut tx = storage.begin_transaction().unwrap();
683    let mut data: &[u8] = b"version 1";
684    collection
685        .write(&mut tx, "config", &mut data)
686        .await
687        .unwrap();
688    tx.commit().await.unwrap();
689
690    // Overwrite with new value.
691    let mut tx = storage.begin_transaction().unwrap();
692    let mut data: &[u8] = b"version 2";
693    collection
694        .write(&mut tx, "config", &mut data)
695        .await
696        .unwrap();
697    tx.commit().await.unwrap();
698
699    let handle = collection.read("config").await.unwrap();
700    let mut buf = vec![0u8; 9];
701    handle.read_at(0, &mut buf).unwrap();
702    assert_eq!(&buf, b"version 2");
703}
704
705/// Verifies the WAL on-disk format at the byte level: appends a mix of `CreateCollection`,
706/// `Write`, and `Delete` operations, then reads them back and checks that each operation is
707/// decoded correctly, in order, with its checksum intact and `truncated` reported as `false`.
708/// This underpins the crash-recovery guarantees, which depend on being able to faithfully replay
709/// exactly the operations that were durably recorded before a crash.
[docs]710/// [[ test~storage-wal-checksum-round-trip, WAL entries round-trip through checksum-verified encode/decode, test ]]
711// WAL checksum round-trip
712#[tokio::test]
713async fn wal_round_trip_with_checksum_verification() {
714    let dir = tempfile::tempdir().unwrap();
715    let wal_path = dir.path().join("test.wal");
716    cda_storage::wal::create_wal(&wal_path).unwrap();
717
718    let ops = vec![
719        cda_interfaces::storage_api::Operation::CreateCollection {
720            name: CollectionName::Custom("col_a".to_string()),
721        },
722        cda_interfaces::storage_api::Operation::Write {
723            collection: CollectionName::DiagnosticDatabase,
724            key: "my_key".to_string(),
725            staged_path: "/tmp/fake.tmp".to_string(),
726        },
727        cda_interfaces::storage_api::Operation::Delete {
728            collection: CollectionName::DiagnosticDatabase,
729            key: "old_key".to_string(),
730        },
731    ];
732
733    for op in &ops {
734        cda_storage::wal::append_operation(&wal_path, op).unwrap();
735    }
736
737    let wal_data = std::fs::read(&wal_path).unwrap();
738    let wal_result = cda_storage::wal::read_wal(&wal_data).unwrap();
739    assert!(!wal_result.truncated);
740    assert_eq!(wal_result.operations.len(), 3);
741
742    // Verify the operations match using .get() to satisfy clippy::indexing_slicing.
743    let read_op_0 = wal_result
744        .operations
745        .first()
746        .expect("Expected 3 operations");
747    assert!(matches!(
748        read_op_0,
749        cda_interfaces::storage_api::Operation::CreateCollection { name }
750        if name.as_str() == "col_a"
751    ));
752    let read_op_1 = wal_result.operations.get(1).expect("Expected 3 operations");
753    assert!(matches!(
754        read_op_1,
755        cda_interfaces::storage_api::Operation::Write { key, .. }
756        if key == "my_key"
757    ));
758    let read_op_2 = wal_result.operations.get(2).expect("Expected 3 operations");
759    assert!(matches!(
760        read_op_2,
761        cda_interfaces::storage_api::Operation::Delete { key, .. }
762        if key == "old_key"
763    ));
764}
765
766/// Writes two valid WAL entries, then truncates the file mid-way through the second entry to
767/// simulate a torn write. The reader must return exactly the first (fully valid) operation and
768/// report `truncated: true`, rather than erroring out or fabricating data for the incomplete
769/// entry. This is the detection mechanism that lets recovery safely distinguish "crash mid-write
770/// to the WAL itself" from genuine filesystem corruption.
[docs]771/// [[ test~storage-wal-truncation-detection, WAL reader detects and stops at a truncated entry, test ]]
772#[tokio::test]
773async fn wal_stops_at_truncated_entry() {
774    let dir = tempfile::tempdir().unwrap();
775    let wal_path = dir.path().join("test.wal");
776    cda_storage::wal::create_wal(&wal_path).unwrap();
777
778    // Write two valid entries.
779    cda_storage::wal::append_operation(
780        &wal_path,
781        &cda_interfaces::storage_api::Operation::CreateCollection {
782            name: CollectionName::Custom("first".to_string()),
783        },
784    )
785    .unwrap();
786    cda_storage::wal::append_operation(
787        &wal_path,
788        &cda_interfaces::storage_api::Operation::CreateCollection {
789            name: CollectionName::Custom("second".to_string()),
790        },
791    )
792    .unwrap();
793
794    // Truncate the file to corrupt the second entry (keep first + partial second).
795    let data = std::fs::read(&wal_path).unwrap();
796    // Write only 80% of the data to truncate the second entry.
797    let truncated_len = data.len() * 4 / 5;
798    std::fs::write(&wal_path, data.get(..truncated_len).unwrap()).unwrap();
799
800    let wal_data = std::fs::read(&wal_path).unwrap();
801    let wal_result = cda_storage::wal::read_wal(&wal_data).unwrap();
802    assert!(wal_result.truncated);
803    // Should only have the first valid entry.
804    assert_eq!(wal_result.operations.len(), 1);
805    let read_op_0 = wal_result.operations.first().expect("Expected 1 operation");
806    assert!(matches!(
807        read_op_0,
808        cda_interfaces::storage_api::Operation::CreateCollection { name }
809        if name.as_str() == "first"
810    ));
811}
812
813/// Simulates a crash during commit of a multi-operation transaction where a `CreateCollection`
814/// and a `Write` into that same new collection were both partially applied (the directory and
815/// its file both exist on disk, with the WAL in `COMMITTING` status). Since the collection did
816/// not exist before this transaction, recovery must roll back the *entire* transaction as a
817/// unit, removing both the written file and the now-empty collection directory - demonstrating
818/// that atomicity applies across all staged operations in an execution, not just individually.
[docs]819/// [[ test~storage-atomicity-recovery-removes-orphaned-collection-dir, Recovery fully rolls back a multi-operation transaction that created a collection and wrote into it, test ]]
820#[tokio::test]
821async fn recovery_create_collection_with_write_removes_orphaned_dir() {
822    let dir = tempfile::tempdir().unwrap();
823    let root = dir.path();
824    let collections_dir = root.join("collections");
825    let journal_dir = root.join("journal");
826    let staging_dir = journal_dir.join("staging");
827    std::fs::create_dir_all(&collections_dir).unwrap();
828    std::fs::create_dir_all(&staging_dir).unwrap();
829
830    // Simulate partial commit state: the collection directory was created AND a file was
831    // written into it before the crash.
832    let new_col_dir = collections_dir.join("fresh_collection");
833    std::fs::create_dir_all(&new_col_dir).unwrap();
834    std::fs::write(new_col_dir.join("data_file"), b"written during commit").unwrap();
835
836    // WAL with COMMITTING status containing both operations in natural order:
837    // CreateCollection first, then Write to that collection.
838    let wal_path = journal_dir.join("transaction.wal");
839    cda_storage::wal::create_wal(&wal_path).unwrap();
840    cda_storage::wal::append_operation(
841        &wal_path,
842        &cda_interfaces::storage_api::Operation::CreateCollection {
843            name: CollectionName::Custom("fresh_collection".to_string()),
844        },
845    )
846    .unwrap();
847    cda_storage::wal::append_operation(
848        &wal_path,
849        &cda_interfaces::storage_api::Operation::Write {
850            collection: CollectionName::Custom("fresh_collection".to_string()),
851            key: "data_file".to_string(),
852            staged_path: "/tmp/irrelevant.tmp".to_string(),
853        },
854    )
855    .unwrap();
856    cda_storage::wal::mark_committing(&wal_path).unwrap();
857
858    // Recovery should fully roll back - the collection did not exist before this transaction.
859    let _storage = LocalStorage::new(root).unwrap();
860
861    // The collection directory must be completely gone after recovery.
862    assert!(
863        !new_col_dir.exists(),
864        "Orphaned empty collection directory was left behind after recovery"
865    );
866}
867
868/// A failed collection swap can leave a COMMITTING WAL where a pre-existing destination was
869/// never renamed to its backup (for example, when `rename` returns EXDEV). Recovery must retain
870/// that untouched current collection rather than treating it as an artifact of the failed copy.
871#[tokio::test]
872async fn recovery_preserves_untouched_existing_copy_destination() {
873    let dir = tempfile::tempdir().unwrap();
874    let root = dir.path();
875    let collections_dir = root.join("collections");
876    let journal_dir = root.join("journal");
877    let staging_dir = journal_dir.join("staging");
878    let current_dir = collections_dir.join("diagnostic_database");
879    std::fs::create_dir_all(&current_dir).unwrap();
880    std::fs::create_dir_all(&staging_dir).unwrap();
881    std::fs::write(current_dir.join("ecu1.mdd"), b"current data").unwrap();
882
883    let wal_path = journal_dir.join("transaction.wal");
884    cda_storage::wal::create_wal(&wal_path).unwrap();
885    cda_storage::wal::append_operation(
886        &wal_path,
887        &cda_interfaces::storage_api::Operation::CopyCollection {
888            source: CollectionName::DiagnosticDatabaseNextUpdate,
889            dest: CollectionName::DiagnosticDatabase,
890            dest_existed: true,
891        },
892    )
893    .unwrap();
894    cda_storage::wal::mark_committing(&wal_path).unwrap();
895
896    let storage = LocalStorage::new(root).unwrap();
897    let current = storage
898        .get_collection(&CollectionName::DiagnosticDatabase)
899        .await
900        .unwrap();
901    let handle = current.read("ecu1.mdd").await.unwrap();
902    let mut data = vec![0; "current data".len()];
903    handle.read_at(0, &mut data).unwrap();
904    assert_eq!(data, b"current data");
905}
906
907/// Regression test for a bug where `list()` returned a file's on-disk (possibly mixed-case)
908/// name verbatim, while `metadata()`/`read()`/`file_path()` normalize keys to lowercase before
909/// resolving them to a path. On a case-sensitive filesystem this mismatch caused every
910/// mixed-case file to appear in `list()` but then fail with `KeyNotFound` as soon as any
911/// per-key lookup (`metadata`, `read`, `file_path`) was attempted - exactly the "key vanished
912/// during iteration" symptom, even with no concurrent writer at all.
913///
914/// Simulates a file that ended up on disk with a mixed-case name outside of `Collection::write`
915/// (e.g. placed there during initial provisioning), then verifies that `list()`, `metadata()`,
916/// and `read()` are all consistent with each other.
917#[tokio::test]
918async fn list_normalizes_mixed_case_filenames_to_match_metadata_and_read() {
919    let (storage, dir) = create_test_storage();
920    let name = CollectionName::DiagnosticDatabase;
921
922    // Create the collection directory and place a mixed-case file directly on disk, bypassing
923    // `Collection::write` (which would have normalized the name to lowercase).
924    let collection_dir = dir.path().join("collections").join(name.as_str());
925    std::fs::create_dir_all(&collection_dir).unwrap();
926    std::fs::write(
927        collection_dir.join("FLXC1000_06.18.13.mdd"),
928        b"mixed case data",
929    )
930    .unwrap();
931
932    let collection = storage.get_or_create_collection(&name).await.unwrap();
933
934    // `list()` must return the normalized (lowercase) key, matching what `metadata`/`read`/
935    // `file_path` expect.
936    let keys = collection.list().await.unwrap();
937    assert_eq!(keys, vec!["flxc1000_06.18.13.mdd".to_string()]);
938
939    // Every key returned by `list()` must be resolvable via `metadata()` without a
940    // `KeyNotFound` error.
941    for key in &keys {
942        let meta = collection.metadata(key).await.unwrap();
943        assert_eq!(meta.data_size, "mixed case data".len() as u64);
944    }
945
946    // Every key returned by `list()` must also be resolvable via `read()`.
947    let Some(key) = keys.first() else {
948        panic!("expected one key");
949    };
950    let handle = collection.read(key).await.unwrap();
951    let mut buf = vec![0u8; "mixed case data".len()];
952    handle.read_at(0, &mut buf).unwrap();
953    assert_eq!(&buf, b"mixed case data");
954}