Skip to main content

doublecrypt_core/
block_store.rs

1use crate::error::{FsError, FsResult};
2use rand::RngCore;
3use std::collections::HashMap;
4use std::fs::{File, OpenOptions};
5use std::io::{Seek, SeekFrom, Write};
6use std::os::unix::fs::FileExt;
7use std::sync::Mutex;
8
9/// Trait for a fixed-size block store backend.
10/// All blocks are the same size. Block IDs are u64.
11pub trait BlockStore: Send + Sync {
12    /// Block size in bytes.
13    fn block_size(&self) -> usize;
14
15    /// Total number of blocks in the store.
16    fn total_blocks(&self) -> u64;
17
18    /// Read a full block. Returns exactly `block_size()` bytes.
19    fn read_block(&self, block_id: u64) -> FsResult<Vec<u8>>;
20
21    /// Write a full block. `data` must be exactly `block_size()` bytes.
22    fn write_block(&self, block_id: u64, data: &[u8]) -> FsResult<()>;
23
24    /// Sync / flush all writes. No-op for in-memory stores.
25    fn sync(&self) -> FsResult<()> {
26        Ok(())
27    }
28
29    /// Read multiple blocks in one call.
30    ///
31    /// The default implementation reads them sequentially; network-backed
32    /// stores should override this with pipelined I/O.
33    fn read_blocks(&self, block_ids: &[u64]) -> FsResult<Vec<Vec<u8>>> {
34        block_ids.iter().map(|&id| self.read_block(id)).collect()
35    }
36
37    /// Write multiple blocks in one call.
38    ///
39    /// The default implementation writes them sequentially; network-backed
40    /// stores should override this with pipelined I/O.
41    fn write_blocks(&self, blocks: &[(u64, &[u8])]) -> FsResult<()> {
42        for &(id, data) in blocks {
43            self.write_block(id, data)?;
44        }
45        Ok(())
46    }
47}
48
49/// Simple in-memory block store for testing and development.
50pub struct MemoryBlockStore {
51    block_size: usize,
52    total_blocks: u64,
53    blocks: Mutex<HashMap<u64, Vec<u8>>>,
54}
55
56impl MemoryBlockStore {
57    pub fn new(block_size: usize, total_blocks: u64) -> Self {
58        Self {
59            block_size,
60            total_blocks,
61            blocks: Mutex::new(HashMap::new()),
62        }
63    }
64}
65
66impl BlockStore for MemoryBlockStore {
67    fn block_size(&self) -> usize {
68        self.block_size
69    }
70
71    fn total_blocks(&self) -> u64 {
72        self.total_blocks
73    }
74
75    fn read_block(&self, block_id: u64) -> FsResult<Vec<u8>> {
76        if block_id >= self.total_blocks {
77            return Err(FsError::BlockOutOfRange(block_id));
78        }
79        let blocks = self
80            .blocks
81            .lock()
82            .map_err(|e| FsError::Internal(e.to_string()))?;
83        match blocks.get(&block_id) {
84            Some(data) => Ok(data.clone()),
85            None => {
86                // Unwritten blocks return zeroes.
87                Ok(vec![0u8; self.block_size])
88            }
89        }
90    }
91
92    fn write_block(&self, block_id: u64, data: &[u8]) -> FsResult<()> {
93        if block_id >= self.total_blocks {
94            return Err(FsError::BlockOutOfRange(block_id));
95        }
96        if data.len() != self.block_size {
97            return Err(FsError::BlockSizeMismatch {
98                expected: self.block_size,
99                got: data.len(),
100            });
101        }
102        let mut blocks = self
103            .blocks
104            .lock()
105            .map_err(|e| FsError::Internal(e.to_string()))?;
106        blocks.insert(block_id, data.to_vec());
107        Ok(())
108    }
109}
110
111/// File-backed block store. Uses a regular file as a virtual block device.
112///
113/// Uses `pread`/`pwrite` (via `FileExt`) for positioned I/O without seeking,
114/// which is safe for concurrent reads without a mutex on the file descriptor.
115pub struct DiskBlockStore {
116    file: File,
117    block_size: usize,
118    total_blocks: u64,
119}
120
121impl DiskBlockStore {
122    /// Open an existing file as a block store.
123    ///
124    /// The file must already exist and be at least `block_size * total_blocks` bytes.
125    /// If `total_blocks` is 0, it is inferred from the file size.
126    pub fn open(path: &str, block_size: usize, total_blocks: u64) -> FsResult<Self> {
127        let file = OpenOptions::new()
128            .read(true)
129            .write(true)
130            .open(path)
131            .map_err(|e| FsError::Internal(format!("open {path}: {e}")))?;
132
133        let file_len = file
134            .metadata()
135            .map_err(|e| FsError::Internal(format!("stat {path}: {e}")))?
136            .len();
137
138        let total_blocks = if total_blocks == 0 {
139            file_len / block_size as u64
140        } else {
141            total_blocks
142        };
143
144        let required = total_blocks * block_size as u64;
145        if file_len < required {
146            return Err(FsError::Internal(format!(
147                "file too small: {file_len} bytes, need {required}"
148            )));
149        }
150
151        Ok(Self {
152            file,
153            block_size,
154            total_blocks,
155        })
156    }
157
158    /// Create a new file of the given size and open it as a block store.
159    ///
160    /// Every block is filled with cryptographically random data so that
161    /// unallocated blocks are indistinguishable from encrypted ones.
162    pub fn create(path: &str, block_size: usize, total_blocks: u64) -> FsResult<Self> {
163        let mut file = OpenOptions::new()
164            .read(true)
165            .write(true)
166            .create_new(true)
167            .open(path)
168            .map_err(|e| FsError::Internal(format!("create {path}: {e}")))?;
169
170        // Fill every block with random bytes so free space looks like ciphertext.
171        let mut rng = rand::thread_rng();
172        let mut buf = vec![0u8; block_size];
173        for _ in 0..total_blocks {
174            rng.fill_bytes(&mut buf);
175            file.write_all(&buf)
176                .map_err(|e| FsError::Internal(format!("write {path}: {e}")))?;
177        }
178        file.sync_all()
179            .map_err(|e| FsError::Internal(format!("sync {path}: {e}")))?;
180
181        Ok(Self {
182            file,
183            block_size,
184            total_blocks,
185        })
186    }
187}
188
189impl BlockStore for DiskBlockStore {
190    fn block_size(&self) -> usize {
191        self.block_size
192    }
193
194    fn total_blocks(&self) -> u64 {
195        self.total_blocks
196    }
197
198    fn read_block(&self, block_id: u64) -> FsResult<Vec<u8>> {
199        if block_id >= self.total_blocks {
200            return Err(FsError::BlockOutOfRange(block_id));
201        }
202        let offset = block_id * self.block_size as u64;
203        let mut buf = vec![0u8; self.block_size];
204        self.file
205            .read_exact_at(&mut buf, offset)
206            .map_err(|e| FsError::Internal(format!("read block {block_id}: {e}")))?;
207        Ok(buf)
208    }
209
210    fn write_block(&self, block_id: u64, data: &[u8]) -> FsResult<()> {
211        if block_id >= self.total_blocks {
212            return Err(FsError::BlockOutOfRange(block_id));
213        }
214        if data.len() != self.block_size {
215            return Err(FsError::BlockSizeMismatch {
216                expected: self.block_size,
217                got: data.len(),
218            });
219        }
220        let offset = block_id * self.block_size as u64;
221        self.file
222            .write_all_at(data, offset)
223            .map_err(|e| FsError::Internal(format!("write block {block_id}: {e}")))?;
224        Ok(())
225    }
226
227    fn sync(&self) -> FsResult<()> {
228        self.file
229            .sync_all()
230            .map_err(|e| FsError::Internal(format!("fsync: {e}")))
231    }
232}
233
234/// Block-device-backed block store for raw devices such as EBS volumes.
235///
236/// Unlike [`DiskBlockStore`] which operates on regular files, this backend
237/// targets raw block devices (e.g. `/dev/xvdf`, `/dev/nvme1n1p1`).  The
238/// device must already exist; Linux does not allow creating device nodes
239/// from userspace in the normal flow.
240///
241/// Device size is discovered via `lseek(SEEK_END)` because `stat()` reports
242/// `st_size = 0` for block devices.  I/O uses `pread`/`pwrite` (via
243/// [`FileExt`]) exactly like `DiskBlockStore`.
244pub struct DeviceBlockStore {
245    file: File,
246    block_size: usize,
247    total_blocks: u64,
248}
249
250impl DeviceBlockStore {
251    /// Open an existing block device.
252    ///
253    /// `total_blocks` – pass 0 to infer from the device size.
254    pub fn open(path: &str, block_size: usize, total_blocks: u64) -> FsResult<Self> {
255        let mut file = OpenOptions::new()
256            .read(true)
257            .write(true)
258            .open(path)
259            .map_err(|e| FsError::Internal(format!("open device {path}: {e}")))?;
260
261        let device_size = file
262            .seek(SeekFrom::End(0))
263            .map_err(|e| FsError::Internal(format!("seek device {path}: {e}")))?;
264
265        let total_blocks = if total_blocks == 0 {
266            device_size / block_size as u64
267        } else {
268            total_blocks
269        };
270
271        let required = total_blocks * block_size as u64;
272        if device_size < required {
273            return Err(FsError::Internal(format!(
274                "device too small: {device_size} bytes, need {required}"
275            )));
276        }
277
278        Ok(Self {
279            file,
280            block_size,
281            total_blocks,
282        })
283    }
284
285    /// Initialize a block device by filling every block with random data so
286    /// that free space is indistinguishable from ciphertext.
287    ///
288    /// **Warning:** this writes to *every* block and can take a long time on
289    /// large volumes.  Call this once when first provisioning the device.
290    ///
291    /// `total_blocks` – pass 0 to use the entire device.
292    pub fn initialize(path: &str, block_size: usize, total_blocks: u64) -> FsResult<Self> {
293        let mut file = OpenOptions::new()
294            .read(true)
295            .write(true)
296            .open(path)
297            .map_err(|e| FsError::Internal(format!("open device {path}: {e}")))?;
298
299        let device_size = file
300            .seek(SeekFrom::End(0))
301            .map_err(|e| FsError::Internal(format!("seek device {path}: {e}")))?;
302
303        let total_blocks = if total_blocks == 0 {
304            device_size / block_size as u64
305        } else {
306            total_blocks
307        };
308
309        let required = total_blocks * block_size as u64;
310        if device_size < required {
311            return Err(FsError::Internal(format!(
312                "device too small: {device_size} bytes, need {required}"
313            )));
314        }
315
316        // Seek back to the start before writing.
317        file.seek(SeekFrom::Start(0))
318            .map_err(|e| FsError::Internal(format!("seek device {path}: {e}")))?;
319
320        let mut rng = rand::thread_rng();
321        let mut buf = vec![0u8; block_size];
322        for _ in 0..total_blocks {
323            rng.fill_bytes(&mut buf);
324            file.write_all(&buf)
325                .map_err(|e| FsError::Internal(format!("write device {path}: {e}")))?;
326        }
327        file.sync_all()
328            .map_err(|e| FsError::Internal(format!("sync device {path}: {e}")))?;
329
330        Ok(Self {
331            file,
332            block_size,
333            total_blocks,
334        })
335    }
336}
337
338impl BlockStore for DeviceBlockStore {
339    fn block_size(&self) -> usize {
340        self.block_size
341    }
342
343    fn total_blocks(&self) -> u64 {
344        self.total_blocks
345    }
346
347    fn read_block(&self, block_id: u64) -> FsResult<Vec<u8>> {
348        if block_id >= self.total_blocks {
349            return Err(FsError::BlockOutOfRange(block_id));
350        }
351        let offset = block_id * self.block_size as u64;
352        let mut buf = vec![0u8; self.block_size];
353        self.file
354            .read_exact_at(&mut buf, offset)
355            .map_err(|e| FsError::Internal(format!("read block {block_id}: {e}")))?;
356        Ok(buf)
357    }
358
359    fn write_block(&self, block_id: u64, data: &[u8]) -> FsResult<()> {
360        if block_id >= self.total_blocks {
361            return Err(FsError::BlockOutOfRange(block_id));
362        }
363        if data.len() != self.block_size {
364            return Err(FsError::BlockSizeMismatch {
365                expected: self.block_size,
366                got: data.len(),
367            });
368        }
369        let offset = block_id * self.block_size as u64;
370        self.file
371            .write_all_at(data, offset)
372            .map_err(|e| FsError::Internal(format!("write block {block_id}: {e}")))?;
373        Ok(())
374    }
375
376    fn sync(&self) -> FsResult<()> {
377        self.file
378            .sync_all()
379            .map_err(|e| FsError::Internal(format!("fsync: {e}")))
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn test_memory_block_store_roundtrip() {
389        let store = MemoryBlockStore::new(64, 10);
390        let data = vec![0xAB; 64];
391        store.write_block(0, &data).unwrap();
392        let read = store.read_block(0).unwrap();
393        assert_eq!(read, data);
394    }
395
396    #[test]
397    fn test_unwritten_block_returns_zeroes() {
398        let store = MemoryBlockStore::new(64, 10);
399        let read = store.read_block(5).unwrap();
400        assert_eq!(read, vec![0u8; 64]);
401    }
402
403    #[test]
404    fn test_out_of_range_read() {
405        let store = MemoryBlockStore::new(64, 10);
406        assert!(store.read_block(10).is_err());
407    }
408
409    #[test]
410    fn test_block_size_mismatch() {
411        let store = MemoryBlockStore::new(64, 10);
412        assert!(store.write_block(0, &[0u8; 32]).is_err());
413    }
414
415    #[test]
416    fn test_disk_block_store_roundtrip() {
417        let dir = std::env::temp_dir();
418        let path = dir.join(format!("doublecrypt_test_{}.img", std::process::id()));
419        let path_str = path.to_str().unwrap();
420
421        // Cleanup if leftover from a previous run.
422        let _ = std::fs::remove_file(&path);
423
424        let store = DiskBlockStore::create(path_str, 512, 16).unwrap();
425        let data = vec![0xAB; 512];
426        store.write_block(0, &data).unwrap();
427        store.sync().unwrap();
428        let read = store.read_block(0).unwrap();
429        assert_eq!(read, data);
430
431        // Unwritten block should be random-filled (not zero).
432        let unwritten = store.read_block(10).unwrap();
433        assert_eq!(unwritten.len(), 512);
434        // Overwhelmingly unlikely that 512 random bytes are all zero.
435        assert!(unwritten.iter().any(|&b| b != 0));
436
437        // Out of range.
438        assert!(store.read_block(16).is_err());
439        assert!(store.write_block(16, &data).is_err());
440
441        // Block size mismatch.
442        assert!(store.write_block(0, &[0u8; 64]).is_err());
443
444        drop(store);
445        std::fs::remove_file(&path).unwrap();
446    }
447
448    #[test]
449    fn test_disk_block_store_open_existing() {
450        let dir = std::env::temp_dir();
451        let path = dir.join(format!("doublecrypt_test_open_{}.img", std::process::id()));
452        let path_str = path.to_str().unwrap();
453        let _ = std::fs::remove_file(&path);
454
455        // Create and write.
456        {
457            let store = DiskBlockStore::create(path_str, 256, 8).unwrap();
458            let data = vec![0xCD; 256];
459            store.write_block(3, &data).unwrap();
460            store.sync().unwrap();
461        }
462
463        // Reopen and verify.
464        {
465            let store = DiskBlockStore::open(path_str, 256, 8).unwrap();
466            let read = store.read_block(3).unwrap();
467            assert_eq!(read, vec![0xCD; 256]);
468        }
469
470        // Open with inferred total_blocks (0).
471        {
472            let store = DiskBlockStore::open(path_str, 256, 0).unwrap();
473            assert_eq!(store.total_blocks(), 8);
474        }
475
476        std::fs::remove_file(&path).unwrap();
477    }
478
479    #[test]
480    fn test_disk_block_store_file_too_small() {
481        let dir = std::env::temp_dir();
482        let path = dir.join(format!("doublecrypt_test_small_{}.img", std::process::id()));
483        let path_str = path.to_str().unwrap();
484        let _ = std::fs::remove_file(&path);
485
486        // Create a small file.
487        std::fs::write(&path, vec![0u8; 100]).unwrap();
488
489        // Try to open with more blocks than fit.
490        assert!(DiskBlockStore::open(path_str, 256, 8).is_err());
491
492        std::fs::remove_file(&path).unwrap();
493    }
494}